mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-19 14:56:27 +00:00
79914fe750
* fix: recognize clean one-shot completions in health gate and drift Treat exit 0 with restart policy no/absent as successful completion so init and migration jobs no longer fail post-update observation or show as service-missing, while long-running restart policies still fail closed. * fix: ignore residual health on clean one-shots and honor deploy.restart_policy Completed exit-0 jobs with no-restart intent no longer fail the health gate on leftover starting/unhealthy state, and Drift treats deploy.restart_policy with Compose precedence so any/on-failure services are not mistaken for one-shots. * fix: require explicit Compose restart no for one-shot recognition Docker inspect reports restart no for both intentional jobs and bare services that omit restart, so Health Gate and Drift now require declared restart:""no"" (or deploy.restart_policy condition none) and load Compose intent once per gate.
216 lines
7.9 KiB
TypeScript
216 lines
7.9 KiB
TypeScript
import YAML from 'yaml';
|
|
|
|
// Refuse to parse anything beyond this bound so a malformed (or adversarial)
|
|
// compose file cannot exhaust heap while building the dependency map. Mirrors
|
|
// the cap in composePreview.ts.
|
|
const MAX_COMPOSE_PARSE_BYTES = 1_048_576; // 1 MiB
|
|
|
|
/** A host-published port declared in a compose service. */
|
|
export interface DeclaredPort {
|
|
/** Host interface ('' means all interfaces). */
|
|
hostIp: string;
|
|
publishedPort: number;
|
|
protocol: string;
|
|
}
|
|
|
|
export interface DeclaredService {
|
|
name: string;
|
|
/** depends_on targets (list or map form, normalized to names). */
|
|
dependsOn: string[];
|
|
/** Service-level network keys (list or map form). */
|
|
networks: string[];
|
|
/** Named-volume source keys only (bind mounts and anonymous volumes excluded). */
|
|
volumes: string[];
|
|
ports: DeclaredPort[];
|
|
/** Service `image:` reference as declared, or undefined for build-only services. */
|
|
image?: string;
|
|
/** Service `network_mode:` as declared (e.g. `host`), or undefined. */
|
|
networkMode?: string;
|
|
/** Service `restart` from raw YAML parse. The effective-model path may set this via normalizeComposeRestartIntent (including deploy.restart_policy). Null when unset. */
|
|
restart?: string | null;
|
|
}
|
|
|
|
/** A top-level networks:/volumes: entry. */
|
|
export interface DeclaredResource {
|
|
/** Explicit `name:` override, when set. */
|
|
name?: string;
|
|
external: boolean;
|
|
}
|
|
|
|
export interface DeclaredCompose {
|
|
services: DeclaredService[];
|
|
networks: Record<string, DeclaredResource>;
|
|
volumes: Record<string, DeclaredResource>;
|
|
/** Top-level `name:` (the Compose project name), when set; it determines the
|
|
* `<project>_<resource>` runtime names, overriding the stack directory name. */
|
|
projectName?: string;
|
|
/** Set when the file could not be parsed; the other fields are then empty. */
|
|
parseError?: string;
|
|
}
|
|
|
|
function asString(value: unknown): string | undefined {
|
|
if (typeof value === 'string') return value;
|
|
if (typeof value === 'number') return String(value);
|
|
return undefined;
|
|
}
|
|
|
|
/** Extracts keys from a depends_on / networks block in list or map form. */
|
|
function collectKeys(value: unknown): string[] {
|
|
if (Array.isArray(value)) {
|
|
return value.map(asString).filter((v): v is string => v !== undefined);
|
|
}
|
|
if (value && typeof value === 'object') {
|
|
return Object.keys(value as Record<string, unknown>);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/** True when a volume source is a named volume (not a bind mount or anonymous). */
|
|
function isNamedVolumeSource(source: string): boolean {
|
|
if (!source) return false;
|
|
// A named-volume key is [a-zA-Z0-9._-]+, so any '$' marks an env-var bind path
|
|
// (or the '$$' literal-dollar escape) whose value is unresolvable at parse time.
|
|
if (source.includes('$')) return false;
|
|
if (source.includes('/')) return false; // bind mount path
|
|
if (source.startsWith('.') || source.startsWith('~')) return false;
|
|
if (/^[a-zA-Z]:[\\/]/.test(source)) return false; // Windows drive path
|
|
return true;
|
|
}
|
|
|
|
/** Pulls the named-volume source from a service volume entry, or null. */
|
|
function namedVolumeSource(entry: unknown): string | null {
|
|
const short = asString(entry);
|
|
if (short !== undefined) {
|
|
const source = short.split(':')[0];
|
|
return isNamedVolumeSource(source) ? source : null;
|
|
}
|
|
if (entry && typeof entry === 'object') {
|
|
const obj = entry as Record<string, unknown>;
|
|
if (obj.type === 'volume') {
|
|
const source = asString(obj.source);
|
|
return source ? source : null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Parses a service `ports:` entry into a host-published port, or null. */
|
|
function declaredPort(entry: unknown): DeclaredPort | null {
|
|
const short = asString(entry);
|
|
if (short !== undefined) {
|
|
const [spec, proto] = short.split('/');
|
|
const parts = spec.split(':');
|
|
let hostIp = '';
|
|
let hostPart: string | undefined;
|
|
if (parts.length >= 3) {
|
|
hostIp = parts[0];
|
|
hostPart = parts[1];
|
|
} else if (parts.length === 2) {
|
|
hostPart = parts[0];
|
|
} else {
|
|
return null; // container-only EXPOSE, not host-published
|
|
}
|
|
const published = parseInt((hostPart ?? '').split('-')[0], 10);
|
|
if (!Number.isFinite(published) || published <= 0) return null;
|
|
return { hostIp, publishedPort: published, protocol: proto || 'tcp' };
|
|
}
|
|
if (entry && typeof entry === 'object') {
|
|
const obj = entry as Record<string, unknown>;
|
|
const publishedRaw = asString(obj.published);
|
|
if (publishedRaw === undefined) return null; // not host-published
|
|
const published = parseInt(publishedRaw.split('-')[0], 10);
|
|
if (!Number.isFinite(published) || published <= 0) return null;
|
|
return {
|
|
hostIp: asString(obj.host_ip) ?? '',
|
|
publishedPort: published,
|
|
protocol: asString(obj.protocol) ?? 'tcp',
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Normalizes a top-level networks:/volumes: entry to { name?, external }. */
|
|
function declaredResource(entry: unknown): DeclaredResource {
|
|
if (!entry || typeof entry !== 'object') return { external: false };
|
|
const obj = entry as Record<string, unknown>;
|
|
const name = asString(obj.name);
|
|
// `external` may be a boolean or the legacy `{ name: ... }` object form.
|
|
let external = false;
|
|
let externalName: string | undefined;
|
|
if (obj.external === true) {
|
|
external = true;
|
|
} else if (obj.external && typeof obj.external === 'object') {
|
|
external = true;
|
|
externalName = asString((obj.external as Record<string, unknown>).name);
|
|
}
|
|
const resolved = name ?? externalName;
|
|
return resolved ? { name: resolved, external } : { external };
|
|
}
|
|
|
|
function collectResources(value: unknown): Record<string, DeclaredResource> {
|
|
const out: Record<string, DeclaredResource> = {};
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
|
|
out[key] = declaredResource(entry);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Parse a compose file's declared dependency metadata: per-service depends_on,
|
|
* networks, named volumes, and host-published ports, plus the top-level
|
|
* networks:/volumes: declarations with their name: overrides and external
|
|
* flags. Never throws: parse failures are reported through `parseError`.
|
|
*
|
|
* Runtime edges in the dependency map come from the live container snapshot;
|
|
* this declared view is used only to flag missing dependencies and to gather
|
|
* declared port claimants for conflict detection.
|
|
*/
|
|
export function parseComposeDependencies(content: string): DeclaredCompose {
|
|
if (content.length > MAX_COMPOSE_PARSE_BYTES) {
|
|
return { services: [], networks: {}, volumes: {}, parseError: 'Compose file is too large to parse.' };
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = YAML.parse(content);
|
|
} catch (error) {
|
|
return { services: [], networks: {}, volumes: {}, parseError: `Could not parse compose file: ${(error as Error).message}` };
|
|
}
|
|
|
|
const root = (parsed ?? {}) as Record<string, unknown>;
|
|
const rawServices = root.services;
|
|
if (!rawServices || typeof rawServices !== 'object') {
|
|
return { services: [], networks: {}, volumes: {}, parseError: 'No services found in this file.' };
|
|
}
|
|
|
|
const services: DeclaredService[] = [];
|
|
for (const [name, raw] of Object.entries(rawServices as Record<string, unknown>)) {
|
|
const svc = (raw ?? {}) as Record<string, unknown>;
|
|
const volumes = Array.isArray(svc.volumes)
|
|
? svc.volumes.map(namedVolumeSource).filter((v): v is string => v !== null)
|
|
: [];
|
|
const ports = Array.isArray(svc.ports)
|
|
? svc.ports.map(declaredPort).filter((p): p is DeclaredPort => p !== null)
|
|
: [];
|
|
services.push({
|
|
name,
|
|
dependsOn: collectKeys(svc.depends_on),
|
|
networks: collectKeys(svc.networks),
|
|
volumes,
|
|
ports,
|
|
image: asString(svc.image),
|
|
networkMode: asString(svc.network_mode),
|
|
restart: asString(svc.restart) ?? null,
|
|
});
|
|
}
|
|
|
|
return {
|
|
services,
|
|
networks: collectResources(root.networks),
|
|
volumes: collectResources(root.volumes),
|
|
projectName: asString(root.name),
|
|
};
|
|
}
|