(
services: Record<
string,
{
ciBuilt: boolean;
imageOf?: string;
environments?: Record<string, unknown>;
}
> = SERVICES,
)
| 2317 | * Accepts an injected map for testing; defaults to the real SERVICES map. |
| 2318 | */ |
| 2319 | export function assertImageConsumersValid( |
| 2320 | services: Record< |
| 2321 | string, |
| 2322 | { |
| 2323 | ciBuilt: boolean; |
| 2324 | imageOf?: string; |
| 2325 | environments?: Record<string, unknown>; |
| 2326 | } |
| 2327 | > = SERVICES, |
| 2328 | ): void { |
| 2329 | const problems: string[] = []; |
| 2330 | for (const [key, entry] of Object.entries(services)) { |
| 2331 | const target = entry.imageOf; |
| 2332 | if (target === undefined) continue; |
| 2333 | if (entry.ciBuilt) { |
| 2334 | problems.push( |
| 2335 | ` - "${key}" is ciBuilt but declares imageOf "${target}" — a build slot is its own image producer; drop one of the two`, |
| 2336 | ); |
| 2337 | continue; |
| 2338 | } |
| 2339 | // Own-property lookup: a bare `services[target]` truthiness check |
| 2340 | // would resolve inherited Object.prototype keys (e.g. imageOf: |
| 2341 | // "toString") to a truthy non-entry and misreport the dangling target. |
| 2342 | if (!Object.hasOwn(services, target)) { |
| 2343 | problems.push( |
| 2344 | ` - imageOf "${target}" on "${key}" is not an SSOT key in SERVICES`, |
| 2345 | ); |
| 2346 | continue; |
| 2347 | } |
| 2348 | const targetEntry = services[target]; |
| 2349 | if (!targetEntry.ciBuilt) { |
| 2350 | problems.push( |
| 2351 | ` - imageOf "${target}" on "${key}" points at a service that is not ciBuilt — only showcase_build.yml build slots can have image consumers`, |
| 2352 | ); |
| 2353 | continue; |
| 2354 | } |
| 2355 | // A consumer with ZERO declared environments passes the env-subset |
| 2356 | // check below vacuously — but a consumer that exists in no env is a |
| 2357 | // service the redeploy expansion can never reach (expandImageConsumers |
| 2358 | // filters on `environments[env]`), i.e. a silently never-redeployed |
| 2359 | // service. Reject it loudly. |
| 2360 | const consumerEnvs = Object.keys(entry.environments ?? {}); |
| 2361 | if (consumerEnvs.length === 0) { |
| 2362 | problems.push( |
| 2363 | ` - "${key}" declares imageOf "${target}" but ZERO environments — the env-subset check passes vacuously and the consumer would never be redeployed in any env`, |
| 2364 | ); |
| 2365 | continue; |
| 2366 | } |
| 2367 | // Env overlap: every env the consumer declares must also be one the |
| 2368 | // producer builds for. A consumer-only env would run an image that no |
| 2369 | // CI build ever refreshes there — a silently never-updating service, |
| 2370 | // the exact stale-image failure this invariant exists to prevent. |
| 2371 | const producerEnvs = targetEntry.environments ?? {}; |
| 2372 | for (const env of consumerEnvs) { |
| 2373 | if (!Object.hasOwn(producerEnvs, env)) { |
| 2374 | problems.push( |
| 2375 | ` - "${key}" declares env "${env}" but its imageOf "${target}" has no "${env}" environment — "${key}" would run a never-rebuilt image there`, |
| 2376 | ); |
no test coverage detected
searching dependent graphs…