diff --git a/src/mgr/provider-profiles.ts b/src/mgr/provider-profiles.ts index ee308f7..758f04e 100644 --- a/src/mgr/provider-profiles.ts +++ b/src/mgr/provider-profiles.ts @@ -56,12 +56,8 @@ export async function setProviderProfileCredential(profileValue: string, body: u const delegatedBy = delegatedBySummary(record.delegatedBy); const rendered = renderCredential(apiKey, await renderedConfigForWrite(profile, record, options)); const namespace = profileNamespace(options); - const manifest: JsonRecord = { - apiVersion: "v1", - kind: "Secret", + const secretPatch: JsonRecord = { metadata: { - name: spec.defaultSecretName, - namespace, labels: { "app.kubernetes.io/part-of": "agentrun", "agentrun.pikastech.local/profile": profile, @@ -71,16 +67,17 @@ 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 ?? "" } : {}), }, }, type: "Opaque", - stringData: { - "auth.json": rendered.authJson, - "config.toml": rendered.config.configToml, + data: { + "auth.json": base64Data(rendered.authJson), + "config.toml": base64Data(rendered.config.configToml), }, }; - const applied = await kubectlApplySecret(manifest, options.kubectlCommand ?? "kubectl"); + const applied = await kubectlPatchSecret(spec.defaultSecretName, namespace, secretPatch, options.kubectlCommand ?? "kubectl"); return { action: "provider-profile-credential-updated", mutation: true, @@ -422,12 +419,12 @@ async function kubectlGetSecret(name: string, namespace: string, kubectlCommand: return parseKubectlObject(result.stdout, "secret"); } -async function kubectlApplySecret(manifest: JsonRecord, kubectlCommand: string): Promise { - const result = await runKubectl(kubectlCommand, ["apply", "-f", "-", "-o", "json"], `${JSON.stringify(manifest)}\n`); +async function kubectlPatchSecret(name: string, namespace: string, patch: JsonRecord, kubectlCommand: string): Promise { + const result = await runKubectl(kubectlCommand, ["patch", "secret", name, "-n", namespace, "--type", "merge", "--patch-file", "/dev/stdin", "-o", "json"], `${JSON.stringify(patch)}\n`); if (result.code !== 0) { - throw new AgentRunError("infra-failed", `kubectl apply provider profile secret failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)) }) }); + throw new AgentRunError("infra-failed", `kubectl patch provider profile secret ${namespace}/${name} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)) }) }); } - return parseKubectlObject(result.stdout, "applied secret", { redactSecretData: true }); + return parseKubectlObject(result.stdout, "patched secret", { redactSecretData: true }); } async function runKubectl(kubectlCommand: string, args: string[], stdin?: string): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> { @@ -479,6 +476,10 @@ function hashDataKey(data: JsonRecord | null, key: string): string | null { } } +function base64Data(value: string): string { + return Buffer.from(value, "utf8").toString("base64"); +} + function shortHash(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 12); } diff --git a/src/selftest/cases/45-provider-profile-management.ts b/src/selftest/cases/45-provider-profile-management.ts index 95edc0d..e46cd85 100644 --- a/src/selftest/cases/45-provider-profile-management.ts +++ b/src/selftest/cases/45-provider-profile-management.ts @@ -18,7 +18,7 @@ const selfTest: SelfTestCase = async (context) => { } const fakeKubectl = path.join(context.tmp, "fake-provider-kubectl.js"); - const appliedManifestPath = path.join(context.tmp, "provider-secret-apply.json"); + const patchedSecretPath = path.join(context.tmp, "provider-secret-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); @@ -32,11 +32,15 @@ if (args[0] === "get" && args[1] === "secret") { process.exit(0); } if (args[0] === "apply") { + console.error("provider credential updates must not use kubectl apply"); + process.exit(1); +} +if (args[0] === "patch" && args[1] === "secret") { const text = await readStdin(); - await Bun.write(${JSON.stringify(appliedManifestPath)}, text); - const manifest = JSON.parse(text); - const annotations = manifest.metadata?.annotations ?? {}; - console.log(JSON.stringify({ apiVersion: "v1", kind: "Secret", metadata: { name: manifest.metadata.name, namespace: manifest.metadata.namespace, resourceVersion: "rv-applied", annotations } })); + await Bun.write(${JSON.stringify(patchedSecretPath)}, JSON.stringify({ args, patch: JSON.parse(text) }, null, 2)); + const patch = JSON.parse(text); + const annotations = patch.metadata?.annotations ?? {}; + console.log(JSON.stringify({ apiVersion: "v1", kind: "Secret", metadata: { name: args[2], namespace: args[4], resourceVersion: "rv-patched", annotations } })); process.exit(0); } if (args[0] === "create") { @@ -77,16 +81,23 @@ process.exit(1); reason: "self-test", }) as JsonRecord; assert.equal(updated.profile, "deepseek"); - assert.equal(updated.resourceVersion, "rv-applied"); + assert.equal(updated.resourceVersion, "rv-patched"); assert.equal(updated.requiresExternalBridgeUpdate, true); assert.equal(JSON.stringify(updated).includes(secretText), false); assertNoSecretLeak(updated); - const manifest = JSON.parse(await readFile(appliedManifestPath, "utf8")) as JsonRecord; - const stringData = manifest.stringData as JsonRecord; - assert.equal(String(stringData["auth.json"]).includes(secretText), true); - assert.equal(String(stringData["auth.json"]).includes("OPENAI_API_KEY"), true); - assert.equal(String(stringData["config.toml"]).includes("hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local"), true); - assert.equal(String(stringData["config.toml"]).includes("hyueapi.com"), false); + const patchRecord = JSON.parse(await readFile(patchedSecretPath, "utf8")) as JsonRecord; + const patchArgs = patchRecord.args as string[]; + assert.deepEqual(patchArgs, ["patch", "secret", "agentrun-v01-provider-deepseek", "-n", "agentrun-v01", "--type", "merge", "--patch-file", "/dev/stdin", "-o", "json"]); + const secretPatch = patchRecord.patch as JsonRecord; + const annotations = (secretPatch.metadata as JsonRecord).annotations as JsonRecord; + assert.equal(annotations["kubectl.kubernetes.io/last-applied-configuration"], null); + const data = secretPatch.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"); + assert.equal(authJson.includes(secretText), true); + assert.equal(authJson.includes("OPENAI_API_KEY"), true); + assert.equal(configToml.includes("hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local"), true); + assert.equal(configToml.includes("hyueapi.com"), false); await assert.rejects( () => client.put("/api/v1/provider-profiles/deepseek/credential", { apiKey: secretText, config: { baseUrl: "https://hyueapi.com/v1" } }), @@ -111,7 +122,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-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-patch-no-last-applied", "provider-profile-deepseek-moon-bridge", "provider-profile-manager-secret-rbac", "provider-profile-validation-runner-job"] }; } finally { await new Promise((resolve) => server.server.close(() => resolve())); }