diff --git a/src/mgr/server.ts b/src/mgr/server.ts index 0cc1265..342e441 100644 --- a/src/mgr/server.ts +++ b/src/mgr/server.ts @@ -908,8 +908,21 @@ async function route({ method, url, body, store, sourceCommit, authSummary, runn } if (method === "POST" && path === "/api/v1/reconciler/runner-jobs") { const record = body === null ? {} : asRecord(body, "runnerReconciler"); - const limit = Math.max(1, Math.min(numberField(record, "limit", runnerReconcilerOptions?.batchSize ?? 20), 500)); - const namespace = optionalString(record.namespace) ?? runnerReconcilerOptions?.namespace ?? process.env.AGENTRUN_RUNTIME_NAMESPACE ?? "agentrun-v01"; + const requestedLimit = Math.max(1, Math.min(numberField(record, "limit", runnerReconcilerOptions.batchSize), 500)); + const limit = Math.min(requestedLimit, runnerReconcilerOptions.batchSize); + const requestedNamespace = optionalString(record.namespace); + if (requestedNamespace !== null && requestedNamespace !== runnerReconcilerOptions.namespace) { + throw new AgentRunError("tenant-policy-denied", "runner reconciler namespace is fixed by manager runtime configuration", { + httpStatus: 403, + details: { + reason: "runner-reconciler-namespace-override-denied", + configuredNamespace: runnerReconcilerOptions.namespace, + requestedNamespace, + valuesPrinted: false, + }, + }); + } + const namespace = runnerReconcilerOptions.namespace; return await reconcileRunnerJobsOnce({ store, namespace, diff --git a/src/selftest/cases/22-runner-retention-stale-pending.ts b/src/selftest/cases/22-runner-retention-stale-pending.ts index 0738388..d7e90ef 100644 --- a/src/selftest/cases/22-runner-retention-stale-pending.ts +++ b/src/selftest/cases/22-runner-retention-stale-pending.ts @@ -168,7 +168,7 @@ const selfTest: SelfTestCase = async (context) => { "periodic-reconciler-protects-unsafe-runners", "periodic-reconciler-cas-conflict-fails-closed", "periodic-reconciler-namespace-fence-prevents-duplicate-terminalization", - "controlled-reconcile-api-exposes-bounded-retention-result", + "controlled-reconcile-api-enforces-yaml-batch-and-namespace", "automatic-reconciler-reports-next-schedule", ], }; @@ -609,15 +609,15 @@ async function assertConcurrentPeriodicReconcileIsSerialized(input: { fixturePat } async function assertControlledReconcileApiExposesRetention(input: { fixturePath: string; statePath: string; kubectlCommand: string }): Promise { - const fact = pendingFact(350, new Date(Date.now() - 60 * 60_000).toISOString()); + const facts = [350, 351].map((index) => pendingFact(index, new Date(Date.now() - 60 * 60_000).toISOString())); await writeFixture(input, { - jobs: [runnerJob(fact, 350)], - pods: [runnerPod(fact, 350, "pending")], - events: [failedMountEvent(fact, 350)], + jobs: facts.map((fact, index) => runnerJob(fact, index + 350)), + pods: facts.map((fact, index) => runnerPod(fact, index + 350, "pending")), + events: facts.map((fact, index) => failedMountEvent(fact, index + 350)), bumpJobResourceVersionAfterPodDelete: true, bumpedJobResourceVersion: "2", }); - const store = new RetentionFactStore([fact]); + const store = new RetentionFactStore(facts); const retention = retentionOptions(input.kubectlCommand, 20); const server = await startManagerServer({ port: 0, @@ -644,8 +644,10 @@ async function assertControlledReconcileApiExposesRetention(input: { fixturePath }, }); try { - const summary = await new ManagerClient(server.baseUrl).post("/api/v1/reconciler/runner-jobs", { limit: 1, namespace: "agentrun-nc01-v02" }) as JsonRecord; + const client = new ManagerClient(server.baseUrl); + const summary = await client.post("/api/v1/reconciler/runner-jobs", { limit: 500 }) as JsonRecord; const cleanup = summary.stalePendingRetention as JsonRecord; + assert.equal(summary.limit, 1); assert.equal(cleanup.enabled, true); assert.equal(cleanup.selectedRunnerCount, 1); assert.equal(cleanup.terminalizedRunCount, 1); @@ -654,6 +656,14 @@ async function assertControlledReconcileApiExposesRetention(input: { fixturePath assert.equal(cleanup.valuesPrinted, false); assert.equal(summary.nextReconcileAt, null); assert.equal(JSON.stringify(summary).includes("abc123-secret-value"), false); + assert.equal(store.getRun((facts[1] as RetentionFact).run.id).status, "pending"); + await assert.rejects( + () => client.post("/api/v1/reconciler/runner-jobs", { limit: 1, namespace: "another-runtime" }), + (error) => error instanceof AgentRunError + && error.failureKind === "tenant-policy-denied" + && ((((error.details?.error as JsonRecord | undefined)?.details) as JsonRecord | undefined)?.reason) === "runner-reconciler-namespace-override-denied", + ); + assert.equal(store.getRun((facts[1] as RetentionFact).run.id).status, "pending"); } finally { await new Promise((resolve) => server.server.close(() => resolve())); }