Files
pikasTech-unidesk/scripts/src/hwlab-legacy-runtime/retirement.ts
T
2026-07-09 10:46:55 +02:00

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" },
};
}