Skip to content

Commit 723090e

Browse files
committed
feat(showcase): mount /api/probes trigger endpoint on the fleet control-plane
The control-plane runs the 8 in-process HTTP probe families but the on-demand trigger endpoint (POST /api/probes/:id/trigger) was only mounted on the legacy boot() path, so operators had no way to fire a family immediately and had to wait on the slow cron. Wire the same registerProbesRoutes onto the control-plane's buildServer using its own httpProbeRegistry/httpProbeConfigs/scheduler/httpRunWriter and OPS_TRIGGER_TOKEN. Only the prefixed in-process probe ids (probe:<id>) are triggerable; browser-only and unknown ids 404. Token handling mirrors boot() (unset -> router omitted; set-but-empty -> fail-loud).
1 parent 23b1412 commit 723090e

2 files changed

Lines changed: 260 additions & 0 deletions

File tree

showcase/harness/src/orchestrator.test.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3901,6 +3901,227 @@ describe("orchestrator runControlPlane in-process HTTP probes", () => {
39013901
// After stop() the watcher's unsubscribe ran → no reload fires anymore.
39023902
expect(unwatched).toBe(true);
39033903
});
3904+
3905+
// ── /api/probes trigger endpoint on the control-plane ──────────────────
3906+
//
3907+
// The control-plane runs the 8 in-process HTTP probe families on its own
3908+
// scheduler under `probe:<id>` entries. The on-demand trigger endpoint
3909+
// (`POST /api/probes/:id/trigger`, bearer-auth gated) lets operators fire a
3910+
// family immediately instead of waiting on the slow cron. It is mounted via
3911+
// the SAME `registerProbesRoutes` boot() uses, wired from the control-plane's
3912+
// own `httpProbeRegistry`/`httpProbeConfigs`/`scheduler`/`httpRunWriter` and
3913+
// `OPS_TRIGGER_TOKEN`. The router id namespace is the prefixed scheduler id
3914+
// (`probe:<cfg.id>`), matching boot() and the in-process registration.
3915+
describe("on-demand trigger endpoint (/api/probes)", () => {
3916+
const TOKEN = "cp-trigger-token";
3917+
let prevToken: string | undefined;
3918+
3919+
beforeEach(() => {
3920+
prevToken = process.env.OPS_TRIGGER_TOKEN;
3921+
process.env.OPS_TRIGGER_TOKEN = TOKEN;
3922+
});
3923+
3924+
afterEach(() => {
3925+
if (prevToken === undefined) delete process.env.OPS_TRIGGER_TOKEN;
3926+
else process.env.OPS_TRIGGER_TOKEN = prevToken;
3927+
});
3928+
3929+
function pbMock() {
3930+
vi.doMock("@hono/node-server", async () => {
3931+
const actual =
3932+
await vi.importActual<typeof import("@hono/node-server")>(
3933+
"@hono/node-server",
3934+
);
3935+
return { ...actual };
3936+
});
3937+
vi.doMock("./storage/pb-client.js", async () => {
3938+
const actual = await vi.importActual<
3939+
typeof import("./storage/pb-client.js")
3940+
>("./storage/pb-client.js");
3941+
return {
3942+
...actual,
3943+
createPbClient: () => ({
3944+
health: async () => true,
3945+
getOne: async () => null,
3946+
getFirst: async () => null,
3947+
getList: async () => ({ items: [] }),
3948+
}),
3949+
};
3950+
});
3951+
}
3952+
3953+
it("GET /api/probes lists the in-process HTTP families (and not the browser kind)", async () => {
3954+
vi.resetModules();
3955+
pbMock();
3956+
const orchMod = await import("./orchestrator.js");
3957+
const handle = await orchMod.runControlPlane(
3958+
{ role: "control-plane", poolCount: 1 },
3959+
{ port, configDir: alertsDir, fleetEnumerate: async () => [] },
3960+
);
3961+
try {
3962+
const res = await fetch(`http://127.0.0.1:${port}/api/probes`);
3963+
expect(res.status).toBe(200);
3964+
const body = (await res.json()) as { probes: Array<{ id: string }> };
3965+
const ids = body.probes.map((p) => p.id).sort();
3966+
// The 3 HTTP families written to the temp probes dir, under their
3967+
// prefixed scheduler ids.
3968+
expect(ids).toEqual(
3969+
["probe:image_drift", "probe:qa", "probe:smoke"].sort(),
3970+
);
3971+
// Browser kind is worker-routed, never in-process → not listed.
3972+
expect(ids).not.toContain("probe:e2e_smoke");
3973+
} finally {
3974+
await handle.stop();
3975+
}
3976+
});
3977+
3978+
it("POST /api/probes/probe:smoke/trigger with the bearer token runs the in-process probe", async () => {
3979+
vi.resetModules();
3980+
pbMock();
3981+
3982+
// Capture trigger() calls on the real scheduler so we can assert the
3983+
// route routed the request to the control-plane's scheduler entry.
3984+
const triggered: string[] = [];
3985+
vi.doMock("./scheduler/scheduler.js", async () => {
3986+
const actual = await vi.importActual<
3987+
typeof import("./scheduler/scheduler.js")
3988+
>("./scheduler/scheduler.js");
3989+
return {
3990+
...actual,
3991+
createScheduler: (
3992+
deps: Parameters<typeof actual.createScheduler>[0],
3993+
) => {
3994+
const real = actual.createScheduler(deps);
3995+
return {
3996+
...real,
3997+
trigger: async (id: string, opts?: unknown) => {
3998+
triggered.push(id);
3999+
return { runId: "run_cp", status: "queued", probe: id };
4000+
},
4001+
};
4002+
},
4003+
};
4004+
});
4005+
4006+
const orchMod = await import("./orchestrator.js");
4007+
const handle = await orchMod.runControlPlane(
4008+
{ role: "control-plane", poolCount: 1 },
4009+
{ port, configDir: alertsDir, fleetEnumerate: async () => [] },
4010+
);
4011+
try {
4012+
const res = await fetch(
4013+
`http://127.0.0.1:${port}/api/probes/probe:smoke/trigger`,
4014+
{
4015+
method: "POST",
4016+
headers: { Authorization: `Bearer ${TOKEN}` },
4017+
},
4018+
);
4019+
expect(res.status).toBe(200);
4020+
const body = (await res.json()) as { runId: string; probe: string };
4021+
expect(body.runId).toBe("run_cp");
4022+
expect(body.probe).toBe("probe:smoke");
4023+
// The route invoked the control-plane scheduler's entry for the probe.
4024+
expect(triggered).toContain("probe:smoke");
4025+
} finally {
4026+
await handle.stop();
4027+
}
4028+
});
4029+
4030+
it("POST /api/probes/probe:smoke/trigger WITHOUT the bearer token 401s", async () => {
4031+
vi.resetModules();
4032+
pbMock();
4033+
const orchMod = await import("./orchestrator.js");
4034+
const handle = await orchMod.runControlPlane(
4035+
{ role: "control-plane", poolCount: 1 },
4036+
{ port, configDir: alertsDir, fleetEnumerate: async () => [] },
4037+
);
4038+
try {
4039+
const res = await fetch(
4040+
`http://127.0.0.1:${port}/api/probes/probe:smoke/trigger`,
4041+
{ method: "POST" },
4042+
);
4043+
expect(res.status).toBe(401);
4044+
} finally {
4045+
await handle.stop();
4046+
}
4047+
});
4048+
4049+
it("POST /api/probes/probe:e2e_smoke/trigger 404s — browser families are not in-process", async () => {
4050+
vi.resetModules();
4051+
pbMock();
4052+
const orchMod = await import("./orchestrator.js");
4053+
const handle = await orchMod.runControlPlane(
4054+
{ role: "control-plane", poolCount: 1 },
4055+
{ port, configDir: alertsDir, fleetEnumerate: async () => [] },
4056+
);
4057+
try {
4058+
const res = await fetch(
4059+
`http://127.0.0.1:${port}/api/probes/probe:e2e_smoke/trigger`,
4060+
{
4061+
method: "POST",
4062+
headers: { Authorization: `Bearer ${TOKEN}` },
4063+
},
4064+
);
4065+
// browser-only id is not a registered in-process probe → 404
4066+
expect(res.status).toBe(404);
4067+
} finally {
4068+
await handle.stop();
4069+
}
4070+
});
4071+
4072+
it("POST /api/probes/probe:nope/trigger 404s for an unknown id", async () => {
4073+
vi.resetModules();
4074+
pbMock();
4075+
const orchMod = await import("./orchestrator.js");
4076+
const handle = await orchMod.runControlPlane(
4077+
{ role: "control-plane", poolCount: 1 },
4078+
{ port, configDir: alertsDir, fleetEnumerate: async () => [] },
4079+
);
4080+
try {
4081+
const res = await fetch(
4082+
`http://127.0.0.1:${port}/api/probes/probe:nope/trigger`,
4083+
{
4084+
method: "POST",
4085+
headers: { Authorization: `Bearer ${TOKEN}` },
4086+
},
4087+
);
4088+
expect(res.status).toBe(404);
4089+
} finally {
4090+
await handle.stop();
4091+
}
4092+
});
4093+
4094+
it("does NOT mount the trigger endpoint when OPS_TRIGGER_TOKEN is unset (fail-safe)", async () => {
4095+
delete process.env.OPS_TRIGGER_TOKEN;
4096+
vi.resetModules();
4097+
pbMock();
4098+
const orchMod = await import("./orchestrator.js");
4099+
const handle = await orchMod.runControlPlane(
4100+
{ role: "control-plane", poolCount: 1 },
4101+
{ port, configDir: alertsDir, fleetEnumerate: async () => [] },
4102+
);
4103+
try {
4104+
// Router not mounted → /api/probes returns Hono's default 404.
4105+
const res = await fetch(`http://127.0.0.1:${port}/api/probes`);
4106+
expect(res.status).toBe(404);
4107+
} finally {
4108+
await handle.stop();
4109+
}
4110+
});
4111+
4112+
it("throws fail-loud when OPS_TRIGGER_TOKEN is set-but-empty", async () => {
4113+
process.env.OPS_TRIGGER_TOKEN = " ";
4114+
vi.resetModules();
4115+
pbMock();
4116+
const orchMod = await import("./orchestrator.js");
4117+
await expect(
4118+
orchMod.runControlPlane(
4119+
{ role: "control-plane", poolCount: 1 },
4120+
{ port, configDir: alertsDir, fleetEnumerate: async () => [] },
4121+
),
4122+
).rejects.toThrow(/OPS_TRIGGER_TOKEN.*empty/i);
4123+
});
4124+
});
39044125
});
39054126

39064127
// A4 drift-lock: the HTTP-only driver set registered by

showcase/harness/src/orchestrator.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2661,6 +2661,44 @@ export async function runControlPlane(
26612661
);
26622662
}
26632663

2664+
// On-demand /api/probes trigger surface. The control-plane now runs the
2665+
// in-process HTTP probe families, so operators need the same manual-trigger
2666+
// endpoint boot() exposes (fire a family immediately instead of waiting on
2667+
// its slow cron). Mounted via the SAME `registerProbesRoutes` boot() uses —
2668+
// wired from the control-plane's own `httpProbeRegistry`/`httpProbeConfigs`/
2669+
// `scheduler`/`httpRunWriter`. The router id namespace is the prefixed
2670+
// scheduler id (`probe:<cfg.id>`), matching both `httpProbeConfigs`'s keying
2671+
// and the `scheduler.register({ id: "probe:<id>" })` entries — so the
2672+
// `isProbeId` guard inside the router admits exactly the in-process HTTP
2673+
// families and 404s everything else (browser-only `probe:e2e_*` ids, the
2674+
// producer's own entries, and unknown ids).
2675+
//
2676+
// Token handling mirrors boot() exactly (fail-safe): unset → router omitted;
2677+
// set-but-empty (incl. whitespace-only) → fail-loud at boot so a mistyped
2678+
// `OPS_TRIGGER_TOKEN=` can't silently ship an insecure/always-reject route.
2679+
const rawTriggerToken = process.env.OPS_TRIGGER_TOKEN;
2680+
if (rawTriggerToken !== undefined && rawTriggerToken.trim() === "") {
2681+
throw new Error(
2682+
"OPS_TRIGGER_TOKEN is set but empty — refusing to mount probes router with insecure auth",
2683+
);
2684+
}
2685+
const triggerToken = rawTriggerToken?.trim(); // undefined or non-empty
2686+
const probesDeps = triggerToken
2687+
? {
2688+
scheduler,
2689+
writer: httpRunWriter,
2690+
getProbeConfig: (id: string): ProbeConfig | undefined =>
2691+
httpProbeConfigs.get(id),
2692+
triggerToken,
2693+
now: () => Date.now(),
2694+
}
2695+
: undefined;
2696+
if (!triggerToken) {
2697+
logger.info("fleet.control-plane.probes-router-disabled", {
2698+
reason: "OPS_TRIGGER_TOKEN unset — /api/probes routes not mounted",
2699+
});
2700+
}
2701+
26642702
// Minimal HTTP surface for the role's liveness `port` (fleet-health probes
26652703
// hit this). /health reports OK once the producer's scheduler entry is live.
26662704
const app = buildServer({
@@ -2686,6 +2724,7 @@ export async function runControlPlane(
26862724
schedulerJobCount: () => scheduler.list().length,
26872725
schedulerIsStopped: () => scheduler.isStopped(),
26882726
bus,
2727+
probes: probesDeps,
26892728
});
26902729

26912730
scheduler.start();

0 commit comments

Comments
 (0)