340 lines
17 KiB
TypeScript
340 lines
17 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. retirement module for scripts/src/hwlab-legacy-runtime.ts.
|
|
|
|
// Moved mechanically from scripts/src/hwlab-legacy-runtime.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 { LegacyRuntimeLegacyRetirementOptions } from "./types";
|
|
import { k8sMetadataName, parseSectionJsonArray } from "./observability";
|
|
import { statusText } from "./pr-monitor";
|
|
import { compactCommandResult, legacyRuntimeK3s, isCommandSuccess, nested, record, shellQuote } from "./remote";
|
|
import { ARGO_NAMESPACE, DEV_APP, DEV_NAMESPACE, LEGACY_RUNTIME_PROVIDER, PROD_APP, PROD_NAMESPACE, DEFAULT_RUNTIME_LANE_APP, DEFAULT_RUNTIME_LANE_RUNTIME_NAMESPACE } from "./types";
|
|
import { parseShellSections, shellSectionOk } from "./runtime-status";
|
|
|
|
export function legacyLegacyRuntimeRetirementStatePath(): string {
|
|
return rootPath(".state", "hwlab-g14", "legacy-g14-retirement.json");
|
|
}
|
|
|
|
export function legacyLegacyRuntimeRetirementApplications(): string[] {
|
|
return [DEV_APP, PROD_APP];
|
|
}
|
|
|
|
export function legacyLegacyRuntimeRetirementNamespaces(): string[] {
|
|
return [DEV_NAMESPACE, PROD_NAMESPACE];
|
|
}
|
|
|
|
export function protectedLegacyRuntimeRuntimeApplications(): string[] {
|
|
return [DEFAULT_RUNTIME_LANE_APP, hwlabRuntimeLaneSpec("v03").app];
|
|
}
|
|
|
|
export function protectedLegacyRuntimeRuntimeNamespaces(): string[] {
|
|
return [DEFAULT_RUNTIME_LANE_RUNTIME_NAMESPACE, hwlabRuntimeLaneSpec("v03").runtimeNamespace];
|
|
}
|
|
|
|
export function readLegacyLegacyRuntimeRetirementMarker(): Record<string, unknown> | null {
|
|
const path = legacyLegacyRuntimeRetirementStatePath();
|
|
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 writeLegacyLegacyRuntimeRetirementMarker(status: string, reason: string, details: Record<string, unknown> = {}): Record<string, unknown> {
|
|
const path = legacyLegacyRuntimeRetirementStatePath();
|
|
mkdirSync(dirname(path), { recursive: true });
|
|
const previous = readLegacyLegacyRuntimeRetirementMarker();
|
|
const marker = {
|
|
status,
|
|
reason,
|
|
requestedAt: typeof previous?.requestedAt === "string" ? previous.requestedAt : new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
legacyApplications: legacyLegacyRuntimeRetirementApplications(),
|
|
legacyNamespaces: legacyLegacyRuntimeRetirementNamespaces(),
|
|
protectedApplications: protectedLegacyRuntimeRuntimeApplications(),
|
|
protectedNamespaces: protectedLegacyRuntimeRuntimeNamespaces(),
|
|
...details,
|
|
};
|
|
writeFileSync(path, `${JSON.stringify(marker, null, 2)}\n`, "utf8");
|
|
return { ...marker, path };
|
|
}
|
|
|
|
export function legacyLegacyRuntimeRetirementBlocksMonitor(): Record<string, unknown> | null {
|
|
const marker = readLegacyLegacyRuntimeRetirementMarker();
|
|
if (marker === null) {
|
|
return {
|
|
status: "retired-by-contract",
|
|
path: legacyLegacyRuntimeRetirementStatePath(),
|
|
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", defaultRuntimeLaneMonitor: "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 legacyLegacyRuntimeRetirementStatusScript(): string {
|
|
const legacyApps = legacyLegacyRuntimeRetirementApplications().map(shellQuote).join(" ");
|
|
const legacyNamespaces = legacyLegacyRuntimeRetirementNamespaces().map(shellQuote).join(" ");
|
|
const protectedApps = protectedLegacyRuntimeRuntimeApplications().map(shellQuote).join(" ");
|
|
const protectedNamespaces = protectedLegacyRuntimeRuntimeNamespaces().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 legacyLegacyRuntimeRetirementStatus(): Record<string, unknown> {
|
|
const result = legacyRuntimeK3s(["sh", "--", legacyLegacyRuntimeRetirementStatusScript()], 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 = protectedLegacyRuntimeRuntimeApplications().filter((name) => !protectedAppNames.has(name));
|
|
const missingProtectedNamespaces = protectedLegacyRuntimeRuntimeNamespaces().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: LEGACY_RUNTIME_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: readLegacyLegacyRuntimeRetirementMarker(),
|
|
result: compactCommandResult(result),
|
|
next: retired
|
|
? { verifyDefaultRuntimeLane: "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 legacyLegacyRuntimeRetirementPlan(status = legacyLegacyRuntimeRetirementStatus()): 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: legacyLegacyRuntimeRetirementApplications().map((name) => ({ apiVersion: "argoproj.io/v1alpha1", kind: "Application", namespace: ARGO_NAMESPACE, name })),
|
|
namespaces: legacyLegacyRuntimeRetirementNamespaces().map((name) => ({ apiVersion: "v1", kind: "Namespace", name })),
|
|
},
|
|
protectedTargets: {
|
|
applications: protectedLegacyRuntimeRuntimeApplications().map((name) => ({ apiVersion: "argoproj.io/v1alpha1", kind: "Application", namespace: ARGO_NAMESPACE, name })),
|
|
namespaces: protectedLegacyRuntimeRuntimeNamespaces().map((name) => ({ apiVersion: "v1", kind: "Namespace", name })),
|
|
},
|
|
localSupportActions: [
|
|
{ action: "write-retirement-marker", path: legacyLegacyRuntimeRetirementStatePath() },
|
|
{ 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 legacyLegacyRuntimeRecordRolloutRetired(): 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: readLegacyLegacyRuntimeRetirementMarker(),
|
|
next: {
|
|
retirementStatus: "bun scripts/cli.ts hwlab g14 retirement status",
|
|
defaultRuntimeLaneStatus: "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 cancelLegacyLegacyRuntimeMonitorJobs(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 legacyLegacyRuntimeRetirementDeleteScript(): string {
|
|
const legacyApps = legacyLegacyRuntimeRetirementApplications();
|
|
const legacyNamespaces = legacyLegacyRuntimeRetirementNamespaces();
|
|
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 waitForLegacyLegacyRuntimeRetirement(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 = legacyLegacyRuntimeRetirementStatus();
|
|
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 runLegacyLegacyRuntimeRetirement(options: LegacyRuntimeLegacyRetirementOptions): Record<string, unknown> {
|
|
const before = legacyLegacyRuntimeRetirementStatus();
|
|
if (options.action === "status") return before;
|
|
if (options.action === "plan" || options.dryRun) return { ...legacyLegacyRuntimeRetirementPlan(before), command: `hwlab g14 retirement ${options.action}`, reason: options.reason };
|
|
|
|
const markerBeforeDelete = writeLegacyLegacyRuntimeRetirementMarker("retiring", options.reason, { command: "hwlab g14 retirement execute --confirm" });
|
|
const localJobs = cancelLegacyLegacyRuntimeMonitorJobs(false);
|
|
const deleteResult = legacyRuntimeK3s(["sh", "--", legacyLegacyRuntimeRetirementDeleteScript()], Math.max(60_000, options.timeoutSeconds * 1000));
|
|
const deleteSections = parseShellSections(statusText(deleteResult));
|
|
const afterDelete = legacyLegacyRuntimeRetirementStatus();
|
|
const wait = options.wait ? waitForLegacyLegacyRuntimeRetirement(options.timeoutSeconds) : null;
|
|
const finalStatus = wait === null ? afterDelete : record(wait.status);
|
|
const liveMutationOk = isCommandSuccess(deleteResult);
|
|
const finalRetired = finalStatus.retired === true;
|
|
const marker = writeLegacyLegacyRuntimeRetirementMarker(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
|
|
? { verifyDefaultRuntimeLane: "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" },
|
|
};
|
|
}
|