Files
pikasTech-unidesk/scripts/src/deploy/options.ts
T
Lyon edfddd2445 fix: YAML-first 治理 CI/CD target (#919)
* docs: specify cicd yaml target governance

* fix: resolve cicd targets from yaml

---------

Co-authored-by: Codex <codex@noreply.local>
2026-06-26 01:14:38 +08:00

360 lines
17 KiB
TypeScript

// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. options module for scripts/src/deploy.ts.
// SPEC: PJ2026-01060308 cicd-yaml-targets draft-2026-06-25-cicd-yaml-targets.
// Moved mechanically from scripts/src/deploy.ts:213-535 for #903.
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { runCommand } from "../command";
import { type UniDeskConfig, type UniDeskMicroserviceConfig, repoRoot, rootPath } from "../config";
import { ensureGithubSshIdentityForProvider } from "../deploy-ssh-identity";
import { baiduNetdiskRuntimeSecretRequirements, runtimeSecretContractFromEnvText, type RuntimeSecretContract, runArtifactRegistryCommand } from "../artifact-registry";
import { startJob } from "../jobs";
import { coreInternalFetch } from "../microservices";
import { codeQueueSourceImportPreflight, codeQueueSourceSubdir } from "../code-queue-source-guard";
import { d601K3sGuardShellLines, d601NativeKubeconfig } from "../d601-k3s-guard";
import { composeRuntimeEnvValue } from "../runtime-env";
import {
compareDeployJsonExecutorMirrors,
deployJsonCommitImage,
deployJsonDriftResult,
deployJsonSourceOfTruth,
encodeDeployJsonServiceContract,
hasDeployJsonExecutorContract,
k3sManifestExecutorMirror,
parseDeployJsonServiceContract,
type DeployJsonExecutorMirror,
type DeployJsonServiceContract,
} from "../deploy-json-contract";
import { resolveArtifactRegistryTarget } from "../ops/targets";
import type { DeployEnvironment, DeployManifest, DeployManifestService, DeployOptions } from "./types";
import { step } from "./remote";
import { defaultDeployFile, defaultTimeoutMs, k8sKubeconfig, resolveFetchTimeout, unideskRepoUrl } from "./types";
export function isHelpArg(value: string | undefined): boolean {
return value === "help" || value === "--help" || value === "-h";
}
export function deployHelp(action: string | undefined = undefined): Record<string, unknown> {
const command = action === undefined || isHelpArg(action) ? "deploy check|plan|apply|guard" : `deploy ${action}`;
return {
ok: true,
command,
usage: {
check: "bun scripts/cli.ts deploy check [--file deploy.json | --env dev|prod] [--service id]",
plan: "bun scripts/cli.ts deploy plan [--file deploy.json | --env dev|prod] [--service id]",
apply: "bun scripts/cli.ts deploy apply [--file deploy.json | --env dev|prod] [--service id] [--commit full-sha] [--dry-run] [--force] [--timeout-ms N] [--target D601|local] [--run-now]",
guard: "bun scripts/cli.ts deploy guard code-queue-source [--root /home/ubuntu/cq-deploy]",
},
actions: {
check: "Validate desired repo+commit state against live service health and commit markers.",
plan: "Show desired/live drift, or with --env show the environment-ref dry-run plan without touching runtime resources.",
apply: "Start an async deploy/reconcile job unless --run-now is explicitly present.",
guard: "Run local deployment guards without mutating runtime resources.",
},
releaseV1Frontend: {
producer: "bun scripts/cli.ts ci publish-user-service --service frontend --commit <release-v1-full-sha> --wait-ms 1200000",
devConsumer: "bun scripts/cli.ts deploy apply --env dev --service frontend --commit <release-v1-full-sha>",
prodConsumer: "bun scripts/cli.ts deploy apply --env prod --service frontend --commit <release-v1-full-sha>",
boundary: "--commit selects an existing commit-pinned registry artifact for one reviewed artifact consumer; it does not read a dirty local manifest or make the server rebuild a release path.",
},
options: [
{ name: "--file <path>", default: defaultDeployFile, description: "Desired-state manifest path relative to the repo root. JSON and ESM JS manifests are supported, for example deploy.json or develop.js. Local D601 maintenance apply is limited to approved direct exceptions; Code Queue direct rollout is disabled." },
{ name: "--env <dev|prod>", description: "Read the named environment from origin/master:deploy.json. Dev apply supports reviewed artifact consumers for backend-core, frontend, baidu-netdisk, decision-center, mdtodo, claudeqq, dev-only code-queue, project-manager, oa-event-flow, code-queue-mgr, todo-note, findjob, pipeline, and met-nonlinear. Prod apply uses reviewed D601 registry artifact consumers; code-queue has no prod target and gated/incomplete services return structured unsupported or dry-run-only output." },
{ name: "--service <id>", description: "Limit reconcile to one service from the manifest." },
{ name: "--commit <full-sha>", description: "Artifact consumer override for one selected --env dev|prod service; the commit-pinned image must already exist in the D601 registry. This is the supported release/v1 frontend validation and rollback shape without editing a local manifest." },
{ name: "--dry-run", description: "Prepare and validate without mutating the target service." },
{ name: "--force", description: "Redeploy even when the live commit appears up to date." },
{ name: "--timeout-ms <n>", default: defaultTimeoutMs, description: "Per-step timeout budget where supported." },
{ name: "--target <id>", default: "config/artifact-registry.yaml#defaults.targetId", description: "Artifact-registry consumer target. --provider-id is accepted as a legacy alias." },
{ name: "--run-now", description: "Run apply in the foreground worker process; omit it for fire-and-forget async job mode." },
{ name: "guard code-queue-source --root <path>", description: "Validate Code Queue hostPath source relative imports before any scheduler rollout; failures report degradedReason and missing import targets." },
],
};
}
export function nowIso(): string {
return new Date().toISOString();
}
export function elapsedMs(startedAt: number): number {
return Math.max(0, Date.now() - startedAt);
}
export function shellQuote(value: string): string {
return `'${value.replace(/'/gu, `'\\''`)}'`;
}
export function d601K3sGuardScript(): string {
return d601K3sGuardShellLines(k8sKubeconfig).join("\n");
}
export function compactTail(text: string, maxChars = 1600): string {
return text.length > maxChars ? text.slice(text.length - maxChars) : text;
}
export function progressLine(step: string, message: string, detail?: unknown): void {
const payload = detail === undefined
? { at: nowIso(), step, message }
: { at: nowIso(), step, message, detail };
process.stderr.write(`${JSON.stringify(payload)}\n`);
}
export function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
export function asString(value: unknown): string {
return typeof value === "string" ? value : "";
}
export function parseJsonObjectFromText(text: string): unknown | null {
const start = text.indexOf("{");
if (start < 0) return null;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = start; index < text.length; index += 1) {
const char = text[index];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === "\"") {
inString = false;
}
continue;
}
if (char === "\"") {
inString = true;
continue;
}
if (char === "{") {
depth += 1;
continue;
}
if (char === "}") {
depth -= 1;
if (depth === 0) return JSON.parse(text.slice(start, index + 1)) as unknown;
}
}
return null;
}
export function parseFullCommit(value: string): string {
const match = value.match(/\b[0-9a-f]{40}\b/iu);
return match?.[0]?.toLowerCase() ?? "";
}
export function safeId(value: string): string {
const sanitized = value.replace(/[^A-Za-z0-9_.-]/gu, "-");
if (sanitized.length === 0) throw new Error(`invalid service id: ${value}`);
return sanitized;
}
export function repoResolveCacheDir(repo: string): string {
return rootPath(".state", "deploy", "resolve", createHash("sha256").update(repo).digest("hex").slice(0, 16));
}
export function repoSlug(repo: string): string | null {
const trimmed = repo.trim().replace(/\.git$/u, "");
const https = trimmed.match(/^https:\/\/([^/]+)\/(.+)$/u);
if (https !== null) return `${https[1]?.toLowerCase()}/${https[2]}`;
const ssh = trimmed.match(/^git@([^:]+):(.+)$/u);
if (ssh !== null) return `${ssh[1]?.toLowerCase()}/${ssh[2]}`;
return null;
}
export function isFullGitSha(value: string): boolean {
return /^[0-9a-f]{40}$/iu.test(value);
}
export function isDeployEnvironment(value: string): value is DeployEnvironment {
return value === "dev" || value === "prod";
}
export function isUnideskRepo(repo: string): boolean {
const desiredSlug = repoSlug(repo);
const unideskSlug = repoSlug(unideskRepoUrl);
return desiredSlug !== null && desiredSlug === unideskSlug;
}
export function sshUrlForSlug(slug: string | null): string | null {
if (slug === null) return null;
const [host = "", ...pathParts] = slug.split("/");
const path = pathParts.join("/");
if (host.length === 0 || path.length === 0) return null;
if (host !== "github.com" && host !== "gitee.com") return null;
return `git@${host}:${path}.git`;
}
export function candidateRepoUrls(repo: string): string[] {
const desiredSlug = repoSlug(repo);
const urls = [repo];
const localOrigin = runCommand(["git", "remote", "get-url", "origin"], repoRoot);
const localOriginUrl = localOrigin.exitCode === 0 ? localOrigin.stdout.trim() : "";
if (localOriginUrl.length > 0 && repoSlug(localOriginUrl) === desiredSlug) urls.push(localOriginUrl);
const sshUrl = sshUrlForSlug(desiredSlug);
if (sshUrl !== null) urls.push(sshUrl);
return [...new Set(urls)];
}
export function providerSourceRepoUrl(repo: string): string {
const slug = repoSlug(repo);
if (slug === null || (!slug.startsWith("github.com/") && !slug.startsWith("gitee.com/"))) return repo;
return sshUrlForSlug(slug) ?? repo;
}
export function localResolvedCommitForDesired(desired: DeployManifestService): string | null {
const desiredSlug = repoSlug(desired.repo);
if (desiredSlug === null || !isFullGitSha(desired.commitId)) return null;
const localOrigin = runCommand(["git", "remote", "get-url", "origin"], repoRoot);
const localOriginUrl = localOrigin.exitCode === 0 ? localOrigin.stdout.trim() : "";
if (localOriginUrl.length === 0 || repoSlug(localOriginUrl) !== desiredSlug) return null;
const resolved = runCommand(["git", "rev-parse", "--verify", `${desired.commitId}^{commit}`], repoRoot);
const fullCommit = parseFullCommit(resolved.stdout);
return resolved.exitCode === 0 && fullCommit === desired.commitId.toLowerCase() ? fullCommit : null;
}
export function commandFailure(result: ReturnType<typeof runCommand>, maxChars = 1200): string {
const detail = [result.stderr, result.stdout].filter(Boolean).join("\n");
return compactTail(detail, maxChars) || `exit ${result.exitCode}`;
}
export function runGitOrThrow(args: string[], cwd: string, message: string): ReturnType<typeof runCommand> {
const result = runCommand(["git", ...args], cwd);
if (result.exitCode !== 0) throw new Error(`${message}: ${commandFailure(result)}`);
return result;
}
export function runResolveGit(args: string[], cwd: string): ReturnType<typeof runCommand> {
return runCommand(["timeout", resolveFetchTimeout, "git", ...args], cwd);
}
export function resolveDesiredCommit(desired: DeployManifestService): DeployManifestService {
const localCommit = localResolvedCommitForDesired(desired);
if (localCommit !== null) return { ...desired, commitId: localCommit };
if (isFullGitSha(desired.commitId) && !isUnideskRepo(desired.repo)) return { ...desired, commitId: desired.commitId.toLowerCase() };
const cacheDir = repoResolveCacheDir(desired.repo);
mkdirSync(cacheDir, { recursive: true });
if (!existsSync(join(cacheDir, ".git", "config"))) {
const init = runCommand(["git", "init", cacheDir], repoRoot);
if (init.exitCode !== 0 && !existsSync(join(cacheDir, ".git", "config"))) {
throw new Error(`failed to initialize deploy commit resolver for ${desired.repo}: ${commandFailure(init)}`);
}
}
const fetchErrors: string[] = [];
for (const repoUrl of candidateRepoUrls(desired.repo)) {
runCommand(["git", "-C", cacheDir, "remote", "remove", "origin"], repoRoot);
const addRemote = runCommand(["git", "-C", cacheDir, "remote", "add", "origin", repoUrl], repoRoot);
if (addRemote.exitCode !== 0) {
fetchErrors.push(`${repoUrl}: ${commandFailure(addRemote)}`);
continue;
}
const fetches = desired.commitId.length === 40
? [
runResolveGit(["-C", cacheDir, "fetch", "--no-tags", "--depth=1", "origin", desired.commitId], repoRoot),
runResolveGit([
"-C",
cacheDir,
"fetch",
"--no-tags",
"--prune",
"origin",
"+refs/heads/*:refs/remotes/origin/*",
"+refs/tags/*:refs/tags/*",
], repoRoot),
]
: [
runResolveGit([
"-C",
cacheDir,
"fetch",
"--no-tags",
"--prune",
"origin",
"+refs/heads/*:refs/remotes/origin/*",
"+refs/tags/*:refs/tags/*",
], repoRoot),
];
const fetch = fetches.find((result) => result.exitCode === 0) ?? fetches[fetches.length - 1];
if (fetch.exitCode === 0) {
fetchErrors.length = 0;
break;
}
fetchErrors.push(`${repoUrl}: ${commandFailure(fetch)}`);
}
if (fetchErrors.length > 0) {
throw new Error(`deploy manifest service ${desired.id} cannot fetch ${desired.repo} before deploy: ${compactTail(fetchErrors.join("\n"), 1600)}`);
}
const resolved = runCommand(["git", "-C", cacheDir, "rev-parse", "--verify", `${desired.commitId}^{commit}`], repoRoot);
const fullCommit = parseFullCommit(resolved.stdout);
if (resolved.exitCode !== 0 || fullCommit.length !== 40) {
throw new Error(`deploy manifest service ${desired.id} commitId ${desired.commitId} cannot be resolved in ${desired.repo}; use a pushed unique 7-40 char SHA. ${commandFailure(resolved)}`);
}
return { ...desired, commitId: fullCommit };
}
export function resolveManifestCommits(manifest: DeployManifest, serviceId: string | null): DeployManifest {
return {
schemaVersion: manifest.schemaVersion,
environment: manifest.environment,
services: manifest.services.map((service) => (serviceId === null || service.id === serviceId ? resolveDesiredCommit(service) : service)),
};
}
export function optionValue(args: string[], names: string[]): string | undefined {
for (const name of names) {
const index = args.indexOf(name);
if (index === -1) continue;
const raw = args[index + 1];
if (raw === undefined || raw.length === 0 || raw.startsWith("--")) throw new Error(`${name} requires a non-empty value`);
return raw;
}
return undefined;
}
export function positiveIntegerOption(args: string[], names: string[], defaultValue: number): number {
const raw = optionValue(args, names);
if (raw === undefined) return defaultValue;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${names[0]} must be a positive integer`);
return parsed;
}
export function environmentOption(args: string[]): DeployEnvironment | null {
const raw = optionValue(args, ["--env", "--environment"]);
if (raw === undefined) return null;
if (!isDeployEnvironment(raw)) throw new Error("--env must be dev or prod");
return raw;
}
export function parseOptions(args: string[]): DeployOptions {
const environment = environmentOption(args);
if (environment !== null && args.includes("--file")) throw new Error("deploy --env reads deploy.json from the fixed Git ref and cannot be combined with --file");
const commitOverride = optionValue(args, ["--commit"]);
const serviceId = optionValue(args, ["--service", "--service-id"]) ?? null;
if (commitOverride !== undefined && environment === null) throw new Error("deploy --commit is only supported for --env dev|prod artifact consumer apply");
if (commitOverride !== undefined && !isFullGitSha(commitOverride)) throw new Error("deploy --commit must be a full 40-character commit SHA");
if (commitOverride !== undefined && serviceId === null) throw new Error("deploy --commit requires --service so artifact consumer apply is unambiguous");
const artifactTarget = resolveArtifactRegistryTarget(optionValue(args, ["--target", "--provider-id", "--provider"]) ?? null);
return {
file: optionValue(args, ["--file"]) ?? defaultDeployFile,
environment,
serviceId,
commitOverride: commitOverride?.toLowerCase() ?? null,
providerId: artifactTarget.providerId,
runNow: args.includes("--run-now"),
dryRun: args.includes("--dry-run"),
force: args.includes("--force"),
timeoutMs: positiveIntegerOption(args, ["--timeout-ms"], defaultTimeoutMs),
};
}