refactor: split control-plane cli modules

This commit is contained in:
Codex
2026-06-25 16:16:00 +00:00
parent cca855daa3
commit e0af0f85ac
147 changed files with 63967 additions and 59999 deletions
+673
View File
@@ -0,0 +1,673 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. cd-trigger module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:9508-10162 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { CommandJsonResult, G14MonitorOptions, OpenPullRequest } from "./types";
import { runRuntimeLaneControlPlane, runV02ControlPlane, runtimeLaneControlPlaneStatus, v02ControlPlaneStatus } from "./control-plane";
import { runG14GitMirror, runGitMirrorStatus } from "./git-mirror";
import { activeV02PipelineRuns, commentRuntimeLanePullRequest, commentV02PullRequest, durationSeconds, reportRuntimeLaneAutomationIssue, runtimeLaneCdFailed, runtimeLaneCdPassed, sourceCommitFromMergeResult, summarizeRuntimeLaneCdStatus, summarizeV02CdStatus, v02CdFailed, v02CdPassed, v02PipelineSucceeded, waitForV02Head } from "./pr-monitor";
import { commandData, compactCommandResult, isCommandSuccess, nested, printEvent, printRuntimeLanePrMonitorProgress, printV02PrMonitorProgress, record, sleep, stringOrNull } from "./remote";
import { resolveRuntimeLaneHead, runtimeLanePipelineRunName, v02PipelineRunName } from "./source";
export function triggerV02Current(timeoutSeconds: number): Record<string, unknown> {
return runV02ControlPlane({
action: "trigger-current",
lane: "v02",
dryRun: false,
confirm: true,
wait: true,
allowLiveDbRead: false,
timeoutSeconds,
minAgeMinutes: 30,
limit: 20,
});
}
export function flushV02GitMirrorIfNeeded(status: Record<string, unknown>, timeoutSeconds: number): Record<string, unknown> {
const pendingFlush = nested(status, ["gitMirror", "summary", "pendingFlush"]);
if (pendingFlush !== true) return { ok: true, skipped: true, reason: "git-mirror-already-flushed" };
return runG14GitMirror({
action: "flush",
lane: "v02",
confirm: true,
dryRun: false,
wait: true,
timeoutSeconds,
});
}
export function triggerRuntimeLaneCurrent(spec: HwlabRuntimeLaneSpec, timeoutSeconds: number): Record<string, unknown> {
return runRuntimeLaneControlPlane(spec, {
action: "trigger-current",
lane: spec.lane,
dryRun: false,
confirm: true,
wait: true,
allowLiveDbRead: false,
timeoutSeconds,
minAgeMinutes: 30,
limit: 20,
history: false,
});
}
export function runtimeLaneStatusWithGitMirror(spec: HwlabRuntimeLaneSpec, sourceCommit: string): Record<string, unknown> {
const status = runtimeLaneControlPlaneStatus(spec, { sourceCommit, mode: "source-commit" });
const gitMirrorStatus = runGitMirrorStatus(spec.lane);
return {
...status,
gitMirror: record(gitMirrorStatus.cache),
gitMirrorStatus,
};
}
export function runtimeLaneGitopsSynced(spec: HwlabRuntimeLaneSpec, status: Record<string, unknown>): boolean {
const summary = record(nested(status, ["gitMirror", "summary"]));
const inSync = spec.lane === "v03" ? summary.v03GitopsInSync : summary.githubInSync;
return summary.pendingFlush !== true && inSync === true;
}
export function runtimeLaneCdPassedWithMirror(spec: HwlabRuntimeLaneSpec, status: Record<string, unknown>): boolean {
return runtimeLaneCdPassed(status) && runtimeLaneGitopsSynced(spec, status);
}
export function flushRuntimeLaneGitMirrorIfNeeded(spec: HwlabRuntimeLaneSpec, status: Record<string, unknown>, timeoutSeconds: number): Record<string, unknown> {
if (runtimeLaneGitopsSynced(spec, status)) return { ok: true, skipped: true, reason: `${spec.lane}-git-mirror-already-flushed` };
return runG14GitMirror({
action: "flush",
lane: spec.lane,
confirm: true,
dryRun: false,
wait: true,
timeoutSeconds,
});
}
export function pullRequestFromMergeCommand(merge: CommandJsonResult): Record<string, unknown> {
const data = commandData(merge);
const direct = record(data.pullRequest);
if (Object.keys(direct).length > 0) return direct;
return record(nested(data, ["details", "details", "pullRequest"]));
}
export function mergeCommandRaceState(merge: CommandJsonResult): "merged" | "closed" | null {
const pullRequest = pullRequestFromMergeCommand(merge);
if (pullRequest.merged === true || pullRequest.stateDetail === "merged") return "merged";
if (pullRequest.closed === true || pullRequest.state === "closed") return "closed";
return null;
}
export async function waitForRuntimeLaneHead(spec: HwlabRuntimeLaneSpec, expectedCommit: string | null, timeoutSeconds: number): Promise<Record<string, unknown>> {
const started = Date.now();
let head: string | null = null;
while (Date.now() - started < timeoutSeconds * 1000) {
head = resolveRuntimeLaneHead(spec).sourceCommit;
if (expectedCommit === null) {
if (head !== null) return { ok: true, sourceCommit: head, expectedCommit, matched: true, waitedSeconds: Math.round((Date.now() - started) / 1000) };
} else if (head === expectedCommit) {
return { ok: true, sourceCommit: head, expectedCommit, matched: true, waitedSeconds: Math.round((Date.now() - started) / 1000) };
}
printEvent(`${spec.lane}.source.wait`, { lane: spec.lane, expectedCommit, head, waitedSeconds: Math.round((Date.now() - started) / 1000) });
await sleep(5_000);
}
return { ok: false, sourceCommit: head, expectedCommit, matched: false, waitedSeconds: Math.round((Date.now() - started) / 1000), degradedReason: `${spec.lane}-source-head-not-aligned-after-merge` };
}
export async function waitForV02Cd(sourceCommit: string, timeoutSeconds: number): Promise<Record<string, unknown>> {
const started = Date.now();
const startedAt = new Date(started).toISOString();
const pipelineRun = v02PipelineRunName(sourceCommit);
let lastStatus: Record<string, unknown> = {};
let firstPassedAt: string | null = null;
while (Date.now() - started < timeoutSeconds * 1000) {
lastStatus = v02ControlPlaneStatus({ sourceCommit, mode: "source-commit" });
const summary = summarizeV02CdStatus(lastStatus);
printEvent("v02.cd.status", { sourceCommit, pipelineRun, summary });
printV02PrMonitorProgress({ stage: "cd-status", status: "running", sourceCommit, pipelineRun, targetValidationState: summary.targetValidationState ?? null, pipelineStatus: summary.pipelineStatus ?? null, pendingFlush: summary.pendingFlush ?? null });
if (v02PipelineSucceeded(lastStatus) && summary.pendingFlush === true) {
const flush = flushV02GitMirrorIfNeeded(lastStatus, Math.min(timeoutSeconds, 600));
if (record(flush).ok !== true) {
return {
ok: false,
phase: "git-mirror-flush",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: summary,
rawStatus: lastStatus,
flush,
};
}
const finalStatus = v02ControlPlaneStatus({ sourceCommit, mode: "source-commit" });
const finalSummary = summarizeV02CdStatus(finalStatus);
if (v02CdPassed(finalStatus)) {
printV02PrMonitorProgress({ stage: "git-mirror-flush", status: "succeeded", sourceCommit, pipelineRun, pendingFlush: finalSummary.pendingFlush ?? null });
return {
ok: true,
phase: "cd-passed",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: finalSummary,
rawStatus: finalStatus,
flush,
};
}
lastStatus = finalStatus;
printEvent("v02.cd.after-flush", { sourceCommit, pipelineRun, flushOk: record(flush).ok, summary: finalSummary });
printV02PrMonitorProgress({ stage: "git-mirror-flush", status: record(flush).ok === true ? "succeeded" : "failed", sourceCommit, pipelineRun, pendingFlush: finalSummary.pendingFlush ?? null });
}
if (v02CdPassed(lastStatus)) {
firstPassedAt ??= new Date().toISOString();
const flush = flushV02GitMirrorIfNeeded(lastStatus, Math.min(timeoutSeconds, 600));
const finalStatus = v02ControlPlaneStatus({ sourceCommit, mode: "source-commit" });
printV02PrMonitorProgress({ stage: "cd-status", status: v02CdPassed(finalStatus) ? "succeeded" : "failed", sourceCommit, pipelineRun, targetValidationState: nested(finalStatus, ["targetValidation", "state"]) ?? null });
return {
ok: v02CdPassed(finalStatus),
phase: v02CdPassed(finalStatus) ? "cd-passed" : "git-mirror-flush",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: summarizeV02CdStatus(finalStatus),
rawStatus: finalStatus,
flush,
};
}
if (v02CdFailed(lastStatus)) {
printV02PrMonitorProgress({ stage: "cd-status", status: "failed", sourceCommit, pipelineRun, pipelineStatus: summary.pipelineStatus ?? null, pipelineReason: summary.pipelineReason ?? null });
return {
ok: false,
phase: "cd-failed",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: summary,
rawStatus: lastStatus,
};
}
await sleep(30_000);
}
return {
ok: false,
phase: "timeout",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
timeoutSeconds,
firstPassedAt,
status: summarizeV02CdStatus(lastStatus),
rawStatus: lastStatus,
};
}
export async function waitForRuntimeLaneCd(spec: HwlabRuntimeLaneSpec, sourceCommit: string, timeoutSeconds: number): Promise<Record<string, unknown>> {
const started = Date.now();
const startedAt = new Date(started).toISOString();
let pipelineRun = runtimeLanePipelineRunName(spec, sourceCommit);
let lastStatus: Record<string, unknown> = {};
let firstPassedAt: string | null = null;
while (Date.now() - started < timeoutSeconds * 1000) {
lastStatus = runtimeLaneStatusWithGitMirror(spec, sourceCommit);
const summary = summarizeRuntimeLaneCdStatus(spec, lastStatus);
pipelineRun = stringOrNull(summary.pipelineRun) ?? pipelineRun;
printEvent(`${spec.lane}.cd.status`, { lane: spec.lane, sourceCommit, pipelineRun, summary });
printRuntimeLanePrMonitorProgress(spec, {
stage: "cd-status",
status: "running",
sourceCommit,
pipelineRun,
pipelineStatus: summary.pipelineStatus ?? null,
pipelineReason: summary.pipelineReason ?? null,
argoSync: summary.argoSync ?? null,
argoHealth: summary.argoHealth ?? null,
publicProbesOk: summary.publicProbesOk ?? null,
pendingFlush: summary.pendingFlush ?? null,
});
if (runtimeLaneCdPassed(lastStatus)) {
firstPassedAt ??= new Date().toISOString();
const flush = flushRuntimeLaneGitMirrorIfNeeded(spec, lastStatus, Math.min(timeoutSeconds, 600));
if (record(flush).ok !== true) {
return {
ok: false,
phase: "git-mirror-flush",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: summary,
rawStatus: lastStatus,
flush,
};
}
const finalStatus = runtimeLaneStatusWithGitMirror(spec, sourceCommit);
const finalSummary = summarizeRuntimeLaneCdStatus(spec, finalStatus);
printRuntimeLanePrMonitorProgress(spec, {
stage: "git-mirror-flush",
status: runtimeLaneCdPassedWithMirror(spec, finalStatus) ? "succeeded" : "failed",
sourceCommit,
pipelineRun,
pendingFlush: finalSummary.pendingFlush ?? null,
});
return {
ok: runtimeLaneCdPassedWithMirror(spec, finalStatus),
phase: runtimeLaneCdPassedWithMirror(spec, finalStatus) ? "cd-passed" : "git-mirror-flush",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: finalSummary,
rawStatus: finalStatus,
flush,
};
}
if (runtimeLaneCdFailed(lastStatus)) {
printRuntimeLanePrMonitorProgress(spec, { stage: "cd-status", status: "failed", sourceCommit, pipelineRun, pipelineStatus: summary.pipelineStatus ?? null, pipelineReason: summary.pipelineReason ?? null });
return {
ok: false,
phase: "cd-failed",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
status: summary,
rawStatus: lastStatus,
};
}
await sleep(30_000);
}
return {
ok: false,
phase: "timeout",
startedAt,
finishedAt: new Date().toISOString(),
sourceCommit,
pipelineRun,
timeoutSeconds,
firstPassedAt,
status: summarizeRuntimeLaneCdStatus(spec, lastStatus),
rawStatus: lastStatus,
};
}
export async function runV02PrAutoCd(pr: OpenPullRequest, preflight: Record<string, unknown>, merge: CommandJsonResult, options: G14MonitorOptions, startedAt: string): Promise<Record<string, unknown>> {
const mergeRaceState = isCommandSuccess(merge) ? null : mergeCommandRaceState(merge);
if (!isCommandSuccess(merge) && mergeRaceState !== "merged") {
printV02PrMonitorProgress({ stage: "merge", status: "failed", pr: pr.number, mergeRaceState });
const comment = commentV02PullRequest({
pr,
phase: "merge",
state: "merge-failed",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: compactCommandResult(merge),
dryRun: options.dryRun,
message: mergeRaceState === "closed"
? "PR preflight 曾通过,但自动合并前 PR 已被关闭且未确认 merged;本轮不会触发 CD。"
: "PR preflight 已通过,但自动合并失败;需要处理 GitHub 返回的 merge blocker 后重试。",
});
return { ok: mergeRaceState === "closed", phase: "merge", action: "merge-race-closed", pr, merge, comment };
}
const expectedCommit = options.dryRun ? null : sourceCommitFromMergeResult(merge, pr.number);
if (!options.dryRun && expectedCommit === null) {
printV02PrMonitorProgress({ stage: "source-head", status: "failed", pr: pr.number, sourceCommit: null, pipelineRun: null, degradedReason: "merge-commit-unresolved" });
const comment = commentV02PullRequest({
pr,
phase: "merge-commit",
state: "cd-failed",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: commandData(merge),
sourceCommit: null,
pipelineRun: null,
dryRun: false,
message: "PR 已合并,但 UniDesk 未能从 GitHub merge/read 结果解析 merge commit;为避免触发错误 source head,本轮未启动 CD。",
});
return { ok: false, phase: "merge-commit", pr, merge: commandData(merge), comment, degradedReason: "merge-commit-unresolved" };
}
const headWait = options.dryRun ? { ok: true, sourceCommit: null, expectedCommit, matched: true } : await waitForV02Head(expectedCommit, 120);
const sourceCommit = typeof record(headWait).sourceCommit === "string" ? String(record(headWait).sourceCommit) : null;
const pipelineRun = sourceCommit === null ? null : v02PipelineRunName(sourceCommit);
printV02PrMonitorProgress({ stage: "merge", status: "succeeded", pr: pr.number, sourceCommit, pipelineRun, mergeRaceState });
const startedComment = commentV02PullRequest({
pr,
phase: options.dryRun ? "dry-run" : "cd-trigger",
state: "cd-started",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
dryRun: options.dryRun,
message: options.dryRun
? "dry-run:PR 已达到自动合并条件;本轮不会写 GitHub merge 或 CD。"
: mergeRaceState === "merged"
? "PR 在本轮 merge 前已被合并;worker 继续对齐 merge commit 并驱动 v0.2 CD。后续成功、失败或超时会继续在本 PR 下回复。"
: "PR 已自动合并,v0.2 CD 准备开始;其他 commit 的运行中 PipelineRun 不会阻塞本轮 CI,旧 run 若发现 source head 已推进会以 superseded/no-op 收口。后续成功、superseded、失败或超时会继续在本 PR 下回复。",
});
if (!options.dryRun && record(startedComment).ok !== true) {
return { ok: false, phase: "pr-comment", pr, sourceCommit, pipelineRun, comment: startedComment };
}
if (options.dryRun) return { ok: true, action: "dry-run-merge", pr, merge: commandData(merge), comment: startedComment };
if (sourceCommit === null || record(headWait).ok !== true) {
printV02PrMonitorProgress({ stage: "source-head", status: "failed", pr: pr.number, sourceCommit, pipelineRun, expectedCommit });
const comment = commentV02PullRequest({
pr,
phase: "v02-source-head",
state: "cd-timeout",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
dryRun: false,
message: "PR 已合并,但 G14 v0.2 CI/CD source repo 没有在等待窗口内对齐 merge commit;为避免触发旧 head,本轮未启动 CD。",
});
return { ok: false, phase: "v02-head", pr, merge: commandData(merge), expectedCommit, headWait, comment: record(comment).ok === true ? comment : startedComment, degradedReason: "v02-head-unresolved-after-merge" };
}
const before = v02ControlPlaneStatus({ sourceCommit, mode: "source-commit" });
const beforeSummary = summarizeV02CdStatus(before);
const activeRuns = activeV02PipelineRuns(before);
if (activeRuns.length > 0) {
printEvent("v02.cd.active-runs-observed", { pr: pr.number, sourceCommit, pipelineRun, activeCount: activeRuns.length, activeRuns: activeRuns.slice(0, 5), latestOnlyPolicy: "do-not-wait-or-cancel-old-runs" });
}
const trigger = v02CdPassed(before) ? { ok: true, skipped: true, reason: "source-commit-already-deployed" } : triggerV02Current(Math.min(options.timeoutSeconds, 600));
printEvent("v02.cd.trigger", { pr: pr.number, sourceCommit, pipelineRun, ok: record(trigger).ok, skipped: record(trigger).skipped ?? false, degradedReason: record(trigger).degradedReason ?? null });
printV02PrMonitorProgress({ stage: "cd-trigger", status: record(trigger).ok === true ? "succeeded" : "failed", pr: pr.number, sourceCommit, pipelineRun, activeCount: activeRuns.length, skipped: record(trigger).skipped ?? false, degradedReason: record(trigger).degradedReason ?? null });
if (record(trigger).ok !== true) {
const comment = commentV02PullRequest({
pr,
phase: "cd-trigger",
state: "cd-failed",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
cd: beforeSummary,
dryRun: false,
message: "PR 已合并,但 v0.2 CD 触发失败;需要查看 trigger 阶段的 degradedReason。",
});
return { ok: false, phase: "cd-trigger", pr, sourceCommit, pipelineRun, trigger, comment };
}
const cd = await waitForV02Cd(sourceCommit, options.timeoutSeconds);
const cdStatus = record(cd.status);
const flush = record(cd.flush);
const cdOk = cd.ok === true;
const cdPhase = String(cd.phase ?? "");
const cdSuperseded = cdOk && cdStatus.targetValidationState === "superseded";
const finalComment = commentV02PullRequest({
pr,
phase: cdPhase,
state: cdOk ? (cdSuperseded ? "cd-superseded" : "cd-succeeded") : cdPhase === "timeout" ? "cd-timeout" : "cd-failed",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
cd: cdStatus,
flush,
dryRun: false,
message: cdOk
? cdSuperseded
? "v0.2 自动 CD 已收口:本 merge head 的 PipelineRun 已完成,但 source head 已被后续提交推进,本轮按 latest-only 语义 superseded/no-op,未回写旧 GitOps revision。"
: "v0.2 自动 CD 已完成:PipelineRun、Argo/runtime、公开探针和 Git mirror flush 收口均已检查。"
: cdPhase === "timeout"
? "v0.2 自动 CD 超时未收口;本评论保留最后一次 targetValidation / PipelineRun / Git mirror 状态用于接续排障。"
: "v0.2 自动 CD 失败;本评论保留失败阶段和最后一次状态用于接续排障。",
});
return {
ok: cdOk && record(finalComment).ok === true,
action: cdOk ? (cdSuperseded ? "merged-and-superseded-v02" : "merged-and-rolled-v02") : "v02-cd-failed",
phase: cdOk ? "cd-passed" : cdPhase,
pr,
sourceCommit,
pipelineRun,
trigger,
cd,
comment: finalComment,
};
}
export async function runRuntimeLanePrAutoCd(spec: HwlabRuntimeLaneSpec, pr: OpenPullRequest, preflight: Record<string, unknown>, merge: CommandJsonResult, options: G14MonitorOptions, startedAt: string): Promise<Record<string, unknown>> {
const mergeRaceState = isCommandSuccess(merge) ? null : mergeCommandRaceState(merge);
if (!isCommandSuccess(merge) && mergeRaceState !== "merged") {
printRuntimeLanePrMonitorProgress(spec, { stage: "merge", status: "failed", pr: pr.number, mergeRaceState });
const observedAt = new Date().toISOString();
const comment = commentRuntimeLanePullRequest(spec, {
pr,
phase: "merge",
state: "merge-failed",
startedAt,
observedAt,
elapsedSeconds: durationSeconds(startedAt, observedAt),
preflight,
merge: compactCommandResult(merge),
dryRun: options.dryRun,
message: mergeRaceState === "closed"
? `PR preflight 曾通过,但自动合并前 PR 已被关闭且未确认 merged;本轮不会触发 ${spec.version} CD。`
: "PR preflight 已通过,但自动合并失败;需要处理 GitHub 返回的 merge blocker 后重试。",
});
const issue = reportRuntimeLaneAutomationIssue({
spec,
pr,
state: "merge-failed",
phase: "merge",
startedAt,
observedAt,
preflight,
details: { merge: compactCommandResult(merge), mergeRaceState },
message: "自动合并失败,v0.3 monitor 已暂停 CD 触发。",
dryRun: options.dryRun,
});
return { ok: mergeRaceState === "closed" && record(comment).ok === true && record(issue).ok === true, phase: "merge", action: "merge-race-closed", pr, merge, comment, issue };
}
const expectedCommit = options.dryRun ? null : sourceCommitFromMergeResult(merge, pr.number);
if (!options.dryRun && expectedCommit === null) {
const observedAt = new Date().toISOString();
printRuntimeLanePrMonitorProgress(spec, { stage: "source-head", status: "failed", pr: pr.number, sourceCommit: null, pipelineRun: null, degradedReason: "merge-commit-unresolved" });
const comment = commentRuntimeLanePullRequest(spec, {
pr,
phase: "merge-commit",
state: "cd-failed",
startedAt,
observedAt,
elapsedSeconds: durationSeconds(startedAt, observedAt),
preflight,
merge: commandData(merge),
sourceCommit: null,
pipelineRun: null,
dryRun: false,
message: "PR 已合并,但 UniDesk 未能从 GitHub merge/read 结果解析 merge commit;为避免触发错误 source head,本轮未启动 CD。",
});
const issue = reportRuntimeLaneAutomationIssue({
spec,
pr,
state: "cd-failed",
phase: "merge-commit",
startedAt,
observedAt,
preflight,
details: { merge: commandData(merge) },
message: "PR 已合并但 merge commit 不可解析,v0.3 CD 未启动。",
dryRun: false,
});
return { ok: false, phase: "merge-commit", pr, merge: commandData(merge), comment, issue, degradedReason: "merge-commit-unresolved" };
}
const headWait = options.dryRun ? { ok: true, sourceCommit: null, expectedCommit, matched: true } : await waitForRuntimeLaneHead(spec, expectedCommit, 120);
const sourceCommit = typeof record(headWait).sourceCommit === "string" ? String(record(headWait).sourceCommit) : null;
const pipelineRun = sourceCommit === null ? null : runtimeLanePipelineRunName(spec, sourceCommit);
printRuntimeLanePrMonitorProgress(spec, { stage: "merge", status: "succeeded", pr: pr.number, sourceCommit, pipelineRun, mergeRaceState });
const startedComment = commentRuntimeLanePullRequest(spec, {
pr,
phase: options.dryRun ? "dry-run" : "cd-trigger",
state: "cd-started",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
dryRun: options.dryRun,
message: options.dryRun
? `dry-runPR 已达到 ${spec.version} 自动合并条件;本轮不会写 GitHub merge 或 CD。`
: mergeRaceState === "merged"
? `PR 在本轮 merge 前已被合并;worker 继续对齐 merge commit 并驱动 ${spec.version} CD。后续成功、失败或超时会继续在本 PR 下回复。`
: `PR 已自动合并,${spec.version} CD 准备开始;后续成功、失败或超时会继续在本 PR 下回复。`,
});
if (!options.dryRun && record(startedComment).ok !== true) {
return { ok: false, phase: "pr-comment", pr, sourceCommit, pipelineRun, comment: startedComment };
}
if (options.dryRun) return { ok: true, action: "dry-run-merge", pr, merge: commandData(merge), comment: startedComment };
if (sourceCommit === null || record(headWait).ok !== true) {
const observedAt = new Date().toISOString();
printRuntimeLanePrMonitorProgress(spec, { stage: "source-head", status: "failed", pr: pr.number, sourceCommit, pipelineRun, expectedCommit });
const comment = commentRuntimeLanePullRequest(spec, {
pr,
phase: `${spec.lane}-source-head`,
state: "cd-timeout",
startedAt,
observedAt,
elapsedSeconds: durationSeconds(startedAt, observedAt),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
dryRun: false,
message: `PR 已合并,但 G14 ${spec.version} CI/CD source repo 没有在等待窗口内对齐 merge commit;为避免触发旧 head,本轮未启动 CD。`,
});
const issue = reportRuntimeLaneAutomationIssue({
spec,
pr,
state: "cd-timeout",
phase: `${spec.lane}-source-head`,
startedAt,
observedAt,
preflight,
sourceCommit,
pipelineRun,
details: { expectedCommit, headWait },
message: `${spec.version} source head 未在等待窗口内对齐 merge commitCD 未启动。`,
dryRun: false,
});
return { ok: false, phase: `${spec.lane}-head`, pr, merge: commandData(merge), expectedCommit, headWait, comment: record(comment).ok === true ? comment : startedComment, issue, degradedReason: `${spec.lane}-head-unresolved-after-merge` };
}
const before = runtimeLaneStatusWithGitMirror(spec, sourceCommit);
const beforeSummary = summarizeRuntimeLaneCdStatus(spec, before);
const trigger = runtimeLaneCdPassedWithMirror(spec, before) ? { ok: true, skipped: true, reason: "source-commit-already-deployed" } : triggerRuntimeLaneCurrent(spec, Math.min(options.timeoutSeconds, 600));
printEvent(`${spec.lane}.cd.trigger`, { pr: pr.number, sourceCommit, pipelineRun, ok: record(trigger).ok, skipped: record(trigger).skipped ?? false, degradedReason: record(trigger).degradedReason ?? null });
printRuntimeLanePrMonitorProgress(spec, { stage: "cd-trigger", status: record(trigger).ok === true ? "succeeded" : "failed", pr: pr.number, sourceCommit, pipelineRun, skipped: record(trigger).skipped ?? false, degradedReason: record(trigger).degradedReason ?? null });
if (record(trigger).ok !== true) {
const observedAt = new Date().toISOString();
const comment = commentRuntimeLanePullRequest(spec, {
pr,
phase: "cd-trigger",
state: "cd-failed",
startedAt,
observedAt,
elapsedSeconds: durationSeconds(startedAt, observedAt),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun,
cd: beforeSummary,
dryRun: false,
message: `PR 已合并,但 ${spec.version} CD 触发失败;需要查看 trigger 阶段的 degradedReason。`,
});
const issue = reportRuntimeLaneAutomationIssue({
spec,
pr,
state: "cd-failed",
phase: "cd-trigger",
startedAt,
observedAt,
preflight,
sourceCommit,
pipelineRun,
cd: beforeSummary,
details: { trigger },
message: `${spec.version} CD 触发失败,PipelineRun 未进入可等待状态。`,
dryRun: false,
});
return { ok: false, phase: "cd-trigger", pr, sourceCommit, pipelineRun, trigger, comment, issue };
}
const cd = await waitForRuntimeLaneCd(spec, sourceCommit, options.timeoutSeconds);
const cdStatus = record(cd.status);
const flush = record(cd.flush);
const cdOk = cd.ok === true;
const cdPhase = String(cd.phase ?? "");
const cdPipelineRun = stringOrNull(cdStatus.pipelineRun) ?? pipelineRun;
const observedAt = new Date().toISOString();
const finalComment = commentRuntimeLanePullRequest(spec, {
pr,
phase: cdPhase,
state: cdOk ? "cd-succeeded" : cdPhase === "timeout" ? "cd-timeout" : "cd-failed",
startedAt,
observedAt,
elapsedSeconds: durationSeconds(startedAt, observedAt),
preflight,
merge: commandData(merge),
sourceCommit,
pipelineRun: cdPipelineRun,
cd: cdStatus,
flush,
dryRun: false,
message: cdOk
? `${spec.version} 自动 CD 已完成:PipelineRun、Argo/runtime、公开探针和 Git mirror flush 收口均已检查。`
: cdPhase === "timeout"
? `${spec.version} 自动 CD 超时未收口;本评论保留最后一次 PipelineRun / Argo / public probe / Git mirror 状态用于接续排障。`
: `${spec.version} 自动 CD 失败;本评论保留失败阶段和最后一次状态用于接续排障。`,
});
const issue = cdOk ? { ok: true, skipped: true, reason: "cd-succeeded" } : reportRuntimeLaneAutomationIssue({
spec,
pr,
state: cdPhase === "timeout" ? "cd-timeout" : "cd-failed",
phase: cdPhase || "cd",
startedAt,
observedAt,
preflight,
sourceCommit,
pipelineRun: cdPipelineRun,
cd: cdStatus,
details: { cd },
message: cdPhase === "timeout" ? `${spec.version} 自动 CD 超时。` : `${spec.version} 自动 CD 失败。`,
dryRun: false,
});
return {
ok: cdOk && record(finalComment).ok === true && record(issue).ok === true,
action: cdOk ? `merged-and-rolled-${spec.lane}` : `${spec.lane}-cd-failed`,
phase: cdOk ? "cd-passed" : cdPhase,
pr,
sourceCommit,
pipelineRun: cdPipelineRun,
trigger,
cd,
comment: finalComment,
issue,
};
}
+576
View File
@@ -0,0 +1,576 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. cleanup module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:2788-3345 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { CommandJsonResult, G14ControlPlaneOptions } from "./types";
import { g14HostScript } from "./images";
import { statusText } from "./pr-monitor";
import { g14K3s, isCommandSuccess, nested, shellQuote, shortSha, stringOrNull } from "./remote";
import { runtimeLanePipelineRunName, v02PipelineRunName } from "./source";
import { CI_NAMESPACE } from "./types";
export interface CleanupPipelineRunRow {
name: string;
createdAt: string;
ageMinutes: number | null;
status: string | null;
reason: string | null;
}
export function pipelinePrefixesForLane(lane: G14ControlPlaneOptions["lane"]): string[] {
if (isHwlabRuntimeLane(lane)) return [`${hwlabRuntimeLaneSpec(lane).pipelineRunPrefix}-`];
if (lane === "g14") return ["hwlab-g14-ci-poll-"];
return [...hwlabRuntimeLaneIds().map((runtimeLane) => `${hwlabRuntimeLaneSpec(runtimeLane).pipelineRunPrefix}-`), "hwlab-g14-ci-poll-"];
}
export function commandErrorSummary(result: CommandJsonResult): string {
const remoteStderr = String(nested(result.parsed, ["data", "stderr"]) ?? "").trim();
const remoteStdout = String(nested(result.parsed, ["data", "stdout"]) ?? "").trim();
const text = [result.stderr.trim(), remoteStderr, remoteStdout, result.stdout.trim()].find((item) => item.length > 0) ?? "";
return text.slice(0, 4000);
}
export function tailText(value: unknown, maxBytes = 2000): string {
const text = String(value ?? "").trim();
return text.length <= maxBytes ? text : text.slice(-maxBytes);
}
export function cleanupPipelineRunFieldsJsonPath(range: boolean): string {
const body = [
"{.metadata.name}",
"{\"\\t\"}",
"{.metadata.creationTimestamp}",
"{\"\\t\"}",
"{.status.conditions[0].status}",
"{\"\\t\"}",
"{.status.conditions[0].reason}",
"{\"\\n\"}",
].join("");
return range ? `jsonpath={range .items[*]}${body}{end}` : `jsonpath=${body}`;
}
export function parseCleanupPipelineRunLine(line: string, nowMs: number): CleanupPipelineRunRow | null {
const [name = "", createdAt = "", status = "", reason = ""] = line.trim().split("\t");
if (name.length === 0) return null;
const createdMs = Date.parse(createdAt);
const ageMinutes = Number.isFinite(createdMs) ? Math.floor((nowMs - createdMs) / 60000) : null;
return { name, createdAt, ageMinutes, status: status || null, reason: reason || null };
}
export function formatRetentionBytes(bytes: number): string | null {
if (!Number.isFinite(bytes)) return null;
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(unit === 0 ? 0 : 1)}${units[unit]}`;
}
export function storageHostPathFromClaim(volume: string | null, namespace: string, claimName: string | null): string | null {
if (volume === null || claimName === null) return null;
if (!/^pvc-[a-z0-9-]+$/iu.test(volume) || !/^pvc-[a-z0-9]+$/iu.test(claimName)) return null;
return `/var/lib/rancher/k3s/storage/${volume}_${namespace}_${claimName}`;
}
export function safeStorageHostPath(value: unknown): string | null {
const path = stringOrNull(value);
return path !== null && path.startsWith("/var/lib/rancher/k3s/storage/") ? path : null;
}
export function remoteStoragePathEstimates(paths: Array<string | null | undefined>): Map<string, number | null> {
const uniquePaths = [...new Set(paths.map((item) => safeStorageHostPath(item)).filter((item): item is string => item !== null))];
const estimates = new Map<string, number | null>(uniquePaths.map((path) => [path, null]));
if (uniquePaths.length === 0) return estimates;
const script = [
"set -eu",
`for path in ${uniquePaths.map((path) => shellQuote(path)).join(" ")}; do`,
" bytes=\"\"",
" if [ -e \"$path\" ]; then bytes=$(du -sB1 \"$path\" 2>/dev/null | awk '{print $1}' || true); fi",
" printf '%s\\t%s\\n' \"$path\" \"$bytes\"",
"done",
].join("\n");
const result = g14HostScript(script, 120_000);
if (!isCommandSuccess(result)) return estimates;
for (const line of statusText(result).split(/\r?\n/u)) {
const [path = "", rawBytes = ""] = line.trim().split("\t");
if (!estimates.has(path)) continue;
const bytes = /^\d+$/u.test(rawBytes) ? Number(rawBytes) : null;
estimates.set(path, Number.isFinite(bytes) ? bytes : null);
}
return estimates;
}
export function sumEstimatedBytes(items: Record<string, unknown>[]): number {
return items.reduce((sum, item) => {
const bytes = item.estimatedBytes;
return sum + (typeof bytes === "number" && Number.isFinite(bytes) ? bytes : 0);
}, 0);
}
export function cleanupPipelineRunTargetCandidate(input: {
targetPipelineRun: string;
text: string;
commandOk: boolean;
exitCode: number | null;
stderr: string;
minAgeMinutes: number;
nowMs: number;
}): Record<string, unknown> {
if (!input.commandOk) {
const queryError = tailText(input.stderr);
const notFound = /notfound|not found/i.test(queryError);
return {
name: input.targetPipelineRun,
createdAt: null,
ageMinutes: null,
status: null,
reason: notFound ? "target-pipelinerun-not-found" : "target-pipelinerun-query-failed",
selected: false,
queryExitCode: input.exitCode,
...(queryError.length > 0 ? { queryError } : {}),
};
}
const target = parseCleanupPipelineRunLine(input.text, input.nowMs);
if (target === null || target.name !== input.targetPipelineRun) {
return {
name: input.targetPipelineRun,
createdAt: null,
ageMinutes: null,
status: null,
reason: "target-pipelinerun-query-empty",
selected: false,
queryOutputPreview: tailText(input.text),
};
}
if (target.status !== "True" && target.status !== "False") {
return {
...target,
selected: false,
selectedReason: "target-pipelinerun-not-terminal",
};
}
const ageMinutes = typeof target.ageMinutes === "number" ? target.ageMinutes : null;
const belowMinAge = ageMinutes === null || ageMinutes < input.minAgeMinutes;
return {
...target,
selected: !belowMinAge,
...(belowMinAge
? { selectedReason: ageMinutes === null ? "missing-creation-timestamp" : "below-min-age" }
: {}),
};
}
export function cleanupPipelineRunTargetCandidateFromTextForTest(input: {
targetPipelineRun: string;
text: string;
commandOk?: boolean;
exitCode?: number | null;
stderr?: string;
minAgeMinutes?: number;
nowMs?: number;
}): Record<string, unknown> {
return cleanupPipelineRunTargetCandidate({
targetPipelineRun: input.targetPipelineRun,
text: input.text,
commandOk: input.commandOk ?? true,
exitCode: input.exitCode ?? 0,
stderr: input.stderr ?? "",
minAgeMinutes: input.minAgeMinutes ?? 60,
nowMs: input.nowMs ?? Date.now(),
});
}
export function listCleanupPipelineRuns(options: G14ControlPlaneOptions): Record<string, unknown>[] {
if (options.lane !== "g14" && options.lane !== "all" && !isHwlabRuntimeLane(options.lane)) {
throw new Error("control-plane cleanup-runs requires --lane v02|v03|g14|all");
}
const targetPipelineRun = options.pipelineRun ?? (options.sourceCommit === undefined
? undefined
: isHwlabRuntimeLane(options.lane)
? runtimeLanePipelineRunName(hwlabRuntimeLaneSpec(options.lane), options.sourceCommit)
: v02PipelineRunName(options.sourceCommit));
const now = Date.now();
if (targetPipelineRun !== undefined) {
const targetResult = g14K3s([
"kubectl",
"get",
"pipelinerun",
"-n",
CI_NAMESPACE,
targetPipelineRun,
"-o",
cleanupPipelineRunFieldsJsonPath(false),
], 60_000);
return [cleanupPipelineRunTargetCandidate({
targetPipelineRun,
text: statusText(targetResult),
commandOk: isCommandSuccess(targetResult),
exitCode: targetResult.exitCode,
stderr: commandErrorSummary(targetResult),
minAgeMinutes: options.minAgeMinutes,
nowMs: now,
})];
}
const result = g14K3s([
"kubectl",
"get",
"pipelinerun",
"-n",
CI_NAMESPACE,
"-o",
cleanupPipelineRunFieldsJsonPath(true),
], 60_000);
if (!isCommandSuccess(result)) {
throw new Error(`failed to list hwlab-ci PipelineRuns: ${commandErrorSummary(result)}`);
}
const prefixes = pipelinePrefixesForLane(options.lane);
const terminalRuns = statusText(result)
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => parseCleanupPipelineRunLine(line, now))
.filter((item): item is CleanupPipelineRunRow => item !== null)
.filter((item) => item.name.length > 0 && prefixes.some((prefix) => item.name.startsWith(prefix)))
.filter((item) => item.status === "True" || item.status === "False")
.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)));
const protectedLatestByPrefix = new Map<string, string>();
for (const prefix of prefixes) {
const latest = terminalRuns
.filter((item) => item.name.startsWith(prefix))
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))[0];
if (latest !== undefined) protectedLatestByPrefix.set(prefix, latest.name);
}
const candidates = terminalRuns
.filter((item) => typeof item.ageMinutes === "number" && item.ageMinutes >= options.minAgeMinutes)
.map((item) => {
const protectedLatest = [...protectedLatestByPrefix.values()].includes(item.name);
return protectedLatest
? { ...item, selected: false, selectedReason: "protected-latest-pipelinerun" }
: item;
})
.slice(0, options.limit);
return candidates;
}
export function listOwnedWorkspacePvcs(pipelineRunNames: string[]): Record<string, unknown>[] {
if (pipelineRunNames.length === 0) {
return [];
}
const result = g14K3s([
"kubectl",
"get",
"pvc",
"-n",
CI_NAMESPACE,
"-o",
'jsonpath={range .items[*]}{.metadata.name}{"\\t"}{.spec.volumeName}{"\\t"}{.status.phase}{"\\t"}{.metadata.ownerReferences[0].kind}{"\\t"}{.metadata.ownerReferences[0].name}{"\\n"}{end}',
], 60_000);
if (!isCommandSuccess(result)) {
throw new Error(`failed to list hwlab-ci PipelineRun PVCs: ${commandErrorSummary(result)}`);
}
const pvResult = g14K3s([
"kubectl",
"get",
"pv",
"-o",
'jsonpath={range .items[*]}{.metadata.name}{"\\t"}{.spec.storageClassName}{"\\t"}{.spec.persistentVolumeReclaimPolicy}{"\\t"}{.spec.local.path}{"\\t"}{.spec.hostPath.path}{"\\n"}{end}',
], 60_000);
if (!isCommandSuccess(pvResult)) {
throw new Error(`failed to list hwlab-ci PipelineRun PV backing paths: ${commandErrorSummary(pvResult)}`);
}
const podResult = g14K3s([
"kubectl",
"get",
"pod",
"-n",
CI_NAMESPACE,
"-o",
'jsonpath={range .items[*]}{.metadata.name}{"\\t"}{.status.phase}{"\\t"}{range .spec.volumes[*]}{.persistentVolumeClaim.claimName}{","}{end}{"\\n"}{end}',
], 60_000);
if (!isCommandSuccess(podResult)) {
throw new Error(`failed to list hwlab-ci active pod PVC mounts: ${commandErrorSummary(podResult)}`);
}
const pvByName = new Map<string, Record<string, unknown>>();
for (const line of statusText(pvResult).split(/\r?\n/u)) {
const [name = "", storageClass = "", reclaimPolicy = "", localPath = "", hostPath = ""] = line.trim().split("\t");
if (name.length === 0) continue;
pvByName.set(name, {
storageClass: storageClass || null,
reclaimPolicy: reclaimPolicy || null,
hostPath: safeStorageHostPath(localPath) ?? safeStorageHostPath(hostPath),
});
}
const activeClaimPods = new Map<string, string[]>();
for (const line of statusText(podResult).split(/\r?\n/u)) {
const [podName = "", phase = "", claims = ""] = line.trim().split("\t");
if (podName.length === 0 || phase === "Succeeded" || phase === "Failed") continue;
for (const claimName of claims.split(",").map((item) => item.trim()).filter(Boolean)) {
const entry = activeClaimPods.get(claimName) ?? [];
entry.push(podName);
activeClaimPods.set(claimName, entry);
}
}
const wanted = new Set(pipelineRunNames);
const ownedPvcs = statusText(result)
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [name = "", volume = "", phase = "", ownerKind = "", owner = ""] = line.split("\t");
const pv = volume.length > 0 ? pvByName.get(volume) : undefined;
const hostPath = safeStorageHostPath(pv?.hostPath) ?? storageHostPathFromClaim(volume || null, CI_NAMESPACE, name || null);
return {
name,
volume: volume || null,
phase: phase || null,
ownerKind: ownerKind || null,
owner: owner || null,
storageClass: pv?.storageClass ?? null,
reclaimPolicy: pv?.reclaimPolicy ?? null,
hostPath,
activeMountPods: activeClaimPods.get(name) ?? [],
};
})
.filter((item) => item.ownerKind === "PipelineRun" && typeof item.owner === "string" && wanted.has(item.owner));
const estimates = remoteStoragePathEstimates(ownedPvcs.map((item) => stringOrNull(item.hostPath)));
return ownedPvcs.map((item) => ({
...item,
estimatedBytes: item.hostPath === null ? null : estimates.get(String(item.hostPath)) ?? null,
}));
}
export function deletePipelineRuns(names: string[], timeoutMs: number): CommandJsonResult {
if (names.length === 0) {
return {
ok: true,
command: [],
exitCode: 0,
stdout: "no candidates",
stderr: "",
parsed: null,
};
}
return g14K3s(["kubectl", "delete", "pipelinerun", "-n", CI_NAMESPACE, ...names, "--ignore-not-found=true"], timeoutMs);
}
export function listReleasedCiWorkspacePvs(options: G14ControlPlaneOptions): Record<string, unknown>[] {
const result = g14K3s([
"kubectl",
"get",
"pv",
"-o",
'jsonpath={range .items[*]}{.metadata.name}{"\\t"}{.metadata.creationTimestamp}{"\\t"}{.status.phase}{"\\t"}{.spec.storageClassName}{"\\t"}{.spec.persistentVolumeReclaimPolicy}{"\\t"}{.spec.claimRef.namespace}{"\\t"}{.spec.claimRef.name}{"\\t"}{.spec.capacity.storage}{"\\t"}{.spec.local.path}{"\\t"}{.spec.hostPath.path}{"\\n"}{end}',
], 60_000);
if (!isCommandSuccess(result)) {
throw new Error(`failed to list released hwlab-ci PVs: ${commandErrorSummary(result)}`);
}
const candidates = statusText(result)
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [name = "", createdAt = "", phase = "", storageClass = "", reclaimPolicy = "", claimNamespace = "", claimName = "", capacity = "", localPath = "", hostPath = ""] = line.split("\t");
return {
name,
createdAt,
phase,
storageClass,
reclaimPolicy,
claimNamespace,
claimName,
capacity,
hostPath: safeStorageHostPath(localPath) ?? safeStorageHostPath(hostPath) ?? (claimNamespace === CI_NAMESPACE ? storageHostPathFromClaim(name || null, claimNamespace, claimName || null) : null),
};
})
.filter((item) => item.phase === "Released")
.filter((item) => item.storageClass === "local-path" && item.reclaimPolicy === "Delete")
.filter((item) => item.claimNamespace === CI_NAMESPACE && typeof item.claimName === "string" && /^pvc-[a-z0-9]+$/u.test(item.claimName))
.sort((a, b) => String(a.createdAt).localeCompare(String(b.createdAt)))
.slice(0, options.limit);
const estimates = remoteStoragePathEstimates(candidates.map((item) => stringOrNull(item.hostPath)));
return candidates.map((item) => ({
...item,
estimatedBytes: item.hostPath === null ? null : estimates.get(String(item.hostPath)) ?? null,
}));
}
export function deletePersistentVolumes(names: string[], timeoutMs: number): CommandJsonResult {
if (names.length === 0) {
return {
ok: true,
command: [],
exitCode: 0,
stdout: "no candidates",
stderr: "",
parsed: null,
};
}
return g14K3s(["kubectl", "delete", "pv", ...names, "--ignore-not-found=true"], timeoutMs);
}
export function runControlPlaneReleasedPvCleanup(options: G14ControlPlaneOptions): Record<string, unknown> {
const candidates = listReleasedCiWorkspacePvs(options);
const candidateNames = candidates.map((item) => String(item.name));
const estimatedReclaimBytes = sumEstimatedBytes(candidates);
if (options.dryRun) {
return {
ok: true,
command: "hwlab g14 control-plane cleanup-released-pvs",
mode: "dry-run",
lane: options.lane,
limit: options.limit,
candidates,
candidateCount: candidates.length,
selectedPersistentVolumes: candidateNames,
selectedPersistentVolumeCount: candidateNames.length,
estimatedReclaimBytes,
estimatedReclaimHuman: formatRetentionBytes(estimatedReclaimBytes),
mutation: false,
next: { confirm: `bun scripts/cli.ts hwlab g14 control-plane cleanup-released-pvs --lane all --limit ${options.limit} --confirm` },
};
}
const deletion = deletePersistentVolumes(candidateNames, options.timeoutSeconds * 1000);
return {
ok: isCommandSuccess(deletion),
command: "hwlab g14 control-plane cleanup-released-pvs",
mode: "confirmed-cleanup",
lane: options.lane,
limit: options.limit,
deletedPersistentVolumes: candidateNames,
deletedPersistentVolumeCount: candidateNames.length,
candidatesBefore: candidates,
estimatedReclaimBytes,
estimatedReclaimHuman: formatRetentionBytes(estimatedReclaimBytes),
deletion,
followUp: {
diskPressure: "trans G14:k3s kubectl get node ubuntu-rog-zephyrus-g14-ga401iv-ga401iv -o jsonpath='{range .status.conditions[*]}{.type}{\"=\"}{.status}{\" \"}{.reason}{\"\\n\"}{end}'",
storage: "trans G14 sh -- 'df -h /; sudo du -xh -d 1 /var/lib/rancher/k3s/storage 2>/dev/null | sort -h | tail -20'",
},
};
}
export function runV02RuntimeMigration(options: G14ControlPlaneOptions, sourceCommit: string): Record<string, unknown> {
const reportPath = `/tmp/hwlab-v02-runtime-migration-${shortSha(sourceCommit)}.json`;
const migrationArgs = options.dryRun
? [
...(options.allowLiveDbRead ? ["--dry-run", "--allow-live-db-read", "--confirm-dev"] : ["--check"]),
"--report",
reportPath,
]
: [
"--apply",
"--confirm-dev",
"--confirmed-non-production",
"--report",
reportPath,
];
const command = [
"exec",
"-n",
"hwlab-v02",
"deployment/hwlab-cloud-api",
"-c",
"hwlab-cloud-api",
"--",
"bun",
"cmd/hwlab-cloud-api/migrate.ts",
...migrationArgs,
];
const result = g14K3s(["kubectl", ...command], options.timeoutSeconds * 1000);
const ok = isCommandSuccess(result);
return {
ok,
command: "hwlab g14 control-plane runtime-migration --lane v02",
lane: "v02",
mode: options.dryRun ? options.allowLiveDbRead ? "live-read-dry-run" : "source-check" : "confirmed-apply",
sourceCommit,
runtimeNamespace: "hwlab-v02",
target: "deployment/hwlab-cloud-api -c hwlab-cloud-api",
migrationCommand: ["bun", "cmd/hwlab-cloud-api/migrate.ts", ...migrationArgs],
reportPath,
mutation: !options.dryRun,
result,
next: options.dryRun
? {
liveReadDryRun: "bun scripts/cli.ts hwlab g14 control-plane runtime-migration --lane v02 --allow-live-db-read --dry-run",
apply: "bun scripts/cli.ts hwlab g14 control-plane runtime-migration --lane v02 --confirm",
}
: {
health: "curl -fsS --max-time 20 http://74.48.78.17:19667/health/live",
triggerCurrent: "bun scripts/cli.ts hwlab g14 control-plane trigger-current --lane v02 --confirm",
},
};
}
export function runControlPlaneCleanup(options: G14ControlPlaneOptions): Record<string, unknown> {
const candidates = listCleanupPipelineRuns(options);
const candidateNames = candidates
.filter((item) => item.selected !== false)
.map((item) => String(item.name));
const ownedPvcs = listOwnedWorkspacePvcs(candidateNames);
const estimatedReclaimBytes = sumEstimatedBytes(ownedPvcs);
const followUpStatusLane = isHwlabRuntimeLane(options.lane) ? options.lane : "v02";
if (options.dryRun) {
return {
ok: true,
command: "hwlab g14 control-plane cleanup-runs",
mode: "dry-run",
lane: options.lane,
minAgeMinutes: options.minAgeMinutes,
limit: options.limit,
sourceCommit: options.sourceCommit,
pipelineRun: options.pipelineRun,
candidates,
candidateCount: candidates.length,
selectedPipelineRuns: candidateNames,
selectedPipelineRunCount: candidateNames.length,
ownedPvcs,
ownedPvcCount: ownedPvcs.length,
estimatedReclaimBytes,
estimatedReclaimHuman: formatRetentionBytes(estimatedReclaimBytes),
mutation: false,
next: { confirm: [
"bun scripts/cli.ts hwlab g14 control-plane cleanup-runs",
`--lane ${options.lane}`,
`--min-age-minutes ${options.minAgeMinutes}`,
`--limit ${options.limit}`,
options.pipelineRun === undefined ? "" : `--pipeline-run ${options.pipelineRun}`,
options.sourceCommit === undefined ? "" : `--source-commit ${options.sourceCommit}`,
"--confirm",
].filter(Boolean).join(" ") },
};
}
const deletion = deletePipelineRuns(candidateNames, options.timeoutSeconds * 1000);
return {
ok: isCommandSuccess(deletion),
command: "hwlab g14 control-plane cleanup-runs",
mode: "confirmed-cleanup",
lane: options.lane,
minAgeMinutes: options.minAgeMinutes,
limit: options.limit,
sourceCommit: options.sourceCommit,
pipelineRun: options.pipelineRun,
deletedPipelineRuns: candidateNames,
deletedPipelineRunCount: candidateNames.length,
ownedPvcsBefore: ownedPvcs,
ownedPvcCountBefore: ownedPvcs.length,
estimatedReclaimBytes,
estimatedReclaimHuman: formatRetentionBytes(estimatedReclaimBytes),
deletion,
followUp: {
status: `bun scripts/cli.ts hwlab g14 control-plane status --lane ${followUpStatusLane}`,
diskPressure: "trans G14:k3s kubectl get node ubuntu-rog-zephyrus-g14-ga401iv-ga401iv -o jsonpath='{.spec.taints}{\"\\n\"}{range .status.conditions[*]}{.type}{\"=\"}{.status}{\" \"}{.reason}{\"\\n\"}{end}'",
},
};
}
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. entry module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:10619-12000 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import { runRuntimeLaneControlPlane, runV02ControlPlane } from "./control-plane";
import { runG14GitMirror } from "./git-mirror";
import { hwlabG14Help, monitorStatus } from "./help";
import { runG14ToolsImage, runG14UpstreamImage, startControlPlaneTriggerJob, startGitMirrorJob } from "./images";
import { runMonitorWorker } from "./monitor";
import { runG14Observability } from "./observability";
import { hwlabG14MonitorStateFileName, hwlabG14MonitorStateRole, parseControlPlaneOptions, parseGitMirrorOptions, parseLegacyRetirementOptions, parseObservabilityOptions, parseOptions, parseSecretOptions, parseToolsImageOptions, parseUpstreamImageOptions } from "./options";
import { monitorBaseBranch } from "./remote";
import { legacyG14RecordRolloutRetired, legacyG14RetirementBlocksMonitor, runLegacyG14Retirement } from "./retirement";
import { runG14Secret } from "./secrets";
import { G14_SOURCE_BRANCH, HWLAB_REPO } from "./types";
export async function runHwlabG14Command(_config: Config, args: string[]): Promise<Record<string, unknown>> {
if (args.length === 0 || args.includes("--help") || args.includes("-h")) return { ok: true, ...hwlabG14Help() };
const [action] = args;
if (action === "record-rollout") {
return legacyG14RecordRolloutRetired();
}
if (action === "control-plane") {
const options = parseControlPlaneOptions(args.slice(1));
if (options.action === "trigger-current" && options.confirm && !options.dryRun && !options.wait) {
return startControlPlaneTriggerJob(options);
}
if (isHwlabRuntimeLane(options.lane) && options.lane !== "v02") {
return runRuntimeLaneControlPlane(hwlabRuntimeLaneSpec(options.lane), options);
}
return runV02ControlPlane(options);
}
if (action === "retirement") {
const options = parseLegacyRetirementOptions(args.slice(1));
return runLegacyG14Retirement(options);
}
if (action === "secret") {
const options = parseSecretOptions(args.slice(1));
return runG14Secret(options);
}
if (action === "tools-image") {
const options = parseToolsImageOptions(args.slice(1));
return runG14ToolsImage(options);
}
if (action === "upstream-image") {
const options = parseUpstreamImageOptions(args.slice(1));
return runG14UpstreamImage(options);
}
if (action === "git-mirror") {
const options = parseGitMirrorOptions(args.slice(1));
if ((options.action === "sync" || options.action === "flush") && options.confirm && !options.dryRun && !options.wait) {
return startGitMirrorJob(options);
}
return runG14GitMirror(options);
}
if (action === "observability") {
const options = parseObservabilityOptions(args.slice(1));
return runG14Observability(options);
}
if (action !== "monitor-prs") {
return { ok: false, command: `hwlab g14 ${action ?? ""}`.trim(), degradedReason: "unsupported-command", message: "supported commands: hwlab g14 monitor-prs, hwlab g14 retirement, hwlab g14 control-plane, hwlab g14 secret, hwlab g14 git-mirror, hwlab g14 observability, hwlab g14 tools-image, hwlab g14 upstream-image" };
}
const options = parseOptions(args.slice(1));
if (options.worker) return runMonitorWorker(options);
if (args.includes("--status")) return monitorStatus(options);
const retirement = options.lane === "g14" ? legacyG14RetirementBlocksMonitor() : null;
if (retirement !== null) {
return {
ok: false,
command: "hwlab g14 monitor-prs",
lane: options.lane,
baseBranch: monitorBaseBranch(options.lane),
degradedReason: "legacy-g14-dev-prod-retired",
message: "Legacy G14 DEV/PROD has a retirement marker; start a runtime lane monitor with --lane v02/v03 or inspect retirement status.",
retirement,
next: {
retirementStatus: "bun scripts/cli.ts hwlab g14 retirement status",
v02Monitor: "bun scripts/cli.ts hwlab g14 monitor-prs --lane v02",
v03Monitor: "bun scripts/cli.ts hwlab g14 monitor-prs --lane v03",
},
};
}
const command = ["bun", "scripts/cli.ts", "hwlab", "g14", "monitor-prs", "--worker", "--lane", options.lane, "--interval-seconds", String(options.intervalSeconds), "--timeout-seconds", String(options.timeoutSeconds), ...(options.once ? ["--once"] : []), ...(options.dryRun ? ["--dry-run"] : []), ...(options.maxCycles > 0 ? ["--max-cycles", String(options.maxCycles)] : [])];
const jobName = options.lane === "g14" ? "hwlab_g14_pr_monitor" : `hwlab_g14_${options.lane}_pr_monitor`;
const jobNote = options.lane === "g14"
? `Monitor ${HWLAB_REPO} PRs targeting ${G14_SOURCE_BRANCH} and roll merged changes to G14 DEV`
: `Monitor ${HWLAB_REPO} PRs targeting ${monitorBaseBranch(options.lane)}, merge ready PRs, trigger ${hwlabRuntimeLaneSpec(options.lane).version} CD, comment PR progress, and file failure issues`;
const job = startJob(jobName, command, jobNote);
const statusCommand = `bun scripts/cli.ts job status ${job.id} --tail-bytes 30000`;
const stateDir = rootPath(".state", "hwlab-g14");
mkdirSync(stateDir, { recursive: true });
const stateFileName = hwlabG14MonitorStateFileName(options);
const stateFileRole = hwlabG14MonitorStateRole(options);
const latestPath = join(stateDir, stateFileName);
const previousLatest = existsSync(latestPath) ? readFileSync(latestPath, "utf8") : null;
writeFileSync(latestPath, `${JSON.stringify({ jobId: job.id, createdAt: job.createdAt, statusCommand, role: stateFileRole, lane: options.lane, baseBranch: monitorBaseBranch(options.lane) }, null, 2)}\n`, "utf8");
return {
ok: true,
command: "hwlab g14 monitor-prs",
lane: options.lane,
baseBranch: monitorBaseBranch(options.lane),
mode: "async-job",
job,
statusCommand,
latestPath,
stateFileName,
stateFileRole,
previousLatest,
next: {
status: statusCommand,
tail: `tail -f ${job.stdoutFile}`,
},
};
}
+952
View File
@@ -0,0 +1,952 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. git-mirror module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:5846-6777 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { CommandJsonResult, G14ControlPlaneOptions, G14GitMirrorOptions } from "./types";
import { tailText } from "./cleanup";
import { statusText } from "./pr-monitor";
import { compactCommandResult, g14K3s, isCommandSuccess, nested, printProgressEvent, record, shellQuote, shortSha, stringOrNull } from "./remote";
import { cleanupRuntimeLaneRenderDir, runRuntimeLaneRenderToTemp } from "./render";
import { resolveRuntimeLaneHead } from "./source";
import { GIT_MIRROR_LEGACY_CRONJOB, GIT_MIRROR_MANIFEST_FIELD_MANAGER, GIT_MIRROR_NAMESPACE, GIT_MIRROR_SYNC_JOB_PREFIX, V02_GIT_MIRROR_PRESYNC_LOCK_STALE_MS, V02_GIT_MIRROR_PRESYNC_MAX_WAIT_MS, V02_GIT_MIRROR_PRESYNC_POLL_MS, V02_GIT_READ_URL, V02_GIT_WRITE_URL } from "./types";
import { parseShellSections, shellSectionOk, timestampMs } from "./v02-status";
export function deleteLegacyGitMirrorCronJob(dryRun: boolean): CommandJsonResult {
return g14K3s([
"kubectl",
"delete",
"cronjob",
"-n",
GIT_MIRROR_NAMESPACE,
GIT_MIRROR_LEGACY_CRONJOB,
"--ignore-not-found=true",
...(dryRun ? ["--dry-run=server", "-o", "name"] : []),
], 60_000);
}
export function applyGitMirrorManifestFile(renderDir: string, dryRun: boolean, timeoutSeconds: number): CommandJsonResult {
return g14K3s([
"kubectl",
"apply",
"--server-side",
"--force-conflicts",
`--field-manager=${GIT_MIRROR_MANIFEST_FIELD_MANAGER}`,
...(dryRun ? ["--dry-run=server"] : []),
"-f",
`${renderDir}/devops-infra/git-mirror.yaml`,
], timeoutSeconds * 1000);
}
export function gitMirrorSyncJobName(): string {
return `${GIT_MIRROR_SYNC_JOB_PREFIX}-${Date.now().toString(36)}`.slice(0, 63);
}
export function gitMirrorFlushJobName(): string {
return `git-mirror-hwlab-flush-manual-${Date.now().toString(36)}`.slice(0, 63);
}
export function gitMirrorSyncJobManifest(name: string): Record<string, unknown> {
return {
apiVersion: "batch/v1",
kind: "Job",
metadata: {
name,
namespace: GIT_MIRROR_NAMESPACE,
labels: {
"app.kubernetes.io/name": "git-mirror",
"app.kubernetes.io/part-of": "devops-infra",
"app.kubernetes.io/component": "sync-controller",
"hwlab.pikastech.local/trigger": "manual-cli",
},
},
spec: {
backoffLimit: 0,
activeDeadlineSeconds: 300,
ttlSecondsAfterFinished: 3600,
template: {
metadata: {
labels: {
"app.kubernetes.io/name": "git-mirror",
"app.kubernetes.io/part-of": "devops-infra",
"app.kubernetes.io/component": "sync-controller",
"hwlab.pikastech.local/trigger": "manual-cli",
},
},
spec: {
restartPolicy: "Never",
hostNetwork: true,
dnsPolicy: "ClusterFirstWithHostNet",
volumes: [
{ name: "cache", persistentVolumeClaim: { claimName: "git-mirror-cache" } },
{ name: "git-ssh", secret: { secretName: "git-mirror-github-ssh", defaultMode: 0o400 } },
{ name: "script", configMap: { name: "git-mirror-sync-script", defaultMode: 0o755 } },
],
containers: [{
name: "sync",
image: "127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1",
imagePullPolicy: "IfNotPresent",
command: ["/script/sync.sh"],
volumeMounts: [
{ name: "cache", mountPath: "/cache" },
{ name: "git-ssh", mountPath: "/git-ssh", readOnly: true },
{ name: "script", mountPath: "/script", readOnly: true },
],
}],
},
},
},
};
}
export function gitMirrorFlushJobManifest(name: string): Record<string, unknown> {
const manifest = gitMirrorSyncJobManifest(name);
record(record(manifest.metadata).labels)["app.kubernetes.io/component"] = "flush-controller";
const template = record(record(manifest.spec).template);
record(record(template.metadata).labels)["app.kubernetes.io/component"] = "flush-controller";
const podSpec = record(template.spec);
const containers = Array.isArray(podSpec.containers) ? podSpec.containers : [];
const first = record(containers[0]);
first.name = "flush";
first.command = ["/script/flush.sh"];
podSpec.containers = [first];
return manifest;
}
export function parseGitMirrorStatusRefs(raw: string): { refs: Record<string, string | null>; pendingFlush: boolean | null } {
const line = raw.split(/\r?\n/u).find((item) => item.startsWith("refs="));
if (line === undefined) return { refs: {}, pendingFlush: null };
try {
const parsed = record(JSON.parse(line.slice("refs=".length)) as unknown);
const refs = record(parsed.refs);
const parsedRefs: Record<string, string | null> = {};
for (const [key, value] of Object.entries(refs)) parsedRefs[key] = stringOrNull(value);
return {
refs: {
...parsedRefs,
localV02: stringOrNull(refs.localV02),
githubV02: stringOrNull(refs.githubV02),
localG14: stringOrNull(refs.localG14),
githubG14: stringOrNull(refs.githubG14),
localGitops: stringOrNull(refs.localGitops),
githubGitops: stringOrNull(refs.githubGitops),
localV03: stringOrNull(refs.localV03),
githubV03: stringOrNull(refs.githubV03),
localV03Gitops: stringOrNull(refs.localV03Gitops),
githubV03Gitops: stringOrNull(refs.githubV03Gitops),
},
pendingFlush: typeof parsed.pendingFlush === "boolean" ? parsed.pendingFlush : null,
};
} catch {
return { refs: {}, pendingFlush: null };
}
}
export function parseLabeledJsonLine(raw: string, label: string): Record<string, unknown> {
const line = raw.split(/\r?\n/u).find((item) => item.startsWith(`${label}=`));
if (line === undefined) return {};
const value = line.slice(label.length + 1).trim();
if (value.length === 0) return {};
try {
return record(JSON.parse(value) as unknown);
} catch {
return {};
}
}
export function gitMirrorStatusSummary(raw: string): Record<string, unknown> {
const refs = parseGitMirrorStatusRefs(raw);
const lastSync = parseLabeledJsonLine(raw, "lastSync");
const lastWrite = parseLabeledJsonLine(raw, "lastWrite");
const lastFlush = parseLabeledJsonLine(raw, "lastFlush");
const localV02 = refs.refs.localV02 ?? null;
const githubV02 = refs.refs.githubV02 ?? null;
const localGitops = refs.refs.localGitops ?? null;
const githubGitops = refs.refs.githubGitops ?? null;
const localV03 = refs.refs.localV03 ?? null;
const githubV03 = refs.refs.githubV03 ?? null;
const localV03Gitops = refs.refs.localV03Gitops ?? null;
const githubV03Gitops = refs.refs.githubV03Gitops ?? null;
const sourceInSync = Boolean(localV02 && githubV02 && localV02 === githubV02);
const gitopsInSync = Boolean(localGitops && githubGitops && localGitops === githubGitops);
const runtimeLanes = Object.fromEntries(hwlabRuntimeLaneIds().map((lane) => {
const normalized = lane.slice(0, 1).toUpperCase() + lane.slice(1).toLowerCase();
const localSource = refs.refs[`local${normalized}`] ?? null;
const githubSource = refs.refs[`github${normalized}`] ?? null;
const localLaneGitops = refs.refs[`local${normalized}Gitops`] ?? null;
const githubLaneGitops = refs.refs[`github${normalized}Gitops`] ?? null;
return [lane, {
localSource,
githubSource,
localGitops: localLaneGitops,
githubGitops: githubLaneGitops,
sourceInSync: Boolean(localSource && githubSource && localSource === githubSource),
gitopsInSync: Boolean(localLaneGitops && githubLaneGitops && localLaneGitops === githubLaneGitops),
}];
}));
const allRuntimeLanesInSync = Object.values(runtimeLanes).every((item) => {
const laneSummary = record(item);
return laneSummary.sourceInSync === true && laneSummary.gitopsInSync === true;
});
return {
localV02,
githubV02,
localV03,
githubV03,
localG14: refs.refs.localG14 ?? null,
githubG14: refs.refs.githubG14 ?? null,
localGitops,
githubGitops,
localV03Gitops,
githubV03Gitops,
pendingFlush: refs.pendingFlush,
lastSync: {
status: stringOrNull(lastSync.status) ?? null,
at: stringOrNull(lastSync.publishedAt) ?? stringOrNull(lastSync.syncedAt) ?? null,
pendingFlush: typeof lastSync.pendingFlush === "boolean" ? lastSync.pendingFlush : null,
},
lastWrite: {
status: stringOrNull(lastWrite.status) ?? null,
at: stringOrNull(lastWrite.writtenAt) ?? null,
pendingFlush: typeof lastWrite.pendingFlush === "boolean" ? lastWrite.pendingFlush : null,
},
lastFlush: {
status: stringOrNull(lastFlush.status) ?? null,
at: stringOrNull(lastFlush.flushedAt) ?? null,
pendingFlush: typeof lastFlush.pendingFlush === "boolean" ? lastFlush.pendingFlush : null,
},
flushNeeded: refs.pendingFlush === true,
flushCommand: refs.pendingFlush === true ? "bun scripts/cli.ts hwlab g14 git-mirror flush --confirm" : null,
sourceInSync,
gitopsInSync,
v03SourceInSync: Boolean(localV03 && githubV03 && localV03 === githubV03),
v03GitopsInSync: Boolean(localV03Gitops && githubV03Gitops && localV03Gitops === githubV03Gitops),
runtimeLanes,
allRuntimeLanesInSync,
githubInSync: sourceInSync && gitopsInSync,
};
}
export function gitMirrorV02SyncRequirement(sourceCommit: string, rawStatus: string): Record<string, unknown> {
const parsed = parseGitMirrorStatusRefs(rawStatus);
const localV02 = parsed.refs.localV02 ?? null;
return {
required: localV02 !== sourceCommit,
sourceCommit,
localV02,
pendingFlush: parsed.pendingFlush,
refs: parsed.refs,
reason: localV02 === sourceCommit ? "local-v02-current" : localV02 === null ? "local-v02-unresolved" : "local-v02-stale",
};
}
export function runtimeLaneGitMirrorSourceInSyncForTest(lane: string, sourceCommit: string, summary: Record<string, unknown>): boolean {
const localKey = lane === "v02" ? "localV02" : lane === "v03" ? "localV03" : null;
return localKey !== null && typeof summary[localKey] === "string" && summary[localKey] === sourceCommit;
}
export function gitMirrorStatusCacheRaw(status: Record<string, unknown>): string {
return String(nested(status, ["cache", "raw"]) ?? "");
}
export function runtimeLaneGitMirrorSourceInSync(spec: HwlabRuntimeLaneSpec, sourceCommit: string, status: Record<string, unknown>): boolean {
return runtimeLaneGitMirrorSourceInSyncForTest(spec.lane, sourceCommit, record(nested(status, ["cache", "summary"])));
}
export function compactGitMirrorStatus(status: Record<string, unknown>, sourceCommit: string): Record<string, unknown> {
const requirement = gitMirrorV02SyncRequirement(sourceCommit, gitMirrorStatusCacheRaw(status));
return {
ok: status.ok === true,
readUrl: status.readUrl ?? V02_GIT_READ_URL,
writeUrl: status.writeUrl ?? V02_GIT_WRITE_URL,
elapsedMs: status.elapsedMs ?? null,
legacyCronJobExists: nested(status, ["legacyCronJob", "exists"]) === true,
required: requirement.required,
sourceCommit,
localV02: requirement.localV02 ?? null,
githubV02: requirement.refs.githubV02 ?? null,
pendingFlush: requirement.pendingFlush ?? null,
sourceInSync: Boolean(requirement.refs.localV02 && requirement.refs.githubV02 && requirement.refs.localV02 === requirement.refs.githubV02),
reason: requirement.reason,
cacheOk: nested(status, ["cache", "ok"]) === true,
cacheStderr: tailText(nested(status, ["cache", "stderr"]), 1000),
resourcesOk: nested(status, ["resources", "ok"]) === true,
};
}
export function compactGitMirrorSync(sync: Record<string, unknown>): Record<string, unknown> {
return {
ok: sync.ok === true,
command: sync.command ?? "hwlab g14 git-mirror sync",
mode: sync.mode ?? null,
skipped: sync.skipped === true,
reason: sync.reason ?? null,
namespace: sync.namespace ?? GIT_MIRROR_NAMESPACE,
jobName: sync.jobName ?? null,
elapsedMs: sync.elapsedMs ?? null,
exitCode: nested(sync, ["result", "exitCode"]) ?? null,
stdoutTail: tailText(nested(sync, ["result", "stdout"]), 1600),
stderrTail: tailText(nested(sync, ["result", "stderr"]), 1200),
stdoutBytes: nested(sync, ["result", "stdoutBytes"]) ?? null,
stderrBytes: nested(sync, ["result", "stderrBytes"]) ?? null,
};
}
export interface V02GitMirrorPreSyncMarker {
ok: boolean;
sourceCommit: string;
syncedAt: string;
localV02?: string | null;
githubV02?: string | null;
reason?: string | null;
}
export function v02GitMirrorPreSyncStateDir(): string {
return rootPath(".state", "hwlab-g14", "v02-git-mirror-presync");
}
export function v02GitMirrorPreSyncMarkerPath(sourceCommit: string): string {
return join(v02GitMirrorPreSyncStateDir(), `${shortSha(sourceCommit)}.json`);
}
export function v02GitMirrorPreSyncLockDir(sourceCommit: string): string {
return join(v02GitMirrorPreSyncStateDir(), `${shortSha(sourceCommit)}.lock`);
}
export function v02GitMirrorPreSyncWaitMs(timeoutSeconds: number): number {
const requestedMs = Math.max(0, Math.floor(timeoutSeconds * 1000));
if (requestedMs === 0) return V02_GIT_MIRROR_PRESYNC_MAX_WAIT_MS;
return Math.min(V02_GIT_MIRROR_PRESYNC_MAX_WAIT_MS, requestedMs);
}
export function v02ReusableGitMirrorPreSyncMarker(marker: unknown, sourceCommit: string, nowMs = Date.now(), minSyncedAtMs = 0): V02GitMirrorPreSyncMarker | null {
const candidate = record(marker) as V02GitMirrorPreSyncMarker;
const syncedAtMs = timestampMs(candidate.syncedAt);
if (candidate.ok !== true || candidate.sourceCommit !== sourceCommit) return null;
if (syncedAtMs === null || nowMs - syncedAtMs > V02_GIT_MIRROR_PRESYNC_MAX_WAIT_MS) return null;
if (syncedAtMs < minSyncedAtMs) return null;
return candidate;
}
export function readV02GitMirrorPreSyncMarker(sourceCommit: string, nowMs = Date.now(), minSyncedAtMs = 0): V02GitMirrorPreSyncMarker | null {
const path = v02GitMirrorPreSyncMarkerPath(sourceCommit);
if (!existsSync(path)) return null;
try {
return v02ReusableGitMirrorPreSyncMarker(JSON.parse(readFileSync(path, "utf8")) as unknown, sourceCommit, nowMs, minSyncedAtMs);
} catch {
return null;
}
}
export function writeV02GitMirrorPreSyncMarker(sourceCommit: string, summary: Record<string, unknown>): V02GitMirrorPreSyncMarker {
const marker = {
ok: true,
sourceCommit,
syncedAt: new Date().toISOString(),
localV02: stringOrNull(summary.localV02),
githubV02: stringOrNull(summary.githubV02),
reason: stringOrNull(summary.reason),
};
const dir = v02GitMirrorPreSyncStateDir();
mkdirSync(dir, { recursive: true });
writeFileSync(v02GitMirrorPreSyncMarkerPath(sourceCommit), `${JSON.stringify(marker, null, 2)}\n`, "utf8");
return marker;
}
export function acquireV02GitMirrorPreSyncLock(sourceCommit: string, waitMs: number, minSyncedAtMs = 0): { acquired: boolean; lockDir: string; waitedMs: number; marker?: V02GitMirrorPreSyncMarker } {
const stateDir = v02GitMirrorPreSyncStateDir();
mkdirSync(stateDir, { recursive: true });
const lockDir = v02GitMirrorPreSyncLockDir(sourceCommit);
const startedAtMs = Date.now();
for (;;) {
const marker = readV02GitMirrorPreSyncMarker(sourceCommit, Date.now(), minSyncedAtMs);
if (marker !== null) return { acquired: false, lockDir, waitedMs: Date.now() - startedAtMs, marker };
try {
mkdirSync(lockDir);
writeFileSync(join(lockDir, "owner.json"), `${JSON.stringify({ pid: process.pid, sourceCommit, acquiredAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
return { acquired: true, lockDir, waitedMs: Date.now() - startedAtMs };
} catch {
const ageMs = (() => {
try {
return Date.now() - statSync(lockDir).mtimeMs;
} catch {
return 0;
}
})();
if (ageMs > V02_GIT_MIRROR_PRESYNC_LOCK_STALE_MS) {
try {
rmSync(lockDir, { recursive: true, force: true });
continue;
} catch {
return { acquired: false, lockDir, waitedMs: Date.now() - startedAtMs };
}
}
if (Date.now() - startedAtMs >= waitMs) return { acquired: false, lockDir, waitedMs: Date.now() - startedAtMs };
const wait = runCommand(["sleep", "1"], repoRoot, { timeoutMs: 2_000 });
if (wait.exitCode !== 0 && wait.timedOut) return { acquired: false, lockDir, waitedMs: Date.now() - startedAtMs };
}
}
}
export function releaseV02GitMirrorPreSyncLock(lockDir: string): void {
try {
rmSync(lockDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup; stale lock expiry handles interrupted workers.
}
}
export function waitForV02GitMirrorPreSync(sourceCommit: string, waitMs: number): Record<string, unknown> {
const startedAtMs = Date.now();
const observations: Record<string, unknown>[] = [];
let attempt = 0;
for (;;) {
attempt += 1;
const statusStartMs = Date.now();
printProgressEvent("hwlab.v02.trigger.progress", {
stage: "git-mirror-pre-sync-wait",
status: "polling",
sourceCommit,
attempt,
elapsedMs: statusStartMs - startedAtMs,
});
const status = runGitMirrorStatus();
const summary = compactGitMirrorStatus(status, sourceCommit);
observations.push({
attempt,
elapsedMs: Date.now() - startedAtMs,
statusElapsedMs: Date.now() - statusStartMs,
ok: summary.ok,
required: summary.required,
localV02: summary.localV02,
githubV02: summary.githubV02,
pendingFlush: summary.pendingFlush,
reason: summary.reason,
});
printProgressEvent("hwlab.v02.trigger.progress", {
stage: "git-mirror-pre-sync-wait",
status: summary.required === false ? "succeeded" : "waiting",
sourceCommit,
attempt,
elapsedMs: Date.now() - startedAtMs,
statusElapsedMs: Date.now() - statusStartMs,
required: summary.required,
localV02: summary.localV02,
githubV02: summary.githubV02,
pendingFlush: summary.pendingFlush,
reason: summary.reason,
});
if (summary.ok === true && summary.required === false) {
return {
ok: true,
sourceCommit,
attempts: attempt,
elapsedMs: Date.now() - startedAtMs,
final: summary,
observations: observations.slice(-6),
};
}
if (Date.now() - startedAtMs >= waitMs) {
return {
ok: false,
sourceCommit,
attempts: attempt,
elapsedMs: Date.now() - startedAtMs,
final: summary,
observations: observations.slice(-6),
degradedReason: "git-mirror-local-v02-not-current-after-wait",
};
}
const remainingMs = waitMs - (Date.now() - startedAtMs);
const sleepMs = Math.max(500, Math.min(V02_GIT_MIRROR_PRESYNC_POLL_MS, remainingMs));
const wait = runCommand(["sleep", String(Math.ceil(sleepMs / 1000))], repoRoot, { timeoutMs: sleepMs + 2_000 });
if (wait.exitCode !== 0 && wait.timedOut) {
return {
ok: false,
sourceCommit,
attempts: attempt,
elapsedMs: Date.now() - startedAtMs,
final: summary,
observations: observations.slice(-6),
degradedReason: "git-mirror-pre-sync-wait-sleep-timeout",
};
}
}
}
export function gitMirrorCacheProbeScript(): string {
const runtimeLanesJson = JSON.stringify(hwlabRuntimeLaneIds().map((lane) => {
const spec = hwlabRuntimeLaneSpec(lane);
return { lane, sourceBranch: spec.sourceBranch, gitopsBranch: spec.gitopsBranch };
}));
return [
"printf 'lastSync='; cat /cache/HWLAB.last-sync.json 2>/dev/null || true; printf '\\n'",
"printf 'lastWrite='; cat /cache/HWLAB.last-write.json 2>/dev/null || true; printf '\\n'",
"printf 'lastFlush='; cat /cache/HWLAB.last-flush.json 2>/dev/null || true; printf '\\n'",
"printf 'refs='",
"node - <<'NODE' 2>/dev/null || true",
"const { execFileSync } = require('node:child_process');",
"const repo = '/cache/pikasTech/HWLAB.git';",
"function rev(ref) {",
" try {",
" return execFileSync('git', ['--git-dir=' + repo, 'rev-parse', ref], { encoding: 'utf8' }).trim();",
" } catch {",
" return null;",
" }",
"}",
`const runtimeLanes = ${runtimeLanesJson};`,
"const refs = {};",
"for (const spec of runtimeLanes) {",
" const key = spec.lane.toUpperCase();",
" const normalized = key.slice(0, 1) + key.slice(1).toLowerCase();",
" const localSource = rev('refs/heads/' + spec.sourceBranch);",
" const githubSource = rev('refs/mirror-stage/heads/' + spec.sourceBranch);",
" const localGitops = rev('refs/heads/' + spec.gitopsBranch);",
" const githubGitops = rev('refs/mirror-stage/heads/' + spec.gitopsBranch);",
" refs['local' + normalized] = localSource;",
" refs['github' + normalized] = githubSource;",
" refs['local' + normalized + 'Gitops'] = localGitops;",
" refs['github' + normalized + 'Gitops'] = githubGitops;",
" if (spec.lane === 'v02') {",
" refs.localV02 = localSource;",
" refs.githubV02 = githubSource;",
" refs.localGitops = localGitops;",
" refs.githubGitops = githubGitops;",
" }",
"}",
"refs.localG14 = rev('refs/heads/G14');",
"refs.githubG14 = rev('refs/mirror-stage/heads/G14');",
"const pendingFlush = runtimeLanes.some((spec) => {",
" const localGitops = rev('refs/heads/' + spec.gitopsBranch);",
" const githubGitops = rev('refs/mirror-stage/heads/' + spec.gitopsBranch);",
" return Boolean(localGitops && githubGitops && localGitops !== githubGitops);",
"});",
"console.log(JSON.stringify({ refs, pendingFlush }));",
"NODE",
].join("\n");
}
export function preSyncV02GitMirror(sourceCommit: string, options: Pick<G14ControlPlaneOptions, "dryRun" | "timeoutSeconds">): Record<string, unknown> {
const waitMs = v02GitMirrorPreSyncWaitMs(options.timeoutSeconds);
const statusStartMs = Date.now();
printProgressEvent("hwlab.v02.trigger.progress", { stage: "git-mirror-pre-sync-status", status: "started", sourceCommit });
const before = runGitMirrorStatus();
const beforeSummary = compactGitMirrorStatus(before, sourceCommit);
printProgressEvent("hwlab.v02.trigger.progress", {
stage: "git-mirror-pre-sync-status",
status: beforeSummary.ok === true ? "succeeded" : "failed",
sourceCommit,
durationMs: Date.now() - statusStartMs,
required: beforeSummary.required,
localV02: beforeSummary.localV02,
pendingFlush: beforeSummary.pendingFlush,
reason: beforeSummary.reason,
});
if (options.dryRun) {
return {
ok: true,
mode: "dry-run",
sourceCommit,
before: beforeSummary,
action: beforeSummary.required === true ? "would-sync-before-trigger" : "already-current",
next: beforeSummary.required === true ? { syncAndTrigger: "bun scripts/cli.ts hwlab g14 control-plane trigger-current --lane v02 --confirm" } : undefined,
};
}
if (beforeSummary.required !== true) {
return {
ok: true,
mode: "already-current",
sourceCommit,
before: beforeSummary,
};
}
const existingMarker = readV02GitMirrorPreSyncMarker(sourceCommit, Date.now(), statusStartMs);
if (existingMarker !== null) {
return {
ok: true,
mode: "reused-recent-git-mirror-presync-marker",
sourceCommit,
before: beforeSummary,
marker: existingMarker,
waitMs,
};
}
const lock = acquireV02GitMirrorPreSyncLock(sourceCommit, waitMs, statusStartMs);
if (!lock.acquired) {
const markerAfterLockTimeout = lock.marker ?? readV02GitMirrorPreSyncMarker(sourceCommit, Date.now(), statusStartMs);
if (markerAfterLockTimeout !== null) {
return {
ok: true,
mode: "waited-for-recent-git-mirror-presync-marker",
sourceCommit,
before: beforeSummary,
marker: markerAfterLockTimeout,
waitedMs: lock.waitedMs,
waitMs,
};
}
return {
ok: false,
mode: "local-git-mirror-presync-lock",
sourceCommit,
before: beforeSummary,
lockDir: lock.lockDir,
waitedMs: lock.waitedMs,
waitMs,
degradedReason: "git-mirror-pre-sync-lock-timeout",
};
}
try {
const markerAfterWait = readV02GitMirrorPreSyncMarker(sourceCommit, Date.now(), statusStartMs);
if (markerAfterWait !== null) {
return {
ok: true,
mode: "waited-for-recent-git-mirror-presync-marker",
sourceCommit,
before: beforeSummary,
marker: markerAfterWait,
waitedMs: lock.waitedMs,
waitMs,
};
}
const recheckStartMs = Date.now();
printProgressEvent("hwlab.v02.trigger.progress", {
stage: "git-mirror-pre-sync-recheck",
status: "started",
sourceCommit,
waitedMs: lock.waitedMs,
});
const recheck = runGitMirrorStatus();
const recheckSummary = compactGitMirrorStatus(recheck, sourceCommit);
printProgressEvent("hwlab.v02.trigger.progress", {
stage: "git-mirror-pre-sync-recheck",
status: recheckSummary.ok === true ? "succeeded" : "failed",
sourceCommit,
durationMs: Date.now() - recheckStartMs,
required: recheckSummary.required,
localV02: recheckSummary.localV02,
githubV02: recheckSummary.githubV02,
pendingFlush: recheckSummary.pendingFlush,
reason: recheckSummary.reason,
});
if (recheckSummary.ok === true && recheckSummary.required === false) {
const marker = writeV02GitMirrorPreSyncMarker(sourceCommit, recheckSummary);
return {
ok: true,
mode: "became-current-after-lock-wait",
sourceCommit,
before: beforeSummary,
recheck: recheckSummary,
marker,
waitedMs: lock.waitedMs,
waitMs,
};
}
printProgressEvent("hwlab.v02.trigger.progress", {
stage: "git-mirror-pre-sync-sync",
status: "started",
sourceCommit,
reason: recheckSummary.reason,
localV02: recheckSummary.localV02,
pendingFlush: recheckSummary.pendingFlush,
});
const sync = runGitMirrorSync({
action: "sync",
lane: "v02",
confirm: true,
dryRun: false,
wait: true,
timeoutSeconds: options.timeoutSeconds,
});
printProgressEvent("hwlab.v02.trigger.progress", {
stage: "git-mirror-pre-sync-sync",
status: sync.ok === true ? "succeeded" : "failed",
sourceCommit,
durationMs: sync.elapsedMs ?? null,
jobName: sync.jobName ?? null,
});
const syncStatus = record(sync.status);
const after = syncStatus.ok === true ? syncStatus : runGitMirrorStatus();
const afterSummary = compactGitMirrorStatus(after, sourceCommit);
const wait = sync.ok === true && afterSummary.required === true ? waitForV02GitMirrorPreSync(sourceCommit, waitMs) : null;
const waitFinal = wait === null ? null : record(record(wait).final);
const finalSummary = wait !== null && wait.ok === true ? waitFinal : afterSummary;
const ok = sync.ok === true && finalSummary.required === false;
const marker = ok ? writeV02GitMirrorPreSyncMarker(sourceCommit, finalSummary) : null;
return {
ok,
mode: "auto-sync-before-trigger",
sourceCommit,
before: beforeSummary,
recheck: recheckSummary,
sync: compactGitMirrorSync(sync),
after: afterSummary,
wait,
final: finalSummary,
marker,
waitedMs: lock.waitedMs,
waitMs,
degradedReason: ok ? undefined : stringOrNull(record(wait).degradedReason) ?? "git-mirror-local-v02-not-current-after-sync",
};
} finally {
releaseV02GitMirrorPreSyncLock(lock.lockDir);
}
}
export function runGitMirrorStatus(lane: HwlabRuntimeLane = "v02"): Record<string, unknown> {
const startedAtMs = Date.now();
const spec = hwlabRuntimeLaneSpec(lane);
const script = [
"set +e",
"section() {",
" name=\"$1\"",
" shift",
" printf '__UNIDESK_SECTION_BEGIN__ %s\\n' \"$name\"",
" \"$@\"",
" code=$?",
" printf '\\n__UNIDESK_SECTION_END__ %s exit=%s\\n' \"$name\" \"$code\"",
"}",
[
"section resources kubectl get deploy,svc,pvc,cm",
`-n ${shellQuote(GIT_MIRROR_NAMESPACE)}`,
"-l app.kubernetes.io/name=git-mirror",
"-o name",
].join(" "),
[
"section legacyCronJob kubectl get cronjob",
`-n ${shellQuote(GIT_MIRROR_NAMESPACE)}`,
shellQuote(GIT_MIRROR_LEGACY_CRONJOB),
"--ignore-not-found",
"-o name",
].join(" "),
[
`section jobs kubectl get job -n ${shellQuote(GIT_MIRROR_NAMESPACE)}`,
"-l app.kubernetes.io/name=git-mirror",
"--sort-by=.metadata.creationTimestamp",
"-o custom-columns=NAME:.metadata.name,SUCCEEDED:.status.succeeded,FAILED:.status.failed,START:.status.startTime,COMPLETION:.status.completionTime",
"--no-headers",
].join(" "),
[
"section cache kubectl exec",
`-n ${shellQuote(GIT_MIRROR_NAMESPACE)}`,
"deploy/git-mirror-http",
"-- sh -lc",
shellQuote(gitMirrorCacheProbeScript()),
].join(" "),
].join("\n");
const bundle = g14K3s(["sh", "--", script], 60_000);
const sections = parseShellSections(statusText(bundle));
const resources = sections.resources;
const legacyCronJob = sections.legacyCronJob;
const jobs = sections.jobs;
const cache = sections.cache;
const bundleStderr = bundle.stderr.trim().slice(0, 2000);
return {
ok: isCommandSuccess(bundle) && shellSectionOk(resources),
command: `hwlab g14 git-mirror status --lane ${lane}`,
lane,
namespace: GIT_MIRROR_NAMESPACE,
readUrl: V02_GIT_READ_URL,
writeUrl: V02_GIT_WRITE_URL,
elapsedMs: Date.now() - startedAtMs,
resources: {
ok: shellSectionOk(resources),
names: String(resources?.stdout ?? "").split(/\r?\n/u).map((line) => line.trim()).filter(Boolean),
stderr: shellSectionOk(resources) ? "" : bundleStderr,
},
legacyCronJob: {
ok: shellSectionOk(legacyCronJob),
exists: String(legacyCronJob?.stdout ?? "").trim().length > 0,
name: String(legacyCronJob?.stdout ?? "").trim() || null,
cleanupCommand: "bun scripts/cli.ts hwlab g14 git-mirror apply --confirm",
},
jobs: {
ok: shellSectionOk(jobs),
raw: String(jobs?.stdout ?? "").trim(),
stderr: shellSectionOk(jobs) ? "" : bundleStderr,
},
cache: {
ok: shellSectionOk(cache),
summary: gitMirrorStatusSummary(String(cache?.stdout ?? "").trim()),
raw: String(cache?.stdout ?? "").trim(),
stderr: shellSectionOk(cache) ? "" : bundleStderr,
},
next: {
apply: `bun scripts/cli.ts hwlab g14 git-mirror apply --lane ${spec.lane} --confirm`,
sync: `bun scripts/cli.ts hwlab g14 git-mirror sync --lane ${spec.lane} --confirm`,
flush: `bun scripts/cli.ts hwlab g14 git-mirror flush --lane ${spec.lane} --confirm`,
},
};
}
export function runGitMirrorApply(options: G14GitMirrorOptions): Record<string, unknown> {
const spec = hwlabRuntimeLaneSpec(options.lane);
const head = resolveRuntimeLaneHead(spec);
const sourceCommit = head.sourceCommit;
if (sourceCommit === null) {
return { ok: false, command: `hwlab g14 git-mirror apply --lane ${spec.lane}`, lane: spec.lane, degradedReason: `${spec.lane}-head-unresolved`, sourceRepo: spec.cicdRepo, workspace: spec.workspace, headProbe: compactCommandResult(head.result) };
}
const render = runRuntimeLaneRenderToTemp(spec, sourceCommit);
if (!isCommandSuccess(render.result)) {
return {
ok: false,
command: `hwlab g14 git-mirror apply --lane ${spec.lane}`,
lane: spec.lane,
phase: "source-render",
sourceCommit,
renderDir: render.renderDir,
render: compactCommandResult(render.result),
};
}
const apply = applyGitMirrorManifestFile(render.renderDir, options.dryRun, options.timeoutSeconds);
const cleanupLegacyCronJob = isCommandSuccess(apply)
? deleteLegacyGitMirrorCronJob(options.dryRun)
: {
ok: false,
command: [],
exitCode: null,
stdout: "",
stderr: "skipped because apply failed",
parsed: null,
};
const cleanupRenderDir = isCommandSuccess(apply) ? cleanupRuntimeLaneRenderDir(spec, render.renderDir) : null;
return {
ok: isCommandSuccess(apply) && isCommandSuccess(cleanupLegacyCronJob),
command: `hwlab g14 git-mirror apply --lane ${spec.lane}`,
lane: spec.lane,
mode: options.dryRun ? "dry-run" : "confirmed-apply",
sourceCommit,
manifest: `${render.renderDir}/devops-infra/git-mirror.yaml`,
render: compactCommandResult(render.result),
apply: compactCommandResult(apply),
cleanupLegacyCronJob: compactCommandResult(cleanupLegacyCronJob),
cleanupRenderDir: cleanupRenderDir === null ? null : compactCommandResult(cleanupRenderDir),
status: runGitMirrorStatus(spec.lane),
next: options.dryRun
? { apply: `bun scripts/cli.ts hwlab g14 git-mirror apply --lane ${spec.lane} --confirm` }
: { sync: `bun scripts/cli.ts hwlab g14 git-mirror sync --lane ${spec.lane} --confirm` },
};
}
export function runGitMirrorSync(options: G14GitMirrorOptions): Record<string, unknown> {
const startedAtMs = Date.now();
const spec = hwlabRuntimeLaneSpec(options.lane);
const jobName = gitMirrorSyncJobName();
const manifest = gitMirrorSyncJobManifest(jobName);
const manifestB64 = Buffer.from(JSON.stringify(manifest), "utf8").toString("base64");
const script = [
"set -eu",
`job=${shellQuote(jobName)}`,
`manifest_b64=${shellQuote(manifestB64)}`,
"manifest_path=\"/tmp/$job.json\"",
"printf '%s' \"$manifest_b64\" | base64 -d > \"$manifest_path\"",
options.dryRun
? "kubectl create --dry-run=server -f \"$manifest_path\" -o name"
: [
`kubectl delete job -n ${shellQuote(GIT_MIRROR_NAMESPACE)} "$job" --ignore-not-found=true >/dev/null`,
"kubectl create -f \"$manifest_path\"",
`deadline=$(( $(date +%s) + ${options.timeoutSeconds} ))`,
"while :; do",
` status=$(kubectl get job -n ${shellQuote(GIT_MIRROR_NAMESPACE)} "$job" -o jsonpath='succeeded={.status.succeeded} failed={.status.failed}' 2>/dev/null || true)`,
" succeeded=$(printf '%s\\n' \"$status\" | awk '{for (i = 1; i <= NF; i++) { split($i, a, \"=\"); if (a[1] == \"succeeded\") print a[2]; }}')",
" failed=$(printf '%s\\n' \"$status\" | awk '{for (i = 1; i <= NF; i++) { split($i, a, \"=\"); if (a[1] == \"failed\") print a[2]; }}')",
" if [ \"${succeeded:-0}\" = \"1\" ]; then break; fi",
" if [ \"${failed:-0}\" != \"\" ] && [ \"${failed:-0}\" != \"0\" ]; then",
` kubectl logs -n ${shellQuote(GIT_MIRROR_NAMESPACE)} "job/$job" --tail=200 || true`,
" exit 44",
" fi",
" if [ \"$(date +%s)\" -ge \"$deadline\" ]; then",
` kubectl get job,pod -n ${shellQuote(GIT_MIRROR_NAMESPACE)} -l job-name="$job" -o wide || true`,
" exit 45",
" fi",
" sleep 2",
"done",
`kubectl logs -n ${shellQuote(GIT_MIRROR_NAMESPACE)} "job/$job" --tail=200 || true`,
`kubectl exec -n ${shellQuote(GIT_MIRROR_NAMESPACE)} deploy/git-mirror-http -- sh -lc ${shellQuote(`cat /cache/HWLAB.last-sync.json 2>/dev/null || true; printf "\\n"; git --git-dir=/cache/pikasTech/HWLAB.git rev-parse refs/heads/${spec.sourceBranch} refs/mirror-stage/heads/${spec.sourceBranch} refs/heads/${spec.gitopsBranch} refs/mirror-stage/heads/${spec.gitopsBranch} 2>/dev/null || true`)}`,
].join("\n"),
].join("\n");
const result = g14K3s(["sh", "--", script], options.timeoutSeconds * 1000 + 30_000);
const syncCommand = spec.lane === "v02"
? "bun scripts/cli.ts hwlab g14 git-mirror sync --lane v02 --confirm"
: `bun scripts/cli.ts hwlab nodes git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`;
const triggerCommand = spec.lane === "v02"
? "bun scripts/cli.ts hwlab g14 control-plane trigger-current --lane v02 --confirm"
: `bun scripts/cli.ts hwlab nodes control-plane trigger-current --node ${spec.nodeId} --lane ${spec.lane} --confirm`;
return {
ok: isCommandSuccess(result),
command: spec.lane === "v02" ? "hwlab g14 git-mirror sync" : "hwlab nodes git-mirror sync",
mode: options.dryRun ? "dry-run" : "confirmed-sync",
namespace: GIT_MIRROR_NAMESPACE,
jobName,
elapsedMs: Date.now() - startedAtMs,
manifest: options.dryRun ? manifest : undefined,
result: compactCommandResult(result),
status: options.dryRun ? undefined : runGitMirrorStatus(spec.lane),
next: options.dryRun ? { sync: syncCommand } : { triggerCurrent: triggerCommand },
};
}
export function runGitMirrorFlush(options: G14GitMirrorOptions): Record<string, unknown> {
const jobName = gitMirrorFlushJobName();
const manifest = gitMirrorFlushJobManifest(jobName);
const manifestB64 = Buffer.from(JSON.stringify(manifest), "utf8").toString("base64");
const script = [
"set -eu",
`job=${shellQuote(jobName)}`,
`manifest_b64=${shellQuote(manifestB64)}`,
"manifest_path=\"/tmp/$job.json\"",
"printf '%s' \"$manifest_b64\" | base64 -d > \"$manifest_path\"",
options.dryRun
? "kubectl create --dry-run=server -f \"$manifest_path\" -o name"
: [
`kubectl delete job -n ${shellQuote(GIT_MIRROR_NAMESPACE)} "$job" --ignore-not-found=true >/dev/null`,
"kubectl create -f \"$manifest_path\"",
`deadline=$(( $(date +%s) + ${options.timeoutSeconds} ))`,
"while :; do",
` status=$(kubectl get job -n ${shellQuote(GIT_MIRROR_NAMESPACE)} "$job" -o jsonpath='succeeded={.status.succeeded} failed={.status.failed}' 2>/dev/null || true)`,
" succeeded=$(printf '%s\n' \"$status\" | awk '{for (i = 1; i <= NF; i++) { split($i, a, \"=\"); if (a[1] == \"succeeded\") print a[2]; }}')",
" failed=$(printf '%s\n' \"$status\" | awk '{for (i = 1; i <= NF; i++) { split($i, a, \"=\"); if (a[1] == \"failed\") print a[2]; }}')",
" if [ \"${succeeded:-0}\" = \"1\" ]; then break; fi",
" if [ \"${failed:-0}\" != \"\" ] && [ \"${failed:-0}\" != \"0\" ]; then",
` kubectl logs -n ${shellQuote(GIT_MIRROR_NAMESPACE)} "job/$job" --tail=200 || true`,
" exit 54",
" fi",
" if [ \"$(date +%s)\" -ge \"$deadline\" ]; then",
` kubectl get job,pod -n ${shellQuote(GIT_MIRROR_NAMESPACE)} -l job-name="$job" -o wide || true`,
" exit 55",
" fi",
" sleep 2",
"done",
`kubectl logs -n ${shellQuote(GIT_MIRROR_NAMESPACE)} "job/$job" --tail=200 || true`,
`kubectl exec -n ${shellQuote(GIT_MIRROR_NAMESPACE)} deploy/git-mirror-http -- sh -lc ${shellQuote(`cat /cache/HWLAB.last-flush.json 2>/dev/null || true; printf "\\n"; git --git-dir=/cache/pikasTech/HWLAB.git rev-parse refs/heads/${options.lane === "v02" ? "v0.2-gitops" : hwlabRuntimeLaneSpec(options.lane).gitopsBranch} refs/mirror-stage/heads/${options.lane === "v02" ? "v0.2-gitops" : hwlabRuntimeLaneSpec(options.lane).gitopsBranch} 2>/dev/null || true`)}`,
].join("\n"),
].join("\n");
const result = g14K3s(["sh", "--", script], options.timeoutSeconds * 1000 + 30_000);
return {
ok: isCommandSuccess(result),
command: "hwlab g14 git-mirror flush",
mode: options.dryRun ? "dry-run" : "confirmed-flush",
namespace: GIT_MIRROR_NAMESPACE,
jobName,
manifest: options.dryRun ? manifest : undefined,
result: compactCommandResult(result),
status: options.dryRun ? undefined : runGitMirrorStatus(options.lane),
next: options.dryRun
? { flush: `bun scripts/cli.ts hwlab g14 git-mirror flush --lane ${options.lane} --confirm` }
: { status: `bun scripts/cli.ts hwlab g14 git-mirror status --lane ${options.lane}` },
};
}
export function runG14GitMirror(options: G14GitMirrorOptions): Record<string, unknown> {
if (options.action === "status") return runGitMirrorStatus(options.lane);
if (options.action === "apply") return runGitMirrorApply(options);
if (options.action === "flush") return runGitMirrorFlush(options);
return runGitMirrorSync(options);
}
+215
View File
@@ -0,0 +1,215 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. help module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:10421-10618 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { G14MonitorOptions } from "./types";
import { hwlabG14MonitorStateFileName, hwlabG14MonitorStateRole } from "./options";
import { monitorBaseBranch, record } from "./remote";
import { legacyG14RetirementBlocksMonitor, legacyG14RetirementStatePath } from "./retirement";
import { DEFAULT_INTERVAL_SECONDS, DEV_APP, G14_BRIEF_INDEX_ISSUE, G14_CI_TOOLS_IMAGE_REPO, G14_OBSERVABILITY_NAMESPACE, G14_PROMETHEUS_OPERATOR_VERSION, G14_PROMETHEUS_VERSION, G14_PROVIDER, G14_SOURCE_BRANCH, G14_WORKSPACE, HWLAB_REPO, PROD_APP, V02_APP, V02_OBSERVABILITY_EXPECTED_TARGET_COUNT, V02_SOURCE_BRANCH, V02_WORKSPACE } from "./types";
export function hwlabG14Help(): Record<string, unknown> {
return {
command: "hwlab g14",
output: "json",
usage: [
"bun scripts/cli.ts hwlab g14 monitor-prs --lane v02",
"bun scripts/cli.ts hwlab g14 monitor-prs --lane v02 --status",
"bun scripts/cli.ts hwlab g14 monitor-prs --lane v03",
"bun scripts/cli.ts hwlab g14 monitor-prs --lane v03 --status",
"bun scripts/cli.ts hwlab g14 retirement status",
"bun scripts/cli.ts hwlab g14 retirement plan",
"bun scripts/cli.ts hwlab g14 retirement execute --confirm",
"bun scripts/cli.ts hwlab g14 monitor-prs --lane v02 --once --dry-run",
"bun scripts/cli.ts hwlab g14 monitor-prs --lane v03 --once --dry-run",
"bun scripts/cli.ts hwlab g14 control-plane status --lane v02",
"bun scripts/cli.ts hwlab g14 control-plane status --lane v02 --history",
"bun scripts/cli.ts hwlab g14 control-plane status --lane v02 --pipeline-run hwlab-v02-ci-poll-<short-sha>",
"bun scripts/cli.ts hwlab g14 control-plane status --lane v02 --source-commit <full-sha>",
"bun scripts/cli.ts hwlab g14 control-plane closeout --lane v02 --pipeline-run hwlab-v02-ci-poll-<short-sha>",
"bun scripts/cli.ts hwlab g14 control-plane closeout --lane v02 --source-commit <full-sha>",
"bun scripts/cli.ts hwlab g14 control-plane apply --lane v02 --dry-run",
"bun scripts/cli.ts hwlab g14 control-plane apply --lane v02 --confirm",
"bun scripts/cli.ts hwlab g14 control-plane refresh --lane v02 --dry-run",
"bun scripts/cli.ts hwlab g14 control-plane refresh --lane v02 --confirm",
"bun scripts/cli.ts hwlab g14 control-plane trigger-current --lane v02 --confirm",
"bun scripts/cli.ts hwlab g14 control-plane trigger-current --lane v02 --confirm --wait",
"bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03",
"bun scripts/cli.ts hwlab nodes control-plane apply --node G14 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane apply --node G14 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes control-plane refresh --node G14 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane refresh --node G14 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes control-plane trigger-current --node G14 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane trigger-current --node G14 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes control-plane trigger-current --node G14 --lane v03 --confirm --wait",
"bun scripts/cli.ts hwlab g14 control-plane cleanup-runs --lane v02 --min-age-minutes 30 --limit 20 --dry-run",
"bun scripts/cli.ts hwlab g14 control-plane cleanup-runs --lane v02 --min-age-minutes 30 --limit 20 --confirm",
"bun scripts/cli.ts hwlab g14 control-plane cleanup-runs --lane v02 --pipeline-run hwlab-v02-ci-poll-<short-sha> --dry-run",
"bun scripts/cli.ts hwlab g14 control-plane cleanup-runs --lane v02 --source-commit <full-sha> --confirm",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-runs --node G14 --lane v03 --pipeline-run hwlab-v03-ci-poll-<short-sha> --dry-run",
"bun scripts/cli.ts hwlab nodes control-plane cleanup-runs --node G14 --lane v03 --source-commit <full-sha> --confirm",
"bun scripts/cli.ts hwlab g14 control-plane cleanup-released-pvs --lane all --limit 20 --dry-run",
"bun scripts/cli.ts hwlab g14 control-plane cleanup-released-pvs --lane all --limit 20 --confirm",
"bun scripts/cli.ts hwlab g14 control-plane runtime-migration --lane v02 --dry-run",
"bun scripts/cli.ts hwlab g14 control-plane runtime-migration --lane v02 --allow-live-db-read --dry-run",
"bun scripts/cli.ts hwlab g14 control-plane runtime-migration --lane v02 --confirm",
"bun scripts/cli.ts hwlab g14 secret status --lane v02 --name hwlab-v02-openfga",
"bun scripts/cli.ts hwlab g14 secret ensure --lane v02 --name hwlab-v02-openfga --dry-run",
"bun scripts/cli.ts hwlab g14 secret ensure --lane v02 --name hwlab-v02-openfga --confirm",
"bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-openfga",
"bun scripts/cli.ts hwlab g14 secret status --lane v02 --name hwlab-v02-master-server-admin-api-key",
"bun scripts/cli.ts hwlab g14 secret ensure --lane v02 --name hwlab-v02-master-server-admin-api-key --confirm",
"bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-master-server-admin-api-key",
"bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-master-server-admin-api-key --confirm",
"bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-cloud-api-v03-db",
"bun scripts/cli.ts hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes secret cleanup-owned-postgres --node G14 --lane v03 --confirm",
"bun scripts/cli.ts hwlab nodes secret cleanup-obsolete --node G14 --lane v03 --name hwpod-v03-db --dry-run",
"bun scripts/cli.ts hwlab nodes secret cleanup-obsolete --node G14 --lane v03 --name hwpod-v03-db --confirm",
"bun scripts/cli.ts hwlab nodes secret status --node G14 --lane v03 --name hwlab-v03-code-agent-provider",
"bun scripts/cli.ts hwlab nodes secret ensure --node G14 --lane v03 --name hwlab-v03-code-agent-provider --confirm",
"bun scripts/cli.ts hwlab g14 secret delete --lane v02 --name <obsolete-hwlab-v02-secret> --dry-run",
"bun scripts/cli.ts hwlab g14 secret delete --lane v02 --name <obsolete-hwlab-v02-secret> --confirm",
"bun scripts/cli.ts hwlab g14 git-mirror status",
"bun scripts/cli.ts hwlab g14 git-mirror apply --lane v02 --confirm",
"bun scripts/cli.ts hwlab nodes git-mirror apply --node G14 --lane v03 --confirm",
"bun scripts/cli.ts hwlab g14 git-mirror sync --confirm",
"bun scripts/cli.ts hwlab g14 git-mirror flush --confirm",
"bun scripts/cli.ts hwlab g14 git-mirror sync --confirm --wait",
"bun scripts/cli.ts hwlab g14 git-mirror flush --confirm --wait",
"bun scripts/cli.ts hwlab g14 observability status",
"bun scripts/cli.ts hwlab g14 observability apply --dry-run",
"bun scripts/cli.ts hwlab g14 observability apply --confirm",
`bun scripts/cli.ts hwlab g14 observability query --promql 'up{namespace=\"hwlab-v02\"}' --expect-count ${V02_OBSERVABILITY_EXPECTED_TARGET_COUNT} --expect-value 1`,
`bun scripts/cli.ts hwlab g14 observability query --promql 'hwlab_service_up{namespace=\"hwlab-v02\"}' --expect-count ${V02_OBSERVABILITY_EXPECTED_TARGET_COUNT} --expect-value 1`,
`bun scripts/cli.ts hwlab g14 observability query --promql 'hwlab_service_health_probe_success{namespace=\"hwlab-v02\"}' --expect-count ${V02_OBSERVABILITY_EXPECTED_TARGET_COUNT} --expect-value 1`,
"bun scripts/cli.ts hwlab g14 observability targets --lane v02",
"bun scripts/cli.ts hwlab g14 observability boundary --lane v02",
"bun scripts/cli.ts hwlab g14 observability closeout --lane v02",
"bun scripts/cli.ts hwlab g14 tools-image status --name ci-node-tools --tag node22-alpine-bun-v1",
"bun scripts/cli.ts hwlab g14 tools-image build --name ci-node-tools --tag node22-alpine-bun-v1 --confirm",
"bun scripts/cli.ts hwlab g14 upstream-image status --name openfga --tag v1.17.0",
"bun scripts/cli.ts hwlab g14 upstream-image ensure --name openfga --tag v1.17.0 --dry-run",
"bun scripts/cli.ts hwlab g14 upstream-image ensure --name openfga --tag v1.17.0 --confirm",
"bun scripts/cli.ts job status <jobId> --tail-bytes 30000",
],
description: "G14 HWLAB PR monitor, legacy DEV/PROD retirement status/plan/execute command, bounded v0.2 control-plane bootstrap/cleanup/runtime-migration helper, node-scoped runtime lane v03 control-plane apply/status/refresh/trigger entry, runtime lane SecretRef bootstrap, devops-infra git mirror and observability maintenance, controlled CI tools image build/status entry, and allowlisted upstream image mirroring. The legacy base=G14 monitor is blocked by the retirement contract; the local retirement marker records live execution evidence. `--lane v02` monitors base=v0.2 PRs, waits for GitHub preflight/CI readiness, automatically merges ready PRs without waiting for other active v0.2 PipelineRuns, triggers v0.2 CD with latest-only GitOps writeback, flushes the git mirror when needed, and posts deduplicated PR comments for pending, blocked/conflict, success, superseded, failure, or timeout states. `--lane v03` monitors base=v0.3 PRs through the runtime lane control-plane, waits for GitHub preflight/CI readiness, auto-merges ready non-conflicting PRs, triggers v0.3 CD, verifies PipelineRun/Argo/runtime public probes/Git mirror flush, posts deduplicated PR comments, and creates or updates failure issues for failed checks, conflicts, merge blockers, CD failures, and timeouts. confirmed control-plane trigger-current and git-mirror sync/flush also return async jobs by default, with --wait reserved for explicit synchronous debugging. control-plane v02 keeps the full closeout/cleanup/runtime-migration verdict path; v03+ is advertised through `hwlab nodes ... --node <node-id> --lane vNN` so node identity remains configuration data instead of a command family. secret status/ensure is the standard runtime lane SecretRef bootstrap path for OpenFGA, cloud-api DB, master admin API key, and code-agent provider refs; it never reads or prints secret values. upstream-image status/ensure only mirrors allowlisted upstream runtime images into the G14 local registry. git-mirror status/apply/sync/flush is the manual devops-infra mirror/relay control path and does not install a CronJob. observability status/apply/query/targets/boundary/closeout owns the shared Prometheus Operator and Prometheus instance in devops-infra, adds bounded PromQL assertions and semantic closeout summaries, while HWLAB lane manifests own only ServiceMonitor and PrometheusRule objects.",
defaults: {
repo: HWLAB_REPO,
legacyBase: G14_SOURCE_BRANCH,
v02Base: V02_SOURCE_BRANCH,
runtimeLaneConfig: hwlabRuntimeLaneConfigPath(),
runtimeLanes: hwlabRuntimeLaneIds(),
provider: G14_PROVIDER,
legacyWorkspace: G14_WORKSPACE,
v02Workspace: V02_WORKSPACE,
v03Workspace: hwlabRuntimeLaneSpec("v03").workspace,
ciToolsImageRepo: G14_CI_TOOLS_IMAGE_REPO,
intervalSeconds: DEFAULT_INTERVAL_SECONDS,
devApplication: DEV_APP,
prodApplication: PROD_APP,
legacyRetirementState: legacyG14RetirementStatePath(),
v02Application: V02_APP,
v03Application: hwlabRuntimeLaneSpec("v03").app,
v03Node: hwlabRuntimeLaneSpec("v03").nodeId,
v03NetworkProfile: hwlabRuntimeLaneSpec("v03").networkProfileId,
v03DownloadProfile: hwlabRuntimeLaneSpec("v03").downloadProfileId,
requiredNoProxy: hwlabRequiredNoProxyEntries(),
briefIndexIssue: G14_BRIEF_INDEX_ISSUE,
observabilityNamespace: G14_OBSERVABILITY_NAMESPACE,
prometheusOperatorVersion: G14_PROMETHEUS_OPERATOR_VERSION,
prometheusVersion: G14_PROMETHEUS_VERSION,
},
stateFiles: {
monitor: ".state/hwlab-g14/latest-monitor-job.json",
once: ".state/hwlab-g14/latest-once-job.json",
dryRun: ".state/hwlab-g14/latest-dry-run-job.json",
onceDryRun: ".state/hwlab-g14/latest-once-dry-run-job.json",
v02Monitor: ".state/hwlab-g14/latest-v02-monitor-job.json",
v02Once: ".state/hwlab-g14/latest-v02-once-job.json",
v02DryRun: ".state/hwlab-g14/latest-v02-dry-run-job.json",
v02OnceDryRun: ".state/hwlab-g14/latest-v02-once-dry-run-job.json",
v02PrCommentSignatures: ".state/hwlab-g14/v02-pr-comment-signatures.json",
v03Monitor: ".state/hwlab-g14/latest-v03-monitor-job.json",
v03Once: ".state/hwlab-g14/latest-v03-once-job.json",
v03DryRun: ".state/hwlab-g14/latest-v03-dry-run-job.json",
v03OnceDryRun: ".state/hwlab-g14/latest-v03-once-dry-run-job.json",
v03PrCommentSignatures: ".state/hwlab-g14/v03-pr-comment-signatures.json",
},
};
}
export function monitorStatus(options: G14MonitorOptions): Record<string, unknown> {
const stateDir = rootPath(".state", "hwlab-g14");
const stateFileName = hwlabG14MonitorStateFileName(options);
const stateFileRole = hwlabG14MonitorStateRole(options);
const latestPath = join(stateDir, stateFileName);
const retirement = options.lane === "g14" ? legacyG14RetirementBlocksMonitor() : null;
const exists = existsSync(latestPath);
let latest: Record<string, unknown> | null = null;
let job: Record<string, unknown> | null = null;
let statusCommand: string | null = null;
let degradedReason: string | null = null;
if (exists) {
try {
latest = record(JSON.parse(readFileSync(latestPath, "utf8")) as unknown);
const jobId = typeof latest.jobId === "string" ? latest.jobId : null;
if (jobId !== null) {
statusCommand = `bun scripts/cli.ts job status ${jobId} --tail-bytes 30000`;
try {
const current = readJob(jobId);
job = {
id: current.id,
name: current.name,
status: current.status,
createdAt: current.createdAt,
startedAt: current.startedAt,
finishedAt: current.finishedAt,
exitCode: current.exitCode,
runnerPid: current.runnerPid ?? null,
stdoutFile: current.stdoutFile,
stderrFile: current.stderrFile,
note: current.note,
};
} catch (error) {
degradedReason = error instanceof Error ? error.message : String(error);
}
}
} catch (error) {
degradedReason = `failed to parse ${latestPath}: ${error instanceof Error ? error.message : String(error)}`;
}
}
return {
ok: degradedReason === null,
command: "hwlab g14 monitor-prs --status",
lane: options.lane,
baseBranch: monitorBaseBranch(options.lane),
mode: "status",
latestPath,
stateFileName,
stateFileRole,
exists,
latest,
job,
statusCommand,
degradedReason,
retirement,
next: retirement !== null ? {
retirementStatus: "bun scripts/cli.ts hwlab g14 retirement status",
v02Monitor: "bun scripts/cli.ts hwlab g14 monitor-prs --lane v02",
v03Monitor: "bun scripts/cli.ts hwlab g14 monitor-prs --lane v03",
} : statusCommand === null ? {
start: `bun scripts/cli.ts hwlab g14 monitor-prs --lane ${options.lane}`,
} : {
status: statusCommand,
},
};
}
+332
View File
@@ -0,0 +1,332 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. images module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:8075-8386 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { CommandJsonResult, G14ControlPlaneOptions, G14GitMirrorOptions, G14ToolsImageOptions, G14UpstreamImageOptions } from "./types";
import { statusText } from "./pr-monitor";
import { cliJson, isCommandSuccess, record, shellQuote } from "./remote";
import { G14_CI_TOOLS_IMAGE_REPO, G14_PROVIDER, GIT_MIRROR_NAMESPACE, V02_REGISTRY_PREFIX, V02_WORKSPACE } from "./types";
export function startAsyncHwlabG14Job(name: string, command: string[], note: string): Record<string, unknown> {
const job = startJob(name, command, note);
const statusCommand = `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`;
return {
ok: true,
mode: "async-job",
job,
statusCommand,
tailCommand: `tail -f ${job.stdoutFile}`,
next: {
status: statusCommand,
tail: `tail -f ${job.stdoutFile}`,
},
};
}
export function startControlPlaneTriggerJob(options: G14ControlPlaneOptions): Record<string, unknown> {
const command = [
"bun",
"scripts/cli.ts",
"hwlab",
"g14",
"control-plane",
"trigger-current",
"--lane",
options.lane,
"--confirm",
"--timeout-seconds",
String(options.timeoutSeconds),
"--wait",
];
if (options.rerun) command.splice(command.length - 1, 0, "--rerun");
return {
command: `hwlab g14 control-plane trigger-current --lane ${options.lane}`,
lane: options.lane,
reason: "confirmed trigger can spend tens of seconds syncing git mirror and creating PipelineRun; default is fire-and-forget to avoid silent blocking",
waitCommand: command.join(" "),
...startAsyncHwlabG14Job(
`hwlab_g14_${options.lane}_trigger_current`,
command,
`Trigger HWLAB ${options.lane} current commit PipelineRun with git mirror pre-sync through G14 control-plane`,
),
};
}
export function startGitMirrorJob(options: G14GitMirrorOptions): Record<string, unknown> {
const command = [
"bun",
"scripts/cli.ts",
"hwlab",
"g14",
"git-mirror",
options.action,
"--lane",
options.lane,
"--confirm",
"--timeout-seconds",
String(options.timeoutSeconds),
"--wait",
];
return {
command: `hwlab g14 git-mirror ${options.action} --lane ${options.lane}`,
lane: options.lane,
namespace: GIT_MIRROR_NAMESPACE,
reason: "manual git mirror sync/flush waits for a Kubernetes Job; default is fire-and-forget to keep CLI output immediately visible",
waitCommand: command.join(" "),
...startAsyncHwlabG14Job(
`hwlab_g14_git_mirror_${options.action}`,
command,
`Run HWLAB devops-infra git mirror ${options.action} through a bounded manual Kubernetes Job`,
),
};
}
export function g14HostScript(script: string, timeoutMs = 120_000): CommandJsonResult {
return cliJson(["ssh", G14_PROVIDER, "sh", "--", script], timeoutMs);
}
export function g14CiToolsImage(tag: string): string {
return `${G14_CI_TOOLS_IMAGE_REPO}:${tag}`;
}
export function runG14ToolsImageStatus(options: G14ToolsImageOptions): Record<string, unknown> {
const image = g14CiToolsImage(options.tag);
const script = [
"set -u",
`image=${shellQuote(image)}`,
"exists=false",
"image_id=",
"created=",
"size=",
"node_version=",
"npm_version=",
"bun_version=",
"git_version=",
"docker_version=",
"python_version=",
"registry_tags=",
"if docker image inspect \"$image\" >/tmp/hwlab-tools-image-inspect.json 2>/tmp/hwlab-tools-image-inspect.err; then",
" exists=true",
" image_id=$(docker image inspect \"$image\" --format '{{.Id}}' 2>/dev/null || true)",
" created=$(docker image inspect \"$image\" --format '{{.Created}}' 2>/dev/null || true)",
" size=$(docker image inspect \"$image\" --format '{{.Size}}' 2>/dev/null || true)",
" node_version=$(docker run --rm \"$image\" node --version 2>/dev/null || true)",
" npm_version=$(docker run --rm \"$image\" npm --version 2>/dev/null || true)",
" bun_version=$(docker run --rm \"$image\" bun --version 2>/dev/null || true)",
" git_version=$(docker run --rm \"$image\" git --version 2>/dev/null || true)",
" docker_version=$(docker run --rm \"$image\" docker --version 2>/dev/null || true)",
" python_version=$(docker run --rm \"$image\" python3 --version 2>/dev/null || true)",
"fi",
`registry_tags=$(curl -fsS --max-time 10 http://127.0.0.1:5000/v2/hwlab/hwlab-ci-node-tools/tags/list 2>/dev/null || true)`,
"export image exists image_id created size node_version npm_version bun_version git_version docker_version python_version registry_tags",
"node - <<'NODE'",
"const fs = require('node:fs');",
"const env = process.env;",
"const payload = {",
" image: env.image,",
" exists: env.exists === 'true',",
" imageId: env.image_id || null,",
" created: env.created || null,",
" sizeBytes: env.size ? Number(env.size) : null,",
" tools: {",
" node: env.node_version || null,",
" npm: env.npm_version || null,",
" bun: env.bun_version || null,",
" git: env.git_version || null,",
" docker: env.docker_version || null,",
" python: env.python_version || null",
" },",
" registryTagsRaw: env.registry_tags || null",
"};",
"console.log(JSON.stringify(payload, null, 2));",
"NODE",
].join("\n");
const result = g14HostScript(script, 180_000);
let parsedStatus: unknown = null;
const text = statusText(result);
if (text.length > 0) {
try {
parsedStatus = JSON.parse(text) as unknown;
} catch {
parsedStatus = null;
}
}
return {
ok: isCommandSuccess(result) && record(parsedStatus).exists === true,
command: "hwlab g14 tools-image status --name ci-node-tools",
name: options.name,
image,
status: parsedStatus ?? text,
result,
};
}
export function runG14ToolsImageBuild(options: G14ToolsImageOptions): Record<string, unknown> {
const image = g14CiToolsImage(options.tag);
const script = [
"set -eu",
`cd ${shellQuote(V02_WORKSPACE)}`,
"git fetch origin v0.2 --prune",
"git checkout v0.2 >/dev/null 2>&1 || true",
"git merge --ff-only origin/v0.2",
`test -f ${shellQuote(options.dockerfile)}`,
`image=${shellQuote(image)}`,
`dockerfile=${shellQuote(options.dockerfile)}`,
"echo \"{\\\"phase\\\":\\\"preflight\\\",\\\"image\\\":\\\"$image\\\",\\\"dockerfile\\\":\\\"$dockerfile\\\",\\\"commit\\\":\\\"$(git rev-parse HEAD)\\\"}\"",
"export HTTP_PROXY=http://127.0.0.1:10808 HTTPS_PROXY=http://127.0.0.1:10808 http_proxy=http://127.0.0.1:10808 https_proxy=http://127.0.0.1:10808",
"export NO_PROXY=localhost,127.0.0.1,::1,host.docker.internal,74.48.78.17,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,10.42.0.0/16,10.43.0.0/16,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,127.0.0.1:5000,localhost:5000",
"export no_proxy=$NO_PROXY",
"docker build --pull --build-arg HTTP_PROXY --build-arg HTTPS_PROXY --build-arg http_proxy --build-arg https_proxy --build-arg NO_PROXY --build-arg no_proxy -f \"$dockerfile\" -t \"$image\" .",
"docker run --rm \"$image\" sh -lc 'node --version && npm --version && bun --version && git --version && python3 --version && docker --version && ssh -V'",
"docker push \"$image\"",
"digest=$(docker image inspect \"$image\" --format '{{index .RepoDigests 0}}' 2>/dev/null || true)",
"echo \"{\\\"phase\\\":\\\"published\\\",\\\"image\\\":\\\"$image\\\",\\\"digest\\\":\\\"$digest\\\"}\"",
].join("\n");
if (options.dryRun) {
return {
ok: true,
command: "hwlab g14 tools-image build --name ci-node-tools",
mode: "dry-run",
image,
workspace: V02_WORKSPACE,
dockerfile: options.dockerfile,
buildScriptPreview: script.split(/\n/u),
next: { build: `bun scripts/cli.ts hwlab g14 tools-image build --name ci-node-tools --tag ${options.tag} --confirm` },
};
}
const result = g14HostScript(script, options.timeoutSeconds * 1000);
return {
ok: isCommandSuccess(result),
command: "hwlab g14 tools-image build --name ci-node-tools",
mode: "confirmed-build",
image,
workspace: V02_WORKSPACE,
dockerfile: options.dockerfile,
result,
status: runG14ToolsImageStatus({ ...options, action: "status", dryRun: true, confirm: false }),
};
}
export function runG14ToolsImage(options: G14ToolsImageOptions): Record<string, unknown> {
if (options.action === "status") return runG14ToolsImageStatus(options);
return runG14ToolsImageBuild(options);
}
export function g14UpstreamImageTarget(options: G14UpstreamImageOptions): string {
return `${V02_REGISTRY_PREFIX}/${options.name}:${options.tag}`;
}
export function g14UpstreamImageSource(options: G14UpstreamImageOptions): string {
if (options.name === "openfga") return `docker.io/openfga/openfga:${options.tag}`;
return `${options.name}:${options.tag}`;
}
export function runG14UpstreamImageStatus(options: G14UpstreamImageOptions): Record<string, unknown> {
const sourceImage = g14UpstreamImageSource(options);
const targetImage = g14UpstreamImageTarget(options);
const script = [
"set +e",
`source_image=${shellQuote(sourceImage)}`,
`target_image=${shellQuote(targetImage)}`,
`repo_path=${shellQuote(`hwlab/${options.name}`)}`,
`tag=${shellQuote(options.tag)}`,
"local_id=$(docker image inspect \"$target_image\" --format '{{.Id}}' 2>/dev/null || true)",
"local_created=$(docker image inspect \"$target_image\" --format '{{.Created}}' 2>/dev/null || true)",
"registry_tags=$(curl -fsS --max-time 10 \"http://127.0.0.1:5000/v2/$repo_path/tags/list\" 2>/dev/null || true)",
"registry_has_tag=false",
"if printf '%s' \"$registry_tags\" | grep -F '\"'$tag'\"' >/dev/null 2>&1; then registry_has_tag=true; fi",
"export source_image target_image local_id local_created registry_tags registry_has_tag",
"node <<'NODE'",
"const env = process.env;",
"console.log(JSON.stringify({",
" sourceImage: env.source_image,",
" targetImage: env.target_image,",
" localExists: Boolean(env.local_id),",
" localImageId: env.local_id || null,",
" localCreated: env.local_created || null,",
" registryHasTag: env.registry_has_tag === 'true',",
" registryTagsRaw: env.registry_tags || null",
"}, null, 2));",
"NODE",
].join("\n");
const result = g14HostScript(script, 120_000);
let parsedStatus: unknown = null;
const text = statusText(result);
if (text.length > 0) {
try {
parsedStatus = JSON.parse(text) as unknown;
} catch {
parsedStatus = null;
}
}
return {
ok: isCommandSuccess(result) && record(parsedStatus).registryHasTag === true,
command: "hwlab g14 upstream-image status --name openfga",
name: options.name,
tag: options.tag,
sourceImage,
targetImage,
status: parsedStatus ?? text,
result,
};
}
export function runG14UpstreamImageEnsure(options: G14UpstreamImageOptions): Record<string, unknown> {
const sourceImage = g14UpstreamImageSource(options);
const targetImage = g14UpstreamImageTarget(options);
if (options.dryRun) {
return {
ok: true,
command: "hwlab g14 upstream-image ensure --name openfga",
mode: "dry-run",
name: options.name,
tag: options.tag,
sourceImage,
targetImage,
mutation: false,
next: { confirm: `bun scripts/cli.ts hwlab g14 upstream-image ensure --name ${options.name} --tag ${options.tag} --confirm` },
};
}
const script = [
"set -eu",
`source_image=${shellQuote(sourceImage)}`,
`target_image=${shellQuote(targetImage)}`,
"export HTTP_PROXY=http://127.0.0.1:10808 HTTPS_PROXY=http://127.0.0.1:10808 http_proxy=http://127.0.0.1:10808 https_proxy=http://127.0.0.1:10808",
"export NO_PROXY=localhost,127.0.0.1,::1,host.docker.internal,74.48.78.17,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12,10.42.0.0/16,10.43.0.0/16,.svc,.svc.cluster.local,.cluster.local,kubernetes,kubernetes.default,kubernetes.default.svc,127.0.0.1:5000,localhost:5000",
"export no_proxy=$NO_PROXY",
"echo \"{\\\"phase\\\":\\\"pull\\\",\\\"sourceImage\\\":\\\"$source_image\\\"}\"",
"docker pull \"$source_image\"",
"docker tag \"$source_image\" \"$target_image\"",
"echo \"{\\\"phase\\\":\\\"push\\\",\\\"targetImage\\\":\\\"$target_image\\\"}\"",
"docker push \"$target_image\"",
"digest=$(docker image inspect \"$target_image\" --format '{{index .RepoDigests 0}}' 2>/dev/null || true)",
"echo \"{\\\"phase\\\":\\\"published\\\",\\\"targetImage\\\":\\\"$target_image\\\",\\\"digest\\\":\\\"$digest\\\"}\"",
].join("\n");
const result = g14HostScript(script, options.timeoutSeconds * 1000);
const status = runG14UpstreamImageStatus(options);
return {
ok: isCommandSuccess(result) && status.ok === true,
command: "hwlab g14 upstream-image ensure --name openfga",
mode: "confirmed-ensure",
name: options.name,
tag: options.tag,
sourceImage,
targetImage,
mutation: true,
result,
status,
};
}
export function runG14UpstreamImage(options: G14UpstreamImageOptions): Record<string, unknown> {
if (options.action === "status") return runG14UpstreamImageStatus(options);
return runG14UpstreamImageEnsure(options);
}
+19
View File
@@ -0,0 +1,19 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. Domain barrel for scripts/src/hwlab-g14.ts.
export * from "./types";
export * from "./options";
export * from "./remote";
export * from "./source";
export * from "./v02-status";
export * from "./cleanup";
export * from "./render";
export * from "./control-plane";
export * from "./secrets";
export * from "./retirement";
export * from "./git-mirror";
export * from "./observability";
export * from "./images";
export * from "./pr-monitor";
export * from "./cd-trigger";
export * from "./monitor";
export * from "./help";
export * from "./entry";
+276
View File
@@ -0,0 +1,276 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. monitor module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:10163-10420 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { CommandJsonResult, G14MonitorOptions } from "./types";
import { runRuntimeLanePrAutoCd, runV02PrAutoCd } from "./cd-trigger";
import { appendRolloutBrief, collectPipelineMetrics, commentRuntimeLanePullRequest, commentV02PullRequest, durationSeconds, getArgoStatus, getDevWorkloads, getG14Head, getLiveHealth, getPipelineStatus, listOpenG14PullRequests, mergePullRequest, preflightPullRequest, preflightSummary, refreshArgoDev, reportRuntimeLaneAutomationIssue, statusText, v02PrConflictState, workloadReadiness } from "./pr-monitor";
import { commandData, compactCommandResult, extractPullRequests, isCommandSuccess, nested, precheckWorkspace, printEvent, printRuntimeLanePrMonitorProgress, printV02PrMonitorProgress, record, shortSha, sleep } from "./remote";
import { legacyG14RetirementBlocksMonitor } from "./retirement";
import { V02_SOURCE_BRANCH } from "./types";
export async function waitForG14Dev(sourceCommit: string, timeoutSeconds: number): Promise<Record<string, unknown>> {
const started = Date.now();
const startedAt = new Date(started).toISOString();
let pipelineText = "";
let argoText = "";
let healthOk = false;
let workloadsReady: Record<string, unknown> = { ready: false };
let pipelineSucceededAt: string | null = null;
while (Date.now() - started < timeoutSeconds * 1000) {
const pipeline = getPipelineStatus(sourceCommit);
pipelineText = statusText(pipeline);
printEvent("g14.pipeline.status", { sourceCommit, pipelineRun: `hwlab-g14-ci-poll-${shortSha(sourceCommit)}`, text: pipelineText.slice(0, 500) });
if (!pipelineText.startsWith("True\nSucceeded")) {
if (pipelineText.startsWith("False\n")) return { ok: false, phase: "pipeline", sourceCommit, pipelineText };
await sleep(30_000);
continue;
}
pipelineSucceededAt ??= new Date().toISOString();
refreshArgoDev();
const argo = getArgoStatus();
argoText = statusText(argo);
printEvent("g14.argo.status", { text: argoText.slice(0, 500) });
const argoLines = argoText.split(/\r?\n/u);
const synced = argoLines[1] === "Synced";
const healthy = argoLines[2] === "Healthy";
const gitopsRevision = argoLines[0] ?? null;
const workloads = getDevWorkloads();
const workloadsText = statusText(workloads);
workloadsReady = workloadReadiness(workloadsText, isCommandSuccess(workloads));
const health = getLiveHealth();
healthOk = health.ok && /"status"\s*:\s*"ok"/u.test(health.stdout);
if (synced && healthy && record(workloadsReady).ready === true && healthOk) {
const finishedAt = new Date().toISOString();
const pipelineRun = `hwlab-g14-ci-poll-${shortSha(sourceCommit)}`;
const ciMetrics = collectPipelineMetrics(pipelineRun);
return { ok: true, sourceCommit, gitopsRevision, pipelineRun, pipelineText, argoText, startedAt, pipelineSucceededAt, finishedAt, workloadsReady, healthOk, ciMetrics };
}
await sleep(30_000);
}
return { ok: false, phase: "timeout", sourceCommit, pipelineText, argoText, startedAt, pipelineSucceededAt, workloadsReady, healthOk, timeoutSeconds };
}
export async function monitorV02Cycle(options: G14MonitorOptions, cycle: number): Promise<Record<string, unknown>> {
printEvent("v02.monitor.cycle.start", { cycle, dryRun: options.dryRun });
printV02PrMonitorProgress({ stage: "cycle", status: "started", cycle, dryRun: options.dryRun });
const listed = listOpenG14PullRequests();
if (!isCommandSuccess(listed)) return { ok: false, cycle, phase: "list-prs", listed };
const prs = extractPullRequests(listed, V02_SOURCE_BRANCH);
printEvent("v02.monitor.prs", { cycle, count: prs.length, pullRequests: prs });
if (prs.length === 0) {
printV02PrMonitorProgress({ stage: "idle", status: "waiting", cycle, pullRequests: 0, intervalSeconds: options.intervalSeconds });
return { ok: true, cycle, action: "none", lane: "v02", pullRequests: [] };
}
const observations: unknown[] = [];
for (const pr of prs) {
const startedAt = new Date().toISOString();
const preflightResult = preflightPullRequest(pr.number);
const preflight = preflightSummary(preflightResult);
printEvent("v02.pr.preflight", {
cycle,
number: pr.number,
ok: isCommandSuccess(preflightResult),
conclusion: preflight.conclusion,
readyForCommanderMerge: preflight.readyForCommanderMerge,
conflict: v02PrConflictState(preflight),
});
printV02PrMonitorProgress({
stage: "preflight",
status: preflight.readyForCommanderMerge === true ? "succeeded" : "running",
pr: pr.number,
conclusion: preflight.conclusion,
conflict: v02PrConflictState(preflight),
});
if (!isCommandSuccess(preflightResult) || preflight.readyForCommanderMerge !== true) {
const conclusion = String(preflight.conclusion ?? "unknown");
const blocked = conclusion === "blocked" || v02PrConflictState(preflight) === "conflict" || !isCommandSuccess(preflightResult);
const comment = commentV02PullRequest({
pr,
phase: "preflight",
state: blocked ? "blocked" : "waiting-ci",
startedAt,
observedAt: new Date().toISOString(),
elapsedSeconds: durationSeconds(startedAt, new Date().toISOString()),
preflight,
dryRun: options.dryRun,
message: blocked
? "v0.2 自动化已暂停在 PR preflight:存在 blocker 或冲突,需要先修复后再继续。"
: "v0.2 自动化正在等待 GitHub CI / mergeability 收敛;CI 通过且无冲突后会自动合并并触发 CD。",
});
observations.push({ pullRequest: pr, preflight, comment });
printV02PrMonitorProgress({ stage: "pr-comment", status: record(comment).ok === true ? "succeeded" : "failed", pr: pr.number, conclusion: preflight.conclusion });
if (record(comment).ok !== true) return { ok: false, cycle, lane: "v02", phase: "pr-comment", pullRequest: pr, preflight, comment, observations };
continue;
}
const merge = mergePullRequest(pr.number, options.dryRun);
printEvent("v02.pr.merge", { cycle, number: pr.number, dryRun: options.dryRun, ok: isCommandSuccess(merge) });
printV02PrMonitorProgress({ stage: "merge", status: isCommandSuccess(merge) ? "succeeded" : "running", pr: pr.number, dryRun: options.dryRun });
const result = await runV02PrAutoCd(pr, preflight, merge, options, startedAt);
observations.push(result);
if (record(result).ok !== true) return { ok: false, cycle, lane: "v02", phase: record(result).phase ?? "v02-auto-cd", pullRequest: pr, result, observations };
return { ok: true, cycle, lane: "v02", action: record(result).action ?? (options.dryRun ? "dry-run-merge" : "merged-and-rolled-v02"), result, observations };
}
return { ok: true, cycle, lane: "v02", action: "none", observations };
}
export function runtimeLanePreflightBlocked(preflightResult: CommandJsonResult, preflight: Record<string, unknown>): boolean {
const blockers = Array.isArray(preflight.blockers) ? preflight.blockers : [];
const pending = Array.isArray(preflight.pending) ? preflight.pending : [];
const conclusion = String(preflight.conclusion ?? "unknown").toLowerCase();
return !isCommandSuccess(preflightResult)
|| v02PrConflictState(preflight) === "conflict"
|| conclusion === "blocked"
|| conclusion === "failed"
|| conclusion === "failure"
|| conclusion === "error"
|| (blockers.length > 0 && pending.length === 0);
}
export async function monitorRuntimeLaneCycle(spec: HwlabRuntimeLaneSpec, options: G14MonitorOptions, cycle: number): Promise<Record<string, unknown>> {
printEvent(`${spec.lane}.monitor.cycle.start`, { cycle, dryRun: options.dryRun });
printRuntimeLanePrMonitorProgress(spec, { stage: "cycle", status: "started", cycle, dryRun: options.dryRun });
const listed = listOpenG14PullRequests();
if (!isCommandSuccess(listed)) return { ok: false, cycle, lane: spec.lane, phase: "list-prs", listed };
const prs = extractPullRequests(listed, spec.sourceBranch);
printEvent(`${spec.lane}.monitor.prs`, { cycle, count: prs.length, pullRequests: prs });
if (prs.length === 0) {
printRuntimeLanePrMonitorProgress(spec, { stage: "idle", status: "waiting", cycle, pullRequests: 0, intervalSeconds: options.intervalSeconds });
return { ok: true, cycle, action: "none", lane: spec.lane, pullRequests: [] };
}
const observations: unknown[] = [];
for (const pr of prs) {
const startedAt = new Date().toISOString();
const preflightResult = preflightPullRequest(pr.number);
const preflight = preflightSummary(preflightResult);
printEvent(`${spec.lane}.pr.preflight`, {
cycle,
number: pr.number,
ok: isCommandSuccess(preflightResult),
conclusion: preflight.conclusion,
readyForCommanderMerge: preflight.readyForCommanderMerge,
conflict: v02PrConflictState(preflight),
});
printRuntimeLanePrMonitorProgress(spec, {
stage: "preflight",
status: preflight.readyForCommanderMerge === true ? "succeeded" : "running",
pr: pr.number,
conclusion: preflight.conclusion,
conflict: v02PrConflictState(preflight),
});
if (!isCommandSuccess(preflightResult) || preflight.readyForCommanderMerge !== true) {
const blocked = runtimeLanePreflightBlocked(preflightResult, preflight);
const observedAt = new Date().toISOString();
const comment = commentRuntimeLanePullRequest(spec, {
pr,
phase: "preflight",
state: blocked ? "blocked" : "waiting-ci",
startedAt,
observedAt,
elapsedSeconds: durationSeconds(startedAt, observedAt),
preflight,
dryRun: options.dryRun,
message: blocked
? `${spec.version} 自动化已暂停在 PR preflight:存在 blocker、失败 check 或冲突,需要先修复后再继续。`
: `${spec.version} 自动化正在等待 GitHub CI / mergeability 收敛;CI 通过且无冲突后会自动合并并触发 CD。`,
});
const issue = blocked ? reportRuntimeLaneAutomationIssue({
spec,
pr,
state: "blocked",
phase: "preflight",
startedAt,
observedAt,
preflight,
details: { preflightResult: compactCommandResult(preflightResult) },
message: `${spec.version} PR preflight 未通过或存在冲突,自动合并/CD 已暂停。`,
dryRun: options.dryRun,
}) : { ok: true, skipped: true, reason: "waiting-ci-without-blocker" };
observations.push({ pullRequest: pr, preflight, comment, issue });
printRuntimeLanePrMonitorProgress(spec, { stage: "pr-comment", status: record(comment).ok === true ? "succeeded" : "failed", pr: pr.number, conclusion: preflight.conclusion });
if (record(comment).ok !== true) return { ok: false, cycle, lane: spec.lane, phase: "pr-comment", pullRequest: pr, preflight, comment, issue, observations };
if (record(issue).ok !== true) return { ok: false, cycle, lane: spec.lane, phase: "failure-issue", pullRequest: pr, preflight, comment, issue, observations };
continue;
}
const merge = mergePullRequest(pr.number, options.dryRun);
printEvent(`${spec.lane}.pr.merge`, { cycle, number: pr.number, dryRun: options.dryRun, ok: isCommandSuccess(merge) });
printRuntimeLanePrMonitorProgress(spec, { stage: "merge", status: isCommandSuccess(merge) ? "succeeded" : "running", pr: pr.number, dryRun: options.dryRun });
const result = await runRuntimeLanePrAutoCd(spec, pr, preflight, merge, options, startedAt);
observations.push(result);
if (record(result).ok !== true) return { ok: false, cycle, lane: spec.lane, phase: record(result).phase ?? `${spec.lane}-auto-cd`, pullRequest: pr, result, observations };
return { ok: true, cycle, lane: spec.lane, action: record(result).action ?? (options.dryRun ? "dry-run-merge" : `merged-and-rolled-${spec.lane}`), result, observations };
}
return { ok: true, cycle, lane: spec.lane, action: "none", observations };
}
export async function monitorCycle(options: G14MonitorOptions, cycle: number): Promise<Record<string, unknown>> {
if (options.lane === "v02") return monitorV02Cycle(options, cycle);
if (options.lane !== "g14") return monitorRuntimeLaneCycle(hwlabRuntimeLaneSpec(options.lane), options, cycle);
const retirement = legacyG14RetirementBlocksMonitor();
if (retirement !== null) {
return {
ok: false,
cycle,
phase: "retired",
degradedReason: "legacy-g14-dev-prod-retired",
retirement,
next: { status: "bun scripts/cli.ts hwlab g14 retirement status", v02Monitor: "bun scripts/cli.ts hwlab g14 monitor-prs --lane v02" },
};
}
printEvent("g14.monitor.cycle.start", { cycle, dryRun: options.dryRun });
const precheck = precheckWorkspace();
if (!isCommandSuccess(precheck)) return { ok: false, cycle, phase: "workspace-precheck", precheck };
const listed = listOpenG14PullRequests();
if (!isCommandSuccess(listed)) return { ok: false, cycle, phase: "list-prs", listed };
const prs = extractPullRequests(listed);
printEvent("g14.monitor.prs", { cycle, count: prs.length, pullRequests: prs });
if (prs.length === 0) return { ok: true, cycle, action: "none", pullRequests: [] };
const merged: unknown[] = [];
for (const pr of prs) {
const preflight = preflightPullRequest(pr.number);
printEvent("g14.pr.preflight", { cycle, number: pr.number, ok: isCommandSuccess(preflight) });
if (!isCommandSuccess(preflight) || nested(preflight.parsed, ["data", "mergeability", "readyForCommanderMerge"]) !== true) {
return { ok: false, cycle, phase: "pr-preflight", pullRequest: pr, preflight };
}
const merge = mergePullRequest(pr.number, options.dryRun);
printEvent("g14.pr.merge", { cycle, number: pr.number, dryRun: options.dryRun, ok: isCommandSuccess(merge) });
if (!isCommandSuccess(merge)) return { ok: false, cycle, phase: "pr-merge", pullRequest: pr, merge };
const sourceCommit = getG14Head();
const rollout = options.dryRun || sourceCommit === null ? { skipped: true, dryRun: options.dryRun, sourceCommit } : await waitForG14Dev(sourceCommit, options.timeoutSeconds);
const brief = options.dryRun || sourceCommit === null || record(rollout).ok !== true
? { skipped: true, dryRun: options.dryRun }
: appendRolloutBrief({ prNumber: pr.number, sourceCommit, dryRun: false }, rollout);
printEvent("g14.rollout.brief", { cycle, number: pr.number, ok: record(brief).ok, skipped: record(brief).skipped ?? false, brief: record(brief).brief ?? null });
merged.push({ pullRequest: pr, merge: commandData(merge), sourceCommit, rollout, brief });
if (record(rollout).ok === false) return { ok: false, cycle, phase: "rollout", pullRequest: pr, sourceCommit, rollout, merged };
if (record(brief).ok === false) return { ok: false, cycle, phase: "rollout-brief", pullRequest: pr, sourceCommit, rollout, brief, merged };
}
return { ok: true, cycle, action: options.dryRun ? "dry-run-merge" : "merged-and-rolled-dev", merged };
}
export async function runMonitorWorker(options: G14MonitorOptions): Promise<Record<string, unknown>> {
const maxCycles = options.once ? 1 : options.maxCycles;
let cycle = 0;
const results: unknown[] = [];
while (maxCycles === 0 || cycle < maxCycles) {
cycle += 1;
const result = await monitorCycle(options, cycle);
results.push(result);
printEvent(`${options.lane}.monitor.cycle.done`, { cycle, lane: options.lane, ok: record(result).ok, action: record(result).action ?? null, phase: record(result).phase ?? null });
if (record(result).ok !== true) return { ok: false, cycles: cycle, lastResult: result, results };
if (options.once || (options.lane === "g14" && record(result).action !== "none")) return { ok: true, cycles: cycle, lastResult: result, results };
printEvent(`${options.lane}.monitor.sleep`, { cycle, lane: options.lane, intervalSeconds: options.intervalSeconds });
if (options.lane === "v02") printV02PrMonitorProgress({ stage: "idle", status: "waiting", cycle, intervalSeconds: options.intervalSeconds });
else if (options.lane !== "g14") printRuntimeLanePrMonitorProgress(hwlabRuntimeLaneSpec(options.lane), { stage: "idle", status: "waiting", cycle, intervalSeconds: options.intervalSeconds });
await sleep(options.intervalSeconds * 1000);
}
return { ok: true, cycles: cycle, results };
}
File diff suppressed because it is too large Load Diff
+365
View File
@@ -0,0 +1,365 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. options module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:289-638 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { G14ControlPlaneOptions, G14GitMirrorOptions, G14LegacyRetirementOptions, G14MonitorLane, G14MonitorOptions, G14ObservabilityOptions, G14RecordRolloutOptions, G14SecretOptions, G14ToolsImageOptions, G14UpstreamImageOptions } from "./types";
import { positiveIntegerOption, runtimeLaneMasterAdminApiKeySecretName, runtimeLaneOpenFgaSecretName } from "./remote";
import { DEFAULT_INTERVAL_SECONDS, DEFAULT_MAX_CYCLES, DEFAULT_TIMEOUT_SECONDS, G14_CI_TOOLS_BASE_TAG, V02_MASTER_ADMIN_API_KEY_SECRET, V02_MASTER_ADMIN_API_KEY_SECRET_KEY, V02_OPENFGA_AUTHN_SECRET_KEY, V02_OPENFGA_DATASTORE_URI_SECRET_KEY, V02_OPENFGA_POSTGRES_PASSWORD_SECRET_KEY, V02_OPENFGA_SECRET } from "./types";
export function hwlabG14MonitorStateFileName(options: Pick<G14MonitorOptions, "once" | "dryRun"> & { lane?: G14MonitorLane }): string {
const prefix = options.lane !== undefined && options.lane !== "g14" ? `latest-${options.lane}-` : "latest-";
if (options.once && options.dryRun) return `${prefix}once-dry-run-job.json`;
if (options.once) return `${prefix}once-job.json`;
if (options.dryRun) return `${prefix}dry-run-job.json`;
return `${prefix}monitor-job.json`;
}
export function hwlabG14MonitorStateRole(options: Pick<G14MonitorOptions, "once" | "dryRun"> & { lane?: G14MonitorLane }): string {
const lanePrefix = options.lane !== undefined && options.lane !== "g14" ? `${options.lane}-` : "";
if (options.once && options.dryRun) return `${lanePrefix}once-dry-run`;
if (options.once) return `${lanePrefix}once`;
if (options.dryRun) return `${lanePrefix}dry-run-monitor`;
return `${lanePrefix}monitor`;
}
export function parseMonitorLane(args: string[]): G14MonitorLane {
const lane = optionValue(args, "--lane") ?? "g14";
if (lane !== "g14" && !isHwlabRuntimeLane(lane)) throw new Error(`monitor-prs --lane must be g14 or ${hwlabRuntimeLaneIds().join("|")}`);
return lane;
}
export function parseOptions(args: string[]): G14MonitorOptions {
return {
lane: parseMonitorLane(args),
intervalSeconds: positiveIntegerOption(args, "--interval-seconds", DEFAULT_INTERVAL_SECONDS, 86400),
maxCycles: positiveIntegerOption(args, "--max-cycles", DEFAULT_MAX_CYCLES, 100000),
once: args.includes("--once"),
dryRun: args.includes("--dry-run"),
worker: args.includes("--worker"),
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", DEFAULT_TIMEOUT_SECONDS, 86400),
};
}
export function optionValue(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) return undefined;
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
export function validateFullShaOption(value: string, name: string): string {
if (!/^[0-9a-f]{40}$/iu.test(value)) throw new Error(`${name} must be a full 40-character git SHA`);
return value.toLowerCase();
}
export function validateV02PipelineRunOption(value: string): string {
if (!/^hwlab-v02-ci-poll-[0-9a-f]{7,40}$/iu.test(value)) throw new Error("--pipeline-run must be a hwlab-v02-ci-poll-<sha> PipelineRun name");
return value.toLowerCase();
}
export function validateRuntimeLanePipelineRunOption(lane: HwlabRuntimeLane, value: string): string {
const spec = hwlabRuntimeLaneSpec(lane);
const escapedPrefix = spec.pipelineRunPrefix.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
if (!new RegExp(`^${escapedPrefix}-[0-9a-f]{7,40}(?:-[a-z0-9][a-z0-9-]{0,24})?$`, "iu").test(value)) {
throw new Error(`--pipeline-run must be a ${spec.pipelineRunPrefix}-<sha>[-rerun] PipelineRun name for --lane ${lane}`);
}
return value.toLowerCase();
}
export function parseRecordRolloutOptions(args: string[]): G14RecordRolloutOptions {
const prRaw = optionValue(args, "--pr") ?? optionValue(args, "--number");
const prNumber = Number(prRaw);
if (!Number.isInteger(prNumber) || prNumber <= 0) throw new Error("record-rollout requires --pr <number>");
return {
prNumber,
sourceCommit: optionValue(args, "--source-commit"),
pipelineRun: optionValue(args, "--pipeline-run"),
gitopsRevision: optionValue(args, "--gitops-revision"),
mergedAt: optionValue(args, "--merged-at"),
pipelineSucceededAt: optionValue(args, "--pipeline-succeeded-at"),
finishedAt: optionValue(args, "--finished-at"),
dryRun: args.includes("--dry-run"),
};
}
export function parseControlPlaneOptions(args: string[]): G14ControlPlaneOptions {
const [rawAction] = args;
const actionRaw = rawAction === "rerun-current" ? "trigger-current" : rawAction;
if (
actionRaw !== "status" &&
actionRaw !== "closeout" &&
actionRaw !== "apply" &&
actionRaw !== "trigger-current" &&
actionRaw !== "refresh" &&
actionRaw !== "cleanup-runs" &&
actionRaw !== "cleanup-released-pvs" &&
actionRaw !== "runtime-migration"
) {
throw new Error("control-plane usage: status|apply|trigger-current|refresh --lane v02|v03 | closeout|runtime-migration --lane v02 | cleanup-runs --lane v02|v03|g14|all | cleanup-released-pvs --lane all [--dry-run|--confirm]");
}
const laneRaw = optionValue(args, "--lane") ?? (actionRaw === "cleanup-released-pvs" ? "all" : "v02");
let lane: G14ControlPlaneOptions["lane"];
if (actionRaw === "cleanup-runs") {
if (laneRaw !== "g14" && laneRaw !== "all" && !isHwlabRuntimeLane(laneRaw)) throw new Error("control-plane cleanup-runs requires --lane v02|v03|g14|all");
lane = laneRaw;
} else if (actionRaw === "cleanup-released-pvs") {
if (laneRaw !== "all") throw new Error("control-plane cleanup-released-pvs requires --lane all because released PVs no longer preserve the v02/g14 PipelineRun lane");
lane = laneRaw;
} else if (actionRaw === "closeout" || actionRaw === "runtime-migration") {
if (laneRaw !== "v02") throw new Error("control-plane closeout/runtime-migration currently requires --lane v02");
lane = laneRaw;
} else if (!isHwlabRuntimeLane(laneRaw)) {
throw new Error(`control-plane ${actionRaw} requires --lane ${hwlabRuntimeLaneIds().join("|")}`);
} else {
lane = laneRaw;
}
const confirm = args.includes("--confirm");
const explicitDryRun = args.includes("--dry-run");
if (confirm && explicitDryRun) throw new Error("control-plane accepts only one of --confirm or --dry-run");
const allowLiveDbRead = args.includes("--allow-live-db-read");
if (allowLiveDbRead && confirm) throw new Error("control-plane runtime-migration accepts --allow-live-db-read only with dry-run/source-check mode, not --confirm");
if (allowLiveDbRead && actionRaw !== "runtime-migration") throw new Error("--allow-live-db-read is only valid for control-plane runtime-migration");
const sourceCommitRaw = optionValue(args, "--source-commit");
const pipelineRunRaw = optionValue(args, "--pipeline-run");
if ((sourceCommitRaw !== undefined || pipelineRunRaw !== undefined) && actionRaw !== "status" && actionRaw !== "closeout" && actionRaw !== "cleanup-runs") {
throw new Error("--source-commit and --pipeline-run are only valid for control-plane status/closeout/cleanup-runs");
}
if (actionRaw === "closeout" && sourceCommitRaw === undefined && pipelineRunRaw === undefined) {
throw new Error("control-plane closeout requires --source-commit <full-sha> or --pipeline-run hwlab-v02-ci-poll-<sha>");
}
if (sourceCommitRaw !== undefined && pipelineRunRaw !== undefined) throw new Error("control-plane status/closeout/cleanup-runs accepts only one of --source-commit or --pipeline-run");
return {
action: actionRaw,
lane,
confirm,
wait: args.includes("--wait"),
rerun: args.includes("--rerun") || rawAction === "rerun-current",
allowLiveDbRead,
dryRun: actionRaw === "status" || actionRaw === "closeout" ? true : explicitDryRun || !confirm,
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 120, 600),
minAgeMinutes: positiveIntegerOption(args, "--min-age-minutes", 60, 10080),
limit: positiveIntegerOption(args, "--limit", 20, 200),
sourceCommit: sourceCommitRaw === undefined ? undefined : validateFullShaOption(sourceCommitRaw, "--source-commit"),
pipelineRun: pipelineRunRaw === undefined
? undefined
: isHwlabRuntimeLane(lane)
? validateRuntimeLanePipelineRunOption(lane, pipelineRunRaw)
: validateV02PipelineRunOption(pipelineRunRaw),
history: args.includes("--history"),
};
}
export function parseLegacyRetirementOptions(args: string[]): G14LegacyRetirementOptions {
const [actionRaw = "status"] = args;
if (actionRaw !== "status" && actionRaw !== "plan" && actionRaw !== "execute") {
throw new Error("retirement usage: status|plan|execute [--dry-run|--confirm] [--wait] [--reason text] [--timeout-seconds N]");
}
const confirm = args.includes("--confirm");
const explicitDryRun = args.includes("--dry-run");
if (confirm && explicitDryRun) throw new Error("retirement accepts only one of --confirm or --dry-run");
if (confirm && actionRaw !== "execute") throw new Error("--confirm is only valid for retirement execute");
if (args.includes("--wait") && actionRaw !== "execute") throw new Error("--wait is only valid for retirement execute");
const reason = optionValue(args, "--reason") ?? "user requested immediate retirement of legacy G14 DEV/PROD";
if (reason.length > 240) throw new Error("--reason is limited to 240 characters");
return {
action: actionRaw,
confirm,
wait: args.includes("--wait"),
dryRun: actionRaw !== "execute" || explicitDryRun || !confirm,
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", actionRaw === "execute" ? 60 : 30, 300),
reason,
};
}
export function parseToolsImageOptions(args: string[]): G14ToolsImageOptions {
const [actionRaw] = args;
if (actionRaw !== "status" && actionRaw !== "build") {
throw new Error("tools-image usage: status|build --name ci-node-tools --tag <tag> [--dockerfile path] [--dry-run|--confirm]");
}
const name = optionValue(args, "--name") ?? "ci-node-tools";
if (name !== "ci-node-tools") throw new Error("tools-image currently supports --name ci-node-tools");
const tag = optionValue(args, "--tag") ?? G14_CI_TOOLS_BASE_TAG;
if (!/^[A-Za-z0-9_.-]+$/u.test(tag)) throw new Error("--tag may only contain letters, numbers, dot, underscore, and dash");
const dockerfile = optionValue(args, "--dockerfile") ?? "deploy/ci/hwlab-ci-node-tools.Dockerfile";
if (dockerfile.startsWith("/") || dockerfile.includes("..")) throw new Error("--dockerfile must be a repo-relative path without '..'");
const confirm = args.includes("--confirm");
const explicitDryRun = args.includes("--dry-run");
if (confirm && explicitDryRun) throw new Error("tools-image accepts only one of --confirm or --dry-run");
return {
action: actionRaw,
name,
tag,
dockerfile,
confirm,
dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm,
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 600, 1800),
};
}
export function parseUpstreamImageOptions(args: string[]): G14UpstreamImageOptions {
const [actionRaw] = args;
if (actionRaw !== "status" && actionRaw !== "ensure") {
throw new Error("upstream-image usage: status|ensure --name openfga --tag v1.17.0 [--dry-run|--confirm]");
}
const name = optionValue(args, "--name") ?? "openfga";
if (name !== "openfga") throw new Error("upstream-image currently supports --name openfga");
const tag = optionValue(args, "--tag") ?? "v1.17.0";
if (tag !== "v1.17.0") throw new Error("upstream-image currently supports OpenFGA tag v1.17.0");
const confirm = args.includes("--confirm");
const explicitDryRun = args.includes("--dry-run");
if (confirm && explicitDryRun) throw new Error("upstream-image accepts only one of --confirm or --dry-run");
return {
action: actionRaw,
name,
tag,
confirm,
dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm,
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 600, 1800),
};
}
export function parseGitMirrorOptions(args: string[]): G14GitMirrorOptions {
const [actionRaw] = args;
if (actionRaw !== "status" && actionRaw !== "apply" && actionRaw !== "sync" && actionRaw !== "flush") {
throw new Error("git-mirror usage: status|apply|sync|flush [--lane v02|v03] [--dry-run|--confirm]");
}
const laneRaw = optionValue(args, "--lane") ?? "v02";
if (!isHwlabRuntimeLane(laneRaw)) throw new Error(`git-mirror --lane must be ${hwlabRuntimeLaneIds().join("|")}`);
const confirm = args.includes("--confirm");
const explicitDryRun = args.includes("--dry-run");
if (confirm && explicitDryRun) throw new Error("git-mirror accepts only one of --confirm or --dry-run");
return {
action: actionRaw,
lane: laneRaw,
confirm,
wait: args.includes("--wait"),
dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm,
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", actionRaw === "sync" || actionRaw === "flush" ? 300 : 120, 900),
};
}
export function parseObservabilityOptions(args: string[]): G14ObservabilityOptions {
const [actionRaw] = args;
if (
actionRaw !== "status" &&
actionRaw !== "apply" &&
actionRaw !== "query" &&
actionRaw !== "targets" &&
actionRaw !== "boundary" &&
actionRaw !== "closeout"
) {
throw new Error("observability usage: status|apply|query|targets|boundary|closeout [--lane v02] [--promql <expr>] [--expect-count N] [--expect-value V] [--dry-run|--confirm]");
}
const valueOptions = new Set(["--lane", "--promql", "--query", "--expect-count", "--expect-value", "--timeout-seconds"]);
const booleanOptions = new Set(["--confirm", "--dry-run"]);
for (let i = 1; i < args.length; i += 1) {
const arg = args[i];
if (valueOptions.has(arg)) {
i += 1;
if (i >= args.length || args[i].startsWith("--")) throw new Error(`${arg} requires a value`);
continue;
}
if (booleanOptions.has(arg)) continue;
if (arg === "--wait") throw new Error("observability does not support --wait; commands are bounded short CLI calls");
if (arg.startsWith("--")) throw new Error(`unsupported observability option: ${arg}`);
throw new Error(`unexpected observability argument: ${arg}`);
}
const lane = optionValue(args, "--lane") ?? "v02";
if (lane !== "v02") throw new Error("observability currently supports --lane v02");
const confirm = args.includes("--confirm");
const explicitDryRun = args.includes("--dry-run");
if (confirm && explicitDryRun) throw new Error("observability accepts only one of --confirm or --dry-run");
if ((confirm || explicitDryRun) && actionRaw !== "apply") throw new Error("--confirm and --dry-run are only supported by observability apply");
const query = optionValue(args, "--promql") ?? optionValue(args, "--query") ?? 'up{namespace="hwlab-v02"}';
if (query.length > 500) throw new Error("--promql is limited to 500 characters");
if (query.includes("\n") || query.includes("\r")) throw new Error("--promql must be a single-line expression");
const expectCountRaw = optionValue(args, "--expect-count");
const expectCount = expectCountRaw === undefined ? undefined : Number(expectCountRaw);
if (expectCountRaw !== undefined && (!Number.isInteger(expectCount) || expectCount < 0)) throw new Error("--expect-count must be a non-negative integer");
const expectValue = optionValue(args, "--expect-value");
if ((expectCount !== undefined || expectValue !== undefined) && actionRaw !== "query") throw new Error("--expect-count and --expect-value are only supported by observability query");
if (expectValue !== undefined && expectValue.trim().length === 0) throw new Error("--expect-value must not be blank");
return {
action: actionRaw,
lane,
confirm,
wait: args.includes("--wait"),
dryRun: actionRaw === "apply" ? explicitDryRun || !confirm : true,
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", actionRaw === "apply" ? 240 : 120, 900),
query,
expectCount,
expectValue,
};
}
export function parseSecretOptions(args: string[]): G14SecretOptions {
const [actionRaw] = args;
if (actionRaw !== "status" && actionRaw !== "ensure" && actionRaw !== "delete") {
throw new Error("secret usage: status|ensure --lane v02|v03 --name hwlab-v0x-openfga|hwlab-v0x-master-server-admin-api-key [--dry-run|--confirm] | delete --lane v02 --name <obsolete-secret> [--dry-run|--confirm]");
}
const laneRaw = optionValue(args, "--lane") ?? "v02";
if (!isHwlabRuntimeLane(laneRaw)) throw new Error(`secret --lane must be one of ${hwlabRuntimeLaneIds().join(", ")}`);
const lane = laneRaw;
const spec = hwlabRuntimeLaneSpec(lane);
const openFgaSecret = runtimeLaneOpenFgaSecretName(spec);
const masterAdminSecret = runtimeLaneMasterAdminApiKeySecretName(spec);
const name = optionValue(args, "--name") ?? openFgaSecret;
const key = optionValue(args, "--key");
const confirm = args.includes("--confirm");
const explicitDryRun = args.includes("--dry-run");
if (confirm && explicitDryRun) throw new Error("secret accepts only one of --confirm or --dry-run");
if (actionRaw === "delete") {
if (lane !== "v02") throw new Error("secret delete is currently limited to --lane v02 obsolete cleanup");
if (key !== undefined) throw new Error("secret delete does not accept --key; it deletes a whole obsolete Secret object");
if (!/^hwlab-v02-[a-z0-9-]+$/u.test(name)) throw new Error("secret delete requires a hwlab-v02-* Secret name");
if (name === V02_OPENFGA_SECRET || name === V02_MASTER_ADMIN_API_KEY_SECRET || name === "hwlab-v02-postgres") throw new Error(`secret delete refuses required v0.2 Secret ${name}`);
return {
action: actionRaw,
lane,
confirm,
dryRun: explicitDryRun || !confirm,
name,
preset: "generic-delete",
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 120, 600),
};
}
if (name === masterAdminSecret) {
if (key !== undefined && key !== V02_MASTER_ADMIN_API_KEY_SECRET_KEY) throw new Error(`secret ${masterAdminSecret} supports only key ${V02_MASTER_ADMIN_API_KEY_SECRET_KEY}`);
return {
action: actionRaw,
lane,
confirm,
dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm,
name,
key: key ?? V02_MASTER_ADMIN_API_KEY_SECRET_KEY,
preset: "master-server-admin-api-key",
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 120, 600),
};
}
if (name !== openFgaSecret) {
throw new Error(`secret status/ensure currently supports only --name ${openFgaSecret} or ${masterAdminSecret} for --lane ${lane}; use secret delete for obsolete v02 Secret objects`);
}
if (key !== undefined && key !== V02_OPENFGA_AUTHN_SECRET_KEY && key !== V02_OPENFGA_DATASTORE_URI_SECRET_KEY && key !== V02_OPENFGA_POSTGRES_PASSWORD_SECRET_KEY) {
throw new Error(`secret ${openFgaSecret} supports keys ${V02_OPENFGA_AUTHN_SECRET_KEY}, ${V02_OPENFGA_DATASTORE_URI_SECRET_KEY}, and ${V02_OPENFGA_POSTGRES_PASSWORD_SECRET_KEY}`);
}
if (lane !== "v02" && key === V02_OPENFGA_POSTGRES_PASSWORD_SECRET_KEY) throw new Error(`${openFgaSecret}/${V02_OPENFGA_POSTGRES_PASSWORD_SECRET_KEY} is derived from ${postgresSecret}/POSTGRES_PASSWORD`);
return {
action: actionRaw,
lane,
confirm,
dryRun: actionRaw === "status" ? true : explicitDryRun || !confirm,
name,
key,
preset: "openfga",
timeoutSeconds: positiveIntegerOption(args, "--timeout-seconds", 120, 600),
};
}
File diff suppressed because it is too large Load Diff
+581
View File
@@ -0,0 +1,581 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. remote module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:639-1204 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { CommandJsonResult, G14MonitorLane, OpenPullRequest, RemoteAsyncCommandSpec } from "./types";
import { tailText } from "./cleanup";
import { G14_PROVIDER, G14_SOURCE_BRANCH, G14_WORKSPACE, V02_MASTER_ADMIN_API_KEY_LOCAL_ENV } from "./types";
export function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
const index = args.indexOf(name);
if (index === -1) return defaultValue;
const raw = args[index + 1];
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`);
return Math.min(value, maxValue);
}
export function shellQuote(value: string): string {
return `'${value.replace(/'/gu, `'"'"'`)}'`;
}
export function commandJson(command: string[], timeoutMs = 60_000): CommandJsonResult {
const startedAtMs = Date.now();
const result = runCommand(command, repoRoot, { timeoutMs });
const durationMs = Date.now() - startedAtMs;
let parsed: unknown | null = null;
if (result.stdout.trim().length > 0) {
try {
parsed = JSON.parse(result.stdout) as unknown;
} catch {
parsed = null;
}
}
return {
ok: result.exitCode === 0,
command,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
parsed,
durationMs,
timedOut: result.timedOut,
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
};
}
export function commandJsonWithInput(command: string[], input: string, timeoutMs = 60_000): CommandJsonResult {
const startedAtMs = Date.now();
const result = runCommand(command, repoRoot, { timeoutMs, input });
const durationMs = Date.now() - startedAtMs;
let parsed: unknown | null = null;
if (result.stdout.trim().length > 0) {
try {
parsed = JSON.parse(result.stdout) as unknown;
} catch {
parsed = null;
}
}
return {
ok: result.exitCode === 0,
command,
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
parsed,
durationMs,
timedOut: result.timedOut,
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
};
}
export function cliJson(args: string[], timeoutMs = 60_000): CommandJsonResult {
return commandJson(["bun", "scripts/cli.ts", ...args], timeoutMs);
}
export function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
export function stringOrNull(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export function fieldBoolean(value: unknown): boolean | null {
if (value === true || value === false) return value;
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "yes" || normalized === "1") return true;
if (normalized === "false" || normalized === "no" || normalized === "0") return false;
return null;
}
export function nested(value: unknown, keys: string[]): unknown {
let current = value;
for (const key of keys) current = record(current)[key];
return current;
}
export function expandedParsedRoot(result: CommandJsonResult): Record<string, unknown> {
const dumpPath = nested(result.parsed, ["data", "dump", "path"]);
if (typeof dumpPath === "string" && existsSync(dumpPath)) {
try {
return record(JSON.parse(readFileSync(dumpPath, "utf8")) as unknown);
} catch {
return record(result.parsed);
}
}
return record(result.parsed);
}
export function commandData(result: CommandJsonResult): Record<string, unknown> {
return record(sanitizeCliOutput(record(expandedParsedRoot(result).data)));
}
export function compactCommandMetadata(result: CommandJsonResult, includeTail = false, includeCommand = false): Record<string, unknown> {
return {
ok: result.ok,
exitCode: result.exitCode,
command: includeCommand ? redactedCommand(result.command) : undefined,
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
stdoutTail: includeTail ? compactInlineText(result.stdout, 4000) : undefined,
stderrTail: includeTail ? compactInlineText(result.stderr, 4000) : undefined,
};
}
export function textHash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 12);
}
export function generateHwlabApiKey(): string {
return `hwl_live_${randomBytes(32).toString("base64url")}`;
}
export function hwlabApiKeyPrefix(value: string): string {
const text = value.trim();
if (!text.startsWith("hwl_live_")) return "";
const remainder = text.slice("hwl_live_".length);
const dot = remainder.indexOf(".");
const segment = dot === -1 ? remainder : remainder.slice(0, dot);
return `hwl_live_${segment}`.slice(0, 24);
}
export function runtimeLaneSecretFieldManager(spec: HwlabRuntimeLaneSpec): string {
return `unidesk-hwlab-${spec.lane}-secret`;
}
export function runtimeLanePostgresSecretName(spec: HwlabRuntimeLaneSpec): string {
return `${spec.runtimeNamespace}-postgres`;
}
export function runtimeLanePrimaryDbName(spec: HwlabRuntimeLaneSpec): string {
return `hwlab_${spec.lane}`;
}
export function runtimeLaneOpenFgaSecretName(spec: HwlabRuntimeLaneSpec): string {
return `hwlab-${spec.lane}-openfga`;
}
export function runtimeLaneMasterAdminApiKeySecretName(spec: HwlabRuntimeLaneSpec): string {
return `hwlab-${spec.lane}-master-server-admin-api-key`;
}
export function runtimeLaneMasterAdminApiKeyLocalEnv(spec: HwlabRuntimeLaneSpec): string {
if (spec.lane === "v02") return V02_MASTER_ADMIN_API_KEY_LOCAL_ENV;
return `/root/.config/hwlab-${spec.lane}/master-server-admin-api-key.env`;
}
export function localMasterAdminApiKeyStatus(spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
const envPath = runtimeLaneMasterAdminApiKeyLocalEnv(spec);
if (!existsSync(envPath)) {
return { exists: false, path: envPath, mode: null, valueBytes: 0, keyPrefix: null };
}
const stat = statSync(envPath);
const content = readFileSync(envPath, "utf8");
const match = content.match(/^HWLAB_API_KEY=(.+)$/mu);
const value = match?.[1]?.trim() ?? "";
return {
exists: true,
path: envPath,
mode: `0${(stat.mode & 0o777).toString(8)}`,
valueBytes: Buffer.byteLength(value, "utf8"),
keyPrefix: value ? hwlabApiKeyPrefix(value) : null,
validPrefix: value.startsWith("hwl_live_"),
};
}
export function readOrCreateLocalMasterAdminApiKey(spec: HwlabRuntimeLaneSpec, dryRun: boolean): { key: string | null; created: boolean; status: Record<string, unknown> } {
const envPath = runtimeLaneMasterAdminApiKeyLocalEnv(spec);
const existing = localMasterAdminApiKeyStatus(spec);
if (existing.exists === true) {
const content = readFileSync(envPath, "utf8");
const match = content.match(/^HWLAB_API_KEY=(.+)$/mu);
const key = match?.[1]?.trim() ?? "";
if (!key.startsWith("hwl_live_")) throw new Error(`${envPath} exists but does not contain a hwl_live_ HWLAB_API_KEY`);
if (!dryRun && existing.mode !== "0600") chmodSync(envPath, 0o600);
return { key, created: false, status: localMasterAdminApiKeyStatus(spec) };
}
if (dryRun) return { key: null, created: false, status: existing };
const key = generateHwlabApiKey();
mkdirSync(dirname(envPath), { recursive: true, mode: 0o700 });
writeFileSync(envPath, `# HWLAB ${spec.version} master server admin API key; do not commit or print.\nHWLAB_API_KEY=${key}\n`, { mode: 0o600 });
return { key, created: true, status: localMasterAdminApiKeyStatus(spec) };
}
export function redactLargePayloads(value: string): string {
return value
.replace(/hwl_live_[A-Za-z0-9._:-]{12,}/gu, (payload: string) => {
return `hwl_live_<redacted chars=${payload.length} sha256=${textHash(payload)}>`;
})
.replace(/manifest_b64='([^']{128,})'/gu, (_match, payload: string) => {
return `manifest_b64='<omitted base64 chars=${payload.length} sha256=${textHash(payload)}>'`;
})
.replace(/manifest_b64="([^"]{128,})"/gu, (_match, payload: string) => {
return `manifest_b64="<omitted base64 chars=${payload.length} sha256=${textHash(payload)}>"`;
})
.replace(/manifest_b64=([A-Za-z0-9+/=]{128,})/gu, (_match, payload: string) => {
return `manifest_b64=<omitted base64 chars=${payload.length} sha256=${textHash(payload)}>`;
})
.replace(/[A-Za-z0-9+/=]{1024,}/gu, (payload: string) => {
return `<omitted base64 chars=${payload.length} sha256=${textHash(payload)}>`;
});
}
export function compactInlineText(value: string, maxChars = 4000): string {
const redacted = redactLargePayloads(value);
if (redacted.length <= maxChars) return redacted;
const headChars = Math.floor(maxChars * 0.35);
const tailChars = maxChars - headChars;
const omittedChars = redacted.length - headChars - tailChars;
return [
redacted.slice(0, headChars),
`<truncated chars=${omittedChars} sha256=${textHash(redacted)}>`,
redacted.slice(-tailChars),
].join("\n");
}
export function sanitizeCliOutput(value: unknown): unknown {
if (typeof value === "string") return compactInlineText(value);
if (Array.isArray(value)) return value.map((item) => sanitizeCliOutput(item));
if (typeof value !== "object" || value === null) return value;
const output: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) output[key] = sanitizeCliOutput(item);
return output;
}
export function redactedCommand(command: string[]): string[] {
return command.map((arg) => compactInlineText(arg, 1200));
}
export function compactCommandResult(result: CommandJsonResult): Record<string, unknown> {
const data = record(expandedParsedRoot(result).data);
const stdout = String(data.stdout ?? result.stdout ?? "");
const stderr = String(data.stderr ?? result.stderr ?? "");
return {
ok: isCommandSuccess(result),
exitCode: result.exitCode,
command: redactedCommand(result.command),
stdout: compactInlineText(stdout.trim(), 4000),
stderr: compactInlineText(stderr.trim(), 4000),
stdoutBytes: Buffer.byteLength(stdout),
stderrBytes: Buffer.byteLength(stderr),
};
}
export function compactControlPlaneRefresh(value: Record<string, unknown>): Record<string, unknown> {
return {
ok: value.ok === true,
phase: value.phase ?? null,
sourceCommit: value.sourceCommit ?? null,
renderDir: value.renderDir ?? null,
applyOk: nested(value, ["apply", "ok"]) ?? null,
cleanupOk: nested(value, ["cleanupObsoleteCronJobs", "ok"]) ?? null,
applyStdout: tailText(nested(value, ["apply", "stdout"]), 1200),
degradedReason: value.degradedReason ?? null,
};
}
export function compactTriggerCommandResult(value: Record<string, unknown> | null): Record<string, unknown> | null {
if (value === null) return null;
return {
ok: value.ok ?? null,
exitCode: value.exitCode ?? null,
stdout: tailText(value.stdout, 1200),
stderr: tailText(value.stderr, 1200),
stdoutBytes: value.stdoutBytes ?? null,
stderrBytes: value.stderrBytes ?? null,
};
}
export function isCommandSuccess(result: CommandJsonResult): boolean {
if (!result.ok) return false;
const topOk = record(result.parsed).ok;
if (topOk === false) return false;
const dataOk = nested(result.parsed, ["data", "ok"]);
return dataOk !== false;
}
export function monitorBaseBranch(lane: G14MonitorLane): string {
return lane === "g14" ? G14_SOURCE_BRANCH : hwlabRuntimeLaneSpec(lane).sourceBranch;
}
export function extractPullRequests(result: CommandJsonResult, baseBranch = G14_SOURCE_BRANCH): OpenPullRequest[] {
const prs = nested(result.parsed, ["data", "pullRequests"]);
if (!Array.isArray(prs)) return [];
return prs
.map((item) => record(item))
.filter((item) => item.baseRefName === baseBranch || record(item.base).ref === baseBranch)
.map((item) => ({
number: Number(item.number),
title: typeof item.title === "string" ? item.title : undefined,
url: typeof item.url === "string" ? item.url : undefined,
baseRefName: typeof item.baseRefName === "string" ? item.baseRefName : typeof record(item.base).ref === "string" ? record(item.base).ref as string : undefined,
headRefName: typeof item.headRefName === "string" ? item.headRefName : typeof record(item.head).ref === "string" ? record(item.head).ref as string : undefined,
mergeable: typeof item.mergeable === "string" ? item.mergeable : null,
mergeStateStatus: typeof item.mergeStateStatus === "string" ? item.mergeStateStatus : null,
draft: item.draft === true,
}))
.filter((item) => Number.isInteger(item.number) && item.number > 0);
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function printEvent(event: string, data: Record<string, unknown> = {}): void {
process.stdout.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...data })}\n`);
}
export function printProgressEvent(event: string, data: Record<string, unknown> = {}): void {
process.stderr.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...data })}\n`);
}
export function printV02PrMonitorProgress(data: Record<string, unknown> = {}): void {
printProgressEvent("hwlab.v02.pr-monitor.progress", data);
}
export function printRuntimeLanePrMonitorProgress(spec: HwlabRuntimeLaneSpec, data: Record<string, unknown> = {}): void {
printProgressEvent("hwlab.runtime-lane.pr-monitor.progress", {
lane: spec.lane,
node: spec.nodeId,
...data,
});
}
export function printRuntimeLaneTriggerProgress(spec: HwlabRuntimeLaneSpec, data: Record<string, unknown> = {}): void {
printProgressEvent("hwlab.runtime-lane.trigger.progress", {
lane: spec.lane,
node: spec.nodeId,
...data,
});
}
export function shortSha(sha: string): string {
return sha.slice(0, 12);
}
export function precheckWorkspace(): CommandJsonResult {
return cliJson(["ssh", `${G14_PROVIDER}:${G14_WORKSPACE}`, "sh", "--", "pwd; git fetch origin G14 --prune; git status --short --branch; git remote -v | sed -n '1,4p'"], 120_000);
}
export function g14HostScript(script: string, timeoutMs = 120_000): CommandJsonResult {
return cliJson(["ssh", G14_PROVIDER, "sh", "--", script], timeoutMs);
}
export function g14K3s(args: string[], timeoutMs = 60_000): CommandJsonResult {
return cliJson(["ssh", `${G14_PROVIDER}:k3s`, ...args], timeoutMs);
}
export function g14K3sInlineScriptWithInput(script: string, input: string, timeoutMs = 60_000): CommandJsonResult {
return commandJsonWithInput(["bun", "scripts/cli.ts", "ssh", `${G14_PROVIDER}:k3s`, "sh", "--", script], input, timeoutMs);
}
export function remoteAsyncStatusDir(label: string): string {
return `/tmp/hwlab-${label}-async`;
}
export function remoteAsyncStatusPath(label: string, token: string): string {
return `${remoteAsyncStatusDir(label)}/${token}.json`;
}
export function remoteAsyncStartScript(spec: RemoteAsyncCommandSpec): string {
const statusPath = remoteAsyncStatusPath(spec.label, spec.token);
const donePath = `${statusPath}.done`;
const stdoutPath = `${statusPath}.stdout`;
const stderrPath = `${statusPath}.stderr`;
const commandB64 = Buffer.from(spec.script, "utf8").toString("base64");
return [
"set -eu",
`status_dir=${shellQuote(remoteAsyncStatusDir(spec.label))}`,
`status_path=${shellQuote(statusPath)}`,
`done_path=${shellQuote(donePath)}`,
`stdout_path=${shellQuote(stdoutPath)}`,
`stderr_path=${shellQuote(stderrPath)}`,
`command_b64=${shellQuote(commandB64)}`,
"mkdir -p \"$status_dir\"",
"rm -f \"$status_path\" \"$done_path\" \"$stdout_path\" \"$stderr_path\"",
"command_path=\"$status_path.command.sh\"",
"printf '%s' \"$command_b64\" | base64 -d > \"$command_path\"",
"chmod +x \"$command_path\"",
"node -e 'const fs=require(\"fs\"); const p=process.argv[1]; const payload={ok:null,state:\"running\",pid:Number(process.argv[2]),startedAt:new Date().toISOString(),finishedAt:null,exitCode:null,stdout:\"\",stderr:\"\"}; fs.writeFileSync(p, JSON.stringify(payload));' \"$status_path\" $$",
"(",
" set +e",
" sh \"$command_path\" >\"$stdout_path\" 2>\"$stderr_path\"",
" code=$?",
" node - \"$status_path\" \"$stdout_path\" \"$stderr_path\" \"$code\" <<'NODE'",
"const fs = require('fs');",
"const [statusPath, stdoutPath, stderrPath, codeRaw] = process.argv.slice(2);",
"const code = Number(codeRaw);",
"const read = (path) => { try { return fs.readFileSync(path, 'utf8'); } catch { return ''; } };",
"fs.writeFileSync(statusPath, JSON.stringify({",
" ok: code === 0,",
" state: 'finished',",
" pid: null,",
" startedAt: null,",
" finishedAt: new Date().toISOString(),",
" exitCode: code,",
" stdout: read(stdoutPath),",
" stderr: read(stderrPath),",
"}));",
"NODE",
" touch \"$done_path\"",
") >/dev/null 2>&1 &",
"pid=$!",
"node - \"$status_path\" \"$pid\" <<'NODE'",
"const fs = require('fs');",
"const [statusPath, pidRaw] = process.argv.slice(2);",
"const current = JSON.parse(fs.readFileSync(statusPath, 'utf8'));",
"current.pid = Number(pidRaw);",
"fs.writeFileSync(statusPath, JSON.stringify(current));",
"console.log(JSON.stringify({ ok: true, state: current.state, pid: current.pid, statusPath }));",
"NODE",
].join("\n");
}
export function remoteAsyncStatusScript(label: string, token: string): string {
const statusPath = remoteAsyncStatusPath(label, token);
const donePath = `${statusPath}.done`;
return [
"set -eu",
`status_path=${shellQuote(statusPath)}`,
`done_path=${shellQuote(donePath)}`,
"if [ ! -f \"$status_path\" ]; then",
" printf '%s\\n' '{\"ok\":false,\"state\":\"missing\",\"exitCode\":null,\"stdout\":\"\",\"stderr\":\"remote async status file is missing\"}'",
" exit 0",
"fi",
"if [ -f \"$done_path\" ]; then",
" cat \"$status_path\"",
" exit 0",
"fi",
"node - \"$status_path\" <<'NODE'",
"const fs = require('fs');",
"const statusPath = process.argv[2];",
"const payload = JSON.parse(fs.readFileSync(statusPath, 'utf8'));",
"payload.state = payload.state || 'running';",
"payload.ok = payload.ok ?? null;",
"payload.stdout = payload.stdout || '';",
"payload.stderr = payload.stderr || '';",
"console.log(JSON.stringify(payload));",
"NODE",
].join("\n");
}
export function remoteAsyncKillScript(label: string, token: string): string {
const statusPath = remoteAsyncStatusPath(label, token);
const donePath = `${statusPath}.done`;
return [
"set -eu",
`status_path=${shellQuote(statusPath)}`,
`done_path=${shellQuote(donePath)}`,
"if [ ! -f \"$status_path\" ]; then",
" printf '%s\\n' '{\"ok\":false,\"state\":\"missing\",\"exitCode\":null,\"stdout\":\"\",\"stderr\":\"remote async status file is missing during kill\"}'",
" exit 0",
"fi",
"node - \"$status_path\" <<'NODE'",
"const fs = require('fs');",
"const statusPath = process.argv[2];",
"const payload = JSON.parse(fs.readFileSync(statusPath, 'utf8'));",
"if (Number.isInteger(payload.pid) && payload.pid > 0) {",
" try { process.kill(payload.pid, 'TERM'); } catch {}",
"}",
"payload.ok = false;",
"payload.state = 'timeout-killed';",
"payload.exitCode = 124;",
"payload.finishedAt = new Date().toISOString();",
"payload.stderr = `${payload.stderr || ''}\\nremote async command exceeded local poll timeout and was terminated`;",
"fs.writeFileSync(statusPath, JSON.stringify(payload));",
"console.log(JSON.stringify(payload));",
"NODE",
"touch \"$done_path\"",
].join("\n");
}
export function parseRemoteAsyncPayload(result: CommandJsonResult): Record<string, unknown> {
const text = result.stdout.trim();
if (text.length === 0) return {};
try {
return record(JSON.parse(text) as unknown);
} catch {
return {};
}
}
export function runG14K3sRemoteAsync(spec: RemoteAsyncCommandSpec): CommandJsonResult {
const startedAtMs = Date.now();
const start = g14K3s(["sh", "--", remoteAsyncStartScript(spec)], 55_000);
const startPayload = parseRemoteAsyncPayload(start);
if (!isCommandSuccess(start) || startPayload.ok !== true) return start;
const deadlineMs = startedAtMs + Math.max(1, spec.timeoutSeconds) * 1000;
let attempts = 0;
let lastStatus: CommandJsonResult | null = null;
while (Date.now() <= deadlineMs) {
attempts += 1;
const wait = runCommand(["sleep", attempts <= 1 ? "1" : "2"], repoRoot, { timeoutMs: 3_000 });
if (wait.exitCode !== 0 && wait.timedOut) break;
const status = g14K3s(["sh", "--", remoteAsyncStatusScript(spec.label, spec.token)], 55_000);
lastStatus = status;
const payload = parseRemoteAsyncPayload(status);
const state = typeof payload.state === "string" ? payload.state : null;
if (state === "finished" || payload.ok === true || payload.ok === false) {
return {
ok: payload.ok === true,
command: spec.command,
exitCode: typeof payload.exitCode === "number" ? payload.exitCode : status.exitCode,
stdout: typeof payload.stdout === "string" ? payload.stdout : status.stdout,
stderr: typeof payload.stderr === "string" ? payload.stderr : status.stderr,
parsed: {
ok: payload.ok === true,
data: {
mode: "remote-async",
label: spec.label,
token: spec.token,
statusPath: remoteAsyncStatusPath(spec.label, spec.token),
attempts,
elapsedMs: Date.now() - startedAtMs,
state,
pid: payload.pid ?? null,
},
},
};
}
}
const killed = g14K3s(["sh", "--", remoteAsyncKillScript(spec.label, spec.token)], 55_000);
const payload = parseRemoteAsyncPayload(killed);
return {
ok: false,
command: spec.command,
exitCode: 124,
stdout: typeof payload.stdout === "string" ? payload.stdout : "",
stderr: [
`remote async command timed out after ${spec.timeoutSeconds}s`,
typeof payload.stderr === "string" ? payload.stderr : "",
lastStatus === null ? "" : `last status stderr: ${lastStatus.stderr.trim()}`,
killed.stderr.trim(),
].filter(Boolean).join("\n"),
parsed: {
ok: false,
data: {
mode: "remote-async",
label: spec.label,
token: spec.token,
statusPath: remoteAsyncStatusPath(spec.label, spec.token),
attempts,
elapsedMs: Date.now() - startedAtMs,
state: typeof payload.state === "string" ? payload.state : "timeout",
pid: payload.pid ?? null,
killed: compactCommandMetadata(killed, true, false),
},
},
};
}
+409
View File
@@ -0,0 +1,409 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. render module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:3346-3736 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { CommandJsonResult } from "./types";
import { g14HostScript } from "./images";
import { compactCommandResult, g14K3s, isCommandSuccess, record, runG14K3sRemoteAsync, shellQuote, shortSha, textHash } from "./remote";
import { runtimeLaneCicdRepoEnsureScript } from "./source";
import { CI_NAMESPACE, G14_PROVIDER, V02_CONTROL_PLANE_REFRESH_LOCK_STALE_MS, V02_CONTROL_PLANE_REFRESH_TTL_MS, V02_LANE_SPEC, V02_POLLER, V02_RECONCILER } from "./types";
import { timestampMs } from "./v02-status";
export function runtimeLaneControlPlaneRenderScript(spec: HwlabRuntimeLaneSpec, sourceCommit: string, token = runtimeLaneRenderToken()): string {
const renderDir = runtimeLaneRenderDir(spec, sourceCommit, token);
const worktreeDir = runtimeLaneRenderWorktreeDir(spec, sourceCommit, token);
return [
"set -eu",
runtimeLaneCicdRepoEnsureScript(spec),
`source_commit=${shellQuote(sourceCommit)}`,
`render_dir=${shellQuote(renderDir)}`,
`worktree_dir=${shellQuote(worktreeDir)}`,
`render_lane=${shellQuote(spec.lane)}`,
"cleanup_render_worktree() { rm -rf \"$worktree_dir\"; }",
"trap cleanup_render_worktree EXIT",
`test "$(git --git-dir="$cicd_repo" rev-parse refs/remotes/origin/${spec.sourceBranch})" = ${shellQuote(sourceCommit)}`,
"cleanup_render_worktree",
"rm -rf \"$render_dir\"",
"mkdir -p \"$render_dir\" \"$(dirname \"$worktree_dir\")\"",
"git clone --shared --no-checkout \"$cicd_repo\" \"$worktree_dir\"",
"git -C \"$worktree_dir\" checkout --detach \"$source_commit\"",
"test \"$(git -C \"$worktree_dir\" rev-parse HEAD)\" = \"$source_commit\"",
"cd \"$worktree_dir\"",
"if [ ! -d node_modules/yaml ]; then",
" if [ -f package-lock.json ]; then",
" npm ci --ignore-scripts --no-audit --prefer-offline",
" elif [ -f bun.lock ] || [ -f bun.lockb ]; then",
" bun install --frozen-lockfile --ignore-scripts",
" else",
` echo "${spec.version} control-plane render cannot install dependencies: no lockfile found" >&2`,
" exit 42",
" fi",
"fi",
"if [ -f scripts/gitops-render.mjs ]; then",
" render_script=scripts/gitops-render.mjs",
"elif [ \"$render_lane\" = v02 ] && [ -f scripts/g14-gitops-render.mjs ]; then",
" render_script=scripts/g14-gitops-render.mjs",
"else",
` echo "${spec.version} control-plane render script missing: scripts/gitops-render.mjs" >&2`,
" exit 43",
"fi",
"if [ -f scripts/run-bun.mjs ]; then",
` node scripts/run-bun.mjs "$render_script" --lane "$render_lane" --source-revision ${shellQuote(sourceCommit)} --out "$render_dir"`,
"else",
` node "$render_script" --lane "$render_lane" --source-revision ${shellQuote(sourceCommit)} --out "$render_dir"`,
"fi",
].join("\n");
}
export function v02ControlPlaneRenderScript(sourceCommit: string, token = v02RenderToken()): string {
return runtimeLaneControlPlaneRenderScript(V02_LANE_SPEC, sourceCommit, token);
}
export interface V02RenderTempResult {
result: CommandJsonResult;
renderDir: string;
worktreeDir: string;
token: string;
}
export function runRuntimeLaneRenderToTemp(spec: HwlabRuntimeLaneSpec, sourceCommit: string): V02RenderTempResult {
const token = runtimeLaneRenderToken();
return {
result: g14HostScript(runtimeLaneControlPlaneRenderScript(spec, sourceCommit, token), 180_000),
renderDir: runtimeLaneRenderDir(spec, sourceCommit, token),
worktreeDir: runtimeLaneRenderWorktreeDir(spec, sourceCommit, token),
token,
};
}
export function runV02RenderToTemp(sourceCommit: string): V02RenderTempResult {
return runRuntimeLaneRenderToTemp(V02_LANE_SPEC, sourceCommit);
}
export function runtimeLaneRenderToken(): string {
const random = Math.random().toString(16).slice(2, 10);
return `${process.pid}-${Date.now().toString(36)}-${random}`.replace(/[^a-zA-Z0-9_.-]/gu, "-");
}
export function v02RenderToken(): string {
return runtimeLaneRenderToken();
}
export function runtimeLaneRenderDir(spec: HwlabRuntimeLaneSpec, sourceCommit: string, token?: string): string {
return `/tmp/hwlab-${spec.lane}-control-plane-${shortSha(sourceCommit)}${token ? `-${token}` : ""}`;
}
export function v02RenderDir(sourceCommit: string, token?: string): string {
return runtimeLaneRenderDir(V02_LANE_SPEC, sourceCommit, token);
}
export function runtimeLaneRenderWorktreeDir(spec: HwlabRuntimeLaneSpec, sourceCommit: string, token?: string): string {
return `/tmp/hwlab-${spec.lane}-control-plane-source-${shortSha(sourceCommit)}${token ? `-${token}` : ""}`;
}
export function v02RenderWorktreeDir(sourceCommit: string, token?: string): string {
return runtimeLaneRenderWorktreeDir(V02_LANE_SPEC, sourceCommit, token);
}
export function cleanupRuntimeLaneRenderDir(spec: HwlabRuntimeLaneSpec, renderDir: string): CommandJsonResult {
return g14HostScript([
"set -eu",
`render_dir=${shellQuote(renderDir)}`,
"case \"$render_dir\" in",
` /tmp/hwlab-${spec.lane}-control-plane-*) rm -rf "$render_dir" ;;`,
` *) echo "refusing to cleanup unexpected ${spec.version} render dir: $render_dir" >&2; exit 44 ;;`,
"esac",
].join("\n"), 60_000);
}
export function cleanupV02RenderDir(renderDir: string): CommandJsonResult {
return cleanupRuntimeLaneRenderDir(V02_LANE_SPEC, renderDir);
}
export function legacyRuntimeLaneArgoApplications(spec: HwlabRuntimeLaneSpec): string[] {
if (spec.lane === "v02") return [];
return [`hwlab-g14-${spec.lane}`].filter((name) => name !== spec.app);
}
export function applyRuntimeLaneControlPlaneFiles(spec: HwlabRuntimeLaneSpec, renderDir: string, dryRun: boolean, timeoutSeconds: number): CommandJsonResult {
const namespaceFile = `${renderDir}/${spec.runtimeRenderDir}/namespace.yaml`;
const rbacFile = `${renderDir}/${spec.tektonDir}/rbac.yaml`;
const pipelineFile = `${renderDir}/${spec.tektonDir}/pipeline.yaml`;
const projectFile = `${renderDir}/argocd/project.yaml`;
const applicationFile = `${renderDir}/argocd/${spec.argoApplicationFile}`;
const resourceFiles = [namespaceFile, rbacFile, pipelineFile, projectFile, applicationFile];
const legacyApplications = legacyRuntimeLaneArgoApplications(spec);
const serverSideArgs = [
"kubectl",
"apply",
"--server-side",
"--force-conflicts",
`--field-manager=${spec.controlPlaneFieldManager}`,
...(dryRun ? ["--dry-run=server"] : []),
...resourceFiles.flatMap((file) => ["-f", file]),
];
const command = ["bun", "scripts/cli.ts", "ssh", `${G14_PROVIDER}:k3s`, ...serverSideArgs];
const shellCommand = dryRun && spec.lane !== "v02"
? [
"set -eu",
[
...serverSideArgs.slice(0, 6),
"-f",
namespaceFile,
"-f",
pipelineFile,
"-f",
projectFile,
"-f",
applicationFile,
].map(shellQuote).join(" "),
[
"kubectl",
"apply",
"--dry-run=client",
...resourceFiles.flatMap((file) => ["-f", file]),
].map(shellQuote).join(" "),
].join("\n")
: `exec ${serverSideArgs.map(shellQuote).join(" ")}`;
const cleanupCommands = legacyApplications.map((name) => [
"kubectl",
"delete",
"application",
"-n",
"argocd",
name,
"--ignore-not-found=true",
...(dryRun ? ["--dry-run=server", "-o", "name"] : []),
].map(shellQuote).join(" "));
const script = cleanupCommands.length > 0
? ["set -eu", shellCommand.replace(/^exec /, ""), ...cleanupCommands].join("\n")
: shellCommand;
const visibleCommand = cleanupCommands.length > 0
? ["bun", "scripts/cli.ts", "ssh", `${G14_PROVIDER}:k3s`, "sh", "--", script]
: command;
return runG14K3sRemoteAsync({
script,
timeoutSeconds,
label: `${spec.lane}-control-plane-apply`,
token: runtimeLaneRenderToken(),
command: visibleCommand,
});
}
export function applyV02ControlPlaneFiles(renderDir: string, dryRun: boolean, timeoutSeconds: number): CommandJsonResult {
return applyRuntimeLaneControlPlaneFiles(V02_LANE_SPEC, renderDir, dryRun, timeoutSeconds);
}
export interface V02RefreshMarker {
ok: boolean;
sourceCommit: string;
refreshedAt: string;
renderScriptHash: string;
renderDir?: string | null;
}
export function v02ControlPlaneRefreshStateDir(): string {
return rootPath(".state", "hwlab-g14", "v02-control-plane-refresh");
}
export function v02ControlPlaneRefreshScriptHash(sourceCommit: string): string {
return textHash(v02ControlPlaneRenderScript(sourceCommit, "contract-token"));
}
export function v02ControlPlaneRefreshMarkerPath(sourceCommit: string): string {
return join(v02ControlPlaneRefreshStateDir(), `${shortSha(sourceCommit)}.json`);
}
export function v02ControlPlaneRefreshLockDir(sourceCommit: string): string {
return join(v02ControlPlaneRefreshStateDir(), `${shortSha(sourceCommit)}.lock`);
}
export function readV02RefreshMarker(sourceCommit: string, scriptHash: string, nowMs = Date.now()): V02RefreshMarker | null {
const path = v02ControlPlaneRefreshMarkerPath(sourceCommit);
if (!existsSync(path)) return null;
try {
const marker = JSON.parse(readFileSync(path, "utf8")) as V02RefreshMarker;
return v02ReusableRefreshMarker(marker, sourceCommit, scriptHash, nowMs);
} catch {
return null;
}
}
export function v02ReusableRefreshMarker(marker: unknown, sourceCommit: string, scriptHash: string, nowMs = Date.now()): V02RefreshMarker | null {
const candidate = record(marker) as V02RefreshMarker;
const refreshedAtMs = timestampMs(candidate.refreshedAt);
if (candidate.ok !== true || candidate.sourceCommit !== sourceCommit || candidate.renderScriptHash !== scriptHash) return null;
if (refreshedAtMs === null || nowMs - refreshedAtMs > V02_CONTROL_PLANE_REFRESH_TTL_MS) return null;
return candidate;
}
export function writeV02RefreshMarker(sourceCommit: string, scriptHash: string, renderDir: string | null): V02RefreshMarker {
const marker = {
ok: true,
sourceCommit,
refreshedAt: new Date().toISOString(),
renderScriptHash: scriptHash,
renderDir,
};
const dir = v02ControlPlaneRefreshStateDir();
mkdirSync(dir, { recursive: true });
writeFileSync(v02ControlPlaneRefreshMarkerPath(sourceCommit), `${JSON.stringify(marker, null, 2)}\n`, "utf8");
return marker;
}
export function acquireV02RefreshLock(sourceCommit: string): { acquired: boolean; lockDir: string; waitedMs: number } {
const stateDir = v02ControlPlaneRefreshStateDir();
mkdirSync(stateDir, { recursive: true });
const lockDir = v02ControlPlaneRefreshLockDir(sourceCommit);
const startedAtMs = Date.now();
for (;;) {
try {
mkdirSync(lockDir);
writeFileSync(join(lockDir, "owner.json"), `${JSON.stringify({ pid: process.pid, sourceCommit, acquiredAt: new Date().toISOString() }, null, 2)}\n`, "utf8");
return { acquired: true, lockDir, waitedMs: Date.now() - startedAtMs };
} catch {
const ageMs = (() => {
try {
return Date.now() - statSync(lockDir).mtimeMs;
} catch {
return 0;
}
})();
if (ageMs > V02_CONTROL_PLANE_REFRESH_LOCK_STALE_MS) {
try {
rmSync(lockDir, { recursive: true, force: true });
continue;
} catch {
return { acquired: false, lockDir, waitedMs: Date.now() - startedAtMs };
}
}
if (Date.now() - startedAtMs >= V02_CONTROL_PLANE_REFRESH_TTL_MS) return { acquired: false, lockDir, waitedMs: Date.now() - startedAtMs };
const wait = runCommand(["sleep", "1"], repoRoot, { timeoutMs: 2_000 });
if (wait.exitCode !== 0 && wait.timedOut) return { acquired: false, lockDir, waitedMs: Date.now() - startedAtMs };
}
}
}
export function releaseV02RefreshLock(lockDir: string): void {
try {
rmSync(lockDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup; stale lock expiry handles interrupted workers.
}
}
export function refreshV02ControlPlaneBeforeTrigger(sourceCommit: string, timeoutSeconds: number): Record<string, unknown> {
const scriptHash = v02ControlPlaneRefreshScriptHash(sourceCommit);
const existingMarker = readV02RefreshMarker(sourceCommit, scriptHash);
if (existingMarker !== null) {
return {
ok: true,
phase: "control-plane-refresh",
mode: "reused-recent-refresh-marker",
sourceCommit,
renderDir: existingMarker.renderDir ?? null,
marker: existingMarker,
markerTtlMs: V02_CONTROL_PLANE_REFRESH_TTL_MS,
};
}
const lock = acquireV02RefreshLock(sourceCommit);
if (!lock.acquired) {
return {
ok: false,
phase: "control-plane-refresh-lock",
mode: "local-refresh-lock",
sourceCommit,
lockDir: lock.lockDir,
waitedMs: lock.waitedMs,
degradedReason: "control-plane-refresh-lock-timeout",
};
}
try {
const markerAfterWait = readV02RefreshMarker(sourceCommit, scriptHash);
if (markerAfterWait !== null) {
return {
ok: true,
phase: "control-plane-refresh",
mode: "waited-for-recent-refresh-marker",
sourceCommit,
renderDir: markerAfterWait.renderDir ?? null,
marker: markerAfterWait,
waitedMs: lock.waitedMs,
markerTtlMs: V02_CONTROL_PLANE_REFRESH_TTL_MS,
};
}
const render = runV02RenderToTemp(sourceCommit);
if (!isCommandSuccess(render.result)) {
return {
ok: false,
phase: "source-render",
sourceCommit,
renderDir: render.renderDir,
render: compactCommandResult(render.result),
degradedReason: "control-plane-render-failed",
};
}
const apply = applyV02ControlPlaneFiles(render.renderDir, false, timeoutSeconds);
const cleanupObsoleteCronJobs = isCommandSuccess(apply) ? deleteV02ObsoleteCronJobs(false) : null;
const cleanupRenderDir = isCommandSuccess(apply) ? cleanupV02RenderDir(render.renderDir) : null;
const ok = isCommandSuccess(apply) && (cleanupObsoleteCronJobs === null || isCommandSuccess(cleanupObsoleteCronJobs));
const marker = ok ? writeV02RefreshMarker(sourceCommit, scriptHash, render.renderDir) : null;
return {
ok,
phase: "control-plane-refresh",
mode: "refreshed",
sourceCommit,
renderDir: render.renderDir,
render: compactCommandResult(render.result),
apply: compactCommandResult(apply),
cleanupObsoleteCronJobs: cleanupObsoleteCronJobs === null ? null : compactCommandResult(cleanupObsoleteCronJobs),
cleanupRenderDir: cleanupRenderDir === null ? null : compactCommandResult(cleanupRenderDir),
marker,
waitedMs: lock.waitedMs,
degradedReason: !isCommandSuccess(apply)
? "control-plane-apply-failed"
: cleanupObsoleteCronJobs !== null && !isCommandSuccess(cleanupObsoleteCronJobs)
? "obsolete-cronjob-cleanup-failed"
: undefined,
};
} finally {
releaseV02RefreshLock(lock.lockDir);
}
}
export function getV02ObsoleteCronJobs(): CommandJsonResult {
return g14K3s([
"kubectl",
"get",
"cronjob",
"-n",
CI_NAMESPACE,
V02_POLLER,
V02_RECONCILER,
"--ignore-not-found",
"-o",
"name",
], 60_000);
}
export function deleteV02ObsoleteCronJobs(dryRun: boolean): CommandJsonResult {
return g14K3s([
"kubectl",
"delete",
"cronjob",
"-n",
CI_NAMESPACE,
V02_POLLER,
V02_RECONCILER,
"--ignore-not-found=true",
...(dryRun ? ["--dry-run=server", "-o", "name"] : []),
], 60_000);
}
+339
View File
@@ -0,0 +1,339 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. retirement module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:5525-5845 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { G14LegacyRetirementOptions } from "./types";
import { k8sMetadataName, parseSectionJsonArray } from "./observability";
import { statusText } from "./pr-monitor";
import { compactCommandResult, g14K3s, isCommandSuccess, nested, record, shellQuote } from "./remote";
import { ARGO_NAMESPACE, DEV_APP, DEV_NAMESPACE, G14_PROVIDER, PROD_APP, PROD_NAMESPACE, V02_APP, V02_RUNTIME_NAMESPACE } from "./types";
import { parseShellSections, shellSectionOk } from "./v02-status";
export function legacyG14RetirementStatePath(): string {
return rootPath(".state", "hwlab-g14", "legacy-g14-retirement.json");
}
export function legacyG14RetirementApplications(): string[] {
return [DEV_APP, PROD_APP];
}
export function legacyG14RetirementNamespaces(): string[] {
return [DEV_NAMESPACE, PROD_NAMESPACE];
}
export function protectedG14RuntimeApplications(): string[] {
return [V02_APP, hwlabRuntimeLaneSpec("v03").app];
}
export function protectedG14RuntimeNamespaces(): string[] {
return [V02_RUNTIME_NAMESPACE, hwlabRuntimeLaneSpec("v03").runtimeNamespace];
}
export function readLegacyG14RetirementMarker(): Record<string, unknown> | null {
const path = legacyG14RetirementStatePath();
if (!existsSync(path)) return null;
try {
return record(JSON.parse(readFileSync(path, "utf8")) as unknown);
} catch (error) {
return { status: "unreadable", path, error: error instanceof Error ? error.message : String(error) };
}
}
export function writeLegacyG14RetirementMarker(status: string, reason: string, details: Record<string, unknown> = {}): Record<string, unknown> {
const path = legacyG14RetirementStatePath();
mkdirSync(dirname(path), { recursive: true });
const previous = readLegacyG14RetirementMarker();
const marker = {
status,
reason,
requestedAt: typeof previous?.requestedAt === "string" ? previous.requestedAt : new Date().toISOString(),
updatedAt: new Date().toISOString(),
legacyApplications: legacyG14RetirementApplications(),
legacyNamespaces: legacyG14RetirementNamespaces(),
protectedApplications: protectedG14RuntimeApplications(),
protectedNamespaces: protectedG14RuntimeNamespaces(),
...details,
};
writeFileSync(path, `${JSON.stringify(marker, null, 2)}\n`, "utf8");
return { ...marker, path };
}
export function legacyG14RetirementBlocksMonitor(): Record<string, unknown> | null {
const marker = readLegacyG14RetirementMarker();
if (marker === null) {
return {
status: "retired-by-contract",
path: legacyG14RetirementStatePath(),
reason: "legacy G14 DEV/PROD monitor is retired; inspect retirement status for live cluster evidence",
next: { status: "bun scripts/cli.ts hwlab g14 retirement status", v02Monitor: "bun scripts/cli.ts hwlab g14 monitor-prs --lane v02", v03Monitor: "bun scripts/cli.ts hwlab g14 monitor-prs --lane v03" },
};
}
const status = String(marker.status ?? "");
return status === "retiring" || status === "retired" || status === "failed" || status === "unreadable" ? marker : null;
}
export function k8sMetadataNamespace(item: Record<string, unknown>): string | null {
const namespace = record(item.metadata).namespace;
return typeof namespace === "string" && namespace.length > 0 ? namespace : null;
}
export function applicationSummary(item: Record<string, unknown>): Record<string, unknown> {
return {
name: k8sMetadataName(item),
namespace: k8sMetadataNamespace(item) ?? ARGO_NAMESPACE,
deletionTimestamp: record(item.metadata).deletionTimestamp ?? null,
sync: nested(item, ["status", "sync", "status"]) ?? null,
health: nested(item, ["status", "health", "status"]) ?? null,
revision: nested(item, ["status", "sync", "revision"]) ?? null,
};
}
export function namespaceSummary(item: Record<string, unknown>): Record<string, unknown> {
return {
name: k8sMetadataName(item),
phase: nested(item, ["status", "phase"]) ?? null,
deletionTimestamp: record(item.metadata).deletionTimestamp ?? null,
};
}
export function parseNamespacedResourcePreview(text: string): Record<string, unknown>[] {
const groups: Record<string, string[]> = {};
let current = "";
for (const line of text.split(/\r?\n/u)) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
const namespaceMatch = /^__namespace\t(.+)$/u.exec(trimmed);
if (namespaceMatch !== null) {
current = namespaceMatch[1] ?? "";
if (current.length > 0) groups[current] = [];
continue;
}
if (current.length > 0) groups[current]?.push(trimmed);
}
return Object.entries(groups).map(([namespace, items]) => ({
namespace,
count: items.length,
preview: items.slice(0, 40),
truncated: items.length > 40,
}));
}
export function legacyG14RetirementStatusScript(): string {
const legacyApps = legacyG14RetirementApplications().map(shellQuote).join(" ");
const legacyNamespaces = legacyG14RetirementNamespaces().map(shellQuote).join(" ");
const protectedApps = protectedG14RuntimeApplications().map(shellQuote).join(" ");
const protectedNamespaces = protectedG14RuntimeNamespaces().map(shellQuote).join(" ");
return [
"set +e",
"section() {",
" name=\"$1\"",
" shift",
" printf '__UNIDESK_SECTION_BEGIN__ %s\\n' \"$name\"",
" \"$@\"",
" code=$?",
" printf '\\n__UNIDESK_SECTION_END__ %s exit=%s\\n' \"$name\" \"$code\"",
"}",
`section legacyApplications kubectl get application -n ${shellQuote(ARGO_NAMESPACE)} ${legacyApps} --ignore-not-found=true -o json`,
`section legacyNamespaces kubectl get namespace ${legacyNamespaces} --ignore-not-found=true -o json`,
`section protectedApplications kubectl get application -n ${shellQuote(ARGO_NAMESPACE)} ${protectedApps} --ignore-not-found=true -o json`,
`section protectedNamespaces kubectl get namespace ${protectedNamespaces} --ignore-not-found=true -o json`,
"section legacyResources sh -lc " + shellQuote([
`for ns in ${legacyNamespaces}; do`,
" printf '__namespace\\t%s\\n' \"$ns\"",
" kubectl get deploy,statefulset,svc,pod,pvc,ingress,configmap,secret -n \"$ns\" -o name --ignore-not-found=true 2>/dev/null | sed -n '1,160p'",
"done",
].join("\n")),
].join("\n");
}
export function legacyG14RetirementStatus(): Record<string, unknown> {
const result = g14K3s(["sh", "--", legacyG14RetirementStatusScript()], 60_000);
const sections = parseShellSections(statusText(result));
const legacyApplications = parseSectionJsonArray(sections.legacyApplications).map(applicationSummary);
const legacyNamespaces = parseSectionJsonArray(sections.legacyNamespaces).map(namespaceSummary);
const protectedApplications = parseSectionJsonArray(sections.protectedApplications).map(applicationSummary);
const protectedNamespaces = parseSectionJsonArray(sections.protectedNamespaces).map(namespaceSummary);
const terminating = legacyApplications.some((item) => item.deletionTimestamp !== null)
|| legacyNamespaces.some((item) => item.deletionTimestamp !== null || item.phase === "Terminating");
const retired = legacyApplications.length === 0 && legacyNamespaces.length === 0;
const retirementState = retired ? "retired" : terminating ? "terminating" : "active";
const protectedAppNames = new Set(protectedApplications.map((item) => item.name).filter((name): name is string => typeof name === "string"));
const protectedNamespaceNames = new Set(protectedNamespaces.map((item) => item.name).filter((name): name is string => typeof name === "string"));
const missingProtectedApplications = protectedG14RuntimeApplications().filter((name) => !protectedAppNames.has(name));
const missingProtectedNamespaces = protectedG14RuntimeNamespaces().filter((name) => !protectedNamespaceNames.has(name));
const sectionsOk =
shellSectionOk(sections.legacyApplications) &&
shellSectionOk(sections.legacyNamespaces) &&
shellSectionOk(sections.protectedApplications) &&
shellSectionOk(sections.protectedNamespaces) &&
shellSectionOk(sections.legacyResources);
return {
ok: isCommandSuccess(result) && sectionsOk,
command: "hwlab g14 retirement status",
provider: G14_PROVIDER,
argoNamespace: ARGO_NAMESPACE,
retirementState,
retired,
legacy: {
applications: legacyApplications,
namespaces: legacyNamespaces,
resources: parseNamespacedResourcePreview(String(sections.legacyResources?.stdout ?? "")),
},
protected: {
applications: protectedApplications,
namespaces: protectedNamespaces,
missingApplications: missingProtectedApplications,
missingNamespaces: missingProtectedNamespaces,
ok: missingProtectedApplications.length === 0 && missingProtectedNamespaces.length === 0,
},
marker: readLegacyG14RetirementMarker(),
result: compactCommandResult(result),
next: retired
? { verifyV02: "bun scripts/cli.ts hwlab g14 control-plane status --lane v02", verifyV03: "bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03" }
: { plan: "bun scripts/cli.ts hwlab g14 retirement plan", execute: "bun scripts/cli.ts hwlab g14 retirement execute --confirm" },
};
}
export function legacyG14RetirementPlan(status = legacyG14RetirementStatus()): Record<string, unknown> {
return {
ok: status.ok === true,
command: "hwlab g14 retirement plan",
mode: "dry-run",
mutation: false,
reason: "retire legacy G14 DEV/PROD runtime and support surface without touching v0.2/v0.3",
destructiveTargets: {
applications: legacyG14RetirementApplications().map((name) => ({ apiVersion: "argoproj.io/v1alpha1", kind: "Application", namespace: ARGO_NAMESPACE, name })),
namespaces: legacyG14RetirementNamespaces().map((name) => ({ apiVersion: "v1", kind: "Namespace", name })),
},
protectedTargets: {
applications: protectedG14RuntimeApplications().map((name) => ({ apiVersion: "argoproj.io/v1alpha1", kind: "Application", namespace: ARGO_NAMESPACE, name })),
namespaces: protectedG14RuntimeNamespaces().map((name) => ({ apiVersion: "v1", kind: "Namespace", name })),
},
localSupportActions: [
{ action: "write-retirement-marker", path: legacyG14RetirementStatePath() },
{ action: "cancel-active-local-job", jobName: "hwlab_g14_pr_monitor" },
{ action: "block-new-monitor-starts", command: "bun scripts/cli.ts hwlab g14 monitor-prs --lane g14" },
],
status,
next: { execute: "bun scripts/cli.ts hwlab g14 retirement execute --confirm" },
};
}
export function legacyG14RecordRolloutRetired(): Record<string, unknown> {
return {
ok: false,
command: "hwlab g14 record-rollout",
phase: "retired",
degradedReason: "legacy-g14-dev-prod-retired",
message: "Legacy G14 DEV rollout recording is retired. Use active runtime lane closeout/status evidence instead.",
retirement: readLegacyG14RetirementMarker(),
next: {
retirementStatus: "bun scripts/cli.ts hwlab g14 retirement status",
v02Status: "bun scripts/cli.ts hwlab g14 control-plane status --lane v02",
v03Status: "bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03",
},
};
}
export function cancelLegacyG14MonitorJobs(dryRun: boolean): Record<string, unknown> {
const candidates = listJobs()
.filter((job) => job.name === "hwlab_g14_pr_monitor" && (job.status === "queued" || job.status === "running"))
.map((job) => ({ id: job.id, name: job.name, status: job.status, createdAt: job.createdAt, runnerPid: job.runnerPid ?? null }));
if (dryRun) return { ok: true, dryRun: true, candidates, actions: candidates.map((job) => ({ action: "would-cancel", ...job })) };
const actions = candidates.map((job) => cancelJob(job.id));
return { ok: actions.every((action) => record(action).ok !== false), dryRun: false, candidates, actions };
}
export function legacyG14RetirementDeleteScript(): string {
const legacyApps = legacyG14RetirementApplications();
const legacyNamespaces = legacyG14RetirementNamespaces();
return [
"set +e",
"overall=0",
"printf '__UNIDESK_SECTION_BEGIN__ deleteApplications\\n'",
...legacyApps.map((app) => [
`kubectl delete application -n ${shellQuote(ARGO_NAMESPACE)} ${shellQuote(app)} --ignore-not-found=true --wait=false`,
"code=$?",
"[ \"$code\" -eq 0 ] || overall=1",
`printf 'application\\t%s\\texit=%s\\n' ${shellQuote(app)} "$code"`,
].join("\n")),
"printf '__UNIDESK_SECTION_END__ deleteApplications exit=0\\n'",
"printf '__UNIDESK_SECTION_BEGIN__ deleteNamespaces\\n'",
...legacyNamespaces.map((namespace) => [
`kubectl delete namespace ${shellQuote(namespace)} --ignore-not-found=true --wait=false`,
"code=$?",
"[ \"$code\" -eq 0 ] || overall=1",
`printf 'namespace\\t%s\\texit=%s\\n' ${shellQuote(namespace)} "$code"`,
].join("\n")),
"printf '__UNIDESK_SECTION_END__ deleteNamespaces exit=0\\n'",
"exit \"$overall\"",
].join("\n");
}
export function waitForLegacyG14Retirement(timeoutSeconds: number): Record<string, unknown> {
const startedAtMs = Date.now();
let lastStatus: Record<string, unknown> = {};
let attempts = 0;
while (Date.now() - startedAtMs <= timeoutSeconds * 1000) {
attempts += 1;
lastStatus = legacyG14RetirementStatus();
if (lastStatus.retired === true) {
return { ok: true, attempts, elapsedMs: Date.now() - startedAtMs, status: lastStatus };
}
const wait = runCommand(["sleep", "5"], repoRoot, { timeoutMs: 7_000 });
if (wait.exitCode !== 0) break;
}
return { ok: false, attempts, elapsedMs: Date.now() - startedAtMs, status: lastStatus, degradedReason: "legacy-g14-retirement-not-complete-before-timeout" };
}
export function runLegacyG14Retirement(options: G14LegacyRetirementOptions): Record<string, unknown> {
const before = legacyG14RetirementStatus();
if (options.action === "status") return before;
if (options.action === "plan" || options.dryRun) return { ...legacyG14RetirementPlan(before), command: `hwlab g14 retirement ${options.action}`, reason: options.reason };
const markerBeforeDelete = writeLegacyG14RetirementMarker("retiring", options.reason, { command: "hwlab g14 retirement execute --confirm" });
const localJobs = cancelLegacyG14MonitorJobs(false);
const deleteResult = g14K3s(["sh", "--", legacyG14RetirementDeleteScript()], Math.max(60_000, options.timeoutSeconds * 1000));
const deleteSections = parseShellSections(statusText(deleteResult));
const afterDelete = legacyG14RetirementStatus();
const wait = options.wait ? waitForLegacyG14Retirement(options.timeoutSeconds) : null;
const finalStatus = wait === null ? afterDelete : record(wait.status);
const liveMutationOk = isCommandSuccess(deleteResult);
const finalRetired = finalStatus.retired === true;
const marker = writeLegacyG14RetirementMarker(finalRetired ? "retired" : liveMutationOk ? "retiring" : "failed", options.reason, {
command: "hwlab g14 retirement execute --confirm",
deleteExitCode: deleteResult.exitCode,
finalRetirementState: finalStatus.retirementState ?? null,
});
return {
ok: liveMutationOk && (options.wait ? finalRetired : finalStatus.retirementState !== "active"),
command: "hwlab g14 retirement execute",
mode: "confirmed-retirement",
mutation: true,
reason: options.reason,
before,
markerBeforeDelete,
localJobs,
delete: {
ok: liveMutationOk,
applications: String(deleteSections.deleteApplications?.stdout ?? "").split(/\r?\n/u).filter(Boolean),
namespaces: String(deleteSections.deleteNamespaces?.stdout ?? "").split(/\r?\n/u).filter(Boolean),
result: compactCommandResult(deleteResult),
},
afterDelete,
wait,
finalStatus,
marker,
next: finalRetired
? { verifyV02: "bun scripts/cli.ts hwlab g14 control-plane status --lane v02", verifyV03: "bun scripts/cli.ts hwlab nodes control-plane status --node G14 --lane v03" }
: { status: "bun scripts/cli.ts hwlab g14 retirement status" },
};
}
+487
View File
@@ -0,0 +1,487 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. secrets module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:5055-5524 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { G14SecretOptions } from "./types";
import { statusText } from "./pr-monitor";
import { compactCommandResult, g14K3s, g14K3sInlineScriptWithInput, isCommandSuccess, readOrCreateLocalMasterAdminApiKey, runtimeLaneMasterAdminApiKeySecretName, runtimeLaneOpenFgaSecretName, runtimeLanePostgresSecretName, runtimeLanePrimaryDbName, runtimeLaneSecretFieldManager, shellQuote } from "./remote";
import { V02_MASTER_ADMIN_API_KEY_SECRET, V02_MASTER_ADMIN_API_KEY_SECRET_KEY, V02_OPENFGA_AUTHN_SECRET_KEY, V02_OPENFGA_DATASTORE_URI_SECRET_KEY, V02_OPENFGA_DB_NAME, V02_OPENFGA_DB_USER, V02_OPENFGA_POSTGRES_PASSWORD_SECRET_KEY, V02_OPENFGA_SECRET, V02_RUNTIME_NAMESPACE } from "./types";
import { keyValueLinesFromText, numericField } from "./v02-status";
export function v02SecretScript(options: G14SecretOptions): string {
const spec = hwlabRuntimeLaneSpec(options.lane);
if (options.preset === "generic-delete") return v02DeleteSecretScript(options);
if (options.preset === "master-server-admin-api-key") return v02MasterAdminApiKeySecretScript(options, spec);
return [
v02OpenFgaSecretScript(options, spec),
].join("\n");
}
export function v02DeleteSecretScript(options: G14SecretOptions): string {
return [
"set +e",
`namespace=${shellQuote(V02_RUNTIME_NAMESPACE)}`,
`name=${shellQuote(options.name)}`,
`dry_run=${shellQuote(options.dryRun ? "true" : "false")}`,
"preset=generic-delete",
"secret_exists_flag() { kubectl -n \"$namespace\" get secret \"$name\" >/dev/null 2>&1 && printf yes || printf no; }",
"before_exists=$(secret_exists_flag)",
"delete_exit=",
"action=observed",
"mutation=false",
"if [ \"$dry_run\" = true ]; then",
" if [ \"$before_exists\" = yes ]; then action=would-delete; else action=already-absent; fi",
"else",
" kubectl -n \"$namespace\" delete secret \"$name\" --ignore-not-found=true >/tmp/hwlab-secret-delete.out 2>/tmp/hwlab-secret-delete.err",
" delete_exit=$?",
" if [ \"$delete_exit\" -eq 0 ]; then",
" if [ \"$before_exists\" = yes ]; then action=deleted; mutation=true; else action=already-absent; fi",
" else",
" action=delete-failed",
" fi",
"fi",
"after_exists=$(secret_exists_flag)",
"printf 'namespace\\t%s\\n' \"$namespace\"",
"printf 'secret\\t%s\\n' \"$name\"",
"printf 'preset\\t%s\\n' \"$preset\"",
"printf 'action\\t%s\\n' \"$action\"",
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
"printf 'mutation\\t%s\\n' \"$mutation\"",
"printf 'beforeExists\\t%s\\n' \"$before_exists\"",
"printf 'afterExists\\t%s\\n' \"$after_exists\"",
"printf 'deleteExitCode\\t%s\\n' \"$delete_exit\"",
"if [ -n \"$delete_exit\" ] && [ \"$delete_exit\" != 0 ]; then exit \"$delete_exit\"; fi",
].join("\n");
}
export function v02OpenFgaSecretScript(options: G14SecretOptions, spec: HwlabRuntimeLaneSpec): string {
const selectedKey = options.key ?? "";
const namespace = spec.runtimeNamespace;
const openFgaSecret = runtimeLaneOpenFgaSecretName(spec);
const postgresSecret = runtimeLanePostgresSecretName(spec);
const primaryDbName = runtimeLanePrimaryDbName(spec);
const dedicatedDb = spec.lane === "v02";
const dbMode = dedicatedDb ? "dedicated" : "primary";
const dbName = dedicatedDb ? V02_OPENFGA_DB_NAME : primaryDbName;
const dbUser = dedicatedDb ? V02_OPENFGA_DB_USER : primaryDbName;
const dbHost = `${postgresSecret}.${namespace}.svc.cluster.local`;
const fieldManager = runtimeLaneSecretFieldManager(spec);
return [
"set +e",
`namespace=${shellQuote(namespace)}`,
`name=${shellQuote(openFgaSecret)}`,
`selected_key=${shellQuote(selectedKey)}`,
`authn_key=${shellQuote(V02_OPENFGA_AUTHN_SECRET_KEY)}`,
`datastore_uri_key=${shellQuote(V02_OPENFGA_DATASTORE_URI_SECRET_KEY)}`,
`postgres_password_key=${shellQuote(V02_OPENFGA_POSTGRES_PASSWORD_SECRET_KEY)}`,
`db_mode=${shellQuote(dbMode)}`,
`db_name=${shellQuote(dbName)}`,
`db_user=${shellQuote(dbUser)}`,
`postgres_secret=${shellQuote(postgresSecret)}`,
`postgres_statefulset=${shellQuote(postgresSecret)}`,
`postgres_admin_user=${shellQuote(primaryDbName)}`,
`db_host=${shellQuote(dbHost)}`,
`action_request=${shellQuote(options.action)}`,
`dry_run=${shellQuote(options.dryRun ? "true" : "false")}`,
`field_manager=${shellQuote(fieldManager)}`,
"preset=openfga",
"secret_exists_flag() { kubectl -n \"$namespace\" get secret \"$name\" >/dev/null 2>&1 && printf yes || printf no; }",
"secret_b64_key() { kubectl -n \"$namespace\" get secret \"$name\" -o \"go-template={{ index .data \\\"$1\\\" }}\" 2>/dev/null || true; }",
"decoded_value() { if [ -n \"$1\" ]; then printf '%s' \"$1\" | base64 -d 2>/dev/null || true; fi; }",
"decoded_length() { if [ -n \"$1\" ]; then printf '%s' \"$1\" | base64 -d 2>/dev/null | wc -c | tr -d ' '; else printf '0'; fi; }",
"psql_scalar() { kubectl -n \"$namespace\" exec \"statefulset/$postgres_statefulset\" -c postgres -- env PGPASSWORD=\"$postgres_admin_password\" psql -U \"$postgres_admin_user\" -d postgres -tAc \"$1\" 2>/dev/null | tr -d '[:space:]'; }",
"probe_db() {",
" if [ \"$db_mode\" = primary ]; then",
" role_result=primary",
" database_result=primary",
" probe_exit=not-required",
" return",
" fi",
" role_result=unknown",
" database_result=unknown",
" probe_exit=missing-postgres-admin-secret",
" if [ -n \"$postgres_admin_password\" ]; then",
" role_result=$(psql_scalar \"select exists(select 1 from pg_roles where rolname='$db_user');\")",
" role_exit=$?",
" database_result=$(psql_scalar \"select exists(select 1 from pg_database where datname='$db_name');\")",
" database_exit=$?",
" if [ \"$role_exit\" -eq 0 ] && [ \"$database_exit\" -eq 0 ]; then probe_exit=0; else probe_exit=$role_exit/$database_exit; fi",
" fi",
"}",
"before_exists=$(secret_exists_flag)",
"before_authn_b64=$(secret_b64_key \"$authn_key\")",
"before_uri_b64=$(secret_b64_key \"$datastore_uri_key\")",
"before_pg_password_b64=$(secret_b64_key \"$postgres_password_key\")",
"before_authn_present=$([ -n \"$before_authn_b64\" ] && printf yes || printf no)",
"before_uri_present=$([ -n \"$before_uri_b64\" ] && printf yes || printf no)",
"before_pg_password_present=$([ -n \"$before_pg_password_b64\" ] && printf yes || printf no)",
"before_authn_bytes=$(decoded_length \"$before_authn_b64\")",
"before_uri_bytes=$(decoded_length \"$before_uri_b64\")",
"before_pg_password_bytes=$(decoded_length \"$before_pg_password_b64\")",
"authn_value=$(decoded_value \"$before_authn_b64\")",
"datastore_uri=$(decoded_value \"$before_uri_b64\")",
"pg_password=$(decoded_value \"$before_pg_password_b64\")",
"postgres_admin_b64=$(kubectl -n \"$namespace\" get secret \"$postgres_secret\" -o 'go-template={{ index .data \"POSTGRES_PASSWORD\" }}' 2>/dev/null || true)",
"postgres_admin_present=$([ -n \"$postgres_admin_b64\" ] && printf yes || printf no)",
"postgres_admin_password=$(decoded_value \"$postgres_admin_b64\")",
"probe_db",
"db_role_exists_before=$role_result",
"db_database_exists_before=$database_result",
"db_probe_exit_before=$probe_exit",
"action=observed",
"mutation=false",
"apply_exit=",
"db_ensure_exit=",
"if [ \"$action_request\" = ensure ]; then",
" missing_secret=false",
" [ \"$before_authn_present\" = yes ] && [ \"$before_authn_bytes\" -gt 0 ] || missing_secret=true",
" [ \"$before_uri_present\" = yes ] && [ \"$before_uri_bytes\" -gt 0 ] || missing_secret=true",
" [ \"$before_pg_password_present\" = yes ] && [ \"$before_pg_password_bytes\" -gt 0 ] || missing_secret=true",
" missing_db=false",
" if [ \"$db_mode\" = dedicated ]; then",
" [ \"$db_role_exists_before\" = t ] || missing_db=true",
" [ \"$db_database_exists_before\" = t ] || missing_db=true",
" fi",
" if [ \"$dry_run\" = true ]; then",
" if [ \"$missing_secret\" = true ] || [ \"$missing_db\" = true ]; then action=would-ensure; else action=kept; fi",
" elif ! command -v openssl >/dev/null 2>&1; then",
" action=openssl-missing",
" apply_exit=127",
" elif [ -z \"$postgres_admin_password\" ]; then",
" action=postgres-admin-secret-missing",
" apply_exit=44",
" else",
" [ -n \"$authn_value\" ] || authn_value=$(openssl rand -base64 48)",
" if [ \"$db_mode\" = primary ]; then",
" [ -n \"$pg_password\" ] || pg_password=\"$postgres_admin_password\"",
" else",
" [ -n \"$pg_password\" ] || pg_password=$(openssl rand -hex 24)",
" fi",
" [ -n \"$datastore_uri\" ] || datastore_uri=\"postgres://$db_user:$pg_password@$db_host:5432/$db_name?sslmode=disable\"",
" kubectl -n \"$namespace\" create secret generic \"$name\" --from-literal=\"$authn_key=$authn_value\" --from-literal=\"$datastore_uri_key=$datastore_uri\" --from-literal=\"$postgres_password_key=$pg_password\" --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=\"$field_manager\" -f -",
" apply_exit=$?",
" if [ \"$apply_exit\" -eq 0 ]; then",
" if [ \"$db_mode\" = primary ]; then",
" action=ensured",
" mutation=true",
" else",
" kubectl -n \"$namespace\" exec -i \"statefulset/$postgres_statefulset\" -c postgres -- env PGPASSWORD=\"$postgres_admin_password\" psql -v ON_ERROR_STOP=1 -U \"$postgres_admin_user\" -d postgres -v db_name=\"$db_name\" -v db_user=\"$db_user\" -v db_pass=\"$pg_password\" >/tmp/hwlab-openfga-psql.out 2>/tmp/hwlab-openfga-psql.err <<'SQL'",
"SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'db_user', :'db_pass')",
"WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'db_user')",
"\\gexec",
"ALTER ROLE :\"db_user\" LOGIN PASSWORD :'db_pass';",
"SELECT format('CREATE DATABASE %I OWNER %I', :'db_name', :'db_user')",
"WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'db_name')",
"\\gexec",
"ALTER DATABASE :\"db_name\" OWNER TO :\"db_user\";",
"SQL",
" db_ensure_exit=$?",
" if [ \"$db_ensure_exit\" -eq 0 ]; then action=ensured; mutation=true; else action=db-ensure-failed; fi",
" fi",
" else",
" action=apply-failed",
" fi",
" fi",
"fi",
"after_exists=$(secret_exists_flag)",
"after_authn_b64=$(secret_b64_key \"$authn_key\")",
"after_uri_b64=$(secret_b64_key \"$datastore_uri_key\")",
"after_pg_password_b64=$(secret_b64_key \"$postgres_password_key\")",
"after_authn_present=$([ -n \"$after_authn_b64\" ] && printf yes || printf no)",
"after_uri_present=$([ -n \"$after_uri_b64\" ] && printf yes || printf no)",
"after_pg_password_present=$([ -n \"$after_pg_password_b64\" ] && printf yes || printf no)",
"after_authn_bytes=$(decoded_length \"$after_authn_b64\")",
"after_uri_bytes=$(decoded_length \"$after_uri_b64\")",
"after_pg_password_bytes=$(decoded_length \"$after_pg_password_b64\")",
"probe_db",
"db_role_exists_after=$role_result",
"db_database_exists_after=$database_result",
"db_probe_exit_after=$probe_exit",
"printf 'namespace\\t%s\\n' \"$namespace\"",
"printf 'secret\\t%s\\n' \"$name\"",
"printf 'key\\t%s\\n' \"$selected_key\"",
"printf 'preset\\t%s\\n' \"$preset\"",
"printf 'action\\t%s\\n' \"$action\"",
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
"printf 'mutation\\t%s\\n' \"$mutation\"",
"printf 'dbMode\\t%s\\n' \"$db_mode\"",
"printf 'beforeExists\\t%s\\n' \"$before_exists\"",
"printf 'beforeAuthnPresent\\t%s\\n' \"$before_authn_present\"",
"printf 'beforeAuthnBytes\\t%s\\n' \"$before_authn_bytes\"",
"printf 'beforeDatastoreUriPresent\\t%s\\n' \"$before_uri_present\"",
"printf 'beforeDatastoreUriBytes\\t%s\\n' \"$before_uri_bytes\"",
"printf 'beforePostgresPasswordPresent\\t%s\\n' \"$before_pg_password_present\"",
"printf 'beforePostgresPasswordBytes\\t%s\\n' \"$before_pg_password_bytes\"",
"printf 'afterExists\\t%s\\n' \"$after_exists\"",
"printf 'afterAuthnPresent\\t%s\\n' \"$after_authn_present\"",
"printf 'afterAuthnBytes\\t%s\\n' \"$after_authn_bytes\"",
"printf 'afterDatastoreUriPresent\\t%s\\n' \"$after_uri_present\"",
"printf 'afterDatastoreUriBytes\\t%s\\n' \"$after_uri_bytes\"",
"printf 'afterPostgresPasswordPresent\\t%s\\n' \"$after_pg_password_present\"",
"printf 'afterPostgresPasswordBytes\\t%s\\n' \"$after_pg_password_bytes\"",
"printf 'postgresAdminSecretPresent\\t%s\\n' \"$postgres_admin_present\"",
"printf 'postgresSecret\\t%s\\n' \"$postgres_secret\"",
"printf 'dbName\\t%s\\n' \"$db_name\"",
"printf 'dbUser\\t%s\\n' \"$db_user\"",
"printf 'dbRoleExistsBefore\\t%s\\n' \"$db_role_exists_before\"",
"printf 'dbDatabaseExistsBefore\\t%s\\n' \"$db_database_exists_before\"",
"printf 'dbProbeExitCodeBefore\\t%s\\n' \"$db_probe_exit_before\"",
"printf 'dbRoleExistsAfter\\t%s\\n' \"$db_role_exists_after\"",
"printf 'dbDatabaseExistsAfter\\t%s\\n' \"$db_database_exists_after\"",
"printf 'dbProbeExitCodeAfter\\t%s\\n' \"$db_probe_exit_after\"",
"printf 'applyExitCode\\t%s\\n' \"$apply_exit\"",
"printf 'dbEnsureExitCode\\t%s\\n' \"$db_ensure_exit\"",
"authn_value=",
"datastore_uri=",
"pg_password=",
"postgres_admin_password=",
"if [ -n \"$apply_exit\" ] && [ \"$apply_exit\" != 0 ]; then exit \"$apply_exit\"; fi",
"if [ -n \"$db_ensure_exit\" ] && [ \"$db_ensure_exit\" != 0 ]; then exit \"$db_ensure_exit\"; fi",
].join("\n");
}
export function v02MasterAdminApiKeySecretScript(options: G14SecretOptions, spec: HwlabRuntimeLaneSpec): string {
const namespace = spec.runtimeNamespace;
const name = runtimeLaneMasterAdminApiKeySecretName(spec);
const fieldManager = runtimeLaneSecretFieldManager(spec);
return [
"set +e",
`namespace=${shellQuote(namespace)}`,
`name=${shellQuote(name)}`,
`api_key_name=${shellQuote(V02_MASTER_ADMIN_API_KEY_SECRET_KEY)}`,
`action_request=${shellQuote(options.action)}`,
`dry_run=${shellQuote(options.dryRun ? "true" : "false")}`,
`field_manager=${shellQuote(fieldManager)}`,
"preset=master-server-admin-api-key",
"secret_exists_flag() { kubectl -n \"$namespace\" get secret \"$name\" >/dev/null 2>&1 && printf yes || printf no; }",
"secret_b64_key() { kubectl -n \"$namespace\" get secret \"$name\" -o \"go-template={{ index .data \\\"$1\\\" }}\" 2>/dev/null || true; }",
"decoded_length() { if [ -n \"$1\" ]; then printf '%s' \"$1\" | base64 -d 2>/dev/null | wc -c | tr -d ' '; else printf '0'; fi; }",
"decoded_prefix() { if [ -n \"$1\" ]; then value=$(printf '%s' \"$1\" | base64 -d 2>/dev/null || true); printf '%s' \"$value\" | cut -c1-24; value=; fi; }",
"before_exists=$(secret_exists_flag)",
"before_api_key_b64=$(secret_b64_key \"$api_key_name\")",
"before_api_key_present=$([ -n \"$before_api_key_b64\" ] && printf yes || printf no)",
"before_api_key_bytes=$(decoded_length \"$before_api_key_b64\")",
"before_api_key_prefix=$(decoded_prefix \"$before_api_key_b64\")",
"action=observed",
"mutation=false",
"apply_exit=",
"if [ \"$action_request\" = ensure ]; then",
" missing_secret=false",
" [ \"$before_api_key_present\" = yes ] && [ \"$before_api_key_bytes\" -gt 0 ] || missing_secret=true",
" if [ \"$dry_run\" = true ]; then",
" if [ \"$missing_secret\" = true ]; then action=would-ensure; else action=kept; fi",
" else",
" api_key=$(cat)",
" case \"$api_key\" in hwl_live_*) ;; *) action=api-key-invalid; apply_exit=43 ;; esac",
" if [ -z \"$apply_exit\" ]; then",
" kubectl -n \"$namespace\" create secret generic \"$name\" --from-literal=\"$api_key_name=$api_key\" --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=\"$field_manager\" -f -",
" apply_exit=$?",
" if [ \"$apply_exit\" -eq 0 ]; then action=ensured; mutation=true; else action=apply-failed; fi",
" fi",
" api_key=",
" fi",
"fi",
"after_exists=$(secret_exists_flag)",
"after_api_key_b64=$(secret_b64_key \"$api_key_name\")",
"after_api_key_present=$([ -n \"$after_api_key_b64\" ] && printf yes || printf no)",
"after_api_key_bytes=$(decoded_length \"$after_api_key_b64\")",
"after_api_key_prefix=$(decoded_prefix \"$after_api_key_b64\")",
"printf 'namespace\\t%s\\n' \"$namespace\"",
"printf 'secret\\t%s\\n' \"$name\"",
"printf 'key\\t%s\\n' \"$api_key_name\"",
"printf 'preset\\t%s\\n' \"$preset\"",
"printf 'action\\t%s\\n' \"$action\"",
"printf 'dryRun\\t%s\\n' \"$dry_run\"",
"printf 'mutation\\t%s\\n' \"$mutation\"",
"printf 'beforeExists\\t%s\\n' \"$before_exists\"",
"printf 'beforeApiKeyPresent\\t%s\\n' \"$before_api_key_present\"",
"printf 'beforeApiKeyBytes\\t%s\\n' \"$before_api_key_bytes\"",
"printf 'beforeApiKeyPrefix\\t%s\\n' \"$before_api_key_prefix\"",
"printf 'afterExists\\t%s\\n' \"$after_exists\"",
"printf 'afterApiKeyPresent\\t%s\\n' \"$after_api_key_present\"",
"printf 'afterApiKeyBytes\\t%s\\n' \"$after_api_key_bytes\"",
"printf 'afterApiKeyPrefix\\t%s\\n' \"$after_api_key_prefix\"",
"printf 'applyExitCode\\t%s\\n' \"$apply_exit\"",
"if [ -n \"$apply_exit\" ] && [ \"$apply_exit\" != 0 ]; then exit \"$apply_exit\"; fi",
].join("\n");
}
export function v02SecretStatusFromText(text: string, commandOk: boolean, exitCode: number | null, stderr: string): Record<string, unknown> {
const fields = keyValueLinesFromText(text);
if (fields.preset === "generic-delete") {
const absent = fields.afterExists !== "yes";
return {
ok: commandOk && absent,
namespace: fields.namespace || V02_RUNTIME_NAMESPACE,
secret: fields.secret || null,
preset: "generic-delete",
action: fields.action || null,
dryRun: fields.dryRun === "true",
mutation: fields.mutation === "true",
before: {
exists: fields.beforeExists === "yes",
},
after: {
exists: fields.afterExists === "yes",
},
deleteExitCode: numericField(fields.deleteExitCode),
exitCode,
stderr: commandOk ? "" : stderr.trim().slice(0, 2000),
valuesRedacted: true,
summary: absent
? `${fields.secret || "secret"} absent`
: `${fields.secret || "secret"} still exists`,
};
}
if (fields.preset === "master-server-admin-api-key") {
const afterBytes = numericField(fields.afterApiKeyBytes);
const healthy =
fields.afterExists === "yes" &&
fields.afterApiKeyPresent === "yes" &&
typeof afterBytes === "number" && afterBytes > 0 &&
String(fields.afterApiKeyPrefix ?? "").startsWith("hwl_live_");
return {
ok: commandOk && healthy,
namespace: fields.namespace || V02_RUNTIME_NAMESPACE,
secret: fields.secret || V02_MASTER_ADMIN_API_KEY_SECRET,
key: fields.key || V02_MASTER_ADMIN_API_KEY_SECRET_KEY,
preset: "master-server-admin-api-key",
action: fields.action || null,
dryRun: fields.dryRun === "true",
mutation: fields.mutation === "true",
before: {
exists: fields.beforeExists === "yes",
apiKey: { keyPresent: fields.beforeApiKeyPresent === "yes", valueBytes: numericField(fields.beforeApiKeyBytes), keyPrefix: fields.beforeApiKeyPrefix || null },
},
after: {
exists: fields.afterExists === "yes",
apiKey: { keyPresent: fields.afterApiKeyPresent === "yes", valueBytes: afterBytes, keyPrefix: fields.afterApiKeyPrefix || null },
},
applyExitCode: numericField(fields.applyExitCode),
exitCode,
stderr: commandOk ? "" : stderr.trim().slice(0, 2000),
valuesRedacted: true,
summary: healthy
? `${fields.secret || V02_MASTER_ADMIN_API_KEY_SECRET}/${fields.key || V02_MASTER_ADMIN_API_KEY_SECRET_KEY} exists`
: `${fields.secret || V02_MASTER_ADMIN_API_KEY_SECRET}/${fields.key || V02_MASTER_ADMIN_API_KEY_SECRET_KEY} missing`,
};
}
if (fields.preset === "openfga") {
const afterAuthnBytes = numericField(fields.afterAuthnBytes);
const afterUriBytes = numericField(fields.afterDatastoreUriBytes);
const afterPasswordBytes = numericField(fields.afterPostgresPasswordBytes);
const keysHealthy =
fields.afterExists === "yes" &&
fields.afterAuthnPresent === "yes" &&
fields.afterDatastoreUriPresent === "yes" &&
fields.afterPostgresPasswordPresent === "yes" &&
typeof afterAuthnBytes === "number" && afterAuthnBytes > 0 &&
typeof afterUriBytes === "number" && afterUriBytes > 0 &&
typeof afterPasswordBytes === "number" && afterPasswordBytes > 0;
const databaseHealthy = fields.dbMode === "primary" || (fields.dbRoleExistsAfter === "t" && fields.dbDatabaseExistsAfter === "t");
const healthy = keysHealthy && databaseHealthy;
return {
ok: commandOk && healthy,
namespace: fields.namespace || V02_RUNTIME_NAMESPACE,
secret: fields.secret || V02_OPENFGA_SECRET,
key: fields.key || null,
preset: "openfga",
action: fields.action || null,
dryRun: fields.dryRun === "true",
mutation: fields.mutation === "true",
dbMode: fields.dbMode || "dedicated",
before: {
exists: fields.beforeExists === "yes",
authnPresharedKey: { keyPresent: fields.beforeAuthnPresent === "yes", valueBytes: numericField(fields.beforeAuthnBytes) },
datastoreUri: { keyPresent: fields.beforeDatastoreUriPresent === "yes", valueBytes: numericField(fields.beforeDatastoreUriBytes) },
postgresPassword: { keyPresent: fields.beforePostgresPasswordPresent === "yes", valueBytes: numericField(fields.beforePostgresPasswordBytes) },
database: {
roleExists: fields.dbRoleExistsBefore || "unknown",
databaseExists: fields.dbDatabaseExistsBefore || "unknown",
probeExitCode: fields.dbProbeExitCodeBefore || null,
},
},
after: {
exists: fields.afterExists === "yes",
authnPresharedKey: { keyPresent: fields.afterAuthnPresent === "yes", valueBytes: afterAuthnBytes },
datastoreUri: { keyPresent: fields.afterDatastoreUriPresent === "yes", valueBytes: afterUriBytes },
postgresPassword: { keyPresent: fields.afterPostgresPasswordPresent === "yes", valueBytes: afterPasswordBytes },
database: {
roleExists: fields.dbRoleExistsAfter || "unknown",
databaseExists: fields.dbDatabaseExistsAfter || "unknown",
probeExitCode: fields.dbProbeExitCodeAfter || null,
},
},
postgresAdminSecretPresent: fields.postgresAdminSecretPresent === "yes",
postgresSecret: fields.postgresSecret || null,
dbName: fields.dbName || V02_OPENFGA_DB_NAME,
dbUser: fields.dbUser || V02_OPENFGA_DB_USER,
applyExitCode: numericField(fields.applyExitCode),
dbEnsureExitCode: numericField(fields.dbEnsureExitCode),
exitCode,
stderr: commandOk ? "" : stderr.trim().slice(0, 2000),
valuesRedacted: true,
summary: healthy
? `${fields.secret || V02_OPENFGA_SECRET} keys and Postgres database exist`
: `${fields.secret || V02_OPENFGA_SECRET} keys or Postgres database missing`,
};
}
return {
ok: false,
namespace: fields.namespace || V02_RUNTIME_NAMESPACE,
secret: fields.secret || null,
preset: fields.preset || null,
action: fields.action || null,
dryRun: fields.dryRun === "true",
mutation: fields.mutation === "true",
exitCode,
stderr: stderr.trim().slice(0, 2000),
valuesRedacted: true,
summary: "unrecognized secret status output",
};
}
export function runG14Secret(options: G14SecretOptions): Record<string, unknown> {
const spec = hwlabRuntimeLaneSpec(options.lane);
const script = v02SecretScript(options);
const localAdminApiKey = options.preset === "master-server-admin-api-key"
? readOrCreateLocalMasterAdminApiKey(spec, options.action !== "ensure" || options.dryRun)
: null;
const result = options.preset === "master-server-admin-api-key"
? g14K3sInlineScriptWithInput(script, localAdminApiKey?.key ?? "", options.timeoutSeconds * 1000 + 2000)
: g14K3s(["sh", "--", script], options.timeoutSeconds * 1000);
const status = v02SecretStatusFromText(statusText(result), isCommandSuccess(result), result.exitCode, result.stderr);
const dryRunOk = options.action === "ensure" && options.dryRun && isCommandSuccess(result);
const deleteDryRunOk = options.action === "delete" && options.dryRun && isCommandSuccess(result);
const ok = dryRunOk || deleteDryRunOk ? true : status.ok === true;
return {
ok,
command: `hwlab g14 secret ${options.action} --lane ${options.lane}`,
lane: options.lane,
namespace: spec.runtimeNamespace,
secret: options.name,
key: options.key ?? null,
preset: options.preset,
mode: options.action === "status" ? "status" : options.dryRun ? "dry-run" : options.action === "delete" ? "confirmed-delete" : "confirmed-ensure",
status,
localMasterServer: localAdminApiKey
? { created: localAdminApiKey.created, envFile: localAdminApiKey.status, valuesRedacted: true }
: undefined,
mutation: status.mutation === true,
result: compactCommandResult(result),
valuesRedacted: true,
next: ok && options.action === "status"
? undefined
: options.action === "delete"
? undefined
: { ensure: `bun scripts/cli.ts hwlab g14 secret ensure --lane ${options.lane} --name ${options.name}${options.key ? ` --key ${options.key}` : ""} --confirm` },
};
}
+121
View File
@@ -0,0 +1,121 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. source module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:1205-1308 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import type { CommandJsonResult } from "./types";
import { g14HostScript } from "./images";
import { statusText } from "./pr-monitor";
import { commandJson, isCommandSuccess, nested, shellQuote, shortSha } from "./remote";
import { V02_GIT_URL, V02_LANE_SPEC } from "./types";
export function runtimeLaneCicdRepoEnsureScript(spec: HwlabRuntimeLaneSpec): string {
return [
`cicd_repo=${shellQuote(spec.cicdRepo)}`,
`cicd_url=${shellQuote(spec.gitUrl)}`,
`cicd_branch=${shellQuote(spec.sourceBranch)}`,
`cicd_repo_lock=${shellQuote(spec.cicdRepoLock)}`,
"cicd_repo_ensure_fetch() {",
" mkdir -p \"$(dirname \"$cicd_repo\")\" || return $?",
" if [ -d \"$cicd_repo/objects\" ] && [ -f \"$cicd_repo/HEAD\" ]; then",
" :",
" elif [ -e \"$cicd_repo\" ]; then",
` echo "${spec.version} CI/CD repo path exists but is not a bare git repo: $cicd_repo" >&2`,
" return 41",
" else",
" git clone --bare \"$cicd_url\" \"$cicd_repo\" || return $?",
" fi",
" git --git-dir=\"$cicd_repo\" remote set-url origin \"$cicd_url\" 2>/dev/null || git --git-dir=\"$cicd_repo\" remote add origin \"$cicd_url\" || return $?",
" git --git-dir=\"$cicd_repo\" config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*' || return $?",
" git --git-dir=\"$cicd_repo\" fetch origin \"+refs/heads/$cicd_branch:refs/remotes/origin/$cicd_branch\" --prune || return $?",
"}",
"cicd_repo_lock_mode=none",
"if command -v flock >/dev/null 2>&1; then",
" exec 9>\"$cicd_repo_lock\"",
` flock -w 120 9 || { echo "timed out waiting for ${spec.version} CI/CD repo lock: $cicd_repo_lock" >&2; exit 42; }`,
" cicd_repo_lock_mode=flock",
"else",
" cicd_repo_lock_dir=\"$cicd_repo_lock.d\"",
" cicd_repo_lock_attempt=0",
" while ! mkdir \"$cicd_repo_lock_dir\" 2>/dev/null; do",
" cicd_repo_lock_attempt=$((cicd_repo_lock_attempt + 1))",
` if [ "$cicd_repo_lock_attempt" -ge 120 ]; then echo "timed out waiting for ${spec.version} CI/CD repo lock: $cicd_repo_lock_dir" >&2; exit 42; fi`,
" sleep 1",
" done",
" cicd_repo_lock_mode=dir",
"fi",
"cicd_repo_status=0",
"cicd_repo_ensure_fetch || cicd_repo_status=$?",
"if [ \"$cicd_repo_lock_mode\" = flock ]; then flock -u 9 >/dev/null 2>&1 || true; fi",
"if [ \"$cicd_repo_lock_mode\" = dir ]; then rmdir \"$cicd_repo_lock_dir\" >/dev/null 2>&1 || true; fi",
`if [ "$cicd_repo_status" -ne 0 ]; then echo "${spec.version} CI/CD repo refresh failed status=$cicd_repo_status repo=$cicd_repo" >&2; exit "$cicd_repo_status"; fi`,
].join("\n");
}
export function v02CicdRepoEnsureScript(): string {
return runtimeLaneCicdRepoEnsureScript(V02_LANE_SPEC);
}
export function resolveV02Head(): { sourceCommit: string | null; result: CommandJsonResult } {
const result = g14HostScript([
"set -eu",
v02CicdRepoEnsureScript(),
"git --git-dir=\"$cicd_repo\" rev-parse refs/remotes/origin/v0.2",
].join("\n"), 180_000);
if (!isCommandSuccess(result)) return { sourceCommit: null, result };
const output = String(nested(result.parsed, ["data", "stdout"]) ?? result.stdout).trim();
const match = /[0-9a-f]{40}/iu.exec(output);
return { sourceCommit: match?.[0] ?? null, result };
}
export function getV02Head(): string | null {
return resolveV02Head().sourceCommit;
}
export function resolveV02LatestRemoteHead(): { sourceCommit: string | null; result: CommandJsonResult } {
const result = commandJson(["git", "ls-remote", V02_GIT_URL, "refs/heads/v0.2"], 30_000);
if (!isCommandSuccess(result)) return { sourceCommit: null, result };
const output = statusText(result);
const match = /^[0-9a-f]{40}/imu.exec(output);
return { sourceCommit: match?.[0] ?? null, result };
}
export function resolveRuntimeLaneHead(spec: HwlabRuntimeLaneSpec): { sourceCommit: string | null; result: CommandJsonResult } {
const result = g14HostScript([
"set -eu",
runtimeLaneCicdRepoEnsureScript(spec),
`git --git-dir="$cicd_repo" rev-parse refs/remotes/origin/${spec.sourceBranch}`,
].join("\n"), 180_000);
if (!isCommandSuccess(result)) return { sourceCommit: null, result };
const output = String(nested(result.parsed, ["data", "stdout"]) ?? result.stdout).trim();
const match = /[0-9a-f]{40}/iu.exec(output);
return { sourceCommit: match?.[0] ?? null, result };
}
export function resolveRuntimeLaneLatestRemoteHead(spec: HwlabRuntimeLaneSpec): { sourceCommit: string | null; result: CommandJsonResult } {
const result = commandJson(["git", "ls-remote", spec.gitUrl, `refs/heads/${spec.sourceBranch}`], 30_000);
if (!isCommandSuccess(result)) return { sourceCommit: null, result };
const output = statusText(result);
const match = /^[0-9a-f]{40}/imu.exec(output);
return { sourceCommit: match?.[0] ?? null, result };
}
export function runtimeLanePipelineRunName(spec: HwlabRuntimeLaneSpec, sourceCommit: string): string {
return `${spec.pipelineRunPrefix}-${shortSha(sourceCommit)}`;
}
export function runtimeLaneRerunPipelineRunName(spec: HwlabRuntimeLaneSpec, sourceCommit: string, now = new Date()): string {
const stamp = now.toISOString().replace(/\D/gu, "").slice(0, 14);
return `${runtimeLanePipelineRunName(spec, sourceCommit)}-r${stamp}`;
}
export function v02PipelineRunName(sourceCommit: string): string {
return runtimeLanePipelineRunName(V02_LANE_SPEC, sourceCommit);
}
+363
View File
@@ -0,0 +1,363 @@
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. types module for scripts/src/hwlab-g14.ts.
// Moved mechanically from scripts/src/hwlab-g14.ts:1-288 for #903.
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHash, randomBytes } from "node:crypto";
import { repoRoot, rootPath, type Config } from "../config";
import { runCommand } from "../command";
import { cancelJob, listJobs, readJob, startJob } from "../jobs";
import { hwlabRequiredNoProxyEntries, hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "../hwlab-node-lanes";
import { durationSeconds } from "./pr-monitor";
export const HWLAB_REPO = "pikasTech/HWLAB";
export const G14_SOURCE_BRANCH = "G14";
export const G14_PROVIDER = "G14";
export const G14_WORKSPACE = "/root/hwlab";
export const V02_LANE_SPEC = hwlabRuntimeLaneSpec("v02");
export const V02_SOURCE_BRANCH = V02_LANE_SPEC.sourceBranch;
export const V02_WORKSPACE = V02_LANE_SPEC.workspace;
export const V02_CICD_REPO = V02_LANE_SPEC.cicdRepo;
export const DEV_NAMESPACE = "hwlab-dev";
export const PROD_NAMESPACE = "hwlab-prod";
export const CI_NAMESPACE = "hwlab-ci";
export const ARGO_NAMESPACE = "argocd";
export const DEV_APP = "hwlab-g14-dev";
export const PROD_APP = "hwlab-g14-prod";
export const V02_APP = V02_LANE_SPEC.app;
export const V02_PIPELINE = V02_LANE_SPEC.pipeline;
export const V02_POLLER = "hwlab-v02-branch-poller";
export const V02_RECONCILER = "hwlab-v02-control-plane-reconciler";
export const V02_PIPELINERUN_PREFIX = V02_LANE_SPEC.pipelineRunPrefix;
export const V02_CONTROL_PLANE_FIELD_MANAGER = V02_LANE_SPEC.controlPlaneFieldManager;
export const V02_GIT_URL = V02_LANE_SPEC.gitUrl;
export const V02_GIT_READ_URL = V02_LANE_SPEC.gitReadUrl;
export const V02_GIT_WRITE_URL = V02_LANE_SPEC.gitWriteUrl;
export const V02_GITOPS_BRANCH = V02_LANE_SPEC.gitopsBranch;
export const V02_CATALOG_PATH = V02_LANE_SPEC.catalogPath;
export const V02_RUNTIME_PATH = V02_LANE_SPEC.runtimePath;
export const V02_RUNTIME_NAMESPACE = V02_LANE_SPEC.runtimeNamespace;
export const V02_OPENFGA_SECRET = "hwlab-v02-openfga";
export const V02_OPENFGA_AUTHN_SECRET_KEY = "authn-preshared-key";
export const V02_OPENFGA_DATASTORE_URI_SECRET_KEY = "datastore-uri";
export const V02_OPENFGA_POSTGRES_PASSWORD_SECRET_KEY = "postgres-password";
export const V02_OPENFGA_DB_NAME = "hwlab_openfga";
export const V02_OPENFGA_DB_USER = "hwlab_openfga";
export const V02_MASTER_ADMIN_API_KEY_SECRET = "hwlab-v02-master-server-admin-api-key";
export const V02_MASTER_ADMIN_API_KEY_SECRET_KEY = "api-key";
export const V02_MASTER_ADMIN_API_KEY_LOCAL_ENV = "/root/.config/hwlab-v02/master-server-admin-api-key.env";
export const V02_REGISTRY_PREFIX = V02_LANE_SPEC.registryPrefix;
export const V02_BASE_IMAGE = V02_LANE_SPEC.baseImage;
export const GIT_MIRROR_NAMESPACE = "devops-infra";
export const GIT_MIRROR_MANIFEST_FIELD_MANAGER = "unidesk-hwlab-git-mirror";
export const GIT_MIRROR_SYNC_JOB_PREFIX = "git-mirror-hwlab-sync-manual";
export const GIT_MIRROR_LEGACY_CRONJOB = "git-mirror-hwlab-sync";
export const G14_OBSERVABILITY_NAMESPACE = "devops-infra";
export const G14_OBSERVABILITY_FIELD_MANAGER = "unidesk-g14-observability";
export const G14_PROMETHEUS_OPERATOR_VERSION = "v0.91.0";
export const G14_PROMETHEUS_VERSION = "v3.12.0";
export const G14_PROMETHEUS_NAME = "g14-shared";
export const G14_PROMETHEUS_SERVICE = "prometheus-g14-shared";
export const G14_PROMETHEUS_SERVICE_ACCOUNT = "g14-observability-prometheus";
export const G14_PROMETHEUS_OPERATOR_RELEASE_ASSET = `https://github.com/prometheus-operator/prometheus-operator/releases/download/${G14_PROMETHEUS_OPERATOR_VERSION}/bundle.yaml`;
export const V02_SERVICE_IDS = [...V02_LANE_SPEC.serviceIds];
export const V02_CLOUD_WEB_URL = V02_LANE_SPEC.publicWebUrl;
export const V02_CLOUD_API_URL = V02_LANE_SPEC.publicApiUrl;
export const V02_OBSERVABILITY_SERVICE_IDS = [
"hwlab-agent-skills",
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-deepseek-proxy",
"hwlab-edge-proxy",
];
export const V02_OBSERVABILITY_EXPECTED_TARGET_COUNT = V02_OBSERVABILITY_SERVICE_IDS.length;
export const V02_OBSERVABILITY_QUERIES = {
scrapeReachable: 'up{namespace="hwlab-v02"}',
sidecarServing: 'hwlab_service_up{namespace="hwlab-v02"}',
businessHealthProbe: 'hwlab_service_health_probe_success{namespace="hwlab-v02"}',
healthProbeConfigured: 'hwlab_service_health_probe_configured{namespace="hwlab-v02"}',
healthProbeStatusCode: 'hwlab_service_health_probe_status_code{namespace="hwlab-v02"}',
healthProbeDurationSeconds: 'hwlab_service_health_probe_duration_seconds{namespace="hwlab-v02"}',
processUptimeSeconds: 'hwlab_service_process_uptime_seconds{namespace="hwlab-v02"}',
scrapeDurationSeconds: 'scrape_duration_seconds{namespace="hwlab-v02"}',
scrapeSamplesScraped: 'scrape_samples_scraped{namespace="hwlab-v02"}',
};
export const V02_OBSERVABILITY_CLOSEOUT_QUERY_NAMES = ["scrapeReachable", "sidecarServing", "businessHealthProbe"] as const;
export const V02_OBSERVABILITY_BOOLEAN_QUERY_NAMES = [...V02_OBSERVABILITY_CLOSEOUT_QUERY_NAMES, "healthProbeConfigured"];
export function v02PipelineServiceIds(): string[] {
return [...V02_SERVICE_IDS];
}
export const G14_CI_TOOLS_IMAGE_REPO = "127.0.0.1:5000/hwlab/hwlab-ci-node-tools";
export const G14_CI_TOOLS_BASE_TAG = "node22-alpine-v1";
export const DEFAULT_INTERVAL_SECONDS = 600;
export const DEFAULT_MAX_CYCLES = 0;
export const DEFAULT_TIMEOUT_SECONDS = 1800;
export const G14_BRIEF_INDEX_ISSUE = 7;
export const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
export const V02_BUILD_TASKRUN_WARNING_SECONDS = 120;
export const V02_BUILD_TASKRUN_CRITICAL_SECONDS = 180;
export const V02_CONTROL_PLANE_REFRESH_TTL_MS = 5 * 60 * 1000;
export const V02_CONTROL_PLANE_REFRESH_LOCK_STALE_MS = 5 * 60 * 1000;
export const V02_GIT_MIRROR_PRESYNC_MAX_WAIT_MS = 120 * 1000;
export const V02_GIT_MIRROR_PRESYNC_LOCK_STALE_MS = 5 * 60 * 1000;
export const V02_GIT_MIRROR_PRESYNC_POLL_MS = 3 * 1000;
export type G14MonitorLane = "g14" | HwlabRuntimeLane;
export interface G14MonitorOptions {
lane: G14MonitorLane;
intervalSeconds: number;
maxCycles: number;
once: boolean;
dryRun: boolean;
worker: boolean;
timeoutSeconds: number;
}
export interface G14RecordRolloutOptions {
prNumber: number;
sourceCommit?: string;
pipelineRun?: string;
gitopsRevision?: string;
mergedAt?: string;
pipelineSucceededAt?: string;
finishedAt?: string;
dryRun: boolean;
}
export interface G14ControlPlaneOptions {
action: "status" | "closeout" | "apply" | "trigger-current" | "refresh" | "cleanup-runs" | "cleanup-released-pvs" | "runtime-migration";
lane: HwlabRuntimeLane | "g14" | "all";
dryRun: boolean;
confirm: boolean;
wait: boolean;
rerun: boolean;
allowLiveDbRead: boolean;
timeoutSeconds: number;
minAgeMinutes: number;
limit: number;
sourceCommit?: string;
pipelineRun?: string;
history: boolean;
}
export interface G14LegacyRetirementOptions {
action: "status" | "plan" | "execute";
dryRun: boolean;
confirm: boolean;
wait: boolean;
timeoutSeconds: number;
reason: string;
}
export type V02StatusTargetMode = "latest-source-head" | "source-commit" | "pipeline-run";
export interface V02ControlPlaneStatusTarget {
sourceCommit?: string | null;
pipelineRun?: string | null;
mode?: V02StatusTargetMode;
includeHistory?: boolean;
}
export interface G14ToolsImageOptions {
action: "status" | "build";
name: "ci-node-tools";
tag: string;
dockerfile: string;
dryRun: boolean;
confirm: boolean;
timeoutSeconds: number;
}
export interface G14UpstreamImageOptions {
action: "status" | "ensure";
name: "openfga";
tag: string;
dryRun: boolean;
confirm: boolean;
timeoutSeconds: number;
}
export interface G14GitMirrorOptions {
action: "status" | "apply" | "sync" | "flush";
lane: HwlabRuntimeLane;
dryRun: boolean;
confirm: boolean;
wait: boolean;
timeoutSeconds: number;
}
export interface G14ObservabilityOptions {
action: "status" | "apply" | "query" | "targets" | "boundary" | "closeout";
lane: "v02";
dryRun: boolean;
confirm: boolean;
wait: boolean;
timeoutSeconds: number;
query: string;
expectCount?: number;
expectValue?: string;
}
export interface G14SecretOptions {
action: "status" | "ensure" | "delete";
lane: HwlabRuntimeLane;
dryRun: boolean;
confirm: boolean;
name: string;
key?: string;
preset: "openfga" | "master-server-admin-api-key" | "generic-delete";
timeoutSeconds: number;
}
export interface CommandJsonResult {
ok: boolean;
command: string[];
exitCode: number | null;
stdout: string;
stderr: string;
parsed: unknown | null;
durationMs?: number;
timedOut?: boolean;
stdoutBytes?: number;
stderrBytes?: number;
}
export interface RemoteAsyncCommandSpec {
script: string;
timeoutSeconds: number;
label: string;
token: string;
command: string[];
}
export interface ShellSection {
stdout: string;
exitCode: number | null;
}
export interface OpenPullRequest {
number: number;
title?: string;
url?: string;
baseRefName?: string;
headRefName?: string;
mergeable?: string | null;
mergeStateStatus?: string | null;
draft?: boolean;
}
export interface CiServiceMetric {
taskRun: string;
serviceId: string;
status: string;
durationSeconds: number | null;
buildBackend: string | null;
reusedFrom: string | null;
imageTag: string | null;
sourceCommitId: string | null;
}
export interface CiPipelineMetrics {
ok: boolean;
pipelineRun: string;
serviceTotal: number;
reusedCount: number;
rebuildCount: number;
reusedServices: string[];
rebuildServices: string[];
services: CiServiceMetric[];
degradedReason?: string;
rawText?: string;
}
export interface V02PrCommentInput {
pr: OpenPullRequest;
phase: string;
state: "waiting-ci" | "blocked" | "merge-failed" | "cd-started" | "cd-succeeded" | "cd-superseded" | "cd-failed" | "cd-timeout" | "cd-blocked";
startedAt: string;
observedAt: string;
elapsedSeconds: number | null;
preflight?: Record<string, unknown>;
merge?: Record<string, unknown>;
sourceCommit?: string | null;
pipelineRun?: string | null;
cd?: Record<string, unknown>;
flush?: Record<string, unknown>;
dryRun?: boolean;
message?: string;
}
File diff suppressed because it is too large Load Diff