支持 provider profile Secret 首次创建
This commit is contained in:
@@ -368,7 +368,7 @@ rules:
|
|||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: ["secrets"]
|
resources: ["secrets"]
|
||||||
resourceNames: ["agentrun-v01-provider-codex", "agentrun-v01-provider-deepseek", "agentrun-v01-provider-minimax-m3", "agentrun-v01-provider-dsflash-go"]
|
resourceNames: ["agentrun-v01-provider-codex", "agentrun-v01-provider-deepseek", "agentrun-v01-provider-minimax-m3", "agentrun-v01-provider-dsflash-go"]
|
||||||
verbs: ["get", "patch", "update"]
|
verbs: ["create", "get", "patch", "update"]
|
||||||
---
|
---
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
kind: RoleBinding
|
kind: RoleBinding
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export async function setProviderProfileConfig(profileValue: string, body: unkno
|
|||||||
"config.toml": base64Data(configToml),
|
"config.toml": base64Data(configToml),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const applied = await kubectlReplaceSecret(secretManifest, options.kubectlCommand ?? "kubectl");
|
const applied = await kubectlUpsertSecret(secretManifest, options.kubectlCommand ?? "kubectl");
|
||||||
return {
|
return {
|
||||||
action: "provider-profile-config-updated",
|
action: "provider-profile-config-updated",
|
||||||
mutation: true,
|
mutation: true,
|
||||||
@@ -167,7 +167,7 @@ export async function setProviderProfileCredential(profileValue: string, body: u
|
|||||||
"config.toml": base64Data(rendered.config.configToml),
|
"config.toml": base64Data(rendered.config.configToml),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const applied = await kubectlReplaceSecret(secretManifest, options.kubectlCommand ?? "kubectl");
|
const applied = await kubectlUpsertSecret(secretManifest, options.kubectlCommand ?? "kubectl");
|
||||||
return {
|
return {
|
||||||
action: "provider-profile-credential-updated",
|
action: "provider-profile-credential-updated",
|
||||||
mutation: true,
|
mutation: true,
|
||||||
@@ -543,14 +543,24 @@ async function kubectlGetSecret(name: string, namespace: string, kubectlCommand:
|
|||||||
return parseKubectlObject(result.stdout, "secret");
|
return parseKubectlObject(result.stdout, "secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function kubectlReplaceSecret(manifest: JsonRecord, kubectlCommand: string): Promise<JsonRecord> {
|
async function kubectlUpsertSecret(manifest: JsonRecord, kubectlCommand: string): Promise<JsonRecord> {
|
||||||
const name = stringPath(manifest, ["metadata", "name"]) ?? "<unknown>";
|
const name = stringPath(manifest, ["metadata", "name"]) ?? "<unknown>";
|
||||||
const namespace = stringPath(manifest, ["metadata", "namespace"]) ?? defaultNamespace;
|
const namespace = stringPath(manifest, ["metadata", "namespace"]) ?? defaultNamespace;
|
||||||
const replace = await runKubectl(kubectlCommand, ["replace", "-f", "-", "-o", "json"], `${JSON.stringify(manifest)}\n`);
|
const stdin = `${JSON.stringify(manifest)}\n`;
|
||||||
|
const replace = await runKubectl(kubectlCommand, ["replace", "-f", "-", "-o", "json"], stdin);
|
||||||
if (replace.code === 0) return await kubectlRemoveLastAppliedAnnotation(kubectlCommand, name, namespace);
|
if (replace.code === 0) return await kubectlRemoveLastAppliedAnnotation(kubectlCommand, name, namespace);
|
||||||
|
if (isKubectlNotFoundFailure(replace)) {
|
||||||
|
const created = await runKubectl(kubectlCommand, ["create", "-f", "-", "-o", "json"], stdin);
|
||||||
|
if (created.code === 0) return parseKubectlObject(created.stdout, "provider profile secret create", { redactSecretData: true });
|
||||||
|
throw new AgentRunError("infra-failed", `kubectl create provider profile secret ${namespace}/${name} failed with code ${created.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(created.stderr.slice(-2000)), stdout: redactText(created.stdout.slice(-1000)) }) });
|
||||||
|
}
|
||||||
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)) }) });
|
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)) }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isKubectlNotFoundFailure(result: { stdout: string; stderr: string }): boolean {
|
||||||
|
return /notfound|not found|not-found/iu.test(`${result.stderr}\n${result.stdout}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function kubectlRemoveLastAppliedAnnotation(kubectlCommand: string, name: string, namespace: string): Promise<JsonRecord> {
|
async function kubectlRemoveLastAppliedAnnotation(kubectlCommand: string, name: string, namespace: string): Promise<JsonRecord> {
|
||||||
const patch = { metadata: { annotations: { [kubectlLastAppliedAnnotation]: null } } };
|
const patch = { metadata: { annotations: { [kubectlLastAppliedAnnotation]: null } } };
|
||||||
const result = await runKubectl(kubectlCommand, ["patch", "secret", name, "-n", namespace, "--type", "merge", "-p", JSON.stringify(patch), "-o", "json"]);
|
const result = await runKubectl(kubectlCommand, ["patch", "secret", name, "-n", namespace, "--type", "merge", "-p", JSON.stringify(patch), "-o", "json"]);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const secretText = "sk-selftest-provider-profile-secret";
|
|||||||
const selfTest: SelfTestCase = async (context) => {
|
const selfTest: SelfTestCase = async (context) => {
|
||||||
const gitopsRenderer = await readFile(path.join(context.root, "scripts/src/gitops-render.ts"), "utf8");
|
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("agentrun-v01-mgr-provider-secret-manager"), true);
|
||||||
assert.equal(gitopsRenderer.includes('verbs: ["get", "patch", "update"]'), true);
|
assert.equal(gitopsRenderer.includes('verbs: ["create", "get", "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"]'), true);
|
assert.equal(gitopsRenderer.includes('resourceNames: ["agentrun-v01-provider-codex", "agentrun-v01-provider-deepseek", "agentrun-v01-provider-minimax-m3", "agentrun-v01-provider-dsflash-go"]'), true);
|
||||||
for (const profile of ["codex", "deepseek", "minimax-m3", "dsflash-go"]) {
|
for (const profile of ["codex", "deepseek", "minimax-m3", "dsflash-go"]) {
|
||||||
assert.equal(gitopsRenderer.includes(`agentrun-v01-provider-${profile}`), true);
|
assert.equal(gitopsRenderer.includes(`agentrun-v01-provider-${profile}`), true);
|
||||||
@@ -20,17 +20,32 @@ 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 replacedSecretPath = path.join(context.tmp, "provider-secret-replace.json");
|
const replacedSecretPath = path.join(context.tmp, "provider-secret-replace.json");
|
||||||
|
const createdSecretPath = path.join(context.tmp, "provider-secret-create.json");
|
||||||
const cleanupPatchPath = path.join(context.tmp, "provider-secret-cleanup-patch.json");
|
const cleanupPatchPath = path.join(context.tmp, "provider-secret-cleanup-patch.json");
|
||||||
const createdJobPath = path.join(context.tmp, "provider-validation-job.json");
|
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
|
await writeFile(fakeKubectl, `#!/usr/bin/env bun
|
||||||
const args = Bun.argv.slice(2);
|
const args = Bun.argv.slice(2);
|
||||||
|
const secretStateDir = ${JSON.stringify(secretStateDir)};
|
||||||
|
const secretStatePath = (name) => secretStateDir + "/" + name + ".json";
|
||||||
|
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 readStdin = async () => {
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
for await (const chunk of Bun.stdin.stream()) chunks.push(Buffer.from(chunk));
|
for await (const chunk of Bun.stdin.stream()) chunks.push(Buffer.from(chunk));
|
||||||
return Buffer.concat(chunks).toString("utf8");
|
return Buffer.concat(chunks).toString("utf8");
|
||||||
};
|
};
|
||||||
if (args[0] === "get" && args[1] === "secret") {
|
if (args[0] === "get" && args[1] === "secret") {
|
||||||
console.log(JSON.stringify({ apiVersion: "v1", kind: "Secret", metadata: { name: args[2], 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 name = args[2];
|
||||||
|
const stateFile = Bun.file(secretStatePath(name));
|
||||||
|
if (await stateFile.exists()) {
|
||||||
|
console.log(await stateFile.text());
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
if (name === "agentrun-v01-provider-dsflash-go") {
|
||||||
|
console.error('Error from server (NotFound): secrets "' + name + '" not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(fixtureSecret(name)));
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
if (args[0] === "apply") {
|
if (args[0] === "apply") {
|
||||||
@@ -54,16 +69,31 @@ if (args[0] === "patch" && args[1] === "secret") {
|
|||||||
}
|
}
|
||||||
if (args[0] === "replace") {
|
if (args[0] === "replace") {
|
||||||
const text = await readStdin();
|
const text = await readStdin();
|
||||||
await Bun.write(${JSON.stringify(replacedSecretPath)}, JSON.stringify({ args, manifest: JSON.parse(text) }, null, 2));
|
|
||||||
const manifest = JSON.parse(text);
|
const manifest = JSON.parse(text);
|
||||||
|
if (manifest.metadata?.name === "agentrun-v01-provider-dsflash-go") {
|
||||||
|
const stateFile = Bun.file(secretStatePath(manifest.metadata.name));
|
||||||
|
if (!(await stateFile.exists())) {
|
||||||
|
console.error('Error from server (NotFound): secrets "' + manifest.metadata.name + '" not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Bun.write(${JSON.stringify(replacedSecretPath)}, JSON.stringify({ args, manifest }, null, 2));
|
||||||
const annotations = manifest.metadata?.annotations ?? {};
|
const annotations = manifest.metadata?.annotations ?? {};
|
||||||
|
await Bun.write(secretStatePath(manifest.metadata.name), JSON.stringify({ ...manifest, metadata: { ...(manifest.metadata ?? {}), resourceVersion: "rv-replaced", annotations } }));
|
||||||
console.log(JSON.stringify({ apiVersion: "v1", kind: "Secret", metadata: { name: manifest.metadata.name, namespace: manifest.metadata.namespace, resourceVersion: "rv-replaced", 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") {
|
||||||
const text = await readStdin();
|
const text = await readStdin();
|
||||||
await Bun.write(${JSON.stringify(createdJobPath)}, text);
|
|
||||||
const manifest = JSON.parse(text);
|
const manifest = JSON.parse(text);
|
||||||
|
if (manifest.kind === "Secret") {
|
||||||
|
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 } }));
|
||||||
|
console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kind, metadata: { name: manifest.metadata.name, namespace: manifest.metadata.namespace, resourceVersion: "rv-created", annotations } }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
await Bun.write(${JSON.stringify(createdJobPath)}, text);
|
||||||
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 } }));
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
@@ -91,6 +121,10 @@ process.exit(1);
|
|||||||
assert.equal(list.count, 4);
|
assert.equal(list.count, 4);
|
||||||
assert.equal(JSON.stringify(list).includes("auth.json"), true);
|
assert.equal(JSON.stringify(list).includes("auth.json"), true);
|
||||||
assert.equal(JSON.stringify(list).includes("redacted-fixture"), false);
|
assert.equal(JSON.stringify(list).includes("redacted-fixture"), false);
|
||||||
|
const listItems = (list.items as JsonRecord[]) ?? [];
|
||||||
|
const dsflashStatus = listItems.find((item) => item.profile === "dsflash-go") as JsonRecord | undefined;
|
||||||
|
assert.equal(dsflashStatus?.configured, false);
|
||||||
|
assert.equal(dsflashStatus?.failureKind, "secret-unavailable");
|
||||||
|
|
||||||
const config = await client.get("/api/v1/provider-profiles/deepseek/config") as JsonRecord;
|
const config = await client.get("/api/v1/provider-profiles/deepseek/config") as JsonRecord;
|
||||||
assert.equal(config.profile, "deepseek");
|
assert.equal(config.profile, "deepseek");
|
||||||
@@ -145,6 +179,31 @@ process.exit(1);
|
|||||||
assert.equal(configToml.includes("hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local"), true);
|
assert.equal(configToml.includes("hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local"), true);
|
||||||
assert.equal(configToml.includes("hyueapi.com"), false);
|
assert.equal(configToml.includes("hyueapi.com"), false);
|
||||||
|
|
||||||
|
const dsflashCreated = await client.put("/api/v1/provider-profiles/dsflash-go/credential", {
|
||||||
|
apiKey: secretText,
|
||||||
|
delegatedBy: { system: "hwlab-v02", userId: "u2", username: "tester2", requestId: "req-dsflash-create-selftest" },
|
||||||
|
reason: "self-test-dsflash-create",
|
||||||
|
}) as JsonRecord;
|
||||||
|
assert.equal(dsflashCreated.profile, "dsflash-go");
|
||||||
|
assert.equal(dsflashCreated.resourceVersion, "rv-created");
|
||||||
|
assert.equal(dsflashCreated.requiresExternalBridgeUpdate, true);
|
||||||
|
const createRecord = JSON.parse(await readFile(createdSecretPath, "utf8")) as JsonRecord;
|
||||||
|
const createArgs = createRecord.args as string[];
|
||||||
|
assert.deepEqual(createArgs, ["create", "-f", "-", "-o", "json"]);
|
||||||
|
const createdSecretManifest = createRecord.manifest as JsonRecord;
|
||||||
|
assert.equal(((createdSecretManifest.metadata as JsonRecord).name), "agentrun-v01-provider-dsflash-go");
|
||||||
|
const createdData = createdSecretManifest.data as JsonRecord;
|
||||||
|
const createdAuthJson = Buffer.from(String(createdData["auth.json"]), "base64").toString("utf8");
|
||||||
|
const createdConfigToml = Buffer.from(String(createdData["config.toml"]), "base64").toString("utf8");
|
||||||
|
assert.equal(createdAuthJson.includes(secretText), true);
|
||||||
|
assert.equal(createdAuthJson.includes("OPENAI_API_KEY"), true);
|
||||||
|
assert.equal(createdConfigToml.includes("deepseek-v4-flash"), true);
|
||||||
|
assert.equal(createdConfigToml.includes("hwlab-deepseek-proxy.hwlab-v02.svc.cluster.local"), true);
|
||||||
|
const dsflashShown = await client.get("/api/v1/provider-profiles/dsflash-go") as JsonRecord;
|
||||||
|
assert.equal(dsflashShown.configured, true);
|
||||||
|
assert.equal(dsflashShown.failureKind, null);
|
||||||
|
assertNoSecretLeak(dsflashCreated);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => client.put("/api/v1/provider-profiles/deepseek/credential", { apiKey: secretText, config: { baseUrl: "https://hyueapi.com/v1" } }),
|
() => 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"),
|
(error) => error instanceof Error && error.message.includes("not hyueapi.com"),
|
||||||
@@ -168,7 +227,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-config", "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"] };
|
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-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