fix: upsert provider secrets without patch-file (#93)
Co-authored-by: Codex <codex@pikas.tech>
This commit is contained in:
@@ -56,8 +56,12 @@ export async function setProviderProfileCredential(profileValue: string, body: u
|
|||||||
const delegatedBy = delegatedBySummary(record.delegatedBy);
|
const delegatedBy = delegatedBySummary(record.delegatedBy);
|
||||||
const rendered = renderCredential(apiKey, await renderedConfigForWrite(profile, record, options));
|
const rendered = renderCredential(apiKey, await renderedConfigForWrite(profile, record, options));
|
||||||
const namespace = profileNamespace(options);
|
const namespace = profileNamespace(options);
|
||||||
const secretPatch: JsonRecord = {
|
const secretManifest: JsonRecord = {
|
||||||
|
apiVersion: "v1",
|
||||||
|
kind: "Secret",
|
||||||
metadata: {
|
metadata: {
|
||||||
|
name: spec.defaultSecretName,
|
||||||
|
namespace,
|
||||||
labels: {
|
labels: {
|
||||||
"app.kubernetes.io/part-of": "agentrun",
|
"app.kubernetes.io/part-of": "agentrun",
|
||||||
"agentrun.pikastech.local/profile": profile,
|
"agentrun.pikastech.local/profile": profile,
|
||||||
@@ -77,7 +81,7 @@ export async function setProviderProfileCredential(profileValue: string, body: u
|
|||||||
"config.toml": base64Data(rendered.config.configToml),
|
"config.toml": base64Data(rendered.config.configToml),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const applied = await kubectlPatchSecret(spec.defaultSecretName, namespace, secretPatch, options.kubectlCommand ?? "kubectl");
|
const applied = await kubectlReplaceOrCreateSecret(secretManifest, options.kubectlCommand ?? "kubectl");
|
||||||
return {
|
return {
|
||||||
action: "provider-profile-credential-updated",
|
action: "provider-profile-credential-updated",
|
||||||
mutation: true,
|
mutation: true,
|
||||||
@@ -419,12 +423,22 @@ async function kubectlGetSecret(name: string, namespace: string, kubectlCommand:
|
|||||||
return parseKubectlObject(result.stdout, "secret");
|
return parseKubectlObject(result.stdout, "secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function kubectlPatchSecret(name: string, namespace: string, patch: JsonRecord, kubectlCommand: string): Promise<JsonRecord> {
|
async function kubectlReplaceOrCreateSecret(manifest: JsonRecord, kubectlCommand: string): Promise<JsonRecord> {
|
||||||
const result = await runKubectl(kubectlCommand, ["patch", "secret", name, "-n", namespace, "--type", "merge", "--patch-file", "/dev/stdin", "-o", "json"], `${JSON.stringify(patch)}\n`);
|
const name = stringPath(manifest, ["metadata", "name"]) ?? "<unknown>";
|
||||||
if (result.code !== 0) {
|
const namespace = stringPath(manifest, ["metadata", "namespace"]) ?? defaultNamespace;
|
||||||
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)) }) });
|
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 });
|
||||||
|
|
||||||
|
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)) }) });
|
||||||
}
|
}
|
||||||
return parseKubectlObject(result.stdout, "patched secret", { redactSecretData: true });
|
|
||||||
|
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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runKubectl(kubectlCommand: string, args: string[], stdin?: string): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> {
|
async function runKubectl(kubectlCommand: string, args: string[], stdin?: string): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const selfTest: SelfTestCase = async (context) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fakeKubectl = path.join(context.tmp, "fake-provider-kubectl.js");
|
const fakeKubectl = path.join(context.tmp, "fake-provider-kubectl.js");
|
||||||
const patchedSecretPath = path.join(context.tmp, "provider-secret-patch.json");
|
const replacedSecretPath = path.join(context.tmp, "provider-secret-replace.json");
|
||||||
const createdJobPath = path.join(context.tmp, "provider-validation-job.json");
|
const createdJobPath = path.join(context.tmp, "provider-validation-job.json");
|
||||||
await writeFile(fakeKubectl, `#!/usr/bin/env bun
|
await writeFile(fakeKubectl, `#!/usr/bin/env bun
|
||||||
const args = Bun.argv.slice(2);
|
const args = Bun.argv.slice(2);
|
||||||
@@ -36,11 +36,15 @@ if (args[0] === "apply") {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (args[0] === "patch" && args[1] === "secret") {
|
if (args[0] === "patch" && args[1] === "secret") {
|
||||||
|
console.error("provider credential updates must not use kubectl patch --patch-file");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (args[0] === "replace") {
|
||||||
const text = await readStdin();
|
const text = await readStdin();
|
||||||
await Bun.write(${JSON.stringify(patchedSecretPath)}, JSON.stringify({ args, patch: JSON.parse(text) }, null, 2));
|
await Bun.write(${JSON.stringify(replacedSecretPath)}, JSON.stringify({ args, manifest: JSON.parse(text) }, null, 2));
|
||||||
const patch = JSON.parse(text);
|
const manifest = JSON.parse(text);
|
||||||
const annotations = patch.metadata?.annotations ?? {};
|
const annotations = manifest.metadata?.annotations ?? {};
|
||||||
console.log(JSON.stringify({ apiVersion: "v1", kind: "Secret", metadata: { name: args[2], namespace: args[4], resourceVersion: "rv-patched", annotations } }));
|
console.log(JSON.stringify({ apiVersion: "v1", kind: "Secret", metadata: { name: manifest.metadata.name, namespace: manifest.metadata.namespace, resourceVersion: "rv-replaced", annotations } }));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
if (args[0] === "create") {
|
if (args[0] === "create") {
|
||||||
@@ -81,17 +85,20 @@ process.exit(1);
|
|||||||
reason: "self-test",
|
reason: "self-test",
|
||||||
}) as JsonRecord;
|
}) as JsonRecord;
|
||||||
assert.equal(updated.profile, "deepseek");
|
assert.equal(updated.profile, "deepseek");
|
||||||
assert.equal(updated.resourceVersion, "rv-patched");
|
assert.equal(updated.resourceVersion, "rv-replaced");
|
||||||
assert.equal(updated.requiresExternalBridgeUpdate, true);
|
assert.equal(updated.requiresExternalBridgeUpdate, true);
|
||||||
assert.equal(JSON.stringify(updated).includes(secretText), false);
|
assert.equal(JSON.stringify(updated).includes(secretText), false);
|
||||||
assertNoSecretLeak(updated);
|
assertNoSecretLeak(updated);
|
||||||
const patchRecord = JSON.parse(await readFile(patchedSecretPath, "utf8")) as JsonRecord;
|
const replaceRecord = JSON.parse(await readFile(replacedSecretPath, "utf8")) as JsonRecord;
|
||||||
const patchArgs = patchRecord.args as string[];
|
const replaceArgs = replaceRecord.args as string[];
|
||||||
assert.deepEqual(patchArgs, ["patch", "secret", "agentrun-v01-provider-deepseek", "-n", "agentrun-v01", "--type", "merge", "--patch-file", "/dev/stdin", "-o", "json"]);
|
assert.deepEqual(replaceArgs, ["replace", "-f", "-", "-o", "json"]);
|
||||||
const secretPatch = patchRecord.patch as JsonRecord;
|
const secretManifest = replaceRecord.manifest as JsonRecord;
|
||||||
const annotations = (secretPatch.metadata as JsonRecord).annotations as JsonRecord;
|
assert.equal(secretManifest.kind, "Secret");
|
||||||
|
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(annotations["kubectl.kubernetes.io/last-applied-configuration"], null);
|
||||||
const data = secretPatch.data as JsonRecord;
|
const data = secretManifest.data as JsonRecord;
|
||||||
const authJson = Buffer.from(String(data["auth.json"]), "base64").toString("utf8");
|
const authJson = Buffer.from(String(data["auth.json"]), "base64").toString("utf8");
|
||||||
const configToml = Buffer.from(String(data["config.toml"]), "base64").toString("utf8");
|
const configToml = Buffer.from(String(data["config.toml"]), "base64").toString("utf8");
|
||||||
assert.equal(authJson.includes(secretText), true);
|
assert.equal(authJson.includes(secretText), true);
|
||||||
@@ -122,7 +129,7 @@ process.exit(1);
|
|||||||
assert.equal(finalValidation.status, "completed");
|
assert.equal(finalValidation.status, "completed");
|
||||||
assert.equal(JSON.stringify(finalValidation).includes(secretText), false);
|
assert.equal(JSON.stringify(finalValidation).includes(secretText), false);
|
||||||
assertNoSecretLeak(finalValidation);
|
assertNoSecretLeak(finalValidation);
|
||||||
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"] };
|
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"] };
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
await new Promise<void>((resolve) => server.server.close(() => resolve()));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user