fix: gate baidu netdisk artifact deploy auth
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { runCommand, type CommandResult } from "./command";
|
||||
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "./config";
|
||||
import { resolveComposeCommand, writeComposeEnv } from "./docker";
|
||||
import { startJob } from "./jobs";
|
||||
|
||||
type ArtifactRegistryAction = "plan" | "render" | "status" | "health" | "install" | "deploy-backend-core" | "deploy-service";
|
||||
@@ -118,6 +118,8 @@ interface ArtifactConsumerTarget {
|
||||
workDir?: string;
|
||||
composeFile?: string;
|
||||
projectHint?: string;
|
||||
requiredRuntimeSecrets?: RuntimeSecretRequirement[];
|
||||
authHealthGate?: AuthHealthGate;
|
||||
};
|
||||
k3s?: {
|
||||
namespace: string;
|
||||
@@ -133,10 +135,46 @@ interface ArtifactConsumerTarget {
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuntimeSecretRequirement {
|
||||
sourceEnvName: string;
|
||||
containerEnvName: string;
|
||||
}
|
||||
|
||||
interface AuthHealthGate {
|
||||
requiredFields: string[];
|
||||
recoveryHint: string;
|
||||
}
|
||||
|
||||
interface ComposeArtifactRuntime {
|
||||
workDir: string;
|
||||
composeFile: string;
|
||||
envFile: string;
|
||||
project: string;
|
||||
command: string[];
|
||||
}
|
||||
|
||||
export interface RuntimeSecretPresence {
|
||||
sourceEnvName: string;
|
||||
containerEnvName: string;
|
||||
present: boolean;
|
||||
length: number;
|
||||
}
|
||||
|
||||
function todoNoteHealthProbeCommand(): string {
|
||||
return "bun -e \"fetch('http://127.0.0.1:4211/api/health').then(async r=>{const text=await r.text(); let body; try{body=JSON.parse(text)}catch{body={ok:r.ok,raw:text}}; const deploy=body.deploy&&typeof body.deploy==='object'&&!Array.isArray(body.deploy)?body.deploy:{}; body.deploy={...deploy,serviceId:deploy.serviceId||process.env.UNIDESK_DEPLOY_SERVICE_ID||'todo-note',ref:deploy.ref||process.env.UNIDESK_DEPLOY_REF||'',repo:deploy.repo||process.env.UNIDESK_DEPLOY_REPO||'',commit:deploy.commit||process.env.UNIDESK_DEPLOY_COMMIT||'',requestedCommit:deploy.requestedCommit||process.env.UNIDESK_DEPLOY_REQUESTED_COMMIT||''}; console.log(JSON.stringify(body)); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"";
|
||||
}
|
||||
|
||||
export const baiduNetdiskRuntimeSecretRequirements: RuntimeSecretRequirement[] = [
|
||||
{ sourceEnvName: "UNIDESK_BAIDU_NETDISK_CLIENT_ID", containerEnvName: "BAIDU_NETDISK_CLIENT_ID" },
|
||||
{ sourceEnvName: "UNIDESK_BAIDU_NETDISK_CLIENT_SECRET", containerEnvName: "BAIDU_NETDISK_CLIENT_SECRET" },
|
||||
{ sourceEnvName: "UNIDESK_BAIDU_NETDISK_TOKEN_KEY", containerEnvName: "BAIDU_NETDISK_TOKEN_KEY" },
|
||||
];
|
||||
|
||||
const baiduNetdiskAuthHealthGate: AuthHealthGate = {
|
||||
requiredFields: ["configured", "clientIdConfigured", "clientSecretConfigured", "tokenKeyConfigured", "loggedIn"],
|
||||
recoveryHint: "Restore UNIDESK_BAIDU_NETDISK_CLIENT_ID, UNIDESK_BAIDU_NETDISK_CLIENT_SECRET, and UNIDESK_BAIDU_NETDISK_TOKEN_KEY in the canonical .state/docker-compose.env, recreate only baidu-netdisk with the canonical env file, then verify microservice health baidu-netdisk.",
|
||||
};
|
||||
|
||||
const artifactConsumerSpecs: Record<string, ArtifactConsumerSpec> = {
|
||||
"backend-core": {
|
||||
serviceId: "backend-core",
|
||||
@@ -178,6 +216,8 @@ const artifactConsumerSpecs: Record<string, ArtifactConsumerSpec> = {
|
||||
deployEnvPrefix: "UNIDESK_BAIDU_NETDISK_DEPLOY",
|
||||
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4244/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
|
||||
requireHealthCommit: true,
|
||||
requiredRuntimeSecrets: baiduNetdiskRuntimeSecretRequirements,
|
||||
authHealthGate: baiduNetdiskAuthHealthGate,
|
||||
},
|
||||
},
|
||||
prod: {
|
||||
@@ -190,6 +230,8 @@ const artifactConsumerSpecs: Record<string, ArtifactConsumerSpec> = {
|
||||
deployEnvPrefix: "UNIDESK_BAIDU_NETDISK_DEPLOY",
|
||||
healthProbeCommand: "bun -e \"fetch('http://127.0.0.1:4244/health').then(async r=>{const text=await r.text(); console.log(text); process.exit(r.ok?0:1)}).catch(e=>{console.error(e); process.exit(1)})\"",
|
||||
requireHealthCommit: true,
|
||||
requiredRuntimeSecrets: baiduNetdiskRuntimeSecretRequirements,
|
||||
authHealthGate: baiduNetdiskAuthHealthGate,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1347,6 +1389,95 @@ function composeLockScript(innerScript: string): string {
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function parseEnvFile(raw: string): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const line of raw.split(/\r?\n/u)) {
|
||||
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
||||
const index = line.indexOf("=");
|
||||
if (index <= 0) continue;
|
||||
const key = line.slice(0, index);
|
||||
let value = line.slice(index + 1);
|
||||
if (value.startsWith("\"") && value.endsWith("\"")) {
|
||||
try {
|
||||
value = JSON.parse(value) as string;
|
||||
} catch {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
}
|
||||
values[key] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function runtimeSecretPresenceFromEnvText(envText: string, requirements: RuntimeSecretRequirement[]): RuntimeSecretPresence[] {
|
||||
const values = parseEnvFile(envText);
|
||||
return requirements.map((requirement) => {
|
||||
const value = values[requirement.sourceEnvName] ?? "";
|
||||
return {
|
||||
sourceEnvName: requirement.sourceEnvName,
|
||||
containerEnvName: requirement.containerEnvName,
|
||||
present: value.length > 0,
|
||||
length: value.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function baiduNetdiskAuthHealthGateStatus(health: unknown): { ok: boolean; requiredFields: string[]; failedFields: string[] } {
|
||||
const record = typeof health === "object" && health !== null && !Array.isArray(health) ? health as Record<string, unknown> : {};
|
||||
const auth = typeof record.auth === "object" && record.auth !== null && !Array.isArray(record.auth) ? record.auth as Record<string, unknown> : {};
|
||||
const failedFields = baiduNetdiskAuthHealthGate.requiredFields.filter((field) => auth[field] !== true);
|
||||
return {
|
||||
ok: failedFields.length === 0,
|
||||
requiredFields: baiduNetdiskAuthHealthGate.requiredFields,
|
||||
failedFields,
|
||||
};
|
||||
}
|
||||
|
||||
function composeArtifactRuntime(config: UniDeskConfig, target: ArtifactConsumerTarget): ComposeArtifactRuntime {
|
||||
if (target.compose === undefined) throw new Error("compose artifact target missing compose config");
|
||||
const compose = target.compose;
|
||||
const workDir = compose.workDir ?? config.providerGateway.upgrade.hostProjectRoot;
|
||||
const composeFile = compose.composeFile ?? config.providerGateway.upgrade.composeFile;
|
||||
const envFile = join(workDir, config.providerGateway.upgrade.composeEnvFile);
|
||||
const project = compose.projectHint ?? (config.providerGateway.upgrade.composeProject || config.docker.projectName);
|
||||
const command = ["docker", "compose", "--env-file", envFile, "-f", join(workDir, composeFile), "-p", project];
|
||||
return { workDir, composeFile, envFile, project, command };
|
||||
}
|
||||
|
||||
function composeArtifactSecretPreflight(envFile: string, requirements: RuntimeSecretRequirement[] | undefined): { ok: boolean; envFile: string; requirements: RuntimeSecretPresence[]; missing: RuntimeSecretPresence[]; valuesLogged: false } {
|
||||
const requirementList = requirements ?? [];
|
||||
const envText = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
|
||||
const presence = runtimeSecretPresenceFromEnvText(envText, requirementList);
|
||||
const missing = presence.filter((item) => !item.present);
|
||||
return {
|
||||
ok: requirementList.length === 0 || (existsSync(envFile) && missing.length === 0),
|
||||
envFile,
|
||||
requirements: presence,
|
||||
missing,
|
||||
valuesLogged: false,
|
||||
};
|
||||
}
|
||||
|
||||
function authHealthGateScript(gate: AuthHealthGate | undefined, healthJsonShellArg: string): string[] {
|
||||
if (gate === undefined) return [];
|
||||
return [
|
||||
`auth_health_gate_fields=${shellQuote(gate.requiredFields.join(","))}`,
|
||||
`python3 - ${healthJsonShellArg} "$auth_health_gate_fields" <<'PY'`,
|
||||
"import json, sys",
|
||||
"path, fields_text = sys.argv[1:]",
|
||||
"fields = [item for item in fields_text.split(',') if item]",
|
||||
"body = json.load(open(path, encoding='utf-8'))",
|
||||
"auth = body.get('auth') if isinstance(body, dict) else None",
|
||||
"auth = auth if isinstance(auth, dict) else {}",
|
||||
"failed = [field for field in fields if auth.get(field) is not True]",
|
||||
"if failed:",
|
||||
" print('baidu_netdisk_auth_health_gate_failed=' + ','.join(failed))",
|
||||
" raise SystemExit(1)",
|
||||
"print('baidu_netdisk_auth_health_gate=ok')",
|
||||
"PY",
|
||||
];
|
||||
}
|
||||
|
||||
function upsertEnvFileValues(path: string, values: Record<string, string>): void {
|
||||
const existing = readFileSync(path, "utf8");
|
||||
const seen = new Set<string>();
|
||||
@@ -1469,6 +1600,24 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec:
|
||||
const commit = options.commit;
|
||||
if (commit === null) throw new Error("artifact-registry deploy-service requires --commit <full-sha>");
|
||||
if (target.compose === undefined) throw new Error(`${spec.serviceId} missing compose artifact consumer config`);
|
||||
const config = readConfig();
|
||||
const runtime = composeArtifactRuntime(config, target);
|
||||
const secretPreflight = composeArtifactSecretPreflight(runtime.envFile, target.compose.requiredRuntimeSecrets);
|
||||
if (!secretPreflight.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
supported: true,
|
||||
serviceId: spec.serviceId,
|
||||
step: "runtime-secret-preflight",
|
||||
error: "runtime-secret-presence-missing",
|
||||
environment: options.environment ?? "prod",
|
||||
envFile: runtime.envFile,
|
||||
requirements: secretPreflight.requirements,
|
||||
missing: secretPreflight.missing,
|
||||
valuesLogged: false,
|
||||
recoveryHint: target.compose.authHealthGate?.recoveryHint ?? "Restore the required runtime secrets in the canonical Compose env file, then rerun artifact deploy.",
|
||||
};
|
||||
}
|
||||
const health = runReadonlyStatus(options, true);
|
||||
if (health.ok !== true) {
|
||||
return { ok: false, serviceId: spec.serviceId, error: "D601 artifact registry is not healthy", health };
|
||||
@@ -1509,14 +1658,11 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec:
|
||||
if (tagCommit.exitCode !== 0 || tagCommit.timedOut) {
|
||||
return { ok: false, step: "docker-tag-commit", targetImage: commitImage, tag: commandTail(tagCommit), registryProbe: commandTail(registryProbe) };
|
||||
}
|
||||
const config = readConfig();
|
||||
const runtimeEnv = writeComposeEnv(config, false);
|
||||
upsertEnvFileValues(runtimeEnv.envFile, {
|
||||
upsertEnvFileValues(runtime.envFile, {
|
||||
...composeArtifactEnvValues(spec, target, options, commit),
|
||||
});
|
||||
const compose = resolveComposeCommand(config, runtimeEnv.envFile);
|
||||
const projectIndex = compose.indexOf("-p");
|
||||
const composeProject = projectIndex >= 0 && compose[projectIndex + 1] !== undefined ? compose[projectIndex + 1] : "unidesk";
|
||||
const compose = runtime.command;
|
||||
const composeProject = runtime.project;
|
||||
const serviceName = target.compose.serviceName;
|
||||
const containerName = target.compose.containerName;
|
||||
const serviceLogPrefix = spec.serviceId.replace(/-/gu, "_");
|
||||
@@ -1544,6 +1690,7 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec:
|
||||
`health_json=/tmp/unidesk-${safeName(spec.serviceId)}-health.json`,
|
||||
`docker exec "$cid" sh -lc ${shellQuote(target.compose.healthProbeCommand)} > "$health_json"`,
|
||||
"cat \"$health_json\"",
|
||||
...authHealthGateScript(target.compose.authHealthGate, "\"$health_json\""),
|
||||
...(target.compose.requireHealthCommit ? [
|
||||
"health_commit=$(python3 -c 'import json,sys; print(((json.load(open(sys.argv[1])).get(\"deploy\") or {}).get(\"commit\") or \"\"))' \"$health_json\")",
|
||||
"health_requested_commit=$(python3 -c 'import json,sys; print(((json.load(open(sys.argv[1])).get(\"deploy\") or {}).get(\"requestedCommit\") or \"\"))' \"$health_json\")",
|
||||
@@ -1560,7 +1707,9 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec:
|
||||
sourceImage,
|
||||
targetImage: composeImage,
|
||||
registryProbe: commandTail(registryProbe),
|
||||
runtimeSecretPreflight: secretPreflight,
|
||||
deploy: commandTail(deploy),
|
||||
recoveryHint: target.compose.authHealthGate?.recoveryHint ?? undefined,
|
||||
};
|
||||
}
|
||||
const running = runCommand(["docker", "inspect", containerName, "--format", "image={{.Config.Image}} imageId={{.Image}} status={{.State.Status}} health={{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}"], repoRoot);
|
||||
@@ -1584,6 +1733,11 @@ async function deployComposeArtifactNow(options: ArtifactRegistryOptions, spec:
|
||||
imageLabelCommit: commit,
|
||||
serviceHealthCommit: target.compose.requireHealthCommit ? commit : "not-required",
|
||||
serviceHealthRequestedCommit: target.compose.requireHealthCommit ? commit : "not-required",
|
||||
requiredRuntimeSecrets: secretPreflight.requirements,
|
||||
authHealthGate: target.compose.authHealthGate === undefined ? "not-required" : {
|
||||
requiredFields: target.compose.authHealthGate.requiredFields,
|
||||
passed: true,
|
||||
},
|
||||
healthyOldVersionAccepted: false,
|
||||
},
|
||||
rollback: {
|
||||
@@ -1696,6 +1850,7 @@ function d601ComposeArtifactDeployScript(options: ArtifactRegistryOptions, spec:
|
||||
"health_probe=$(printf '%s' \"$health_probe_b64\" | base64 -d)",
|
||||
"docker exec \"$cid\" sh -lc \"$health_probe\" >/tmp/unidesk-artifact-health.out",
|
||||
"cat /tmp/unidesk-artifact-health.out",
|
||||
...authHealthGateScript(compose.authHealthGate, shellQuote("/tmp/unidesk-artifact-health.out")),
|
||||
"printf 'artifact_cd_service=%s\\nartifact_cd_source_repo=%s\\nartifact_cd_deploy_ref=%s\\nartifact_cd_source_image=%s\\nartifact_cd_stable_image=%s\\nartifact_cd_runtime_image=%s\\nartifact_cd_commit=%s\\nartifact_cd_container=%s\\nartifact_cd_container_id=%s\\nartifact_cd_image_label_commit=%s\\nartifact_cd_container_label_commit=%s\\n' \"$service_id\" \"$source_repo\" \"$deploy_ref\" \"$registry_image\" \"$stable_image\" \"$commit_image\" \"$commit\" \"$container\" \"$cid\" \"$actual_commit\" \"$container_commit\"",
|
||||
].join("\n");
|
||||
}
|
||||
@@ -1866,7 +2021,28 @@ function dryRunArtifactConsumerPlan(options: ArtifactRegistryOptions, spec: Arti
|
||||
: target.compose.requireHealthCommit
|
||||
? `${spec.serviceId} runtime health probe succeeds and reports deploy.commit/deploy.requestedCommit matching the artifact commit`
|
||||
: `${spec.serviceId} /health succeeds for the recreated container`,
|
||||
...(target.compose.requiredRuntimeSecrets === undefined ? [] : [
|
||||
`${spec.serviceId} live apply checks required runtime secret presence in the canonical Compose env file without reading or printing values`,
|
||||
]),
|
||||
...(target.compose.authHealthGate === undefined ? [] : [
|
||||
`${spec.serviceId} live apply gates success on /health.auth fields: ${target.compose.authHealthGate.requiredFields.join(", ")}`,
|
||||
]),
|
||||
],
|
||||
runtimeSecrets: target.compose.requiredRuntimeSecrets === undefined ? undefined : {
|
||||
check: "live-apply-preflight",
|
||||
valuesPrinted: false,
|
||||
requirements: target.compose.requiredRuntimeSecrets.map((item) => ({
|
||||
sourceEnvName: item.sourceEnvName,
|
||||
containerEnvName: item.containerEnvName,
|
||||
presence: "not-read-during-dry-run",
|
||||
})),
|
||||
dryRunDisposition: "pending-live-check",
|
||||
},
|
||||
authHealthGate: target.compose.authHealthGate === undefined ? undefined : {
|
||||
path: "/health",
|
||||
requiredAuthFields: target.compose.authHealthGate.requiredFields,
|
||||
dryRunDisposition: "pending-live-check",
|
||||
},
|
||||
rollback: rollbackInfo(spec, target, environment, commit),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -300,12 +300,14 @@ export function runChecks(config: UniDeskConfig, options: CheckOptions = default
|
||||
items.push(commandItem("code-queue:stale-active-owner-expired", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:stale-active-owner-expired"], 30_000));
|
||||
items.push(commandItem("code-queue:control-plane-split-brain-diagnostics", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:control-plane-split-brain-diagnostics"], 30_000));
|
||||
items.push(commandItem("code-queue:oa-publisher-degraded-visible", ["bun", "scripts/code-queue-liveness-diagnostics-test.ts", "--only", "code-queue:oa-publisher-degraded-visible"], 30_000));
|
||||
items.push(commandItem("baidu-netdisk:artifact-guard-contract", ["bun", "scripts/baidu-netdisk-artifact-guard-contract-test.ts"], 30_000));
|
||||
} else {
|
||||
items.push(skippedItem("typescript:scripts", "scripts TypeScript typecheck is opt-in", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:prompt-observation-contract", "prompt observation contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:issue3-diagnostics-and-image-preflight", "Code Queue issue #3 regression fixtures are opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:trace-summary-contract", "Code Queue trace summary contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("code-queue:liveness-diagnostics-fixtures", "Code Queue liveness diagnostics fixtures are opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
items.push(skippedItem("baidu-netdisk:artifact-guard-contract", "Baidu Netdisk artifact guard contract is opt-in with script checks", "--scripts-typecheck or --full"));
|
||||
}
|
||||
if (options.logs) {
|
||||
items.push(unifiedLogRotationItem());
|
||||
|
||||
Reference in New Issue
Block a user