feat: add provider profile removal

This commit is contained in:
Codex
2026-06-08 05:29:11 +08:00
parent 509c2aa6fd
commit 601d8190d0
8 changed files with 121 additions and 12 deletions
+42
View File
@@ -75,6 +75,37 @@ export async function getProviderProfileConfig(profileValue: string, options: Pr
};
}
export async function removeProviderProfile(profileValue: string, options: ProviderProfileOptions = {}): Promise<JsonRecord> {
const profile = validateBackendProfile(profileValue);
const spec = requiredSpec(profile);
const namespace = profileNamespace(options);
const secret = await kubectlGetSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl");
const data = asOptionalRecord(secret?.data);
const annotations = asOptionalRecord(asOptionalRecord(secret?.metadata)?.annotations);
if (secret) await kubectlDeleteSecret(spec.defaultSecretName, namespace, options.kubectlCommand ?? "kubectl");
return {
action: "provider-profile-removed",
mutation: true,
profile,
configured: false,
removed: Boolean(secret),
...(secret ? {} : { alreadyAbsent: true }),
...(isBuiltinProviderProfile(profile) ? { builtinCapabilityRetained: true } : {}),
secretRef: secretRefSummary(profile, namespace),
deletedResourceVersion: stringPath(secret, ["metadata", "resourceVersion"]),
credentialHashSuffix: hashDataKey(data, "auth.json") ?? stringPath(annotations, [`${credentialAnnotationPrefix}-credential-hash-suffix`]),
configHashSuffix: hashDataKey(data, "config.toml") ?? stringPath(annotations, [`${credentialAnnotationPrefix}-config-hash-suffix`]),
updatedAt: new Date().toISOString(),
valuesPrinted: false,
pollCommands: {
list: "./scripts/agentrun provider-profiles list",
show: `./scripts/agentrun provider-profiles show ${profile}`,
setKey: `./scripts/agentrun provider-profiles set-key ${profile} --key-stdin`,
setConfig: `./scripts/agentrun provider-profiles set-config ${profile} --config-stdin`,
},
};
}
export async function setProviderProfileConfig(profileValue: string, body: unknown, options: ProviderProfileOptions = {}): Promise<JsonRecord> {
const profile = validateBackendProfile(profileValue);
const spec = requiredSpec(profile);
@@ -304,6 +335,10 @@ function compareProviderProfiles(left: string, right: string): number {
return left.localeCompare(right);
}
function isBuiltinProviderProfile(profile: BackendProfile): boolean {
return backendProfileSpecs.some((item) => item.profile === profile);
}
function providerProfileFromSecret(secret: JsonRecord): BackendProfile | null {
const metadata = asOptionalRecord(secret.metadata);
const labels = asOptionalRecord(metadata?.labels);
@@ -615,6 +650,13 @@ async function kubectlUpsertSecret(manifest: JsonRecord, kubectlCommand: string)
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 kubectlDeleteSecret(name: string, namespace: string, kubectlCommand: string): Promise<void> {
const result = await runKubectl(kubectlCommand, ["delete", "secret", name, "-n", namespace, "--ignore-not-found=true"]);
if (result.code !== 0) {
throw new AgentRunError("infra-failed", `kubectl delete 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)) }) });
}
}
function isKubectlNotFoundFailure(result: { stdout: string; stderr: string }): boolean {
return /notfound|not found|not-found/iu.test(`${result.stderr}\n${result.stdout}`);
}
+2 -1
View File
@@ -14,7 +14,7 @@ import { runnerJobStatusSummary } from "./runner-job-status.js";
import { createSessionPvc, deleteSessionPvc, getSessionPvcSummary, refreshSessionPvcSummary, runSessionStorageGc } from "./session-pvc.js";
import type { SessionPvcSummary } from "./session-pvc.js";
import type { SessionPvcOptions } from "./session-pvc.js";
import { getProviderProfileConfig, getProviderProfileValidation, listProviderProfiles, setProviderProfileConfig, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js";
import { getProviderProfileConfig, getProviderProfileValidation, listProviderProfiles, removeProviderProfile, setProviderProfileConfig, setProviderProfileCredential, showProviderProfile, validateProviderProfile } from "./provider-profiles.js";
function pvcOptions(defaults: { kubectlCommand?: string } | undefined): SessionPvcOptions {
return defaults?.kubectlCommand ? { kubectlCommand: defaults.kubectlCommand } : {};
@@ -96,6 +96,7 @@ async function route({ method, url, body, store, sourceCommit, runnerJobDefaults
if (method === "GET" && path === "/api/v1/provider-profiles") return await listProviderProfiles(providerProfileDefaults) as JsonValue;
const providerProfileMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)$/u);
if (method === "GET" && providerProfileMatch) return await showProviderProfile(providerProfileMatch[1] ?? "", providerProfileDefaults) as JsonValue;
if (method === "DELETE" && providerProfileMatch) return await removeProviderProfile(providerProfileMatch[1] ?? "", providerProfileDefaults) as JsonValue;
const providerConfigMatch = path.match(/^\/api\/v1\/provider-profiles\/([^/]+)\/config$/u);
if (method === "GET" && providerConfigMatch) return await getProviderProfileConfig(providerConfigMatch[1] ?? "", providerProfileDefaults) as JsonValue;
if (method === "PUT" && providerConfigMatch) return await setProviderProfileConfig(providerConfigMatch[1] ?? "", body, providerProfileDefaults) as JsonValue;
@@ -12,7 +12,7 @@ const secretText = "sk-selftest-provider-profile-secret";
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: ["create", "get", "list", "patch", "update"]'), true);
assert.equal(gitopsRenderer.includes('verbs: ["create", "delete", "get", "list", "patch", "update"]'), true);
assert.equal(gitopsRenderer.includes('resourceNames: ["agentrun-v01-provider-codex", "agentrun-v01-provider-deepseek", "agentrun-v01-provider-minimax-m3", "agentrun-v01-provider-dsflash-go"]'), false);
assert.equal(gitopsRenderer.includes('resources: ["secrets"]'), true);
@@ -23,9 +23,11 @@ const selfTest: SelfTestCase = async (context) => {
const createdJobPath = path.join(context.tmp, "provider-validation-job.json");
const secretStateDir = path.join(context.tmp, "provider-secret-state");
await writeFile(fakeKubectl, `#!/usr/bin/env bun
import { rmSync } from "node:fs";
const args = Bun.argv.slice(2);
const secretStateDir = ${JSON.stringify(secretStateDir)};
const secretStatePath = (name) => secretStateDir + "/" + name + ".json";
const deletedMarkerPath = (name) => secretStateDir + "/" + name + ".deleted";
const fixtureSecret = (name) => ({ apiVersion: "v1", kind: "Secret", metadata: { name, namespace: "agentrun-v01", resourceVersion: "rv-selftest", creationTimestamp: "2026-06-05T00:00:00.000Z" }, data: { "auth.json": Buffer.from(JSON.stringify({ token: "redacted-fixture" })).toString("base64"), "config.toml": Buffer.from("model = \\\"fixture\\\"\\n").toString("base64") } });
const readStdin = async () => {
const chunks = [];
@@ -34,7 +36,12 @@ const readStdin = async () => {
};
if (args[0] === "get" && args[1] === "secret") {
const name = args[2];
const deletedMarker = Bun.file(deletedMarkerPath(name));
const stateFile = Bun.file(secretStatePath(name));
if (await deletedMarker.exists()) {
console.error('Error from server (NotFound): secrets "' + name + '" not found');
process.exit(1);
}
if (await stateFile.exists()) {
console.log(await stateFile.text());
process.exit(0);
@@ -49,11 +56,14 @@ if (args[0] === "get" && args[1] === "secret") {
process.exit(0);
}
if (args[0] === "get" && args[1] === "secrets") {
const items = [fixtureSecret("agentrun-v01-provider-codex"), fixtureSecret("agentrun-v01-provider-deepseek"), fixtureSecret("agentrun-v01-provider-minimax-m3")];
const items = [];
if (!(await Bun.file(deletedMarkerPath("agentrun-v01-provider-codex")).exists())) items.push(fixtureSecret("agentrun-v01-provider-codex"));
if (!(await Bun.file(deletedMarkerPath("agentrun-v01-provider-deepseek")).exists())) items.push(fixtureSecret("agentrun-v01-provider-deepseek"));
if (!(await Bun.file(deletedMarkerPath("agentrun-v01-provider-minimax-m3")).exists())) items.push(fixtureSecret("agentrun-v01-provider-minimax-m3"));
const dsflashState = Bun.file(secretStatePath("agentrun-v01-provider-dsflash-go"));
if (await dsflashState.exists()) items.push(JSON.parse(await dsflashState.text()));
if (!(await Bun.file(deletedMarkerPath("agentrun-v01-provider-dsflash-go")).exists()) && await dsflashState.exists()) items.push(JSON.parse(await dsflashState.text()));
const dynamicState = Bun.file(secretStatePath("agentrun-v01-provider-dsflash-go-cli-selftest"));
if (await dynamicState.exists()) items.push(JSON.parse(await dynamicState.text()));
if (!(await Bun.file(deletedMarkerPath("agentrun-v01-provider-dsflash-go-cli-selftest")).exists()) && await dynamicState.exists()) items.push(JSON.parse(await dynamicState.text()));
console.log(JSON.stringify({ apiVersion: "v1", kind: "SecretList", items }));
process.exit(0);
}
@@ -79,6 +89,7 @@ if (args[0] === "patch" && args[1] === "secret") {
if (args[0] === "replace") {
const text = await readStdin();
const manifest = JSON.parse(text);
try { rmSync(deletedMarkerPath(manifest.metadata.name)); } catch {}
const stateFile = Bun.file(secretStatePath(manifest.metadata.name));
if (!(await stateFile.exists())) {
console.error('Error from server (NotFound): secrets "' + manifest.metadata.name + '" not found');
@@ -94,6 +105,7 @@ if (args[0] === "create") {
const text = await readStdin();
const manifest = JSON.parse(text);
if (manifest.kind === "Secret") {
try { rmSync(deletedMarkerPath(manifest.metadata.name)); } catch {}
const annotations = manifest.metadata?.annotations ?? {};
await Bun.write(${JSON.stringify(createdSecretPath)}, JSON.stringify({ args, manifest }, null, 2));
await Bun.write(secretStatePath(manifest.metadata.name), JSON.stringify({ ...manifest, metadata: { ...(manifest.metadata ?? {}), resourceVersion: "rv-created", annotations } }));
@@ -104,6 +116,13 @@ if (args[0] === "create") {
console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kind, metadata: { uid: "job-provider-validation", resourceVersion: "rv-job", name: manifest.metadata.name, namespace: manifest.metadata.namespace } }));
process.exit(0);
}
if (args[0] === "delete" && args[1] === "secret") {
const name = args[2];
try { rmSync(secretStatePath(name)); } catch {}
await Bun.write(deletedMarkerPath(name), "deleted\n");
console.log(JSON.stringify({ kind: "Status", status: "Success", details: { name } }));
process.exit(0);
}
console.error("unsupported fake kubectl args: " + JSON.stringify(args));
process.exit(1);
`);
@@ -256,6 +275,30 @@ process.exit(1);
assert.equal(dynamicListItems.some((item) => item.profile === dynamicProfile), true);
assertNoSecretLeak(dynamicCredential);
const removedDeepseek = await client.delete("/api/v1/provider-profiles/deepseek") as JsonRecord;
assert.equal(removedDeepseek.profile, "deepseek");
assert.equal(removedDeepseek.removed, true);
assert.equal(removedDeepseek.builtinCapabilityRetained, true);
assertNoSecretLeak(removedDeepseek);
const deepseekShownAfterRemove = await client.get("/api/v1/provider-profiles/deepseek") as JsonRecord;
assert.equal(deepseekShownAfterRemove.configured, false);
assert.equal(deepseekShownAfterRemove.failureKind, "secret-unavailable");
const listAfterBuiltinRemove = await client.get("/api/v1/provider-profiles") as JsonRecord;
assert.equal(listAfterBuiltinRemove.count, 5);
const deepseekAfterRemove = ((listAfterBuiltinRemove.items as JsonRecord[]) ?? []).find((item) => item.profile === "deepseek") as JsonRecord | undefined;
assert.equal(deepseekAfterRemove?.configured, false);
assert.equal(deepseekAfterRemove?.failureKind, "secret-unavailable");
const removedDynamic = await client.delete(`/api/v1/provider-profiles/${encodeURIComponent(dynamicProfile)}`) as JsonRecord;
assert.equal(removedDynamic.profile, dynamicProfile);
assert.equal(removedDynamic.removed, true);
assert.equal(removedDynamic.builtinCapabilityRetained, undefined);
assertNoSecretLeak(removedDynamic);
const listAfterDynamicRemove = await client.get("/api/v1/provider-profiles") as JsonRecord;
assert.equal(listAfterDynamicRemove.count, 4);
const itemsAfterDynamicRemove = (listAfterDynamicRemove.items as JsonRecord[]) ?? [];
assert.equal(itemsAfterDynamicRemove.some((item) => item.profile === dynamicProfile), false);
await assert.rejects(
() => client.put("/api/v1/provider-profiles/deepseek/credential", { apiKey: secretText, config: { baseUrl: "https://hyueapi.com/v1" } }),
(error) => error instanceof Error && error.message.includes("not hyueapi.com"),
@@ -279,7 +322,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-config", "provider-profile-set-key-redacted", "provider-profile-secret-replace-annotation-cleanup", "provider-profile-secret-create-upsert", "provider-profile-config-only-create", "provider-profile-dynamic-slug-roundtrip", "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-config", "provider-profile-set-key-redacted", "provider-profile-secret-replace-annotation-cleanup", "provider-profile-secret-create-upsert", "provider-profile-config-only-create", "provider-profile-dynamic-slug-roundtrip", "provider-profile-remove-builtin", "provider-profile-remove-dynamic-slug", "provider-profile-deepseek-moon-bridge", "provider-profile-manager-secret-rbac", "provider-profile-validation-runner-job"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}