diff --git a/packages/runtime-core/tests/project_contract.rs b/packages/runtime-core/tests/project_contract.rs index c62d502cd..e48c9bdd5 100644 --- a/packages/runtime-core/tests/project_contract.rs +++ b/packages/runtime-core/tests/project_contract.rs @@ -1309,6 +1309,33 @@ fn execution_dependency_compiler_preserves_review_conditions_and_refuses_invalid ); } +#[test] +fn execution_dependency_compiler_refuses_readiness_for_inactive_or_unknown_services() { + use project::execution::{Condition, Graph}; + use std::collections::BTreeMap; + let fixture = Fixture::new( + "services:\n web:\n image: alpine:3.21\n tools:\n image: alpine:3.21\n profiles: [debug]\n", + ); + let plan = fixture.plan().plan; + assert!(plan.enrollment_compatible); + assert!(plan.services["web"].active); + assert!(!plan.services["tools"].active); + let active = BTreeMap::from([("web".into(), Condition::Started)]); + let graph = Graph::from_plan(&plan, &active).unwrap(); + assert_eq!(graph.services.keys().collect::>(), ["web"]); + for extra in ["tools", "ghost"] { + let mut goals = active.clone(); + goals.insert(extra.into(), Condition::Started); + let refusal = Graph::from_plan(&plan, &goals).unwrap_err(); + assert_eq!(refusal.code, "graph_readiness", "{extra}"); + assert!( + refusal.message.contains("inactive or unknown"), + "{extra}: {}", + refusal.message + ); + } +} + #[test] fn executable_inputs_preserve_argv_and_resolve_only_explicit_values() { use project::inputs; diff --git a/src/backends/native-project-routing.ts b/src/backends/native-project-routing.ts index 167bad7b8..780dd2a8c 100644 --- a/src/backends/native-project-routing.ts +++ b/src/backends/native-project-routing.ts @@ -30,7 +30,13 @@ export function hasOnlyNativeSupportedLabels(labels: unknown): boolean { ) ?? false ); } -/** Count all declared routes, including inactive profiles, before allocating the pool. */ +/** + * Count all declared routes, including inactive profiles, before allocating the pool. + * + * This runs before the profile-aware review, so it is a conservative upper bound: profile + * activation stays the reviewed plan's decision, and `reviewedNativeRoutes` must never find + * more active routes than this floor. Unlike readiness goals, an unused slot is harmless. + */ export function nativeBridgeCapacity( specs: Readonly>>> ): number { diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 1d85a18fe..c1413120a 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -334,18 +334,36 @@ async function pinNativeProjectImages(opts: { spec.image = ensured.image_id; } } -function readiness( - specs: Record>, - initializers: ReadonlySet, - routed: ReadonlySet -): string[] { +/** + * Build `--ready` goals for exactly the services the reviewed plan marks active. + * + * Compose services outside the selected profiles stay in the normalized specs (they are + * still reviewed and pinned), but the runtime does not schedule them and refuses readiness + * goals for inactive or unknown services. Dependency edges declared by inactive services + * are ignored so they cannot raise an active service's readiness goal. + */ +export function nativeReadinessArgs(opts: { + readonly specs: Readonly>>>; + readonly plan: unknown; + readonly initializers: ReadonlySet; + readonly routed: ReadonlySet; +}): string[] { + const reviewed = + isRecord(opts.plan) && isRecord(opts.plan.services) + ? opts.plan.services + : {}; + const active = (name: string): boolean => { + const service = reviewed[name]; + return isRecord(service) && service.active === true; + }; + const specs = Object.entries(opts.specs).filter(([name]) => active(name)); const ready: Record = Object.fromEntries( - Object.entries(specs).map(([name, spec]) => [ + specs.map(([name, spec]) => [ name, spec.healthcheck ? "healthy" : "started", ]) ); - for (const spec of Object.values(specs)) { + for (const [, spec] of specs) { if (!isRecord(spec.depends_on)) { continue; } @@ -361,16 +379,15 @@ function readiness( } } } - for (const name of initializers) { + for (const name of opts.initializers) { ready[name] = "completed"; } - for (const name of routed) { + for (const name of opts.routed) { ready[name] = "healthy"; } - return Object.entries(ready).flatMap(([name, condition]) => [ - "--ready", - `${name}=${condition}`, - ]); + return Object.entries(ready) + .filter(([name]) => active(name)) + .flatMap(([name, condition]) => ["--ready", `${name}=${condition}`]); } function authoritative( value: unknown, @@ -1138,7 +1155,12 @@ export async function startNativeProject(opts: { review.planId, ...nativeSharedSourceFlags(review.report.plan), ...cache.flags, - ...readiness(specs, cache.initializers, routes.services), + ...nativeReadinessArgs({ + specs, + plan: review.report.plan, + initializers: cache.initializers, + routed: routes.services, + }), ...routes.flags, "--dependencies", dependencyFile, diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 710df0980..e34f75ec4 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -24,6 +24,7 @@ import { preflightNativeRestart } from "../src/backends/native-project-restart-p import { selectNativeRetainedImages } from "../src/backends/native-project-retained-images.ts"; import type { NativeProjectRun } from "../src/backends/native-project-run.ts"; import { + nativeReadinessArgs, parseNativeHttpsSelection, reviewedNativeHttpsRoutes, startNativeProject, @@ -1018,6 +1019,166 @@ test("routed startup reserves bridges and enrolls reviewed healthy services with expect(await startNativeProject(opts)).toBe(0); }); +const profileProbe = { + port: 3000, + path: "/health", + interval_ms: 100, + timeout_ms: 500, + retries: 3, + start_period_ms: 0, +}; +/** One active service plus a routed and an unrouted service in the `debug` profile. */ +function profileSpecs(): Record> { + return { + web: { image: `sha256:${"e".repeat(64)}` }, + admin: { + image: `sha256:${"e".repeat(64)}`, + profiles: ["debug"], + labels: { + caddy: "admin.example.com", + "caddy.reverse_proxy": "{{upstreams 3000}}", + "caddy.tls": "internal", + }, + healthcheck: { "x-hack-http": profileProbe }, + }, + worker: { + image: `sha256:${"e".repeat(64)}`, + profiles: ["debug"], + depends_on: { web: { condition: "service_healthy" } }, + }, + }; +} +function profilePlan(debug: boolean) { + return { + enrollment_compatible: true, + services: { + web: { active: true }, + admin: { + active: debug, + routing: { port: 3000, hostnames: ["admin.example.com"] }, + healthcheck: { native_http: profileProbe, disabled: false }, + }, + worker: { active: debug }, + }, + }; +} +function readyGoals(args: readonly string[]): string[] { + return args.flatMap((arg, index) => + args[index - 1] === "--ready" ? [arg] : [] + ); +} + +test("readiness goals cover only services the reviewed plan marks active", () => { + expect( + nativeReadinessArgs({ + specs: profileSpecs(), + plan: profilePlan(false), + initializers: new Set(), + routed: new Set(), + }) + ).toEqual(["--ready", "web=started"]); + expect( + readyGoals( + nativeReadinessArgs({ + specs: profileSpecs(), + plan: profilePlan(true), + initializers: new Set(), + routed: new Set(["admin"]), + }) + ).sort() + ).toEqual(["admin=healthy", "web=healthy", "worker=started"]); + // Services absent from the plan are unknown, and a missing plan admits nothing. + expect( + nativeReadinessArgs({ + specs: profileSpecs(), + plan: { + enrollment_compatible: true, + services: { web: { active: true } }, + }, + initializers: new Set(["worker"]), + routed: new Set(["admin"]), + }) + ).toEqual(["--ready", "web=started"]); + expect( + nativeReadinessArgs({ + specs: profileSpecs(), + plan: undefined, + initializers: new Set(), + routed: new Set(), + }) + ).toEqual([]); +}); + +async function profileStartup(profiles: readonly string[] | undefined) { + const { opts } = await fixture(false); + const prepare = opts.dependencies.prepare!; + const review = opts.dependencies.review!; + const invoke = opts.dependencies.invoke!; + const serve = opts.dependencies.serve!; + const seen: { reviewed?: readonly string[]; serve?: readonly string[] } = {}; + let bridges: string | undefined; + opts.dependencies.prepare = async (call) => ({ + ...(await prepare(call)), + normalizedComposeJson: JSON.stringify({ services: profileSpecs() }), + serviceNames: ["web", "admin", "worker"], + }); + opts.dependencies.review = async (call) => { + seen.reviewed = call.profiles; + return await review({ + ...call, + run: async (checked) => + call.run({ + ...checked, + report: { + plan: profilePlan(call.profiles?.includes("debug") === true), + }, + }), + }); + }; + opts.dependencies.invoke = async (call) => { + if (call.args[0] === "runtime" && call.args[1] === "up") { + const index = call.args.indexOf("--minimum-bridge-sockets"); + bridges = index < 0 ? undefined : call.args[index + 1]; + } + return await invoke(call); + }; + opts.dependencies.serve = async (call) => { + seen.serve = call.args; + return await serve(call); + }; + expect( + await startNativeProject(profiles ? { ...opts, profiles } : opts) + ).toBe(0); + return { + reviewed: seen.reviewed, + goals: readyGoals(seen.serve ?? []).sort(), + serveArgs: seen.serve ?? [], + bridges, + }; +} + +test("native up omits routed and unrouted inactive-profile services from readiness", async () => { + const started = await profileStartup(undefined); + expect(started.reviewed).not.toContain("debug"); + // An inactive worker's healthy edge must not raise the active web goal. + expect(started.goals).toEqual(["web=started"]); + expect(started.serveArgs).not.toContain("admin=auto"); + // Pool capacity still counts the inactive routed service on purpose. + expect(started.bridges).toBe("1"); +}); + +test("an explicit profile activates its services' readiness and routes", async () => { + const started = await profileStartup(["debug"]); + expect(started.reviewed).toContain("debug"); + expect(started.goals).toEqual([ + "admin=healthy", + "web=healthy", + "worker=started", + ]); + expect(started.serveArgs).toContain("admin=auto"); + expect(started.bridges).toBe("1"); +}); + function leaseIdentity( selection: Parameters[0] ) {