// 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 { 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 { 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; 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 = {}; 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 { 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 { 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 { 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): 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 { return String(nested(status, ["cache", "raw"]) ?? ""); } export function runtimeLaneGitMirrorSourceInSync(spec: HwlabRuntimeLaneSpec, sourceCommit: string, status: Record): boolean { return runtimeLaneGitMirrorSourceInSyncForTest(spec.lane, sourceCommit, record(nested(status, ["cache", "summary"]))); } export function compactGitMirrorStatus(status: Record, sourceCommit: string): Record { 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): Record { 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): 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 { const startedAtMs = Date.now(); const observations: Record[] = []; 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): Record { 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 { 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 { 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 { 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 { 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 { 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); }