import type { IDeploymentPreflightProposedConfiguration, IDeploymentPreflightRuntimeTask } from './deploymentpreflight.js'; import type { IGatewayRouteDnsResult } from './gateway.js'; import type { IImageRolloutStatus, IImmutableImageDeploymentPlan, TSha256Digest } from './immutableimage.js'; import { normalizeImmutableReleaseTag, normalizeSha256Digest } from './immutableimage.js'; import type { IRegistryTarget } from './registry.js'; import { normalizeServiceAbsolutePath, validateServiceContainerArgs } from './service.js'; import type { TServiceTargetPortRef } from './serviceports.js'; export type TDeploymentOperationMode = 'existing-service' | 'greenfield'; export type TDeploymentOperationPhase = 'reserve' | 'promote' | 'route'; interface IDeploymentReservationInputBase { organizationId: string; serviceId: string; idempotencyKey: string; sourceRevision: string; version: string; intentDigest: TSha256Digest; proposedConfiguration: IDeploymentPreflightProposedConfiguration; releaseTag: string; routes: IDeploymentRouteRequest[]; } export interface IExistingServiceDeploymentReservationInput extends IDeploymentReservationInputBase { mode: 'existing-service'; expectedRolloutGeneration: number; expectedCurrentRolloutId?: string; } export interface IGreenfieldServiceDeploymentReservationInput extends IDeploymentReservationInputBase { mode: 'greenfield'; expectedRolloutGeneration?: never; expectedCurrentRolloutId?: never; } export type IDeploymentReservationInput = | IExistingServiceDeploymentReservationInput | IGreenfieldServiceDeploymentReservationInput; export type TDeploymentOperationState = | 'reserving' | 'reserved' | 'provisioning' | 'awaiting-image' | 'image-recorded' | 'promoting-image' | 'rolling-out' | 'ready-for-route' | 'promoting-route' | 'verifying-route' | 'succeeded' | 'rolling-back' | 'rolled-back' | 'failed' | 'cleaning' | 'cleaned'; export interface IDeploymentRouteRequest { hostname: string; targetPort: Extract; /** Provider-specific desired DNS proxy state. Omission preserves legacy behavior. */ proxied?: boolean; /** Required exact one-label hostname used to verify a wildcard route. */ verificationHostname?: string; readinessPath?: string; expectedStatusCodes?: number[]; } export interface IDeploymentRouteClaimTarget { hostname: string; bucketHostname: string; wildcard: boolean; } const canonicalHostnameRegex = /^(?:\*\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/; export const canonicalizeDeploymentHostname = (hostnameArg: unknown): string | undefined => { if (typeof hostnameArg !== 'string') return undefined; const hostname = hostnameArg.trim().toLowerCase().replace(/\.$/, ''); return hostname.length <= 253 && canonicalHostnameRegex.test(hostname) ? hostname : undefined; }; /** * Wildcards match exactly one label. Exact siblings share a bucket document * but may coexist; a wildcard claim excludes every exact name in its bucket. */ export const getDeploymentRouteClaimTarget = ( hostnameArg: string, ): IDeploymentRouteClaimTarget | undefined => { const hostname = canonicalizeDeploymentHostname(hostnameArg); if (!hostname) return undefined; const wildcard = hostname.startsWith('*.'); const labels = (wildcard ? hostname.slice(2) : hostname).split('.'); const bucketHostname = wildcard ? labels.join('.') : labels.slice(1).join('.'); if (!bucketHostname || (wildcard && labels.length < 2)) return undefined; return { hostname, bucketHostname, wildcard }; }; export const getDeploymentRouteVerificationHostname = ( routeArg: IDeploymentRouteRequest, ): string | undefined => { const target = getDeploymentRouteClaimTarget(routeArg.hostname); if (!target) return undefined; if (!target.wildcard) { return routeArg.verificationHostname === undefined || routeArg.verificationHostname === target.hostname ? target.hostname : undefined; } if (!routeArg.verificationHostname) return undefined; const verificationTarget = getDeploymentRouteClaimTarget(routeArg.verificationHostname); return verificationTarget && !verificationTarget.wildcard && verificationTarget.bucketHostname === target.bucketHostname ? verificationTarget.hostname : undefined; }; export const normalizeDeploymentReadinessPath = (pathArg?: unknown): string | undefined => { if (pathArg !== undefined && typeof pathArg !== 'string') return undefined; const path = pathArg || '/'; return /^\/(?!\/)[A-Za-z0-9._~!$&'()*+,;=:@%\/-]{0,1023}$/.test(path) ? path : undefined; }; const deploymentIdentifierRegex = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/; const fullGitRevisionRegex = /^[a-f0-9]{40}$/; const semanticVersionRegex = /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; const isRecord = (valueArg: unknown): valueArg is Record => ( Boolean(valueArg) && typeof valueArg === 'object' && !Array.isArray(valueArg) ); const validateStringArray = ( valueArg: unknown, maxItemsArg: number, validatorArg: (valueArg: string) => boolean, ): valueArg is string[] => Array.isArray(valueArg) && valueArg.length <= maxItemsArg && valueArg.every((itemArg) => typeof itemArg === 'string' && itemArg.length > 0 && itemArg.length <= 253 && validatorArg(itemArg)) && new Set(valueArg).size === valueArg.length; export const validateDeploymentProposedConfiguration = ( configurationArg: unknown, ): string[] => { if (!isRecord(configurationArg)) return ['proposedConfiguration must be an object']; const errors: string[] = []; if (configurationArg.schemaVersion !== 1) errors.push('schemaVersion must be 1'); if (typeof configurationArg.declarationComplete !== 'boolean') { errors.push('declarationComplete must be boolean'); } if (typeof configurationArg.projectName !== 'string' || !configurationArg.projectName.trim() || configurationArg.projectName.length > 200) { errors.push('projectName must be bounded and non-empty'); } if (!validateStringArray(configurationArg.targetPlatforms, 2, (platformArg) => ( platformArg === 'linux/amd64' || platformArg === 'linux/arm64' )) || (configurationArg.targetPlatforms as string[]).length === 0 || JSON.stringify(configurationArg.targetPlatforms) !== JSON.stringify([...(configurationArg.targetPlatforms as string[])].sort())) { errors.push('targetPlatforms must be a sorted non-empty supported platform set'); } if (!validateStringArray(configurationArg.publicDomains, 32, (domainArg) => ( canonicalizeDeploymentHostname(domainArg) === domainArg ))) { errors.push('publicDomains must be unique canonical exact or wildcard hostnames'); } if (!validateStringArray(configurationArg.environmentVariableNames, 256, (nameArg) => ( nameArg.length <= 253 && /^[A-Z_][A-Z0-9_]*$/.test(nameArg) ))) { errors.push('environmentVariableNames must be unique canonical names'); } errors.push(...validateServiceContainerArgs(configurationArg.containerArgs)); if (!Array.isArray(configurationArg.containerPorts) || configurationArg.containerPorts.length > 64 || configurationArg.containerPorts.some((portArg) => !Number.isSafeInteger(portArg) || portArg < 1 || portArg > 65535) || new Set(configurationArg.containerPorts).size !== configurationArg.containerPorts.length) { errors.push('containerPorts must be unique valid ports'); } if (!Array.isArray(configurationArg.volumeMounts) || configurationArg.volumeMounts.length > 32 || configurationArg.volumeMounts.some((mountArg) => !isRecord(mountArg) || typeof mountArg.mountPath !== 'string' || !/^\/[A-Za-z0-9._/-]{0,254}$/.test(mountArg.mountPath) || normalizeServiceAbsolutePath(mountArg.mountPath) !== mountArg.mountPath || (mountArg.storageClass !== 'corestore' && mountArg.storageClass !== 'ephemeral') || (mountArg.capability !== undefined && mountArg.capability !== 'database' && mountArg.capability !== 'objectstorage'))) { errors.push('volumeMounts must be canonical supported declarations'); } if (!Array.isArray(configurationArg.requiredCapabilities) || configurationArg.requiredCapabilities.length > 3 || configurationArg.requiredCapabilities.some((capabilityArg) => ( capabilityArg !== 'database' && capabilityArg !== 'objectstorage' && capabilityArg !== 'pushnotification' )) || new Set(configurationArg.requiredCapabilities).size !== configurationArg.requiredCapabilities.length) { errors.push('requiredCapabilities must be a unique supported capability set'); } return errors; }; export const validateDeploymentReservationInput = (inputArg: unknown): string[] => { if (!isRecord(inputArg)) return ['reservation input must be an object']; const errors: string[] = []; if (inputArg.mode !== 'existing-service' && inputArg.mode !== 'greenfield') { errors.push('mode must identify an existing-service or greenfield deployment'); } for (const field of ['organizationId', 'serviceId', 'idempotencyKey'] as const) { if (typeof inputArg[field] !== 'string' || !deploymentIdentifierRegex.test(inputArg[field] as string)) { errors.push(`${field} must be a bounded canonical identifier`); } } if (typeof inputArg.sourceRevision !== 'string' || !fullGitRevisionRegex.test(inputArg.sourceRevision)) { errors.push('sourceRevision must be a full lowercase git revision'); } if (typeof inputArg.version !== 'string' || !semanticVersionRegex.test(inputArg.version)) { errors.push('version must be a canonical semantic version'); } if (!normalizeSha256Digest(inputArg.intentDigest)) { errors.push('intentDigest must be a canonical sha256 digest'); } if (!normalizeImmutableReleaseTag(inputArg.releaseTag)) { errors.push('releaseTag must be a canonical non-latest OCI tag'); } const configuration = isRecord(inputArg.proposedConfiguration) ? inputArg.proposedConfiguration : undefined; errors.push(...validateDeploymentProposedConfiguration(inputArg.proposedConfiguration)); const containerPorts = configuration && Array.isArray(configuration.containerPorts) ? configuration.containerPorts : undefined; const publicDomains = configuration && Array.isArray(configuration.publicDomains) ? configuration.publicDomains.filter((domainArg): domainArg is string => ( typeof domainArg === 'string' )) : undefined; if (configuration?.declarationComplete !== true) { errors.push('proposedConfiguration must declare itself complete'); } const routes = Array.isArray(inputArg.routes) ? inputArg.routes : undefined; const routeHostnames = routes?.map((routeArg) => ( isRecord(routeArg) && typeof routeArg.hostname === 'string' ? routeArg.hostname : undefined )); if (!routes || routes.length > 32 || !routeHostnames || new Set(routeHostnames).size !== routeHostnames.length || routes.some((routeArg) => { if (!isRecord(routeArg) || typeof routeArg.hostname !== 'string' || canonicalizeDeploymentHostname(routeArg.hostname) !== routeArg.hostname || !Number.isSafeInteger(routeArg.targetPort) || (routeArg.targetPort as number) < 1 || (routeArg.targetPort as number) > 65535 || (routeArg.proxied !== undefined && typeof routeArg.proxied !== 'boolean') || !containerPorts?.includes(routeArg.targetPort)) { return true; } const typedRoute = routeArg as unknown as IDeploymentRouteRequest; return getDeploymentRouteVerificationHostname(typedRoute) === undefined || (routeArg.readinessPath !== undefined && (typeof routeArg.readinessPath !== 'string' || normalizeDeploymentReadinessPath(routeArg.readinessPath) !== routeArg.readinessPath)) || (routeArg.expectedStatusCodes !== undefined && (!Array.isArray(routeArg.expectedStatusCodes) || routeArg.expectedStatusCodes.length < 1 || routeArg.expectedStatusCodes.length > 16 || new Set(routeArg.expectedStatusCodes).size !== routeArg.expectedStatusCodes.length || routeArg.expectedStatusCodes.some((statusArg) => !Number.isSafeInteger(statusArg) || statusArg < 200 || statusArg > 299))); })) { errors.push('routes must be unique canonical declarations over declared numeric ports'); } if (inputArg.mode === 'greenfield') { if (inputArg.expectedRolloutGeneration !== undefined || inputArg.expectedCurrentRolloutId !== undefined) { errors.push('greenfield reservation must not carry an existing rollout fence'); } if (publicDomains && routeHostnames) { const declared = [...new Set(publicDomains)].sort(); const routed = [...new Set(routeHostnames.filter((valueArg): valueArg is string => ( typeof valueArg === 'string' )))].sort(); if (JSON.stringify(declared) !== JSON.stringify(routed)) { errors.push('greenfield routes must exactly match proposed publicDomains'); } } } if (inputArg.mode === 'existing-service') { if (!Number.isSafeInteger(inputArg.expectedRolloutGeneration) || (inputArg.expectedRolloutGeneration as number) < 0) { errors.push('existing-service reservation requires an explicit rollout generation fence'); } if (inputArg.expectedCurrentRolloutId !== undefined && (typeof inputArg.expectedCurrentRolloutId !== 'string' || !deploymentIdentifierRegex.test(inputArg.expectedCurrentRolloutId))) { errors.push('expectedCurrentRolloutId must be a bounded canonical identifier'); } } return errors; }; interface IDeploymentRouteVerificationEvidenceBase { hostname: string; url: string; checkedAt: number; resolvedAddresses: string[]; rolloutId: string; rolloutGeneration: number; expectedDigest: TSha256Digest; gatewayDns?: IGatewayRouteDnsResult; verificationAttemptCount?: number; verificationStartedAt?: number; } export interface IDeploymentRouteVerificationSuccessEvidence extends IDeploymentRouteVerificationEvidenceBase { outcome: 'succeeded'; tls: { authorized: true; servername: string; subjectAlternativeNames: string[]; validFrom?: string; validTo?: string; }; http: { statusCode: number; redirected: false; }; readiness: { path: string; expectedStatusCodes: number[]; ready: true; }; } export interface IDeploymentRouteVerificationFailureEvidence extends IDeploymentRouteVerificationEvidenceBase { outcome: 'failed'; stage: 'dns' | 'address-policy' | 'connect' | 'tls' | 'http' | 'readiness'; code: string; message: string; tls?: IDeploymentRouteVerificationSuccessEvidence['tls']; http?: IDeploymentRouteVerificationSuccessEvidence['http']; readiness?: IDeploymentRouteVerificationSuccessEvidence['readiness']; } export type IDeploymentRouteVerificationEvidence = | IDeploymentRouteVerificationSuccessEvidence | IDeploymentRouteVerificationFailureEvidence; export interface IDeploymentOperationFailure { code: string; message: string; retryable: boolean; failedAt: number; } /** * Durable deployment fence. All state transitions compare-and-set both id and * revision. Claim keys are server-generated and never accepted as authority. */ export interface IServiceDeploymentOperation { id: string; revision: number; data: { mode: TDeploymentOperationMode; organizationId: string; serviceId: string; actorUserId: string; idempotencyKey: string; requestDigest: TSha256Digest; sourceRevision: string; version: string; intentDigest: TSha256Digest; configurationDigest: TSha256Digest; proposedConfiguration: IDeploymentPreflightProposedConfiguration; namespace: string; registryTarget: IRegistryTarget; releaseTag: string; routes: IDeploymentRouteRequest[]; claimKeys: string[]; state: TDeploymentOperationState; releaseId?: string; registryRootDigest?: TSha256Digest; rolloutId?: string; rolloutGeneration?: number; routeGeneration?: number; routeVerification?: IDeploymentRouteVerificationEvidence[]; /** * Most recent failure record. Set when the operation fails and retained * as audit history through 'cleaning' and 'cleaned'; cleared only by a * forward transition such as a successful retry. A populated failure does * therefore NOT imply state === 'failed'. */ failure?: IDeploymentOperationFailure; createdAt: number; updatedAt: number; }; } export interface IServiceDeploymentStatus { operation: IServiceDeploymentOperation; imageDeployment?: IImmutableImageDeploymentPlan; imageRolloutStatus?: IImageRolloutStatus; runtimeTasks: IDeploymentRuntimeTaskEvidence[]; routeVerification: IDeploymentRouteVerificationEvidence[]; immutableRolloutSucceeded: boolean; runtimeDigestVerified: boolean; routeVerified: boolean; succeeded: boolean; } interface IDeploymentRuntimeTaskEvidenceBase extends Omit< IDeploymentPreflightRuntimeTask, 'verificationStatus' | 'observedDigest' | 'reportedDigest' > { rolloutId: string; rolloutGeneration: number; expectedDigest: TSha256Digest; observedAt: number; } export interface IVerifiedDeploymentRuntimeTaskEvidence extends IDeploymentRuntimeTaskEvidenceBase { verificationStatus: 'verified'; observedDigest: TSha256Digest; reportedDigest: TSha256Digest; } export interface IUnverifiedDeploymentRuntimeTaskEvidence extends IDeploymentRuntimeTaskEvidenceBase { verificationStatus: 'missing' | 'mismatched'; observedDigest?: TSha256Digest; reportedDigest?: TSha256Digest; } export type IDeploymentRuntimeTaskEvidence = | IVerifiedDeploymentRuntimeTaskEvidence | IUnverifiedDeploymentRuntimeTaskEvidence;