import type { IServiceRessources } from './docker.js'; import type { IRegistryTarget } from './registry.js'; import type { IAppStorePublishedPort } from '../appstore/index.js'; import type { IHostedAppLifecycleState } from './hostedapp.js'; import type { IServiceMailConfig } from './mail.js'; import type { IServiceWebPushConfig } from './webpush.js'; import type { IImageRolloutStatus, IImmutableImageDeploymentPlan, } from './immutableimage.js'; import type { IServiceDomainRoute, IServicePublicPortMapping, IServiceTargetPort, } from './serviceports.js'; import type { IServiceSecretConfiguration, IServiceSecretDeploymentPlan, } from './secret.js'; export interface IServiceVolume { /** Stable Docker volume name. If omitted, Coreflow derives one from service id and mount path. */ name?: string; /** Alias for name when a volume is shared intentionally across services. */ source?: string; /** Container path where the volume is mounted. */ mountPath: string; /** Docker volume driver. Defaults to corestore. */ driver?: 'corestore' | 'local' | string; readOnly?: boolean; /** Whether backup orchestration should snapshot this volume. Defaults to true. */ backup?: boolean; /** Driver-specific options forwarded to Docker's VolumeDriver.Create request. */ options?: Record; } const organizationIdRegex = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/; /** Validates the canonical organization authority used by service operations. */ export const validateOrganizationId = (valueArg: unknown): string[] => { if (typeof valueArg !== 'string' || !organizationIdRegex.test(valueArg)) { return ['organizationId must be a canonical 1-200 character identifier']; } return []; }; export const serviceContainerArgLimits = { maximumArgs: 64, maximumArgBytes: 4096, maximumTotalBytes: 32 * 1024, } as const; export const normalizeServiceAbsolutePath = (pathArg: string): string | undefined => { if (typeof pathArg !== 'string' || !pathArg.startsWith('/') || pathArg.includes('\0') || pathArg.length > 255) { return undefined; } const segments: string[] = []; for (const segment of pathArg.split('/')) { if (!segment || segment === '.') continue; if (segment === '..') { if (segments.length === 0) return undefined; segments.pop(); continue; } if (!/^[A-Za-z0-9._-]+$/.test(segment)) return undefined; segments.push(segment); } return `/${segments.join('/')}`; }; /** Pure validation for the exact OCI/Docker argument vector after image entrypoint. */ export const validateServiceContainerArgs = (containerArgsArg: unknown): string[] => { if (containerArgsArg === undefined) return []; if (!Array.isArray(containerArgsArg)) return ['containerArgs must be an array']; if (containerArgsArg.length === 0) { return ['containerArgs must be omitted instead of using an empty array']; } if (containerArgsArg.length > serviceContainerArgLimits.maximumArgs) { return [`containerArgs must contain at most ${serviceContainerArgLimits.maximumArgs} entries`]; } let totalBytes = 0; for (const [index, argument] of containerArgsArg.entries()) { if (typeof argument !== 'string' || argument.length === 0) { return [`containerArgs[${index}] must be a non-empty string`]; } if (/[\u0000-\u001f\u007f]/.test(argument)) { return [`containerArgs[${index}] must not contain control characters`]; } const argumentBytes = new TextEncoder().encode(argument).byteLength; if (argumentBytes > serviceContainerArgLimits.maximumArgBytes) { return [`containerArgs[${index}] exceeds the maximum encoded size`]; } totalBytes += argumentBytes; } if (totalBytes > serviceContainerArgLimits.maximumTotalBytes) { return ['containerArgs exceeds the maximum total encoded size']; } return []; }; /** * Where a service is allowed to run. * - absent or mode 'replicated': the service runs on every node (legacy * full-replication behavior). * - mode 'pinned' + nodeName: the service runs only on that swarm node; * other nodes stop it but preserve local data (volumes, corestore * resources) so a migration can move it safely. * - hold: the service is stopped on all nodes with data preserved; used by * the migration orchestrator between the stop and deploy phases. */ export interface IServicePlacement { mode: 'pinned' | 'replicated'; /** Swarm node hostname for pinned mode. */ nodeName?: string; /** When true, no node runs the service (data preserved everywhere). */ hold?: boolean; } export type TServiceReconciliationStatus = 'running' | 'ready' | 'degraded' | 'failed'; export interface IServiceReconciliationStageStatus { stage: string; stageKey: string; status: TServiceReconciliationStatus; domainName?: string; message?: string; errorText?: string; reportedAt: number; } export interface IServiceReconciliationStatus { status: TServiceReconciliationStatus; updatedAt: number; stages: Record; } export interface IService { id: string; data: { /** Server-managed deployment ownership boundary. */ organizationId?: string; name: string; description: string; imageId: string; imageVersion: string; registryTarget?: IRegistryTarget; deployOnPush?: boolean; /** Fail closed unless Cloudly supplies and Coreflow verifies a digest-pinned rollout. */ immutableImageRequired?: boolean; /** Server-managed immutable desired state. Use the promotion/rollback APIs to change it. */ imageDeployment?: IImmutableImageDeploymentPlan; /** Server-managed aggregate of task-derived runtime evidence. */ imageRolloutStatus?: IImageRolloutStatus; /** * When true the service tolerates overlapping deployments during * updates (rolling). Default false: the previous deployment is stopped * and archived before a replacement deployment starts. */ rollingCapable?: boolean; placement?: IServicePlacement; appTemplateId?: string; appTemplateVersion?: string; appStoreUpgradePolicy?: 'manual' | 'notify' | 'auto'; hostedAppLifecycle?: IHostedAppLifecycleState; reconciliationStatus?: IServiceReconciliationStatus; environment: { [key: string]: string }; /** Value-free, caller-managed SecretSet attachment configuration. */ secretConfiguration?: IServiceSecretConfiguration; /** Server-managed immutable manifest authority pointer. */ secretDeployment?: IServiceSecretDeploymentPlan; /** Exact OCI/Docker argument vector appended after the image entrypoint. */ containerArgs?: string[]; /** * Service category determines deployment behavior * - base: Core services that run on every node (coreflow, coretraffic, corelog) * - distributed: Services that run on limited nodes (cores3, coremongo) * - workload: User applications */ serviceCategory: 'base' | 'distributed' | 'workload'; /** * Deployment strategy for the service * - all-nodes: Deploy to every node in the cluster * - limited-replicas: Deploy to a limited number of nodes * - custom: Custom deployment logic */ deploymentStrategy: 'all-nodes' | 'limited-replicas' | 'custom'; /** * Maximum number of replicas for distributed services * For example, 3 for cores3 or coremongo */ maxReplicas?: number; /** * How many pushed image versions to keep for this service. Older * versions beyond this count are pruned from the registry, always * protecting versions referenced by the current desired state or an * active immutable deployment. Absent means the platform default of 3. */ imageRetentionCount?: number; /** * Whether to enforce anti-affinity rules * When true, tries to spread deployments across different BareMetal servers */ antiAffinity?: boolean; scaleFactor: number; balancingStrategy: 'round-robin' | 'least-connections'; /** * Canonical backend/container ports for this service. New domain routes * and public mappings reference these targets by name or exact port. */ targetPorts?: IServiceTargetPort[]; ports: { /** * Legacy main backend port. New writes should declare targetPorts and * keep this only as compatibility shorthand during migration. */ web: number; /** * Legacy implicit domain-to-port routes. Runtime code must not treat * this as a permissive routing source for new services. */ custom?: { [domain: string]: string }; }; mail?: IServiceMailConfig; webPush?: IServiceWebPushConfig; volumes?: IServiceVolume[]; publishedPorts?: IAppStorePublishedPort[]; /** Edge/coretraffic public TCP/UDP exposure, distinct from Docker Swarm publishedPorts. */ publicPortMappings?: IServicePublicPortMapping[]; resources?: IServiceRessources; domains: IServiceDomainRoute[]; deploymentIds: string[]; }; } /** * Caller-writable service data. Immutable rollout state and SecretSet * attachments are changed only through their dedicated fenced APIs. */ export type TServiceWritableData = Omit< IService['data'], | 'organizationId' | 'immutableImageRequired' | 'imageDeployment' | 'imageRolloutStatus' | 'secretConfiguration' | 'secretDeployment' >; /** Caller-writable update data. Null explicitly removes an existing argument vector. */ export type TServiceUpdateWritableData = Omit< TServiceWritableData, 'containerArgs' > & { containerArgs?: string[] | null; };