feat: add JD01 YAML-first deployment support
This commit is contained in:
@@ -35,10 +35,17 @@ import { sha256Fingerprint } from "../platform-infra-ops-library";
|
||||
|
||||
import { runYamlLaneGitMirrorFlushJob, runYamlLaneGitMirrorSyncJob, yamlLanePipelineRunCreateScript } from "./secrets";
|
||||
import { capture, captureJsonPayload, compactCapture, isGitSha, progressEvent, stringOrNull } from "./utils";
|
||||
import { runYamlLaneGitopsPublishJob, waitForYamlLaneBuildImage, waitForYamlLaneSourceBootstrap, yamlLaneBuildImageSubmitScript, yamlLaneSourceBootstrapSubmitScript, yamlLaneSourceRestoreScript } from "./yaml-lane";
|
||||
import { runYamlLaneGitopsPublishJob, runYamlLaneK3sBuildImageJob, waitForYamlLaneBuildImage, waitForYamlLaneSourceBootstrap, yamlLaneBuildImageSubmitScript, yamlLaneK3sSourceStatusScript, yamlLaneSourceBootstrapSubmitScript, yamlLaneSourceRestoreScript } from "./yaml-lane";
|
||||
|
||||
export async function triggerCurrentYamlLaneConfirmed(config: UniDeskConfig, spec: AgentRunLaneSpec, configPath: string, waited: boolean): Promise<Record<string, unknown>> {
|
||||
const result = await triggerCurrentYamlLaneConfirmedSteps(config, spec, configPath, waited);
|
||||
if (spec.source.statusMode === "k3s-git-mirror") {
|
||||
return {
|
||||
...result,
|
||||
sourceWorkspaceRestore: { ok: true, status: "skipped", statusMode: spec.source.statusMode, reason: "k3s-git-mirror-does-not-use-host-worktree", valuesPrinted: false },
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const restore = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneSourceRestoreScript(spec)]);
|
||||
const restorePayload = captureJsonPayload(restore);
|
||||
const restoreOk = restore.exitCode === 0 && restorePayload.ok === true;
|
||||
@@ -53,63 +60,27 @@ export async function triggerCurrentYamlLaneConfirmed(config: UniDeskConfig, spe
|
||||
}
|
||||
|
||||
export async function triggerCurrentYamlLaneConfirmedSteps(config: UniDeskConfig, spec: AgentRunLaneSpec, configPath: string, waited: boolean): Promise<Record<string, unknown>> {
|
||||
progressEvent("agentrun.yaml-lane.source-bootstrap.progress", {
|
||||
node: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
status: "submitting",
|
||||
});
|
||||
const bootstrapSubmit = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneSourceBootstrapSubmitScript(spec)]);
|
||||
const bootstrapSubmitPayload = captureJsonPayload(bootstrapSubmit);
|
||||
if (bootstrapSubmit.exitCode !== 0 || bootstrapSubmitPayload.ok === false) {
|
||||
const source = await resolveTriggerCurrentSource(config, spec, configPath, waited);
|
||||
if (source.ok !== true) return source;
|
||||
const sourceCommit = stringOrNull(source.sourceCommit);
|
||||
const bootstrapPayload = source.sourcePayload;
|
||||
if (sourceCommit === null || !isGitSha(sourceCommit)) {
|
||||
return {
|
||||
ok: false,
|
||||
command: "agentrun control-plane trigger-current",
|
||||
mode: waited ? "confirmed-waited" : "confirmed-trigger",
|
||||
configPath,
|
||||
target: agentRunLaneSummary(spec),
|
||||
phase: "source-bootstrap-submit",
|
||||
degradedReason: "yaml-lane-source-bootstrap-submit-failed",
|
||||
result: bootstrapSubmitPayload,
|
||||
capture: compactCapture(bootstrapSubmit, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const bootstrap = await waitForYamlLaneSourceBootstrap(config, spec, stringOrNull(bootstrapSubmitPayload.jobId));
|
||||
const bootstrapPayload = bootstrap.payload;
|
||||
const sourceCommit = stringOrNull(bootstrapPayload.sourceCommit);
|
||||
if (bootstrap.ok !== true || sourceCommit === null || !isGitSha(sourceCommit)) {
|
||||
return {
|
||||
ok: false,
|
||||
command: "agentrun control-plane trigger-current",
|
||||
mode: waited ? "confirmed-waited" : "confirmed-trigger",
|
||||
configPath,
|
||||
target: agentRunLaneSummary(spec),
|
||||
phase: "source-bootstrap",
|
||||
degradedReason: "yaml-lane-source-bootstrap-failed",
|
||||
phase: "source-resolve",
|
||||
degradedReason: "yaml-lane-source-commit-invalid",
|
||||
result: bootstrapPayload,
|
||||
bootstrapStatus: bootstrap,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const buildSubmit = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneBuildImageSubmitScript(spec, sourceCommit)]);
|
||||
const buildSubmitPayload = captureJsonPayload(buildSubmit);
|
||||
if (buildSubmit.exitCode !== 0 || buildSubmitPayload.ok === false) {
|
||||
return {
|
||||
ok: false,
|
||||
command: "agentrun control-plane trigger-current",
|
||||
mode: waited ? "confirmed-waited" : "confirmed-trigger",
|
||||
configPath,
|
||||
target: agentRunLaneSummary(spec),
|
||||
phase: "image-build-submit",
|
||||
sourceCommit,
|
||||
degradedReason: "yaml-lane-image-build-submit-failed",
|
||||
sourceBootstrap: bootstrapPayload,
|
||||
result: buildSubmitPayload,
|
||||
capture: compactCapture(buildSubmit, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const build = await waitForYamlLaneBuildImage(config, spec, sourceCommit, stringOrNull(buildSubmitPayload.jobId));
|
||||
const buildResult = await buildTriggerCurrentImage(config, spec, sourceCommit, bootstrapPayload, configPath, waited);
|
||||
if (buildResult.ok !== true) return buildResult;
|
||||
const build = buildResult.buildStatus;
|
||||
const buildSubmitPayload = buildResult.buildSubmitPayload;
|
||||
const buildPayload = build.payload;
|
||||
const digest = stringOrNull(buildPayload.digest);
|
||||
const envIdentity = stringOrNull(buildPayload.envIdentity);
|
||||
@@ -227,3 +198,124 @@ export async function triggerCurrentYamlLaneConfirmedSteps(config: UniDeskConfig
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveTriggerCurrentSource(config: UniDeskConfig, spec: AgentRunLaneSpec, configPath: string, waited: boolean): Promise<Record<string, unknown> & { ok: boolean; sourceCommit?: string | null; sourcePayload?: Record<string, unknown> }> {
|
||||
if (spec.source.statusMode === "k3s-git-mirror") {
|
||||
progressEvent("agentrun.yaml-lane.source-status.progress", {
|
||||
node: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
statusMode: spec.source.statusMode,
|
||||
status: "probing",
|
||||
});
|
||||
const sourceStatus = await capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)]);
|
||||
const sourcePayload = captureJsonPayload(sourceStatus);
|
||||
const sourceCommit = stringOrNull(sourcePayload.sourceCommit) ?? stringOrNull(sourcePayload.remoteBranchCommit);
|
||||
if (sourceStatus.exitCode !== 0 || sourcePayload.ok !== true || sourceCommit === null || !isGitSha(sourceCommit)) {
|
||||
return {
|
||||
ok: false,
|
||||
command: "agentrun control-plane trigger-current",
|
||||
mode: waited ? "confirmed-waited" : "confirmed-trigger",
|
||||
configPath,
|
||||
target: agentRunLaneSummary(spec),
|
||||
phase: "source-status",
|
||||
degradedReason: "yaml-lane-k3s-source-status-failed",
|
||||
result: sourcePayload,
|
||||
capture: compactCapture(sourceStatus, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
return { ok: true, sourceCommit, sourcePayload, valuesPrinted: false };
|
||||
}
|
||||
|
||||
progressEvent("agentrun.yaml-lane.source-bootstrap.progress", {
|
||||
node: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
status: "submitting",
|
||||
});
|
||||
const bootstrapSubmit = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneSourceBootstrapSubmitScript(spec)]);
|
||||
const bootstrapSubmitPayload = captureJsonPayload(bootstrapSubmit);
|
||||
if (bootstrapSubmit.exitCode !== 0 || bootstrapSubmitPayload.ok === false) {
|
||||
return {
|
||||
ok: false,
|
||||
command: "agentrun control-plane trigger-current",
|
||||
mode: waited ? "confirmed-waited" : "confirmed-trigger",
|
||||
configPath,
|
||||
target: agentRunLaneSummary(spec),
|
||||
phase: "source-bootstrap-submit",
|
||||
degradedReason: "yaml-lane-source-bootstrap-submit-failed",
|
||||
result: bootstrapSubmitPayload,
|
||||
capture: compactCapture(bootstrapSubmit, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const bootstrap = await waitForYamlLaneSourceBootstrap(config, spec, stringOrNull(bootstrapSubmitPayload.jobId));
|
||||
const sourcePayload = bootstrap.payload;
|
||||
const sourceCommit = stringOrNull(sourcePayload.sourceCommit);
|
||||
if (bootstrap.ok !== true || sourceCommit === null || !isGitSha(sourceCommit)) {
|
||||
return {
|
||||
ok: false,
|
||||
command: "agentrun control-plane trigger-current",
|
||||
mode: waited ? "confirmed-waited" : "confirmed-trigger",
|
||||
configPath,
|
||||
target: agentRunLaneSummary(spec),
|
||||
phase: "source-bootstrap",
|
||||
degradedReason: "yaml-lane-source-bootstrap-failed",
|
||||
result: sourcePayload,
|
||||
bootstrapStatus: bootstrap,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
return { ok: true, sourceCommit, sourcePayload, sourceBootstrapSubmit: bootstrapSubmitPayload, valuesPrinted: false };
|
||||
}
|
||||
|
||||
async function buildTriggerCurrentImage(config: UniDeskConfig, spec: AgentRunLaneSpec, sourceCommit: string, sourcePayload: Record<string, unknown>, configPath: string, waited: boolean): Promise<Record<string, unknown> & { ok: boolean; buildStatus?: Record<string, unknown> & { ok: boolean; payload: Record<string, unknown> }; buildSubmitPayload?: Record<string, unknown> }> {
|
||||
if (spec.source.statusMode === "k3s-git-mirror") {
|
||||
const build = await runYamlLaneK3sBuildImageJob(config, spec, sourceCommit);
|
||||
const buildSubmitPayload = {
|
||||
ok: build.ok !== false,
|
||||
status: "submitted",
|
||||
mode: "k3s-buildkit-job",
|
||||
jobName: stringOrNull(build.jobName) ?? null,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
if (build.ok !== true) {
|
||||
return {
|
||||
ok: false,
|
||||
command: "agentrun control-plane trigger-current",
|
||||
mode: waited ? "confirmed-waited" : "confirmed-trigger",
|
||||
configPath,
|
||||
target: agentRunLaneSummary(spec),
|
||||
phase: "image-build",
|
||||
sourceCommit,
|
||||
degradedReason: "yaml-lane-k3s-image-build-failed",
|
||||
sourceBootstrap: sourcePayload,
|
||||
buildSubmit: buildSubmitPayload,
|
||||
result: build.payload,
|
||||
buildStatus: build,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
return { ok: true, buildStatus: build, buildSubmitPayload, valuesPrinted: false };
|
||||
}
|
||||
|
||||
const buildSubmit = await capture(config, spec.nodeRoute, ["sh", "--", yamlLaneBuildImageSubmitScript(spec, sourceCommit)]);
|
||||
const buildSubmitPayload = captureJsonPayload(buildSubmit);
|
||||
if (buildSubmit.exitCode !== 0 || buildSubmitPayload.ok === false) {
|
||||
return {
|
||||
ok: false,
|
||||
command: "agentrun control-plane trigger-current",
|
||||
mode: waited ? "confirmed-waited" : "confirmed-trigger",
|
||||
configPath,
|
||||
target: agentRunLaneSummary(spec),
|
||||
phase: "image-build-submit",
|
||||
sourceCommit,
|
||||
degradedReason: "yaml-lane-image-build-submit-failed",
|
||||
sourceBootstrap: sourcePayload,
|
||||
result: buildSubmitPayload,
|
||||
capture: compactCapture(buildSubmit, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const build = await waitForYamlLaneBuildImage(config, spec, sourceCommit, stringOrNull(buildSubmitPayload.jobId));
|
||||
return { ok: true, buildStatus: build, buildSubmitPayload, valuesPrinted: false };
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { agentRunControlPlaneStatusCommand } from "./public-exposure";
|
||||
import { applyYamlScript, manifestObjectRef, yamlLaneGitMirrorStatusScript } from "./secrets";
|
||||
import { compactAgentRunLaneStatusTarget, compactLaneSecretsStatus } from "./trigger";
|
||||
import { capture, captureJsonPayload, compactCapture, record, stringOrNull, timedStatusStage } from "./utils";
|
||||
import { yamlLaneRuntimeStatusScript, yamlLaneSourceStatusScript } from "./yaml-lane";
|
||||
import { yamlLaneK3sSourceStatusScript, yamlLaneRuntimeStatusScript, yamlLaneSourceStatusScript } from "./yaml-lane";
|
||||
|
||||
export function parseSecretSyncOptions(args: string[]): SecretSyncOptions {
|
||||
const base = parseConfirmOptions(args);
|
||||
@@ -315,7 +315,9 @@ export async function status(config: UniDeskConfig, options: StatusOptions): Pro
|
||||
|
||||
export async function statusYamlLane(config: UniDeskConfig, options: StatusOptions, target: { configPath: string; spec: AgentRunLaneSpec }): Promise<Record<string, unknown>> {
|
||||
const spec = target.spec;
|
||||
const sourceProbe = await timedStatusStage("source", () => capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)]));
|
||||
const sourceProbe = await timedStatusStage("source", () => spec.source.statusMode === "k3s-git-mirror"
|
||||
? capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)])
|
||||
: capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)]));
|
||||
const sourcePayload = captureJsonPayload(sourceProbe.value);
|
||||
const branchTipCommit = stringOrNull(sourcePayload.remoteBranchCommit) ?? stringOrNull(sourcePayload.localHead);
|
||||
const initialSourceCommit = options.sourceCommit
|
||||
@@ -362,14 +364,15 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
|
||||
: null,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
const sourceWorkspaceRequired = spec.source.statusMode === "host-worktree";
|
||||
const warnings = [
|
||||
...(sourceWorktreeDetached ? ["source-worktree-detached"] : []),
|
||||
...(sourceBranchAdvanced ? ["source-branch-advanced-after-target"] : []),
|
||||
];
|
||||
const blockers = [
|
||||
...(sourcePayload.workspaceExists === true ? [] : ["source-worktree-missing"]),
|
||||
...(sourceWorkspaceRequired && sourcePayload.workspaceExists !== true ? ["source-worktree-missing"] : []),
|
||||
...(sourcePayload.remoteBranchExists === true ? [] : ["source-branch-missing"]),
|
||||
...(sourcePayload.workspaceClean === true || sourcePayload.workspaceExists !== true ? [] : ["source-worktree-dirty"]),
|
||||
...(sourceWorkspaceRequired && sourcePayload.workspaceClean !== true && sourcePayload.workspaceExists === true ? ["source-worktree-dirty"] : []),
|
||||
...(mirrorPayload.readReady === true ? [] : ["git-mirror-read-not-ready"]),
|
||||
...(mirrorPayload.writeReady === true ? [] : ["git-mirror-write-not-ready"]),
|
||||
...(mirrorPayload.cacheReady === true ? [] : ["git-mirror-cache-not-ready"]),
|
||||
@@ -422,6 +425,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
|
||||
? { code: "runtime-blocked", summary: `runtime alignment is blocked: ${runtimeBlockers.join(", ")}`, command: statusFullCommand }
|
||||
: { code: "inspect-full-status", summary: "alignment is inconclusive; inspect full status details", command: statusFullCommand };
|
||||
const compactSourceStatus = {
|
||||
statusMode: spec.source.statusMode,
|
||||
workspaceExists: sourcePayload.workspaceExists ?? false,
|
||||
workspaceClean: sourcePayload.workspaceClean ?? null,
|
||||
branch: sourcePayload.branch ?? null,
|
||||
|
||||
@@ -446,21 +446,27 @@ export function refreshYamlLaneScript(spec: AgentRunLaneSpec): string {
|
||||
`application=${shQuote(spec.gitops.argoApplication)}`,
|
||||
"kubectl -n \"$namespace\" annotate application \"$application\" argocd.argoproj.io/refresh=hard --overwrite >/tmp/agentrun-refresh-output.txt",
|
||||
"kubectl -n \"$namespace\" get application \"$application\" -o json >/tmp/agentrun-refresh-app.json",
|
||||
"NAMESPACE=\"$namespace\" APPLICATION=\"$application\" node <<'NODE'",
|
||||
"const fs = require('node:fs');",
|
||||
"let app = {};",
|
||||
"try { app = JSON.parse(fs.readFileSync('/tmp/agentrun-refresh-app.json', 'utf8')); } catch {}",
|
||||
"console.log(JSON.stringify({",
|
||||
" ok: true,",
|
||||
" namespace: process.env.NAMESPACE,",
|
||||
" application: process.env.APPLICATION,",
|
||||
" revision: app.status?.sync?.revision ?? null,",
|
||||
" syncStatus: app.status?.sync?.status ?? null,",
|
||||
" healthStatus: app.status?.health?.status ?? null,",
|
||||
" observedAt: new Date().toISOString(),",
|
||||
" valuesPrinted: false",
|
||||
"}));",
|
||||
"NODE",
|
||||
"NAMESPACE=\"$namespace\" APPLICATION=\"$application\" python3 - <<'PY'",
|
||||
"import datetime, json, os",
|
||||
"try:",
|
||||
" with open('/tmp/agentrun-refresh-app.json', 'r', encoding='utf-8') as fh:",
|
||||
" app = json.load(fh)",
|
||||
"except Exception:",
|
||||
" app = {}",
|
||||
"status = app.get('status') or {}",
|
||||
"sync = status.get('sync') or {}",
|
||||
"health = status.get('health') or {}",
|
||||
"print(json.dumps({",
|
||||
" 'ok': True,",
|
||||
" 'namespace': os.environ.get('NAMESPACE'),",
|
||||
" 'application': os.environ.get('APPLICATION'),",
|
||||
" 'revision': sync.get('revision'),",
|
||||
" 'syncStatus': sync.get('status'),",
|
||||
" 'healthStatus': health.get('status'),",
|
||||
" 'observedAt': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z'),",
|
||||
" 'valuesPrinted': False,",
|
||||
"}, ensure_ascii=False))",
|
||||
"PY",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
|
||||
+182
-95
@@ -343,9 +343,10 @@ export function yamlLanePipelineRunCreateScript(spec: AgentRunLaneSpec, sourceCo
|
||||
"if [ \"$existing_status\" = False ]; then kubectl -n \"$namespace\" delete pipelinerun \"$pipeline_run\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true; fi",
|
||||
"if kubectl -n \"$namespace\" get pipelinerun \"$pipeline_run\" >/dev/null 2>&1; then created=false; else kubectl create -f \"$tmp\"; created=true; fi",
|
||||
"status=$(kubectl -n \"$namespace\" get pipelinerun \"$pipeline_run\" -o 'jsonpath={.status.conditions[0].status}' 2>/dev/null || true)",
|
||||
"CREATED=\"$created\" STATUS=\"$status\" PIPELINE_RUN=\"$pipeline_run\" node <<'NODE'",
|
||||
"console.log(JSON.stringify({ ok: true, pipelineRun: process.env.PIPELINE_RUN, created: process.env.CREATED === 'true', status: process.env.STATUS || null, valuesPrinted: false }));",
|
||||
"NODE",
|
||||
"CREATED=\"$created\" STATUS=\"$status\" PIPELINE_RUN=\"$pipeline_run\" python3 - <<'PY'",
|
||||
"import json, os",
|
||||
"print(json.dumps({'ok': True, 'pipelineRun': os.environ.get('PIPELINE_RUN'), 'created': os.environ.get('CREATED') == 'true', 'status': os.environ.get('STATUS') or None, 'valuesPrinted': False}, ensure_ascii=False))",
|
||||
"PY",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -361,9 +362,10 @@ export function createYamlLaneJobScript(namespace: string, jobName: string, mani
|
||||
"printf '%s' \"$manifest_b64\" | base64 -d > \"$tmp\"",
|
||||
"kubectl -n \"$namespace\" delete job \"$job\" --ignore-not-found=true --wait=false >/dev/null 2>&1 || true",
|
||||
"kubectl create -f \"$tmp\"",
|
||||
"JOB=\"$job\" node <<'NODE'",
|
||||
"console.log(JSON.stringify({ ok: true, jobName: process.env.JOB, valuesPrinted: false }));",
|
||||
"NODE",
|
||||
"JOB=\"$job\" python3 - <<'PY'",
|
||||
"import json, os",
|
||||
"print(json.dumps({'ok': True, 'jobName': os.environ.get('JOB'), 'valuesPrinted': False}, ensure_ascii=False))",
|
||||
"PY",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -375,14 +377,27 @@ export function yamlLaneJobProbeScript(namespace: string, jobName: string): stri
|
||||
"kubectl -n \"$namespace\" get job \"$job\" -o json > /tmp/agentrun-job.json 2>/dev/null",
|
||||
"job_exit=$?",
|
||||
"kubectl -n \"$namespace\" logs \"job/$job\" --tail=120 > /tmp/agentrun-job.log 2>/dev/null",
|
||||
"JOB_EXIT=\"$job_exit\" JOB=\"$job\" node <<'NODE'",
|
||||
"const fs = require('node:fs');",
|
||||
"let job = null; try { job = JSON.parse(fs.readFileSync('/tmp/agentrun-job.json', 'utf8')); } catch {}",
|
||||
"let log = ''; try { log = fs.readFileSync('/tmp/agentrun-job.log', 'utf8'); } catch {}",
|
||||
"const succeeded = Number(job?.status?.succeeded || 0) > 0;",
|
||||
"const failed = Number(job?.status?.failed || 0) > 0;",
|
||||
"console.log(JSON.stringify({ ok: process.env.JOB_EXIT === '0', jobName: process.env.JOB, succeeded, failed, active: job?.status?.active || 0, logsTail: log.slice(-4000), valuesPrinted: false }));",
|
||||
"NODE",
|
||||
"JOB_EXIT=\"$job_exit\" JOB=\"$job\" python3 - <<'PY'",
|
||||
"import json, os",
|
||||
"def read_json(path):",
|
||||
" try:",
|
||||
" with open(path, 'r', encoding='utf-8') as fh:",
|
||||
" return json.load(fh)",
|
||||
" except Exception:",
|
||||
" return None",
|
||||
"def read_text(path):",
|
||||
" try:",
|
||||
" with open(path, 'r', encoding='utf-8') as fh:",
|
||||
" return fh.read()",
|
||||
" except Exception:",
|
||||
" return ''",
|
||||
"job = read_json('/tmp/agentrun-job.json') or {}",
|
||||
"status = job.get('status') or {}",
|
||||
"log = read_text('/tmp/agentrun-job.log')",
|
||||
"succeeded = int(status.get('succeeded') or 0) > 0",
|
||||
"failed = int(status.get('failed') or 0) > 0",
|
||||
"print(json.dumps({'ok': os.environ.get('JOB_EXIT') == '0', 'jobName': os.environ.get('JOB'), 'succeeded': succeeded, 'failed': failed, 'active': status.get('active') or 0, 'logsTail': log[-4000:], 'valuesPrinted': False}, ensure_ascii=False))",
|
||||
"PY",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -619,38 +634,53 @@ export function yamlLaneGitMirrorStatusScript(spec: AgentRunLaneSpec): string {
|
||||
"repo_path=\"/cache/${repository}.git\"",
|
||||
"rm -f /tmp/agentrun-gitmirror-refs.txt",
|
||||
"if [ \"$read_exit\" -eq 0 ]; then",
|
||||
" kubectl -n \"$namespace\" exec deploy/\"$read_deployment\" -- sh -lc 'repo_path=\"$1\"; source_branch=\"$2\"; gitops_branch=\"$3\"; printf \"sourceCommit=\"; git --git-dir=\"$repo_path\" rev-parse \"refs/heads/$source_branch\" 2>/dev/null || true; printf \"gitopsCommit=\"; git --git-dir=\"$repo_path\" rev-parse \"refs/heads/$gitops_branch\" 2>/dev/null || true' sh \"$repo_path\" \"$source_branch\" \"$gitops_branch\" > /tmp/agentrun-gitmirror-refs.txt 2>/dev/null",
|
||||
" kubectl -n \"$namespace\" exec deploy/\"$read_deployment\" -- sh -lc 'repo_path=\"$1\"; source_branch=\"$2\"; gitops_branch=\"$3\"; source_commit=$(git --git-dir=\"$repo_path\" rev-parse --verify \"refs/heads/$source_branch^{commit}\" 2>/dev/null || true); gitops_commit=$(git --git-dir=\"$repo_path\" rev-parse --verify \"refs/heads/$gitops_branch^{commit}\" 2>/dev/null || true); printf \"sourceCommit=%s\\n\" \"$source_commit\"; printf \"gitopsCommit=%s\\n\" \"$gitops_commit\"' sh \"$repo_path\" \"$source_branch\" \"$gitops_branch\" > /tmp/agentrun-gitmirror-refs.txt 2>/dev/null",
|
||||
"fi",
|
||||
"NAMESPACE=\"$namespace\" READ_EXIT=\"$read_exit\" WRITE_EXIT=\"$write_exit\" CACHE_EXIT=\"$cache_exit\" CACHE_MODE=\"$cache_mode\" CACHE_HOST_PATH=\"$cache_host_path\" REPOSITORY=\"$repository\" SOURCE_BRANCH=\"$source_branch\" GITOPS_BRANCH=\"$gitops_branch\" REPOSITORIES_JSON=\"$repositories_json\" node <<'NODE'",
|
||||
"const fs = require('node:fs');",
|
||||
"const repositories = JSON.parse(process.env.REPOSITORIES_JSON || '[]');",
|
||||
"let names = ''; try { names = fs.readFileSync('/tmp/agentrun-gitmirror-names.txt', 'utf8'); } catch {}",
|
||||
"let refs = ''; try { refs = fs.readFileSync('/tmp/agentrun-gitmirror-refs.txt', 'utf8'); } catch {}",
|
||||
"const refValue = (key) => refs.split(/\\r?\\n/).find((line) => line.startsWith(`${key}=`))?.slice(key.length + 1).trim() || null;",
|
||||
"const readReady = process.env.READ_EXIT === '0';",
|
||||
"const writeReady = process.env.WRITE_EXIT === '0';",
|
||||
"const cachePvcExists = process.env.CACHE_EXIT === '0';",
|
||||
"const cacheMode = process.env.CACHE_MODE || 'pvc';",
|
||||
"const cacheReady = cacheMode === 'hostPath' ? true : cachePvcExists;",
|
||||
"console.log(JSON.stringify({",
|
||||
" ok: readReady && writeReady && cacheReady,",
|
||||
" namespace: process.env.NAMESPACE,",
|
||||
" readReady,",
|
||||
" writeReady,",
|
||||
" cacheMode,",
|
||||
" cacheReady,",
|
||||
" cachePvcExists,",
|
||||
" cacheHostPath: process.env.CACHE_HOST_PATH || null,",
|
||||
" resources: names.split(/\\r?\\n/).filter(Boolean).slice(0, 40),",
|
||||
" repository: process.env.REPOSITORY,",
|
||||
" sourceBranch: process.env.SOURCE_BRANCH,",
|
||||
" gitopsBranch: process.env.GITOPS_BRANCH,",
|
||||
" sourceCommit: refValue('sourceCommit'),",
|
||||
" gitopsCommit: refValue('gitopsCommit'),",
|
||||
" repositories,",
|
||||
" valuesPrinted: false",
|
||||
"}));",
|
||||
"NODE",
|
||||
"NAMESPACE=\"$namespace\" READ_EXIT=\"$read_exit\" WRITE_EXIT=\"$write_exit\" CACHE_EXIT=\"$cache_exit\" CACHE_MODE=\"$cache_mode\" CACHE_HOST_PATH=\"$cache_host_path\" REPOSITORY=\"$repository\" SOURCE_BRANCH=\"$source_branch\" GITOPS_BRANCH=\"$gitops_branch\" REPOSITORIES_JSON=\"$repositories_json\" python3 - <<'PY'",
|
||||
"import json, os, re",
|
||||
"def read_text(path):",
|
||||
" try:",
|
||||
" with open(path, 'r', encoding='utf-8') as fh:",
|
||||
" return fh.read()",
|
||||
" except Exception:",
|
||||
" return ''",
|
||||
"def ref_value(text, key):",
|
||||
" prefix = key + '='",
|
||||
" for line in re.split(r'\\r?\\n', text):",
|
||||
" if line.startswith(prefix):",
|
||||
" value = line[len(prefix):].strip()",
|
||||
" return value or None",
|
||||
" return None",
|
||||
"try:",
|
||||
" repositories = json.loads(os.environ.get('REPOSITORIES_JSON') or '[]')",
|
||||
"except Exception:",
|
||||
" repositories = []",
|
||||
"names = read_text('/tmp/agentrun-gitmirror-names.txt')",
|
||||
"refs = read_text('/tmp/agentrun-gitmirror-refs.txt')",
|
||||
"read_ready = os.environ.get('READ_EXIT') == '0'",
|
||||
"write_ready = os.environ.get('WRITE_EXIT') == '0'",
|
||||
"cache_pvc_exists = os.environ.get('CACHE_EXIT') == '0'",
|
||||
"cache_mode = os.environ.get('CACHE_MODE') or 'pvc'",
|
||||
"cache_ready = True if cache_mode == 'hostPath' else cache_pvc_exists",
|
||||
"print(json.dumps({",
|
||||
" 'ok': read_ready and write_ready and cache_ready,",
|
||||
" 'namespace': os.environ.get('NAMESPACE'),",
|
||||
" 'readReady': read_ready,",
|
||||
" 'writeReady': write_ready,",
|
||||
" 'cacheMode': cache_mode,",
|
||||
" 'cacheReady': cache_ready,",
|
||||
" 'cachePvcExists': cache_pvc_exists,",
|
||||
" 'cacheHostPath': os.environ.get('CACHE_HOST_PATH') or None,",
|
||||
" 'resources': [line for line in re.split(r'\\r?\\n', names) if line][:40],",
|
||||
" 'repository': os.environ.get('REPOSITORY'),",
|
||||
" 'sourceBranch': os.environ.get('SOURCE_BRANCH'),",
|
||||
" 'gitopsBranch': os.environ.get('GITOPS_BRANCH'),",
|
||||
" 'sourceCommit': ref_value(refs, 'sourceCommit'),",
|
||||
" 'gitopsCommit': ref_value(refs, 'gitopsCommit'),",
|
||||
" 'repositories': repositories,",
|
||||
" 'valuesPrinted': False",
|
||||
"}, ensure_ascii=False))",
|
||||
"PY",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -660,6 +690,7 @@ export type LaneSecretSource = {
|
||||
sourceMode: "env" | "file";
|
||||
sourceKey: string | null;
|
||||
targetRef: { namespace: string; name: string; key: string };
|
||||
transform?: "local-postgres-database" | "local-postgres-user" | "local-postgres-database-url";
|
||||
};
|
||||
|
||||
export function readSecretSourceValue(spec: AgentRunLaneSpec, source: LaneSecretSource): { redactedPath: string; value: string; valueBytes: number; fingerprint: string } {
|
||||
@@ -680,6 +711,7 @@ export function readSecretSourceValue(spec: AgentRunLaneSpec, source: LaneSecret
|
||||
value = envValue;
|
||||
}
|
||||
if (value.length === 0) throw new Error(`secret source ${sourceRef} is empty`);
|
||||
value = transformSecretSourceValue(spec, source, value);
|
||||
return {
|
||||
redactedPath: sourceRef.startsWith("/") ? redactAbsoluteSecretPath(sourceRef) : `.state/secrets/${sourceRef}`,
|
||||
value,
|
||||
@@ -688,6 +720,20 @@ export function readSecretSourceValue(spec: AgentRunLaneSpec, source: LaneSecret
|
||||
};
|
||||
}
|
||||
|
||||
function transformSecretSourceValue(spec: AgentRunLaneSpec, source: LaneSecretSource, rawValue: string): string {
|
||||
if (source.transform === undefined) return rawValue;
|
||||
const localPostgres = spec.deployment.localPostgres;
|
||||
if (!localPostgres.enabled || localPostgres.serviceName === null || localPostgres.database === null || localPostgres.user === null || localPostgres.port === null) {
|
||||
throw new Error(`secret source ${source.id} requires enabled localPostgres with database/user/port`);
|
||||
}
|
||||
if (source.transform === "local-postgres-database") return localPostgres.database;
|
||||
if (source.transform === "local-postgres-user") return localPostgres.user;
|
||||
const user = encodeURIComponent(localPostgres.user);
|
||||
const password = encodeURIComponent(rawValue);
|
||||
const database = encodeURIComponent(localPostgres.database);
|
||||
return `postgresql://${user}:${password}@${localPostgres.serviceName}:${localPostgres.port}/${database}`;
|
||||
}
|
||||
|
||||
export function redactAbsoluteSecretPath(sourceRef: string): string {
|
||||
const parts = sourceRef.split("/").filter(Boolean);
|
||||
return parts.length === 0 ? "/" : `/${parts.slice(0, -1).join("/")}/<redacted>`;
|
||||
@@ -732,6 +778,45 @@ export function collectLaneSecretSources(spec: AgentRunLaneSpec): LaneSecretSour
|
||||
targetRef: { namespace: spec.runtime.namespace, name: spec.database.secretRef.name, key: spec.database.secretRef.key },
|
||||
});
|
||||
}
|
||||
const localPostgres = spec.deployment.localPostgres;
|
||||
if (spec.database.mode === "local-postgres" && localPostgres.enabled) {
|
||||
if (localPostgres.passwordSourceRef === null || localPostgres.passwordSourceKey === null) {
|
||||
throw new Error(`config/agentrun.yaml controlPlane.lanes.${spec.lane}.deployment.localPostgres must declare passwordSourceRef/passwordSourceKey`);
|
||||
}
|
||||
result.push(
|
||||
{
|
||||
id: "local-postgres-password",
|
||||
sourceRef: localPostgres.passwordSourceRef,
|
||||
sourceMode: "env",
|
||||
sourceKey: localPostgres.passwordSourceKey,
|
||||
targetRef: { namespace: spec.runtime.namespace, name: spec.database.secretRef.name, key: "POSTGRES_PASSWORD" },
|
||||
},
|
||||
{
|
||||
id: "local-postgres-user",
|
||||
sourceRef: localPostgres.passwordSourceRef,
|
||||
sourceMode: "env",
|
||||
sourceKey: localPostgres.passwordSourceKey,
|
||||
targetRef: { namespace: spec.runtime.namespace, name: spec.database.secretRef.name, key: "POSTGRES_USER" },
|
||||
transform: "local-postgres-user",
|
||||
},
|
||||
{
|
||||
id: "local-postgres-database",
|
||||
sourceRef: localPostgres.passwordSourceRef,
|
||||
sourceMode: "env",
|
||||
sourceKey: localPostgres.passwordSourceKey,
|
||||
targetRef: { namespace: spec.runtime.namespace, name: spec.database.secretRef.name, key: "POSTGRES_DB" },
|
||||
transform: "local-postgres-database",
|
||||
},
|
||||
{
|
||||
id: "database",
|
||||
sourceRef: localPostgres.passwordSourceRef,
|
||||
sourceMode: "env",
|
||||
sourceKey: localPostgres.passwordSourceKey,
|
||||
targetRef: { namespace: spec.runtime.namespace, name: spec.database.secretRef.name, key: spec.database.secretRef.key },
|
||||
transform: "local-postgres-database-url",
|
||||
},
|
||||
);
|
||||
}
|
||||
for (const secret of spec.secrets) {
|
||||
result.push({
|
||||
id: secret.id,
|
||||
@@ -762,47 +847,44 @@ export function secretSyncScript(_spec: AgentRunLaneSpec, values: Array<{ target
|
||||
"trap 'rm -rf \"$tmp_dir\"' EXIT",
|
||||
"payload_json=\"$tmp_dir/payload.json\"",
|
||||
"printf '%s' \"$payload_b64\" | base64 -d > \"$payload_json\"",
|
||||
"PAYLOAD_JSON=\"$payload_json\" TMP_DIR=\"$tmp_dir\" node <<'NODE'",
|
||||
"const fs = require('node:fs');",
|
||||
"const cp = require('node:child_process');",
|
||||
"const crypto = require('node:crypto');",
|
||||
"const items = JSON.parse(fs.readFileSync(process.env.PAYLOAD_JSON, 'utf8'));",
|
||||
"const results = [];",
|
||||
"const groups = new Map();",
|
||||
"function run(argv, input) {",
|
||||
" const out = cp.spawnSync(argv[0], argv.slice(1), { input, encoding: 'utf8' });",
|
||||
" if (out.status !== 0) throw new Error(`${argv.join(' ')} failed: ${out.stderr || out.stdout}`);",
|
||||
" return out;",
|
||||
"}",
|
||||
"function tryRun(argv) {",
|
||||
" return cp.spawnSync(argv[0], argv.slice(1), { encoding: 'utf8' });",
|
||||
"}",
|
||||
"for (let index = 0; index < items.length; index += 1) {",
|
||||
" const item = items[index];",
|
||||
" const ref = item.targetRef;",
|
||||
" const groupKey = `${ref.namespace}\\u0000${ref.name}`;",
|
||||
" if (!groups.has(groupKey)) groups.set(groupKey, { namespace: ref.namespace, name: ref.name, items: [] });",
|
||||
" groups.get(groupKey).items.push({ key: ref.key, valueBase64: item.valueBase64 });",
|
||||
"}",
|
||||
"for (const group of groups.values()) {",
|
||||
" const ns = run(['kubectl', 'create', 'namespace', group.namespace, '--dry-run=client', '-o', 'yaml']).stdout;",
|
||||
" run(['kubectl', 'apply', '--server-side', '--field-manager=unidesk-agentrun-secret-sync', '-f', '-'], ns);",
|
||||
" const existing = tryRun(['kubectl', '-n', group.namespace, 'get', 'secret', group.name]);",
|
||||
" if (existing.status !== 0) run(['kubectl', '-n', group.namespace, 'create', 'secret', 'generic', group.name]);",
|
||||
" const patch = { data: {} };",
|
||||
" for (const entry of group.items) patch.data[entry.key] = entry.valueBase64;",
|
||||
" const patchFile = `${process.env.TMP_DIR}/${group.namespace}-${group.name}-patch.json`;",
|
||||
" fs.writeFileSync(patchFile, JSON.stringify(patch));",
|
||||
" run(['kubectl', '-n', group.namespace, 'patch', 'secret', group.name, '--type=merge', `--patch-file=${patchFile}`]);",
|
||||
" const fetched = JSON.parse(run(['kubectl', '-n', group.namespace, 'get', 'secret', group.name, '-o', 'json']).stdout);",
|
||||
" for (const entry of group.items) {",
|
||||
" const raw = fetched.data?.[entry.key] || '';",
|
||||
" const decoded = raw ? Buffer.from(raw, 'base64') : Buffer.alloc(0);",
|
||||
" results.push({ namespace: group.namespace, secret: group.name, key: entry.key, ok: raw.length > 0, valueBytes: decoded.length, fingerprint: raw ? 'sha256:' + crypto.createHash('sha256').update(decoded).digest('hex') : null, valuesPrinted: false });",
|
||||
" }",
|
||||
"}",
|
||||
"console.log(JSON.stringify({ ok: results.every((item) => item.ok), secretCount: results.length, items: results, valuesPrinted: false }));",
|
||||
"NODE",
|
||||
"PAYLOAD_JSON=\"$payload_json\" TMP_DIR=\"$tmp_dir\" python3 - <<'PY'",
|
||||
"import base64, hashlib, json, os, subprocess",
|
||||
"def run(argv, input_text=None):",
|
||||
" out = subprocess.run(argv, input=input_text, text=True, capture_output=True)",
|
||||
" if out.returncode != 0:",
|
||||
" raise RuntimeError('%s failed: %s' % (' '.join(argv), (out.stderr or out.stdout).strip()))",
|
||||
" return out",
|
||||
"def try_run(argv):",
|
||||
" return subprocess.run(argv, text=True, capture_output=True)",
|
||||
"with open(os.environ['PAYLOAD_JSON'], 'r', encoding='utf-8') as fh:",
|
||||
" items = json.load(fh)",
|
||||
"groups = {}",
|
||||
"for item in items:",
|
||||
" ref = item.get('targetRef') or {}",
|
||||
" key = (ref.get('namespace'), ref.get('name'))",
|
||||
" groups.setdefault(key, {'namespace': ref.get('namespace'), 'name': ref.get('name'), 'items': []})['items'].append({'key': ref.get('key'), 'valueBase64': item.get('valueBase64') or ''})",
|
||||
"results = []",
|
||||
"for group in groups.values():",
|
||||
" namespace = group['namespace']",
|
||||
" name = group['name']",
|
||||
" ns_yaml = run(['kubectl', 'create', 'namespace', namespace, '--dry-run=client', '-o', 'yaml']).stdout",
|
||||
" run(['kubectl', 'apply', '--server-side', '--field-manager=unidesk-agentrun-secret-sync', '-f', '-'], ns_yaml)",
|
||||
" existing = try_run(['kubectl', '-n', namespace, 'get', 'secret', name])",
|
||||
" if existing.returncode != 0:",
|
||||
" run(['kubectl', '-n', namespace, 'create', 'secret', 'generic', name])",
|
||||
" patch = {'data': {entry['key']: entry['valueBase64'] for entry in group['items']}}",
|
||||
" patch_file = os.path.join(os.environ['TMP_DIR'], '%s-%s-patch.json' % (namespace, name))",
|
||||
" with open(patch_file, 'w', encoding='utf-8') as fh:",
|
||||
" json.dump(patch, fh)",
|
||||
" run(['kubectl', '-n', namespace, 'patch', 'secret', name, '--type=merge', '--patch-file=%s' % patch_file])",
|
||||
" fetched = json.loads(run(['kubectl', '-n', namespace, 'get', 'secret', name, '-o', 'json']).stdout)",
|
||||
" data = fetched.get('data') or {}",
|
||||
" for entry in group['items']:",
|
||||
" raw = data.get(entry['key']) or ''",
|
||||
" decoded = base64.b64decode(raw.encode('utf-8')) if raw else b''",
|
||||
" results.append({'namespace': namespace, 'secret': name, 'key': entry['key'], 'ok': bool(raw), 'valueBytes': len(decoded), 'fingerprint': ('sha256:' + hashlib.sha256(decoded).hexdigest()) if raw else None, 'valuesPrinted': False})",
|
||||
"print(json.dumps({'ok': all(item['ok'] for item in results), 'secretCount': len(results), 'items': results, 'valuesPrinted': False}, ensure_ascii=False))",
|
||||
"PY",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -869,15 +951,20 @@ export function applyYamlScript(yaml: string, fieldManager: string, dryRun: bool
|
||||
"kubectl apply $args -f \"$manifest\" > \"$tmp_dir/apply.out\" 2> \"$tmp_dir/apply.err\"",
|
||||
"apply_exit=$?",
|
||||
"set -e",
|
||||
"APPLY_EXIT=\"$apply_exit\" APPLY_OUT=\"$tmp_dir/apply.out\" APPLY_ERR=\"$tmp_dir/apply.err\" MANIFEST=\"$manifest\" node <<'NODE'",
|
||||
"const fs = require('node:fs');",
|
||||
"const crypto = require('node:crypto');",
|
||||
"const out = fs.readFileSync(process.env.APPLY_OUT, 'utf8');",
|
||||
"const err = fs.readFileSync(process.env.APPLY_ERR, 'utf8');",
|
||||
"const manifest = fs.readFileSync(process.env.MANIFEST, 'utf8');",
|
||||
"const resources = out.split(/\\r?\\n/).map((line) => line.trim()).filter(Boolean).slice(0, 80);",
|
||||
"console.log(JSON.stringify({ ok: process.env.APPLY_EXIT === '0', exitCode: Number(process.env.APPLY_EXIT), resourceCount: resources.length, resources, manifestBytes: Buffer.byteLength(manifest, 'utf8'), manifestDigest: 'sha256:' + crypto.createHash('sha256').update(manifest).digest('hex'), stderrTail: err.slice(-3000), valuesPrinted: false }));",
|
||||
"NODE",
|
||||
"APPLY_EXIT=\"$apply_exit\" APPLY_OUT=\"$tmp_dir/apply.out\" APPLY_ERR=\"$tmp_dir/apply.err\" MANIFEST=\"$manifest\" python3 - <<'PY'",
|
||||
"import hashlib, json, os, re",
|
||||
"def read_bytes(path):",
|
||||
" try:",
|
||||
" with open(path, 'rb') as fh:",
|
||||
" return fh.read()",
|
||||
" except Exception:",
|
||||
" return b''",
|
||||
"out = read_bytes(os.environ['APPLY_OUT']).decode('utf-8', errors='replace')",
|
||||
"err = read_bytes(os.environ['APPLY_ERR']).decode('utf-8', errors='replace')",
|
||||
"manifest = read_bytes(os.environ['MANIFEST'])",
|
||||
"resources = [line.strip() for line in re.split(r'\\r?\\n', out) if line.strip()][:80]",
|
||||
"print(json.dumps({'ok': os.environ.get('APPLY_EXIT') == '0', 'exitCode': int(os.environ.get('APPLY_EXIT') or '1'), 'resourceCount': len(resources), 'resources': resources, 'manifestBytes': len(manifest), 'manifestDigest': 'sha256:' + hashlib.sha256(manifest).hexdigest(), 'stderrTail': err[-3000:], 'valuesPrinted': False}, ensure_ascii=False))",
|
||||
"PY",
|
||||
"exit \"$apply_exit\"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -354,26 +354,78 @@ export function yamlLaneSourceStatusScript(spec: AgentRunLaneSpec): string {
|
||||
" actual_workspace=$(pwd)",
|
||||
"fi",
|
||||
"export expected_workspace source_branch workspace_exists workspace_clean local_head branch remote_url remote_branch_exists remote_branch_commit status_short actual_workspace",
|
||||
"node <<'NODE'",
|
||||
"function nullable(value) { return value && value !== 'null' ? value : null; }",
|
||||
"function booleanValue(value) { if (value === 'true') return true; if (value === 'false') return false; return null; }",
|
||||
"const env = process.env;",
|
||||
"console.log(JSON.stringify({",
|
||||
" ok: env.workspace_exists === 'true',",
|
||||
" expectedWorkspace: env.expected_workspace,",
|
||||
" actualWorkspace: env.actual_workspace || null,",
|
||||
" workspaceExists: env.workspace_exists === 'true',",
|
||||
" workspaceClean: booleanValue(env.workspace_clean),",
|
||||
" branch: nullable(env.branch),",
|
||||
" remoteUrl: nullable(env.remote_url),",
|
||||
" localHead: nullable(env.local_head),",
|
||||
" remoteBranch: env.source_branch,",
|
||||
" remoteBranchExists: env.remote_branch_exists === 'true',",
|
||||
" remoteBranchCommit: nullable(env.remote_branch_commit),",
|
||||
" statusShort: nullable(env.status_short),",
|
||||
" valuesPrinted: false",
|
||||
"}));",
|
||||
"NODE",
|
||||
"python3 - <<'PY'",
|
||||
"import json, os",
|
||||
"def nullable(value):",
|
||||
" return value if value and value != 'null' else None",
|
||||
"def boolean_value(value):",
|
||||
" if value == 'true': return True",
|
||||
" if value == 'false': return False",
|
||||
" return None",
|
||||
"env = os.environ",
|
||||
"print(json.dumps({",
|
||||
" 'ok': env.get('workspace_exists') == 'true',",
|
||||
" 'statusMode': 'host-worktree',",
|
||||
" 'expectedWorkspace': env.get('expected_workspace'),",
|
||||
" 'actualWorkspace': env.get('actual_workspace') or None,",
|
||||
" 'workspaceExists': env.get('workspace_exists') == 'true',",
|
||||
" 'workspaceClean': boolean_value(env.get('workspace_clean')),",
|
||||
" 'branch': nullable(env.get('branch')),",
|
||||
" 'remoteUrl': nullable(env.get('remote_url')),",
|
||||
" 'localHead': nullable(env.get('local_head')),",
|
||||
" 'remoteBranch': env.get('source_branch'),",
|
||||
" 'remoteBranchExists': env.get('remote_branch_exists') == 'true',",
|
||||
" 'remoteBranchCommit': nullable(env.get('remote_branch_commit')),",
|
||||
" 'statusShort': nullable(env.get('status_short')),",
|
||||
" 'valuesPrinted': False,",
|
||||
"}, ensure_ascii=False))",
|
||||
"PY",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function yamlLaneK3sSourceStatusScript(spec: AgentRunLaneSpec): string {
|
||||
return [
|
||||
"set +e",
|
||||
`namespace=${shQuote(spec.gitMirror.namespace)}`,
|
||||
`read_deployment=${shQuote(spec.gitMirror.readDeployment)}`,
|
||||
`repository=${shQuote(spec.source.repository)}`,
|
||||
`source_branch=${shQuote(spec.source.branch)}`,
|
||||
"repo_path=\"/cache/${repository}.git\"",
|
||||
"kubectl -n \"$namespace\" get deploy \"$read_deployment\" >/tmp/agentrun-source-read-deploy.txt 2>/dev/null",
|
||||
"read_exit=$?",
|
||||
"source_commit=''",
|
||||
"if [ \"$read_exit\" -eq 0 ]; then",
|
||||
" source_commit=$(kubectl -n \"$namespace\" exec deploy/\"$read_deployment\" -- sh -lc 'repo_path=\"$1\"; source_branch=\"$2\"; git --git-dir=\"$repo_path\" rev-parse --verify \"refs/heads/$source_branch^{commit}\" 2>/dev/null || true' sh \"$repo_path\" \"$source_branch\" 2>/dev/null | tail -n 1 | tr -d '\\r')",
|
||||
"fi",
|
||||
"export namespace read_deployment repository source_branch read_exit source_commit",
|
||||
"python3 - <<'PY'",
|
||||
"import json, os",
|
||||
"source_commit = os.environ.get('source_commit') or None",
|
||||
"read_ready = os.environ.get('read_exit') == '0'",
|
||||
"print(json.dumps({",
|
||||
" 'ok': read_ready and source_commit is not None,",
|
||||
" 'statusMode': 'k3s-git-mirror',",
|
||||
" 'expectedWorkspace': None,",
|
||||
" 'actualWorkspace': None,",
|
||||
" 'workspaceExists': None,",
|
||||
" 'workspaceClean': None,",
|
||||
" 'branch': os.environ.get('source_branch'),",
|
||||
" 'remoteUrl': None,",
|
||||
" 'localHead': source_commit,",
|
||||
" 'remoteBranch': os.environ.get('source_branch'),",
|
||||
" 'remoteBranchExists': source_commit is not None,",
|
||||
" 'remoteBranchCommit': source_commit,",
|
||||
" 'sourceCommit': source_commit,",
|
||||
" 'gitMirror': {",
|
||||
" 'namespace': os.environ.get('namespace'),",
|
||||
" 'readDeployment': os.environ.get('read_deployment'),",
|
||||
" 'readReady': read_ready,",
|
||||
" 'repository': os.environ.get('repository'),",
|
||||
" },",
|
||||
" 'statusShort': None,",
|
||||
" 'valuesPrinted': False,",
|
||||
"}, ensure_ascii=False))",
|
||||
"PY",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -412,54 +464,70 @@ export function yamlLaneRuntimeStatusScript(spec: AgentRunLaneSpec, pipelineRun:
|
||||
"manager_svc_exit=$?",
|
||||
"kubectl -n \"$runtime_namespace\" get secret \"$database_secret\" -o json > \"$tmp_dir/db-secret.json\" 2>/dev/null",
|
||||
"db_secret_exit=$?",
|
||||
"SECRET_REFS_JSON=\"$secrets_json\" NODE_TMP=\"$tmp_dir\" node <<'NODE' > \"$tmp_dir/secrets.json\"",
|
||||
"const fs = require('node:fs');",
|
||||
"const cp = require('node:child_process');",
|
||||
"const refs = JSON.parse(process.env.SECRET_REFS_JSON || '[]');",
|
||||
"const result = [];",
|
||||
"for (const ref of refs) {",
|
||||
" const out = cp.spawnSync('kubectl', ['-n', ref.namespace, 'get', 'secret', ref.name, '-o', 'json'], { encoding: 'utf8' });",
|
||||
" let keyPresent = false;",
|
||||
" if (out.status === 0) {",
|
||||
" try { keyPresent = Object.prototype.hasOwnProperty.call((JSON.parse(out.stdout).data || {}), ref.key); } catch {}",
|
||||
" }",
|
||||
" result.push({ namespace: ref.namespace, name: ref.name, key: ref.key, present: out.status === 0, keyPresent, valuesPrinted: false });",
|
||||
"}",
|
||||
"console.log(JSON.stringify({ ready: result.every((item) => item.present && item.keyPresent), count: result.length, items: result, valuesPrinted: false }));",
|
||||
"NODE",
|
||||
"SECRET_REFS_JSON=\"$secrets_json\" python3 - <<'PY' > \"$tmp_dir/secrets.json\"",
|
||||
"import json, os, subprocess",
|
||||
"refs = json.loads(os.environ.get('SECRET_REFS_JSON') or '[]')",
|
||||
"result = []",
|
||||
"for ref in refs:",
|
||||
" out = subprocess.run(['kubectl', '-n', ref.get('namespace', ''), 'get', 'secret', ref.get('name', ''), '-o', 'json'], text=True, capture_output=True)",
|
||||
" key_present = False",
|
||||
" if out.returncode == 0:",
|
||||
" try:",
|
||||
" key_present = ref.get('key') in (json.loads(out.stdout).get('data') or {})",
|
||||
" except Exception:",
|
||||
" key_present = False",
|
||||
" result.append({'namespace': ref.get('namespace'), 'name': ref.get('name'), 'key': ref.get('key'), 'present': out.returncode == 0, 'keyPresent': key_present, 'valuesPrinted': False})",
|
||||
"print(json.dumps({'ready': all(item['present'] and item['keyPresent'] for item in result), 'count': len(result), 'items': result, 'valuesPrinted': False}, ensure_ascii=False))",
|
||||
"PY",
|
||||
"kubectl -n \"$runtime_namespace\" get deploy,sts,svc,secret -o name > \"$tmp_dir/runtime-names.txt\" 2>/dev/null",
|
||||
"NODE_TMP=\"$tmp_dir\" RUNTIME_NS_EXIT=\"$runtime_ns_exit\" CI_NS_EXIT=\"$ci_ns_exit\" PIPELINE_EXIT=\"$pipeline_exit\" SERVICE_ACCOUNT_EXIT=\"$sa_exit\" PIPELINERUN_EXIT=\"$pr_exit\" ARGO_EXIT=\"$argo_exit\" MANAGER_DEPLOY_EXIT=\"$manager_deploy_exit\" MANAGER_SVC_EXIT=\"$manager_svc_exit\" DB_SECRET_EXIT=\"$db_secret_exit\" node <<'NODE'",
|
||||
"const fs = require('node:fs');",
|
||||
"const path = require('node:path');",
|
||||
"const dir = process.env.NODE_TMP;",
|
||||
"function readJson(name) { try { return JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')); } catch { return null; } }",
|
||||
"function exists(exitName) { return process.env[exitName] === '0'; }",
|
||||
"function condition(obj) { return obj?.status?.conditions?.[0] || null; }",
|
||||
"function envValue(deploy, name) { return deploy?.spec?.template?.spec?.containers?.[0]?.env?.find((entry) => entry.name === name)?.value || null; }",
|
||||
"function pipelineRunParam(obj, name) { return (obj?.spec?.params || []).find((entry) => entry.name === name)?.value || null; }",
|
||||
"const pipelineRun = readJson('pipelinerun.json');",
|
||||
"const argo = readJson('argo.json');",
|
||||
"const managerDeploy = readJson('manager-deploy.json');",
|
||||
"const managerSvc = readJson('manager-svc.json');",
|
||||
"const dbSecret = readJson('db-secret.json');",
|
||||
"const secrets = readJson('secrets.json') || { ready: true, count: 0, items: [], valuesPrinted: false };",
|
||||
"let names = ''; try { names = fs.readFileSync(path.join(dir, 'runtime-names.txt'), 'utf8'); } catch {}",
|
||||
"const c = condition(pipelineRun);",
|
||||
"console.log(JSON.stringify({",
|
||||
" ok: exists('RUNTIME_NS_EXIT') && exists('CI_NS_EXIT'),",
|
||||
" runtimeNamespaceExists: exists('RUNTIME_NS_EXIT'),",
|
||||
" ciNamespaceExists: exists('CI_NS_EXIT'),",
|
||||
" serviceAccountExists: exists('SERVICE_ACCOUNT_EXIT'),",
|
||||
" pipeline: { exists: exists('PIPELINE_EXIT'), name: process.env.pipeline_name },",
|
||||
" pipelineRun: { exists: exists('PIPELINERUN_EXIT'), name: process.env.pipeline_run || null, status: c?.status || null, reason: c?.reason || null, sourceCommit: pipelineRunParam(pipelineRun, 'revision'), startTime: pipelineRun?.status?.startTime || null, completionTime: pipelineRun?.status?.completionTime || null },",
|
||||
" argo: { exists: exists('ARGO_EXIT'), namespace: process.env.argo_namespace, application: process.env.argo_application, revision: argo?.status?.sync?.revision || null, syncStatus: argo?.status?.sync?.status || null, healthStatus: argo?.status?.health?.status || null },",
|
||||
" manager: { deploymentExists: exists('MANAGER_DEPLOY_EXIT'), serviceExists: exists('MANAGER_SVC_EXIT'), deployment: process.env.manager_deployment, service: process.env.manager_service, image: managerDeploy?.spec?.template?.spec?.containers?.[0]?.image || null, sourceCommit: envValue(managerDeploy, 'AGENTRUN_SOURCE_COMMIT'), servicePorts: Array.isArray(managerSvc?.spec?.ports) ? managerSvc.spec.ports.map((port) => ({ name: port.name || null, port: port.port || null, targetPort: port.targetPort || null })) : [] },",
|
||||
" database: { secretPresent: exists('DB_SECRET_EXIT'), secretName: process.env.database_secret, key: process.env.database_key, keyPresent: Boolean(dbSecret?.data && Object.prototype.hasOwnProperty.call(dbSecret.data, process.env.database_key || '')) , valuesPrinted: false },",
|
||||
" secrets,",
|
||||
" localPostgres: { absent: !/postgres/i.test(names), matchingObjects: names.split(/\\r?\\n/).filter((line) => /postgres/i.test(line)).slice(0, 20) },",
|
||||
" valuesPrinted: false",
|
||||
"}));",
|
||||
"NODE",
|
||||
"NODE_TMP=\"$tmp_dir\" RUNTIME_NS_EXIT=\"$runtime_ns_exit\" CI_NS_EXIT=\"$ci_ns_exit\" PIPELINE_EXIT=\"$pipeline_exit\" SERVICE_ACCOUNT_EXIT=\"$sa_exit\" PIPELINERUN_EXIT=\"$pr_exit\" ARGO_EXIT=\"$argo_exit\" MANAGER_DEPLOY_EXIT=\"$manager_deploy_exit\" MANAGER_SVC_EXIT=\"$manager_svc_exit\" DB_SECRET_EXIT=\"$db_secret_exit\" python3 - <<'PY'",
|
||||
"import json, os, pathlib, re",
|
||||
"base = pathlib.Path(os.environ['NODE_TMP'])",
|
||||
"def read_json(name):",
|
||||
" try: return json.loads((base / name).read_text())",
|
||||
" except Exception: return None",
|
||||
"def exists(name): return os.environ.get(name) == '0'",
|
||||
"def condition(obj):",
|
||||
" conds = (((obj or {}).get('status') or {}).get('conditions') or [])",
|
||||
" return conds[0] if conds else {}",
|
||||
"def env_value(deploy, name):",
|
||||
" containers = (((((deploy or {}).get('spec') or {}).get('template') or {}).get('spec') or {}).get('containers') or [])",
|
||||
" envs = (containers[0].get('env') or []) if containers else []",
|
||||
" for entry in envs:",
|
||||
" if entry.get('name') == name: return entry.get('value')",
|
||||
" return None",
|
||||
"def pipeline_run_param(obj, name):",
|
||||
" for entry in (((obj or {}).get('spec') or {}).get('params') or []):",
|
||||
" if entry.get('name') == name: return entry.get('value')",
|
||||
" return None",
|
||||
"pipeline_run = read_json('pipelinerun.json')",
|
||||
"argo = read_json('argo.json')",
|
||||
"manager_deploy = read_json('manager-deploy.json')",
|
||||
"manager_svc = read_json('manager-svc.json')",
|
||||
"db_secret = read_json('db-secret.json')",
|
||||
"secrets = read_json('secrets.json') or {'ready': True, 'count': 0, 'items': [], 'valuesPrinted': False}",
|
||||
"try: names = (base / 'runtime-names.txt').read_text()",
|
||||
"except Exception: names = ''",
|
||||
"c = condition(pipeline_run)",
|
||||
"ports = []",
|
||||
"for port in ((((manager_svc or {}).get('spec') or {}).get('ports')) or []):",
|
||||
" ports.append({'name': port.get('name'), 'port': port.get('port'), 'targetPort': port.get('targetPort')})",
|
||||
"matching_postgres = [line for line in re.split(r'\\r?\\n', names) if re.search('postgres', line, re.I)][:20]",
|
||||
"print(json.dumps({",
|
||||
" 'ok': exists('RUNTIME_NS_EXIT') and exists('CI_NS_EXIT'),",
|
||||
" 'runtimeNamespaceExists': exists('RUNTIME_NS_EXIT'),",
|
||||
" 'ciNamespaceExists': exists('CI_NS_EXIT'),",
|
||||
" 'serviceAccountExists': exists('SERVICE_ACCOUNT_EXIT'),",
|
||||
" 'pipeline': {'exists': exists('PIPELINE_EXIT'), 'name': os.environ.get('pipeline_name')},",
|
||||
" 'pipelineRun': {'exists': exists('PIPELINERUN_EXIT'), 'name': os.environ.get('pipeline_run') or None, 'status': c.get('status'), 'reason': c.get('reason'), 'sourceCommit': pipeline_run_param(pipeline_run, 'revision'), 'startTime': ((pipeline_run or {}).get('status') or {}).get('startTime'), 'completionTime': ((pipeline_run or {}).get('status') or {}).get('completionTime')},",
|
||||
" 'argo': {'exists': exists('ARGO_EXIT'), 'namespace': os.environ.get('argo_namespace'), 'application': os.environ.get('argo_application'), 'revision': (((argo or {}).get('status') or {}).get('sync') or {}).get('revision'), 'syncStatus': (((argo or {}).get('status') or {}).get('sync') or {}).get('status'), 'healthStatus': (((argo or {}).get('status') or {}).get('health') or {}).get('status')},",
|
||||
" 'manager': {'deploymentExists': exists('MANAGER_DEPLOY_EXIT'), 'serviceExists': exists('MANAGER_SVC_EXIT'), 'deployment': os.environ.get('manager_deployment'), 'service': os.environ.get('manager_service'), 'image': ((((((manager_deploy or {}).get('spec') or {}).get('template') or {}).get('spec') or {}).get('containers') or [{}])[0]).get('image'), 'sourceCommit': env_value(manager_deploy, 'AGENTRUN_SOURCE_COMMIT'), 'servicePorts': ports},",
|
||||
" 'database': {'secretPresent': exists('DB_SECRET_EXIT'), 'secretName': os.environ.get('database_secret'), 'key': os.environ.get('database_key'), 'keyPresent': os.environ.get('database_key') in (((db_secret or {}).get('data') or {})), 'valuesPrinted': False},",
|
||||
" 'secrets': secrets,",
|
||||
" 'localPostgres': {'absent': len(matching_postgres) == 0, 'matchingObjects': matching_postgres},",
|
||||
" 'valuesPrinted': False,",
|
||||
"}, ensure_ascii=False))",
|
||||
"PY",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -930,6 +998,234 @@ export function yamlLaneBuildImageStatusScript(spec: AgentRunLaneSpec, jobId: st
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function yamlLaneK3sBuildImageJobManifest(spec: AgentRunLaneSpec, sourceCommit: string, jobName: string): Record<string, unknown> {
|
||||
const build = spec.deployment.manager.imageBuild;
|
||||
if (spec.ci.buildkitImage === null) throw new Error(`config/agentrun.yaml controlPlane.lanes.${spec.lane}.ci.buildkitImage is required when source.statusMode=k3s-git-mirror`);
|
||||
const imageRepository = `${spec.ci.registryPrefix}/${build.repository}`;
|
||||
const envIdentity = `source-${sourceCommit.slice(0, 12)}`;
|
||||
const image = `${imageRepository}:${envIdentity}`;
|
||||
const proxyEnv = yamlLaneK3sBuildProxyEnv(spec);
|
||||
return {
|
||||
apiVersion: "batch/v1",
|
||||
kind: "Job",
|
||||
metadata: {
|
||||
name: jobName,
|
||||
namespace: spec.ci.namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/name": "agentrun-manager-image-build",
|
||||
"app.kubernetes.io/part-of": "agentrun",
|
||||
"agentrun.pikastech.local/lane": spec.version,
|
||||
"agentrun.pikastech.local/node": spec.nodeId,
|
||||
"agentrun.pikastech.local/source-commit": sourceCommit,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
backoffLimit: 0,
|
||||
activeDeadlineSeconds: Math.max(60, build.timeoutSeconds),
|
||||
ttlSecondsAfterFinished: 3600,
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
"app.kubernetes.io/name": "agentrun-manager-image-build",
|
||||
"app.kubernetes.io/part-of": "agentrun",
|
||||
"agentrun.pikastech.local/lane": spec.version,
|
||||
"agentrun.pikastech.local/node": spec.nodeId,
|
||||
"agentrun.pikastech.local/source-commit": sourceCommit,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
restartPolicy: "Never",
|
||||
serviceAccountName: spec.ci.serviceAccountName,
|
||||
hostNetwork: true,
|
||||
dnsPolicy: "ClusterFirstWithHostNet",
|
||||
securityContext: { fsGroup: 1000 },
|
||||
volumes: [
|
||||
{ name: "workspace", emptyDir: { sizeLimit: "8Gi" } },
|
||||
{ name: "buildkit-state", emptyDir: { sizeLimit: "8Gi" } },
|
||||
{ name: "tmp", emptyDir: {} },
|
||||
],
|
||||
initContainers: [{
|
||||
name: "source",
|
||||
image: spec.ci.toolsImage,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
env: proxyEnv,
|
||||
command: ["/bin/sh", "-ec", yamlLaneK3sBuildSourceShell(spec, sourceCommit)],
|
||||
volumeMounts: [{ name: "workspace", mountPath: "/workspace" }],
|
||||
}],
|
||||
containers: [{
|
||||
name: "buildkit",
|
||||
image: spec.ci.buildkitImage,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
env: [
|
||||
...proxyEnv,
|
||||
{ name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" },
|
||||
],
|
||||
command: ["/bin/sh", "-ec", yamlLaneK3sBuildImageShell(spec, sourceCommit, image, envIdentity)],
|
||||
securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 },
|
||||
volumeMounts: [
|
||||
{ name: "workspace", mountPath: "/workspace" },
|
||||
{ name: "buildkit-state", mountPath: "/home/user/.local/share/buildkit" },
|
||||
{ name: "tmp", mountPath: "/tmp" },
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function runYamlLaneK3sBuildImageJob(config: UniDeskConfig, spec: AgentRunLaneSpec, sourceCommit: string): Promise<Record<string, unknown> & { ok: boolean; payload: Record<string, unknown> }> {
|
||||
const jobName = `agentrun-build-${spec.nodeId.toLowerCase()}-${spec.lane}-${sourceCommit.slice(0, 12)}`.slice(0, 63);
|
||||
const manifest = yamlLaneK3sBuildImageJobManifest(spec, sourceCommit, jobName);
|
||||
const created = await capture(config, spec.nodeKubeRoute, ["sh", "--", createYamlLaneJobScript(spec.ci.namespace, jobName, manifest)]);
|
||||
const createPayload = captureJsonPayload(created);
|
||||
if (created.exitCode !== 0 || createPayload.ok === false) {
|
||||
return {
|
||||
ok: false,
|
||||
payload: { ok: false, status: "create-failed", jobName, degradedReason: "k3s-buildkit-job-create-failed", valuesPrinted: false },
|
||||
create: createPayload,
|
||||
capture: compactCapture(created, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
const startedAt = Date.now();
|
||||
const timeoutMs = Math.max(60, spec.deployment.manager.imageBuild.timeoutSeconds) * 1000;
|
||||
const pollMs = Math.max(1, spec.deployment.manager.imageBuild.pollSeconds) * 1000;
|
||||
let polls = 0;
|
||||
let lastPayload: Record<string, unknown> = {};
|
||||
let lastProbe: SshCaptureResult | null = null;
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
polls += 1;
|
||||
lastProbe = await capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneJobProbeScript(spec.ci.namespace, jobName)]);
|
||||
const probePayload = captureJsonPayload(lastProbe);
|
||||
const buildPayload = yamlLaneGitopsPublishPayloadFromProbe(probePayload);
|
||||
if (Object.keys(buildPayload).length > 0) lastPayload = buildPayload;
|
||||
progressEvent("agentrun.yaml-lane.k3s-image-build.progress", {
|
||||
node: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
sourceCommit,
|
||||
jobName,
|
||||
polls,
|
||||
status: stringOrNull(buildPayload.status) ?? (probePayload.succeeded === true ? "succeeded" : probePayload.failed === true ? "failed" : "running"),
|
||||
digest: stringOrNull(buildPayload.digest),
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
});
|
||||
if (probePayload.succeeded === true) {
|
||||
if (buildPayload.ok === true && stringOrNull(buildPayload.digest) !== null && stringOrNull(buildPayload.envIdentity) !== null) {
|
||||
return { ok: true, payload: buildPayload, jobName, create: createPayload, polls, elapsedMs: Date.now() - startedAt, probe: compactCapture(lastProbe), valuesPrinted: false };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
payload: { ...buildPayload, ok: false, status: stringOrNull(buildPayload.status) ?? "succeeded-without-result", degradedReason: "k3s-buildkit-result-missing", jobName, valuesPrinted: false },
|
||||
jobName,
|
||||
create: createPayload,
|
||||
polls,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
probe: compactCapture(lastProbe, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
if (probePayload.failed === true) {
|
||||
const payload = Object.keys(buildPayload).length > 0 ? buildPayload : { ok: false, status: "failed", degradedReason: "k3s-buildkit-job-failed", jobName, valuesPrinted: false };
|
||||
return {
|
||||
ok: false,
|
||||
payload,
|
||||
jobName,
|
||||
create: createPayload,
|
||||
polls,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
probe: compactCapture(lastProbe, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
await sleep(pollMs);
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
payload: { ...lastPayload, ok: false, status: "timeout", degradedReason: "k3s-buildkit-job-timeout", jobName, valuesPrinted: false },
|
||||
jobName,
|
||||
create: createPayload,
|
||||
polls,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
probe: lastProbe === null ? null : compactCapture(lastProbe, { full: true, stdoutTailChars: 5000, stderrTailChars: 5000 }),
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function yamlLaneK3sBuildProxyEnv(spec: AgentRunLaneSpec): Array<{ name: string; value: string }> {
|
||||
const build = spec.deployment.manager.imageBuild;
|
||||
const result: Array<{ name: string; value: string }> = [];
|
||||
const noProxy = build.noProxy.join(",");
|
||||
const allProxy = build.httpsProxy ?? build.httpProxy;
|
||||
if (build.httpProxy !== null) result.push({ name: "HTTP_PROXY", value: build.httpProxy }, { name: "http_proxy", value: build.httpProxy });
|
||||
if (build.httpsProxy !== null) result.push({ name: "HTTPS_PROXY", value: build.httpsProxy }, { name: "https_proxy", value: build.httpsProxy });
|
||||
if (allProxy !== null) result.push({ name: "ALL_PROXY", value: allProxy }, { name: "all_proxy", value: allProxy });
|
||||
result.push({ name: "NO_PROXY", value: noProxy }, { name: "no_proxy", value: noProxy });
|
||||
return result;
|
||||
}
|
||||
|
||||
function yamlLaneK3sBuildSourceShell(spec: AgentRunLaneSpec, sourceCommit: string): string {
|
||||
return [
|
||||
"set -eu",
|
||||
`read_url=${shQuote(spec.gitMirror.readUrl)}`,
|
||||
`source_branch=${shQuote(spec.source.branch)}`,
|
||||
`source_commit=${shQuote(sourceCommit)}`,
|
||||
"rm -rf /workspace/repo",
|
||||
"git clone --no-checkout \"$read_url\" /workspace/repo",
|
||||
"cd /workspace/repo",
|
||||
"git fetch origin \"refs/heads/$source_branch:refs/remotes/origin/$source_branch\"",
|
||||
"git checkout --detach \"$source_commit\"",
|
||||
"actual=$(git rev-parse HEAD)",
|
||||
"test \"$actual\" = \"$source_commit\"",
|
||||
"chmod -R a+rwX /workspace",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function yamlLaneK3sBuildImageShell(spec: AgentRunLaneSpec, sourceCommit: string, image: string, envIdentity: string): string {
|
||||
const build = spec.deployment.manager.imageBuild;
|
||||
const contextPath = build.context === "." ? "/workspace/repo" : `/workspace/repo/${build.context.replace(/^\.\//u, "")}`;
|
||||
const buildContainerNoProxy = build.buildContainerProxy.noProxy.join(",");
|
||||
const buildArgs = [
|
||||
...Object.entries(build.buildArgs).sort(([left], [right]) => left.localeCompare(right)).map(([key, value]) => `build-arg:${key}=${value}`),
|
||||
...yamlLaneK3sBuildProxyBuildArgs(build.buildContainerProxy.httpProxy, build.buildContainerProxy.httpsProxy, buildContainerNoProxy),
|
||||
];
|
||||
const buildctlArgs = [
|
||||
"build",
|
||||
"--allow", "network.host",
|
||||
"--frontend", "dockerfile.v0",
|
||||
"--local", `context=${contextPath}`,
|
||||
"--local", "dockerfile=/workspace/repo",
|
||||
"--opt", `filename=${build.containerfile}`,
|
||||
"--opt", `network=${build.network}`,
|
||||
...buildArgs.flatMap((arg) => ["--opt", arg]),
|
||||
"--metadata-file", "/workspace/build-metadata.json",
|
||||
"--output", `type=image,name=${image},push=true,registry.insecure=true`,
|
||||
];
|
||||
const repositoryDigestBase = image.slice(0, image.lastIndexOf(":"));
|
||||
return [
|
||||
"set -eu",
|
||||
"cd /workspace/repo",
|
||||
`buildctl-daemonless.sh ${buildctlArgs.map((arg) => shQuote(arg)).join(" ")}`,
|
||||
"metadata_compact=$(tr -d '\\n' < /workspace/build-metadata.json)",
|
||||
"digest=$(printf '%s' \"$metadata_compact\" | sed -n 's/.*\"containerimage.digest\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' | head -n 1)",
|
||||
"if [ -z \"$digest\" ]; then",
|
||||
` printf '{"ok":false,"status":"failed","failureKind":"image-digest-missing","sourceCommit":"%s","envIdentity":"%s","image":"%s","valuesPrinted":false}\\n' ${shQuote(sourceCommit)} ${shQuote(envIdentity)} ${shQuote(image)}`,
|
||||
" exit 1",
|
||||
"fi",
|
||||
`printf '{"ok":true,"status":"built","sourceCommit":"%s","envIdentity":"%s","image":"%s","digest":"%s","repositoryDigest":"%s@%s","valuesPrinted":false}\\n' ${shQuote(sourceCommit)} ${shQuote(envIdentity)} ${shQuote(image)} "$digest" ${shQuote(repositoryDigestBase)} "$digest"`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function yamlLaneK3sBuildProxyBuildArgs(httpProxy: string | null, httpsProxy: string | null, noProxy: string): string[] {
|
||||
const result: string[] = [];
|
||||
const allProxy = httpsProxy ?? httpProxy;
|
||||
if (httpProxy !== null) result.push(`build-arg:HTTP_PROXY=${httpProxy}`, `build-arg:http_proxy=${httpProxy}`);
|
||||
if (httpsProxy !== null) result.push(`build-arg:HTTPS_PROXY=${httpsProxy}`, `build-arg:https_proxy=${httpsProxy}`);
|
||||
if (allProxy !== null) result.push(`build-arg:ALL_PROXY=${allProxy}`, `build-arg:all_proxy=${allProxy}`);
|
||||
if (noProxy.length > 0) result.push(`build-arg:NO_PROXY=${noProxy}`, `build-arg:no_proxy=${noProxy}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runYamlLaneGitopsPublishJob(config: UniDeskConfig, spec: AgentRunLaneSpec, sourceCommit: string, files: readonly { path: string; content: string }[]): Promise<Record<string, unknown> & { ok: boolean; payload: Record<string, unknown> }> {
|
||||
const jobName = `gitops-publish-${spec.nodeId.toLowerCase()}-${spec.lane}-${Date.now().toString(36)}`.slice(0, 63);
|
||||
const manifest = yamlLaneGitopsPublishJobManifest(spec, files, jobName);
|
||||
|
||||
Reference in New Issue
Block a user