Skip to content
27 changes: 27 additions & 0 deletions packages/runtime-core/tests/project_contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>(), ["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;
Expand Down
8 changes: 7 additions & 1 deletion src/backends/native-project-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, Readonly<Record<string, unknown>>>>
): number {
Expand Down
50 changes: 36 additions & 14 deletions src/backends/native-project-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,18 +334,36 @@ async function pinNativeProjectImages(opts: {
spec.image = ensured.image_id;
}
}
function readiness(
specs: Record<string, Record<string, unknown>>,
initializers: ReadonlySet<string>,
routed: ReadonlySet<string>
): 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<Record<string, Readonly<Record<string, unknown>>>>;
readonly plan: unknown;
readonly initializers: ReadonlySet<string>;
readonly routed: ReadonlySet<string>;
}): 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<string, string> = 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;
}
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
161 changes: 161 additions & 0 deletions tests/native-project-start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, Record<string, unknown>> {
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<typeof acquireNativeHttpsLease>[0]
) {
Expand Down
Loading