Files
pikasTech-unidesk/scripts/src/artifact-registry/runtime-secrets.ts
T
2026-06-25 16:16:25 +00:00

229 lines
12 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. runtime-secrets module for scripts/src/artifact-registry.ts.
// Moved mechanically from scripts/src/artifact-registry.ts:2232-2390 for #903.
import { createHash } from "node:crypto";
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { runCommand, type CommandResult } from "../command";
import { readConfig, type UniDeskConfig, repoRoot, rootPath } from "../config";
import { startJob } from "../jobs";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContractBase64,
readDeployJsonServiceContractFromFile,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { d601K3sGuardShellLines } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import type { ArtifactConsumerTarget, ArtifactDeployEnvironment, ArtifactRegistryOptions, AuthHealthGate, ComposeArtifactRuntime, RuntimeSecretContract, RuntimeSecretPresence, RuntimeSecretRequirement, RuntimeSecretSource } from "./types";
import { composeFile, safeName, shellQuote } from "./bundle";
import { baiduNetdiskAuthHealthGate } from "./catalog";
import { runtimeSecretPresenceFromEnvText } from "./install";
import { combineCommandResults, downloadRemoteFile, runRemoteScript, runRemoteScriptBackground } from "./remote";
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,
};
}
export 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 composeEnvFile = config.providerGateway.upgrade.composeEnvFile;
const envFile = join(workDir, 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, composeEnvFile, envFile, project, command };
}
export function runtimeSecretSourceForTarget(config: UniDeskConfig, target: ArtifactConsumerTarget): RuntimeSecretSource {
const runtime = composeArtifactRuntime(config, target);
return {
kind: "compose-env-file",
path: runtime.envFile,
exists: existsSync(runtime.envFile),
configSource: "config.providerGateway.upgrade.hostProjectRoot + config.providerGateway.upgrade.composeEnvFile",
workDir: runtime.workDir,
composeEnvFile: runtime.composeEnvFile,
composeService: target.compose?.serviceName ?? null,
containerName: target.compose?.containerName ?? null,
valuesPrinted: false,
};
}
export function runtimeSecretRecommendedAction(missing: string[]): string {
if (missing.length === 0) return "none";
return "Restore the missing source env keys in the canonical Compose env file without printing values, then rerun deploy apply --env <env> --service <service> --dry-run before any live apply.";
}
export function runtimeSecretRecommendedActionForService(contract: RuntimeSecretContract, environment: ArtifactDeployEnvironment, serviceId: string): RuntimeSecretContract {
if (contract.requiredSecretsPresent) return { ...contract, recommendedAction: "none" };
return {
...contract,
recommendedAction: `Restore ${contract.missingSecretKeys.join(", ")} in the canonical Compose env file without printing values, then rerun deploy apply --env ${environment} --service ${serviceId} --dry-run before any live apply.`,
};
}
export function runtimeSecretContractFromPresence(
source: RuntimeSecretSource,
requirements: RuntimeSecretRequirement[],
presence: RuntimeSecretPresence[],
): RuntimeSecretContract {
const missingSecretKeys = presence.filter((item) => !item.present).map((item) => item.sourceEnvName);
const requiredSecretsPresent = requirements.length === 0 || (source.exists && missingSecretKeys.length === 0);
return {
check: "runtime-secret-presence",
secretSource: source,
requiredSecretsPresent,
missingSecretKeys,
recommendedAction: runtimeSecretRecommendedAction(missingSecretKeys),
valuesPrinted: false,
requirements: presence.map((item) => ({
sourceEnvName: item.sourceEnvName,
containerEnvName: item.containerEnvName,
present: item.present,
valuePrinted: false,
})),
dryRunDisposition: requirements.length === 0
? "not-required"
: requiredSecretsPresent
? "ready-for-live-apply"
: "secret-source-blocked",
};
}
export function runtimeSecretContractFromEnvText(
envText: string,
requirements: RuntimeSecretRequirement[],
sourceOverrides: Partial<Omit<RuntimeSecretSource, "kind" | "valuesPrinted">> = {},
): RuntimeSecretContract {
const source: RuntimeSecretSource = {
kind: "compose-env-file",
path: sourceOverrides.path ?? "<env-text>",
exists: sourceOverrides.exists ?? true,
configSource: sourceOverrides.configSource ?? "config.providerGateway.upgrade.hostProjectRoot + config.providerGateway.upgrade.composeEnvFile",
workDir: sourceOverrides.workDir ?? "",
composeEnvFile: sourceOverrides.composeEnvFile ?? "",
composeService: sourceOverrides.composeService ?? null,
containerName: sourceOverrides.containerName ?? null,
valuesPrinted: false,
};
return runtimeSecretContractFromPresence(source, requirements, runtimeSecretPresenceFromEnvText(envText, requirements));
}
export function runtimeSecretContractForComposeTarget(
config: UniDeskConfig,
target: ArtifactConsumerTarget,
requirements: RuntimeSecretRequirement[] | undefined,
): RuntimeSecretContract | undefined {
if (requirements === undefined) return undefined;
const source = runtimeSecretSourceForTarget(config, target);
const envText = source.exists ? readFileSync(source.path, "utf8") : "";
return runtimeSecretContractFromPresence(source, requirements, runtimeSecretPresenceFromEnvText(envText, requirements));
}
export 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,
};
}
export 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",
];
}
export function upsertEnvFileValues(path: string, values: Record<string, string>): void {
const existing = readFileSync(path, "utf8");
const seen = new Set<string>();
const lines = existing.split(/\n/u).filter((line, index, array) => index < array.length - 1 || line.length > 0).map((line) => {
const match = /^([A-Za-z0-9_]+)=/u.exec(line);
if (match === null || values[match[1]] === undefined) return line;
seen.add(match[1]);
return `${match[1]}=${values[match[1]]}`;
});
for (const [key, value] of Object.entries(values)) {
if (!seen.has(key)) lines.push(`${key}=${value}`);
}
writeFileSync(path, `${lines.join("\n")}\n`, "utf8");
}
export async function pullArtifactFromD601(options: ArtifactRegistryOptions, sourceImage: string): Promise<CommandResult> {
const localArchive = `/tmp/unidesk-artifact-${safeName(options.serviceId ?? "service")}-${safeName(options.commit ?? "commit")}-${Date.now().toString(36)}.tar.gz`;
const remoteArchive = `/tmp/unidesk-artifact-${safeName(options.serviceId ?? "service")}-${safeName(options.commit ?? "commit")}-${Date.now().toString(36)}.tar.gz`;
const remoteScript = [
"set -euo pipefail",
`image=${shellQuote(sourceImage)}`,
`archive=${shellQuote(remoteArchive)}`,
"rm -f \"$archive\"",
"export DOCKER_CONFIG=$(mktemp -d /tmp/unidesk-artifact-docker-config.XXXXXX)",
"trap 'rm -rf \"$DOCKER_CONFIG\"' EXIT",
"printf '{}\\n' > \"$DOCKER_CONFIG/config.json\"",
"docker pull -q \"$image\" >/dev/null",
"docker image inspect \"$image\" --format 'remote_source={{ index .Config.Labels \"unidesk.ai/source-commit\" }} remote_service={{ index .Config.Labels \"unidesk.ai/service-id\" }} remote_dockerfile={{ index .Config.Labels \"unidesk.ai/dockerfile\" }}' >&2",
"docker save \"$image\" | gzip -1 > \"$archive\"",
"remote_archive_bytes=$(wc -c < \"$archive\" | tr -d '[:space:]')",
"remote_archive_sha256=$(sha256sum \"$archive\" | awk '{print $1}')",
"printf 'remote_archive=%s\\nremote_archive_bytes=%s\\nremote_archive_sha256=%s\\n' \"$archive\" \"$remote_archive_bytes\" \"$remote_archive_sha256\"",
].join("\n");
const archive = await runRemoteScriptBackground(options, remoteScript, Math.max(options.timeoutMs, 900_000), "docker-save");
if (archive.exitCode !== 0 || archive.timedOut) return archive;
const download = downloadRemoteFile(options, remoteArchive, localArchive, Math.max(options.timeoutMs, 900_000));
if (download.exitCode !== 0 || download.timedOut) {
runRemoteScript(options, `rm -f ${shellQuote(remoteArchive)}`, 30_000);
try {
rmSync(localArchive, { force: true });
} catch {
// Best-effort cleanup only.
}
return combineCommandResults(["artifact-registry", "pull-artifact-from-d601"], [archive, download]);
}
const load = runCommand(["bash", "-lc", `set -euo pipefail; gzip -dc ${shellQuote(localArchive)} | docker load`], repoRoot, { timeoutMs: Math.max(options.timeoutMs, 900_000) });
runRemoteScript(options, `rm -f ${shellQuote(remoteArchive)}`, 30_000);
try {
rmSync(localArchive, { force: true });
} catch {
// Best-effort cleanup only.
}
return combineCommandResults(["artifact-registry", "pull-artifact-from-d601"], [archive, download, load]);
}