edfddd2445
* docs: specify cicd yaml target governance * fix: resolve cicd targets from yaml --------- Co-authored-by: Codex <codex@noreply.local>
360 lines
17 KiB
TypeScript
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),
|
|
};
|
|
}
|