export type TServiceTargetProtocol = | 'http' | 'https' | 'ssh' | 'tcp' | 'udp' | `custom:${string}`; export type TServiceTargetPortRef = string | number; export interface IServiceTargetPort { name: string; port: number; portEnd?: number; protocol: TServiceTargetProtocol; description?: string; default?: boolean; } export interface IServiceDomainRoute { domainId?: string; name: string; targetPort?: TServiceTargetPortRef; /** * Legacy backend target port. New writes must use targetPort. */ port?: number; protocol?: 'http' | 'https'; sourceProfileRef?: string; /** * Provider-specific desired DNS proxy state (for example Cloudflare). * Producers should persist an explicit boolean when gateway DNS reconciliation is enabled. */ proxied?: boolean; } export interface IServicePublicPortMapping { name: string; publicPort: number; publicPortEnd?: number; targetPort: TServiceTargetPortRef; protocol: 'tcp' | 'udp'; sourceProfileRef?: string; exclusive: true; } export interface IResolvedServiceDomainRoute extends IServiceDomainRoute { targetPort: TServiceTargetPortRef; target: IServiceTargetPort; } export interface IResolvedServicePublicPortMapping extends IServicePublicPortMapping { target: IServiceTargetPort; targetPortStart: number; targetPortEnd: number; publicPortEnd: number; } export interface IServicePortSourceData { name?: string; ports?: { web?: number; custom?: Record; }; targetPorts?: IServiceTargetPort[]; domains?: IServiceDomainRoute[]; publicPortMappings?: IServicePublicPortMapping[]; } export interface INormalizedServicePortConfig { targetPorts: IServiceTargetPort[]; defaultTargetPort?: IServiceTargetPort; domains: IResolvedServiceDomainRoute[]; publicPortMappings: IResolvedServicePublicPortMapping[]; } export interface IServicePortNormalizationOptions { /** * Accept legacy domains[].port only when it resolves to a declared target. * Defaults to true for staged migration reads. */ allowLegacyDomainPort?: boolean; /** * Domain routes are HTTP(S) virtual-host routes. Defaults to true. */ requireHttpDomainTargets?: boolean; } export function normalizeServicePortConfig( serviceDataArg: IServicePortSourceData, optionsArg: IServicePortNormalizationOptions = {}, ): INormalizedServicePortConfig { const options: Required = { allowLegacyDomainPort: optionsArg.allowLegacyDomainPort ?? true, requireHttpDomainTargets: optionsArg.requireHttpDomainTargets ?? true, }; const serviceName = serviceDataArg.name || 'service'; const targetPorts = normalizeServiceTargetPorts(serviceDataArg, serviceName); const defaultTargetPort = resolveDefaultServiceTargetPort(targetPorts); const domains = (serviceDataArg.domains || []).map((domainArg) => { const target = resolveServiceDomainTarget(domainArg, targetPorts, defaultTargetPort, serviceName, options); if (options.requireHttpDomainTargets && !isHttpServiceTargetProtocol(target.protocol)) { throw new Error(`Domain ${domainArg.name} on ${serviceName} references non-HTTP target '${target.name}' (${target.protocol})`); } if (target.portEnd && target.portEnd !== target.port) { throw new Error(`Domain ${domainArg.name} on ${serviceName} references ranged target '${target.name}'`); } const normalizedDomain: IResolvedServiceDomainRoute = { ...domainArg, targetPort: domainArg.targetPort ?? target.name, target, }; delete normalizedDomain.port; return normalizedDomain; }); const publicPortMappings = normalizeServicePublicPortMappings( serviceDataArg.publicPortMappings || [], targetPorts, serviceName, ); return { targetPorts, defaultTargetPort, domains, publicPortMappings, }; } export function normalizeServicePortData( serviceDataArg: T, optionsArg: IServicePortNormalizationOptions = {}, ): T & { targetPorts: IServiceTargetPort[]; domains?: IServiceDomainRoute[]; publicPortMappings?: IServicePublicPortMapping[]; } { const normalized = normalizeServicePortConfig(serviceDataArg, optionsArg); return { ...serviceDataArg, targetPorts: normalized.targetPorts, domains: normalized.domains.map(({ target, ...domainArg }) => domainArg), publicPortMappings: normalized.publicPortMappings.map(({ target, targetPortStart, targetPortEnd, ...mappingArg }) => mappingArg), }; } export function resolveServiceTargetPort( targetPortsArg: IServiceTargetPort[], refArg: TServiceTargetPortRef, ): IServiceTargetPort | undefined { if (typeof refArg === 'string') { const normalizedRef = refArg.trim().toLowerCase(); return targetPortsArg.find((targetArg) => targetArg.name.trim().toLowerCase() === normalizedRef); } return targetPortsArg.find((targetArg) => targetArg.port === refArg && !targetArg.portEnd); } export function resolveDefaultServiceTargetPort( targetPortsArg: IServiceTargetPort[], ): IServiceTargetPort | undefined { const explicitDefaults = targetPortsArg.filter((targetArg) => targetArg.default); if (explicitDefaults.length > 1) { throw new Error(`Multiple default target ports declared: ${explicitDefaults.map((targetArg) => targetArg.name).join(', ')}`); } return explicitDefaults[0] || targetPortsArg.find((targetArg) => targetArg.name === 'web') || targetPortsArg.find((targetArg) => isHttpServiceTargetProtocol(targetArg.protocol)) || targetPortsArg[0]; } export function isHttpServiceTargetProtocol(protocolArg: TServiceTargetProtocol): boolean { return protocolArg === 'http' || protocolArg === 'https'; } function normalizeServiceTargetPorts( serviceDataArg: IServicePortSourceData, serviceNameArg: string, ): IServiceTargetPort[] { const declaredTargets = [...(serviceDataArg.targetPorts || [])]; const legacyWebPort = Number(serviceDataArg.ports?.web); if (declaredTargets.length === 0 && Number.isInteger(legacyWebPort) && legacyWebPort > 0) { declaredTargets.push({ name: 'web', port: legacyWebPort, protocol: 'http', default: true, }); } if (declaredTargets.length === 0) { return []; } const seenNames = new Set(); const seenTuples = new Set(); const normalizedTargets = declaredTargets.map((targetArg) => { const name = targetArg.name?.trim(); if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/.test(name)) { throw new Error(`Invalid target port name '${targetArg.name}' on ${serviceNameArg}`); } const normalizedName = name.toLowerCase(); if (seenNames.has(normalizedName)) { throw new Error(`Duplicate target port name '${name}' on ${serviceNameArg}`); } seenNames.add(normalizedName); const port = assertValidServicePort(targetArg.port, `target port ${name} on ${serviceNameArg}`); const portEnd = targetArg.portEnd == null ? undefined : assertValidServicePort(targetArg.portEnd, `target port end ${name} on ${serviceNameArg}`); if (portEnd !== undefined && portEnd < port) { throw new Error(`target port range '${name}' on ${serviceNameArg} must be ascending`); } const protocol = targetArg.protocol || 'tcp'; const tuple = `${protocol}:${port}:${portEnd || port}`; if (seenTuples.has(tuple)) { throw new Error(`Duplicate target port tuple ${tuple} on ${serviceNameArg}`); } seenTuples.add(tuple); return { ...targetArg, name, port, portEnd, protocol, }; }); if (!normalizedTargets.some((targetArg) => targetArg.default)) { const webTarget = normalizedTargets.find((targetArg) => targetArg.name === 'web'); if (webTarget) { webTarget.default = true; } } return normalizedTargets; } function resolveServiceDomainTarget( domainArg: IServiceDomainRoute, targetPortsArg: IServiceTargetPort[], defaultTargetPortArg: IServiceTargetPort | undefined, serviceNameArg: string, optionsArg: Required, ): IServiceTargetPort { const domainName = domainArg.name || '(unnamed domain)'; if (!targetPortsArg.length) { throw new Error(`Domain ${domainName} on ${serviceNameArg} requires at least one declared target port`); } if (domainArg.targetPort != null) { const target = resolveServiceTargetPort(targetPortsArg, domainArg.targetPort); if (!target) { throw new Error(`Domain ${domainName} on ${serviceNameArg} references undeclared targetPort '${domainArg.targetPort}'`); } return target; } if (domainArg.port != null) { if (!optionsArg.allowLegacyDomainPort) { throw new Error(`Domain ${domainName} on ${serviceNameArg} uses legacy port; use targetPort instead`); } const legacyPort = Number(domainArg.port); const target = resolveServiceTargetPort(targetPortsArg, legacyPort); if (!target) { throw new Error(`Domain ${domainName} on ${serviceNameArg} references undeclared legacy port ${domainArg.port}`); } return target; } if (!defaultTargetPortArg) { throw new Error(`Domain ${domainName} on ${serviceNameArg} has no targetPort and no default target port exists`); } return defaultTargetPortArg; } function normalizeServicePublicPortMappings( mappingsArg: IServicePublicPortMapping[], targetPortsArg: IServiceTargetPort[], serviceNameArg: string, ): IResolvedServicePublicPortMapping[] { const seenNames = new Set(); const seenPublicPorts = new Set(); return mappingsArg.map((mappingArg) => { const name = mappingArg.name?.trim(); if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,62}$/.test(name)) { throw new Error(`Invalid public port mapping name '${mappingArg.name}' on ${serviceNameArg}`); } const normalizedName = name.toLowerCase(); if (seenNames.has(normalizedName)) { throw new Error(`Duplicate public port mapping '${name}' on ${serviceNameArg}`); } seenNames.add(normalizedName); if (mappingArg.exclusive !== true) { throw new Error(`Public port mapping '${name}' on ${serviceNameArg} must be exclusive`); } if (!['tcp', 'udp'].includes(mappingArg.protocol)) { throw new Error(`Public port mapping '${name}' on ${serviceNameArg} has unsupported protocol '${mappingArg.protocol}'`); } const target = resolveServiceTargetPort(targetPortsArg, mappingArg.targetPort); if (!target) { throw new Error(`Public port mapping '${name}' on ${serviceNameArg} references undeclared targetPort '${mappingArg.targetPort}'`); } const publicPort = assertValidServicePort(mappingArg.publicPort, `public port ${name} on ${serviceNameArg}`); const targetStart = target.port; const targetEnd = target.portEnd || target.port; const publicPortEnd = mappingArg.publicPortEnd == null ? publicPort + (targetEnd - targetStart) : assertValidServicePort(mappingArg.publicPortEnd, `public port end ${name} on ${serviceNameArg}`); if (publicPortEnd < publicPort) { throw new Error(`Public port mapping '${name}' on ${serviceNameArg} must have an ascending public range`); } if ((publicPortEnd - publicPort) !== (targetEnd - targetStart)) { throw new Error(`Public and target port ranges must have the same length for mapping '${name}' on ${serviceNameArg}`); } for (let offset = 0; offset <= publicPortEnd - publicPort; offset++) { const key = `${mappingArg.protocol}:${publicPort + offset}`; if (seenPublicPorts.has(key)) { throw new Error(`Duplicate public port ${publicPort + offset}/${mappingArg.protocol} on ${serviceNameArg}`); } seenPublicPorts.add(key); } return { ...mappingArg, name, publicPort, publicPortEnd, protocol: mappingArg.protocol, target, targetPortStart: targetStart, targetPortEnd: targetEnd, }; }).sort((a, b) => { return a.protocol.localeCompare(b.protocol) || a.publicPort - b.publicPort || a.name.localeCompare(b.name); }); } function assertValidServicePort(portArg: number, labelArg: string): number { const port = Number(portArg); if (!Number.isInteger(port) || port < 1 || port > 65535) { throw new Error(`Invalid ${labelArg}: ${portArg}. Expected an integer between 1 and 65535.`); } return port; }