diff --git a/src/mgr/provider-profiles.ts b/src/mgr/provider-profiles.ts index acbee2c..d5240fb 100644 --- a/src/mgr/provider-profiles.ts +++ b/src/mgr/provider-profiles.ts @@ -12,6 +12,7 @@ import { runnerJobStatusSummary } from "./runner-job-status.js"; const defaultNamespace = "agentrun-v01"; const credentialAnnotationPrefix = "agentrun.pikastech.local/provider-profile"; +const kubectlLastAppliedAnnotation = "kubectl.kubernetes.io/last-applied-configuration"; export interface ProviderProfileOptions { namespace?: string; @@ -71,7 +72,6 @@ export async function setProviderProfileCredential(profileValue: string, body: u [`${credentialAnnotationPrefix}-credential-hash-suffix`]: shortHash(rendered.authJson), [`${credentialAnnotationPrefix}-config-hash-suffix`]: shortHash(rendered.config.configToml), [`${credentialAnnotationPrefix}-updated-at`]: new Date().toISOString(), - "kubectl.kubernetes.io/last-applied-configuration": null, ...(delegatedBy ? { [`${credentialAnnotationPrefix}-delegated-system`]: delegatedBy.system, [`${credentialAnnotationPrefix}-delegated-request-id`]: delegatedBy.requestId ?? "" } : {}), }, }, @@ -81,7 +81,7 @@ export async function setProviderProfileCredential(profileValue: string, body: u "config.toml": base64Data(rendered.config.configToml), }, }; - const applied = await kubectlReplaceOrCreateSecret(secretManifest, options.kubectlCommand ?? "kubectl"); + const applied = await kubectlReplaceSecret(secretManifest, options.kubectlCommand ?? "kubectl"); return { action: "provider-profile-credential-updated", mutation: true, @@ -423,22 +423,21 @@ async function kubectlGetSecret(name: string, namespace: string, kubectlCommand: return parseKubectlObject(result.stdout, "secret"); } -async function kubectlReplaceOrCreateSecret(manifest: JsonRecord, kubectlCommand: string): Promise { +async function kubectlReplaceSecret(manifest: JsonRecord, kubectlCommand: string): Promise { const name = stringPath(manifest, ["metadata", "name"]) ?? ""; const namespace = stringPath(manifest, ["metadata", "namespace"]) ?? defaultNamespace; const replace = await runKubectl(kubectlCommand, ["replace", "-f", "-", "-o", "json"], `${JSON.stringify(manifest)}\n`); - if (replace.code === 0) return parseKubectlObject(replace.stdout, "replaced secret", { redactSecretData: true }); + if (replace.code === 0) return await kubectlRemoveLastAppliedAnnotation(kubectlCommand, name, namespace); + throw new AgentRunError("infra-failed", `kubectl replace provider profile secret ${namespace}/${name} failed with code ${replace.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(replace.stderr.slice(-2000)), stdout: redactText(replace.stdout.slice(-1000)) }) }); +} - const replaceFailure = `${replace.stderr}\n${replace.stdout}`; - if (!/notfound|not found|not-found/iu.test(replaceFailure)) { - throw new AgentRunError("infra-failed", `kubectl replace provider profile secret ${namespace}/${name} failed with code ${replace.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(replace.stderr.slice(-2000)), stdout: redactText(replace.stdout.slice(-1000)) }) }); +async function kubectlRemoveLastAppliedAnnotation(kubectlCommand: string, name: string, namespace: string): Promise { + const patch = { metadata: { annotations: { [kubectlLastAppliedAnnotation]: null } } }; + const result = await runKubectl(kubectlCommand, ["patch", "secret", name, "-n", namespace, "--type", "merge", "-p", JSON.stringify(patch), "-o", "json"]); + if (result.code !== 0) { + throw new AgentRunError("infra-failed", `kubectl remove provider profile secret last-applied annotation ${namespace}/${name} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)) }) }); } - - const create = await runKubectl(kubectlCommand, ["create", "-f", "-", "-o", "json"], `${JSON.stringify(manifest)}\n`); - if (create.code !== 0) { - throw new AgentRunError("infra-failed", `kubectl create provider profile secret ${namespace}/${name} failed with code ${create.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(create.stderr.slice(-2000)), stdout: redactText(create.stdout.slice(-1000)) }) }); - } - return parseKubectlObject(create.stdout, "created secret", { redactSecretData: true }); + return parseKubectlObject(result.stdout, "provider profile secret annotation cleanup", { redactSecretData: true }); } async function runKubectl(kubectlCommand: string, args: string[], stdin?: string): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> { diff --git a/src/selftest/cases/45-provider-profile-management.ts b/src/selftest/cases/45-provider-profile-management.ts index 3a9ab47..7ebab5e 100644 --- a/src/selftest/cases/45-provider-profile-management.ts +++ b/src/selftest/cases/45-provider-profile-management.ts @@ -13,12 +13,14 @@ const selfTest: SelfTestCase = async (context) => { const gitopsRenderer = await readFile(path.join(context.root, "scripts/src/gitops-render.ts"), "utf8"); assert.equal(gitopsRenderer.includes("agentrun-v01-mgr-provider-secret-manager"), true); assert.equal(gitopsRenderer.includes('verbs: ["get", "patch", "update"]'), true); + assert.equal(gitopsRenderer.includes('resourceNames: ["agentrun-v01-provider-codex", "agentrun-v01-provider-deepseek", "agentrun-v01-provider-minimax-m3"]'), true); for (const profile of ["codex", "deepseek", "minimax-m3"]) { assert.equal(gitopsRenderer.includes(`agentrun-v01-provider-${profile}`), true); } const fakeKubectl = path.join(context.tmp, "fake-provider-kubectl.js"); const replacedSecretPath = path.join(context.tmp, "provider-secret-replace.json"); + const cleanupPatchPath = path.join(context.tmp, "provider-secret-cleanup-patch.json"); const createdJobPath = path.join(context.tmp, "provider-validation-job.json"); await writeFile(fakeKubectl, `#!/usr/bin/env bun const args = Bun.argv.slice(2); @@ -35,10 +37,21 @@ if (args[0] === "apply") { console.error("provider credential updates must not use kubectl apply"); process.exit(1); } -if (args[0] === "patch" && args[1] === "secret") { +if (args[0] === "patch" && args[1] === "secret" && args.includes("--patch-file")) { console.error("provider credential updates must not use kubectl patch --patch-file"); process.exit(1); } +if (args[0] === "patch" && args[1] === "secret") { + const patchArg = args[args.indexOf("-p") + 1] ?? "{}"; + const patch = JSON.parse(patchArg); + await Bun.write(${JSON.stringify(cleanupPatchPath)}, JSON.stringify({ args, patch }, null, 2)); + if (patch.metadata?.annotations?.["kubectl.kubernetes.io/last-applied-configuration"] !== null) { + console.error("provider credential patch may only remove last-applied annotation"); + process.exit(1); + } + console.log(JSON.stringify({ apiVersion: "v1", kind: "Secret", metadata: { name: args[2], namespace: "agentrun-v01", resourceVersion: "rv-cleaned", annotations: { "agentrun.pikastech.local/provider-profile-updated-at": "2026-06-05T00:00:01.000Z" } }, data: { "auth.json": "REDACTED", "config.toml": "REDACTED" } })); + process.exit(0); +} if (args[0] === "replace") { const text = await readStdin(); await Bun.write(${JSON.stringify(replacedSecretPath)}, JSON.stringify({ args, manifest: JSON.parse(text) }, null, 2)); @@ -85,7 +98,7 @@ process.exit(1); reason: "self-test", }) as JsonRecord; assert.equal(updated.profile, "deepseek"); - assert.equal(updated.resourceVersion, "rv-replaced"); + assert.equal(updated.resourceVersion, "rv-cleaned"); assert.equal(updated.requiresExternalBridgeUpdate, true); assert.equal(JSON.stringify(updated).includes(secretText), false); assertNoSecretLeak(updated); @@ -97,7 +110,12 @@ process.exit(1); assert.equal(((secretManifest.metadata as JsonRecord).name), "agentrun-v01-provider-deepseek"); assert.equal(((secretManifest.metadata as JsonRecord).namespace), "agentrun-v01"); const annotations = (secretManifest.metadata as JsonRecord).annotations as JsonRecord; - assert.equal(annotations["kubectl.kubernetes.io/last-applied-configuration"], null); + assert.equal(Object.hasOwn(annotations, "kubectl.kubernetes.io/last-applied-configuration"), false); + const cleanupRecord = JSON.parse(await readFile(cleanupPatchPath, "utf8")) as JsonRecord; + const cleanupArgs = cleanupRecord.args as string[]; + assert.deepEqual(cleanupArgs, ["patch", "secret", "agentrun-v01-provider-deepseek", "-n", "agentrun-v01", "--type", "merge", "-p", JSON.stringify({ metadata: { annotations: { "kubectl.kubernetes.io/last-applied-configuration": null } } }), "-o", "json"]); + const cleanupPatch = cleanupRecord.patch as JsonRecord; + assert.deepEqual(cleanupPatch, { metadata: { annotations: { "kubectl.kubernetes.io/last-applied-configuration": null } } }); const data = secretManifest.data as JsonRecord; const authJson = Buffer.from(String(data["auth.json"]), "base64").toString("utf8"); const configToml = Buffer.from(String(data["config.toml"]), "base64").toString("utf8"); @@ -129,7 +147,7 @@ process.exit(1); assert.equal(finalValidation.status, "completed"); assert.equal(JSON.stringify(finalValidation).includes(secretText), false); assertNoSecretLeak(finalValidation); - return { name: "provider-profile-management", tests: ["provider-profiles-list-redacted", "provider-profile-set-key-redacted", "provider-profile-secret-upsert-no-last-applied", "provider-profile-deepseek-moon-bridge", "provider-profile-manager-secret-rbac", "provider-profile-validation-runner-job"] }; + return { name: "provider-profile-management", tests: ["provider-profiles-list-redacted", "provider-profile-set-key-redacted", "provider-profile-secret-replace-annotation-cleanup", "provider-profile-deepseek-moon-bridge", "provider-profile-manager-secret-rbac", "provider-profile-validation-runner-job"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); }