2794 lines
124 KiB
JavaScript
2794 lines
124 KiB
JavaScript
#!/usr/bin/env node
|
|
import assert from "node:assert/strict";
|
|
import { spawn } from "node:child_process";
|
|
import {
|
|
mkdir,
|
|
mkdtemp,
|
|
readFile,
|
|
rm,
|
|
writeFile
|
|
} from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs";
|
|
import { ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";
|
|
import { createCiPlan } from "./src/ci-plan-lib.mjs";
|
|
import {
|
|
BUILDABLE_IMPLEMENTATION_STATES,
|
|
resolveDevArtifactServices,
|
|
serviceInventoryFromServices
|
|
} from "./src/dev-artifact-services.mjs";
|
|
import { probeRegistryCapabilities } from "./src/registry-capabilities.mjs";
|
|
import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs";
|
|
import { readStructuredFile } from "./src/structured-config.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
const cliEntrypoint = process.env.HWLAB_ARTIFACT_PUBLISH_ENTRYPOINT || "scripts/artifact-publish.mjs";
|
|
const defaultLane = process.env.HWLAB_ARTIFACT_LANE || process.env.HWLAB_GITOPS_LANE || "node";
|
|
const defaultRegistryPrefix =
|
|
process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab";
|
|
const defaultBaseImage = process.env.HWLAB_DEV_BASE_IMAGE || null;
|
|
const defaultReportPath = tempReportPath("dev-artifacts.json");
|
|
const buildBackend = "buildkit";
|
|
const v02EnvReuseRuntimeMode = "env-reuse-gitea-checkout";
|
|
const defaultBuildkitCommand = process.env.HWLAB_BUILDKIT_BUILDCTL || "buildctl-daemonless.sh";
|
|
const defaultBuildkitAddr = process.env.HWLAB_BUILDKIT_ADDR || null;
|
|
const defaultBuildCacheMode = process.env.HWLAB_BUILDKIT_CACHE_MODE || "registry";
|
|
const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json";
|
|
const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.yaml";
|
|
|
|
const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]);
|
|
const shaDigestPattern = /^sha256:[a-f0-9]{64}$/u;
|
|
const fatalBlockerTypes = new Set([
|
|
"contract_blocker",
|
|
"environment_blocker",
|
|
"network_blocker",
|
|
"safety_blocker"
|
|
]);
|
|
const buildableImplementationStates = new Set(BUILDABLE_IMPLEMENTATION_STATES);
|
|
const sourceStates = new Set([
|
|
"source-present",
|
|
"intentionally-disabled"
|
|
]);
|
|
const runtimeArtifactLanePattern = /^[a-z][a-z0-9-]*$/u;
|
|
|
|
const ciArtifactIdentityEnvNames = [
|
|
"HWLAB_CI_ARTIFACT_RUN_ID",
|
|
"GITHUB_RUN_ID",
|
|
"CI_PIPELINE_ID",
|
|
"BUILDKITE_BUILD_ID"
|
|
];
|
|
|
|
const servicePorts = new Map([
|
|
["hwlab-cloud-api", 6667],
|
|
["hwlab-workbench-runtime", 6671],
|
|
["hwlab-user-billing", 6670],
|
|
["hwlab-cloud-web", 8080],
|
|
["hwlab-gateway", 7001],
|
|
["hwlab-edge-proxy", 6667],
|
|
["hwlab-agent-skills", 7430]
|
|
]);
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
mode: "preflight",
|
|
lane: defaultLane,
|
|
catalogPath: defaultCatalogPath,
|
|
deployPath: defaultDeployPath,
|
|
imageTagMode: process.env.HWLAB_ARTIFACT_IMAGE_TAG_MODE || "short",
|
|
registryPrefix: defaultRegistryPrefix,
|
|
baseImage: defaultBaseImage,
|
|
reportPath: defaultReportPath,
|
|
services: [...SERVICE_IDS],
|
|
servicesExplicit: false,
|
|
affectedOnly: true,
|
|
buildBackend,
|
|
buildkitCommand: defaultBuildkitCommand,
|
|
buildkitAddr: defaultBuildkitAddr,
|
|
buildCacheMode: defaultBuildCacheMode,
|
|
externalServiceReportDir: null,
|
|
externalServiceResultsEnv: false,
|
|
ciPlanPath: null,
|
|
tektonResultsDir: null,
|
|
emitReport: true,
|
|
quietBuild: false,
|
|
concurrency: parseConcurrency(process.env.HWLAB_DEV_ARTIFACT_CONCURRENCY, 4)
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--preflight") args.mode = "preflight";
|
|
else if (arg === "--build") args.mode = "build";
|
|
else if (arg === "--publish") args.mode = "publish";
|
|
else if (arg === "--lane") args.lane = readOption(argv, ++index, arg);
|
|
else if (arg === "--catalog-path") args.catalogPath = readOption(argv, ++index, arg);
|
|
else if (arg === "--deploy-config") args.deployPath = readOption(argv, ++index, arg);
|
|
else if (arg === "--image-tag-mode") args.imageTagMode = readOption(argv, ++index, arg);
|
|
else if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg);
|
|
else if (arg === "--base-image") args.baseImage = readOption(argv, ++index, arg);
|
|
else if (arg === "--report") args.reportPath = readOption(argv, ++index, arg);
|
|
else if (arg === "--services") {
|
|
args.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean);
|
|
args.servicesExplicit = true;
|
|
}
|
|
else if (arg === "--affected-only") args.affectedOnly = true;
|
|
else if (arg === "--buildkit-command") args.buildkitCommand = readOption(argv, ++index, arg);
|
|
else if (arg === "--buildkit-addr") args.buildkitAddr = readOption(argv, ++index, arg);
|
|
else if (arg === "--build-cache-mode") args.buildCacheMode = readOption(argv, ++index, arg);
|
|
else if (arg === "--external-service-report-dir") args.externalServiceReportDir = readOption(argv, ++index, arg);
|
|
else if (arg === "--external-service-results-env") args.externalServiceResultsEnv = true;
|
|
else if (arg === "--ci-plan-path") args.ciPlanPath = readOption(argv, ++index, arg);
|
|
else if (arg === "--tekton-results-dir") args.tektonResultsDir = readOption(argv, ++index, arg);
|
|
else if (arg === "--no-report") args.emitReport = false;
|
|
else if (arg === "--quiet-build") args.quietBuild = true;
|
|
else if (arg === "--concurrency") args.concurrency = parseConcurrency(readOption(argv, ++index, arg), 4);
|
|
else if (arg === "--help" || arg === "-h") args.help = true;
|
|
else throw new Error(`unknown argument ${arg}`);
|
|
}
|
|
|
|
assert.ok(args.lane === "node" || isRuntimeArtifactLane(args.lane), `unknown artifact lane ${args.lane}`);
|
|
assert.ok(["short", "full"].includes(args.imageTagMode), `unknown image tag mode ${args.imageTagMode}`);
|
|
assert.ok(["registry", "disabled"].includes(args.buildCacheMode), `unknown build cache mode ${args.buildCacheMode}`);
|
|
|
|
return args;
|
|
}
|
|
|
|
function readOption(argv, index, name) {
|
|
const value = argv[index];
|
|
if (!value || value.startsWith("--")) {
|
|
throw new Error(`${name} requires a value`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function parseConcurrency(value, fallback) {
|
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
if (!Number.isInteger(parsed)) return fallback;
|
|
return Math.min(Math.max(parsed, 1), 8);
|
|
}
|
|
|
|
function artifactIdentityValue(env, name) {
|
|
const value = env?.[name];
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function artifactIdentityName(env = process.env) {
|
|
return ciArtifactIdentityEnvNames.find((name) => artifactIdentityValue(env, name));
|
|
}
|
|
|
|
function artifactProducer(env = process.env) {
|
|
const idName = artifactIdentityName(env);
|
|
if (!idName) {
|
|
return {
|
|
kind: "missing",
|
|
runId: null,
|
|
owner: null
|
|
};
|
|
}
|
|
return {
|
|
kind: "node-ci-artifact",
|
|
runId: artifactIdentityValue(env, idName),
|
|
owner: artifactIdentityValue(env, "HWLAB_CI_ARTIFACT_RUN_OWNER") ?? idName
|
|
};
|
|
}
|
|
|
|
function artifactPublishIdentityGuardFailure({ script, mode }) {
|
|
return {
|
|
ok: false,
|
|
status: "blocked",
|
|
error: "artifact-publish-identity-required",
|
|
code: "artifact-publish-identity-required",
|
|
script,
|
|
mode,
|
|
requiredEnv: "HWLAB_CI_ARTIFACT_RUN_ID or standard CI run identity",
|
|
acceptedEnv: ciArtifactIdentityEnvNames,
|
|
entrypoint: `HWLAB_CI_ARTIFACT_RUN_ID=<run-id> HWLAB_CI_ARTIFACT_RUN_OWNER=<owner> node scripts/artifact-publish.mjs --publish --report ${tempReportPath("dev-artifacts.json")}`,
|
|
summary: `${script} ${mode} is a node artifact publish side-effect step and must run under CI artifact identity. Legacy D601 CD transaction identity is not accepted.`,
|
|
devOnly: true,
|
|
prodTouched: false,
|
|
cdLockRequired: false,
|
|
rolloutAttempted: false,
|
|
mutationAttempted: false
|
|
};
|
|
}
|
|
|
|
function requireArtifactPublishIdentity({ env = process.env, script, mode }) {
|
|
return artifactIdentityName(env) ? null : artifactPublishIdentityGuardFailure({ script, mode });
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log([
|
|
`usage: node ${cliEntrypoint} [--preflight|--build|--publish]`,
|
|
"",
|
|
"Commit-pinned artifact build/publish helper for controlled CI execution.",
|
|
"",
|
|
"options:",
|
|
" --lane LANE node or a lane declared in deploy.lanes; default: node",
|
|
` --catalog-path PATH default: ${defaultCatalogPath}`,
|
|
` --deploy-config PATH default: ${defaultDeployPath}`,
|
|
" --image-tag-mode MODE short or full; v02 uses full source commit IDs",
|
|
" --registry-prefix PREFIX default: 127.0.0.1:5000/hwlab",
|
|
" --base-image IMAGE override HWLAB_DEV_BASE_IMAGE for this run; must already be local",
|
|
" --services LIST comma-separated service IDs; runtime lane default comes from deploy.lanes.<lane>.envReuseServices",
|
|
" --affected-only default and only supported scope: build/publish affected services and reuse unchanged catalog digests",
|
|
" --buildkit-command PATH buildctl-daemonless.sh/buildctl command for buildkit backend",
|
|
" --buildkit-addr ADDR optional buildctl --addr endpoint for a sidecar BuildKit daemon",
|
|
" --build-cache-mode MODE registry or disabled; default: registry",
|
|
" --external-service-report-dir DIR collect affected service artifacts from per-service publish reports",
|
|
" --external-service-results-env collect service artifacts from HWLAB_SERVICE_RESULT_* env vars",
|
|
" --ci-plan-path PATH reuse plan-artifacts JSON to scope external service reports",
|
|
" --tekton-results-dir DIR write single-service build result files for Tekton results",
|
|
` --report PATH default: ${defaultReportPath}`,
|
|
" --no-report print JSON without updating the temporary artifact JSON",
|
|
" --quiet-build keep BuildKit progress quiet; default keeps build output visible",
|
|
" --concurrency N parallel service build/push workers; default: 4, max: 8"
|
|
].join("\n"));
|
|
}
|
|
|
|
async function readConfig(relativePath) {
|
|
return readStructuredFile(repoRoot, relativePath);
|
|
}
|
|
|
|
async function readOptionalCiPlan(pathValue) {
|
|
const text = typeof pathValue === "string" ? pathValue.trim() : "";
|
|
if (!text) return null;
|
|
const fullPath = path.isAbsolute(text) ? text : path.resolve(repoRoot, text);
|
|
return JSON.parse(await readFile(fullPath, "utf8"));
|
|
}
|
|
|
|
async function readConfigIfPresent(relativePath, fallback = null) {
|
|
try {
|
|
return await readConfig(relativePath);
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return fallback;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function emit(event, payload = {}) {
|
|
process.stderr.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...payload })}\n`);
|
|
}
|
|
|
|
function ciTimingEnabled(env = process.env) {
|
|
return env.HWLAB_NODE_CICD_TIMING === "1" || env.HWLAB_CI_TIMING === "1";
|
|
}
|
|
|
|
function emitCiTiming(payload) {
|
|
emit("node-cicd-timing", {
|
|
schemaVersion: "v1",
|
|
source: "scripts/artifact-publish.mjs",
|
|
pipelineRun: process.env.HWLAB_TEKTON_PIPELINERUN ?? null,
|
|
taskRun: process.env.HWLAB_TEKTON_TASKRUN ?? null,
|
|
task: process.env.HWLAB_TEKTON_TASK ?? null,
|
|
revision: process.env.HWLAB_SOURCE_REVISION ?? null,
|
|
...payload
|
|
});
|
|
}
|
|
|
|
function parseDurationMs(value) {
|
|
const text = String(value ?? "").trim();
|
|
if (!text) return null;
|
|
let total = 0;
|
|
let matched = false;
|
|
for (const match of text.matchAll(/(\d+(?:\.\d+)?)(ms|s|m|h)/gu)) {
|
|
matched = true;
|
|
const amount = Number(match[1]);
|
|
const unit = match[2];
|
|
if (unit === "ms") total += amount;
|
|
else if (unit === "s") total += amount * 1000;
|
|
else if (unit === "m") total += amount * 60 * 1000;
|
|
else if (unit === "h") total += amount * 60 * 60 * 1000;
|
|
}
|
|
return matched ? Math.round(total) : null;
|
|
}
|
|
|
|
function parseBuildkitPlainTimings(text) {
|
|
const idToStage = new Map();
|
|
const timings = new Map();
|
|
const stageMatchers = [
|
|
{ stage: "buildkit-context-transfer", pattern: /load build context|transferring context/iu },
|
|
{ stage: "cache-import", pattern: /importing cache manifest/iu },
|
|
{ stage: "cache-export", pattern: /exporting cache/iu }
|
|
];
|
|
for (const rawLine of String(text ?? "").split("\n")) {
|
|
const line = rawLine.replace(/\u001b\[[0-9;]*m/gu, "").trim();
|
|
const match = line.match(/^#(\d+)\s+(.*)$/u);
|
|
if (!match) continue;
|
|
const [, id, body] = match;
|
|
const identified = stageMatchers.find((item) => item.pattern.test(body));
|
|
if (identified) idToStage.set(id, identified.stage);
|
|
const stage = idToStage.get(id);
|
|
if (!stage || timings.has(stage)) continue;
|
|
const durationMatch = body.match(/(?:DONE|done)\s+([0-9.]+(?:ms|s|m|h)(?:[0-9.]+(?:ms|s|m|h))*)/iu) ??
|
|
body.match(/([0-9.]+(?:ms|s|m|h)(?:[0-9.]+(?:ms|s|m|h))*)\s+done/iu);
|
|
const durationMs = parseDurationMs(durationMatch?.[1]);
|
|
if (durationMs !== null) timings.set(stage, { stage, status: "succeeded", durationMs, source: "buildkit-plain-progress" });
|
|
}
|
|
return timings;
|
|
}
|
|
|
|
function parseDockerfileStepTimings(text) {
|
|
const timings = [];
|
|
for (const rawLine of String(text ?? "").split("\n")) {
|
|
const line = rawLine.replace(/\u001b\[[0-9;]*m/gu, "");
|
|
const start = line.indexOf("{");
|
|
const end = line.lastIndexOf("}");
|
|
if (start < 0 || end <= start) continue;
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(line.slice(start, end + 1));
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (parsed?.event !== "node-cicd-build-step-timing") continue;
|
|
timings.push({
|
|
stage: parsed.stage,
|
|
status: parsed.status ?? "unknown",
|
|
durationMs: Number.isFinite(parsed.durationMs) ? parsed.durationMs : null,
|
|
source: parsed.source ?? "dockerfile-run"
|
|
});
|
|
}
|
|
return timings;
|
|
}
|
|
|
|
function emitBuildkitTimingEvents({ service, result, timingEnabled }) {
|
|
if (!timingEnabled) return;
|
|
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
const buildkitTimings = parseBuildkitPlainTimings(output);
|
|
for (const stage of ["buildkit-context-transfer", "cache-import", "cache-export"]) {
|
|
const timing = buildkitTimings.get(stage) ?? { stage, status: "not-observed", durationMs: null, source: "buildkit-plain-progress" };
|
|
emitCiTiming({
|
|
...timing,
|
|
serviceId: service.serviceId,
|
|
buildkitDurationMs: result.durationMs
|
|
});
|
|
}
|
|
for (const timing of parseDockerfileStepTimings(output)) {
|
|
emitCiTiming({
|
|
...timing,
|
|
serviceId: service.serviceId,
|
|
buildkitDurationMs: result.durationMs
|
|
});
|
|
}
|
|
}
|
|
|
|
function blocker({ type, scope, summary, next }) {
|
|
return {
|
|
type,
|
|
scope,
|
|
status: "open",
|
|
summary,
|
|
next
|
|
};
|
|
}
|
|
|
|
function buildkitBaseImagePreflight(baseImage) {
|
|
const requestedImage = baseImage || defaultBaseImage || "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim";
|
|
return {
|
|
preflight: "hwlab-dev-base-image",
|
|
environment: ENVIRONMENT_DEV,
|
|
status: "pass",
|
|
publishUsable: true,
|
|
artifactPublish: {
|
|
issue: "#35",
|
|
usable: true,
|
|
reason: "BuildKit pulls the approved builder base image from the registry during k8s-native publish."
|
|
},
|
|
imageSource: "buildkit-registry-pull",
|
|
requestedImage,
|
|
localTag: requestedImage,
|
|
imageId: null,
|
|
repoDigests: [],
|
|
candidates: [],
|
|
rejectedCandidates: [],
|
|
blockers: [],
|
|
nextSteps: [],
|
|
recommendation: {
|
|
source: "node k8s registry image used by rootless BuildKit.",
|
|
envVar: "HWLAB_DEV_BASE_IMAGE",
|
|
recommendedEnvValue: requestedImage
|
|
},
|
|
provision: {
|
|
required: false,
|
|
exactBlocker: null,
|
|
recommendedEnv: {
|
|
name: "HWLAB_DEV_BASE_IMAGE",
|
|
value: requestedImage,
|
|
assignment: `HWLAB_DEV_BASE_IMAGE=${requestedImage}`
|
|
},
|
|
commands: {}
|
|
},
|
|
blockedReport: null,
|
|
containerEngine: "buildkit-rootless"
|
|
};
|
|
}
|
|
|
|
function summarizeBaseImagePreflight(result) {
|
|
return {
|
|
status: result.status,
|
|
imageSource: result.imageSource,
|
|
requestedImage: result.requestedImage,
|
|
localTag: result.localTag,
|
|
imageId: result.imageId,
|
|
repoDigests: result.repoDigests,
|
|
candidates: result.candidates,
|
|
rejectedCandidates: result.rejectedCandidates,
|
|
publishUsable: result.publishUsable,
|
|
blockers: result.blockers,
|
|
nextSteps: result.nextSteps,
|
|
recommendation: result.recommendation,
|
|
provision: result.provision,
|
|
blockedReport: result.blockedReport,
|
|
containerEngine: result.containerEngine
|
|
};
|
|
}
|
|
|
|
function summarizeRegistryCapabilities(result) {
|
|
return {
|
|
version: result.version,
|
|
registryPrefix: result.registryPrefix,
|
|
generatedAt: result.generatedAt,
|
|
classification: result.classification,
|
|
dimensions: result.dimensions,
|
|
interpretation: result.interpretation
|
|
};
|
|
}
|
|
|
|
function baseImagePreflightBlocker(result) {
|
|
return blocker({
|
|
type: "environment_blocker",
|
|
scope: "base-image",
|
|
summary: result.blockers.length
|
|
? result.blockers.join("; ")
|
|
: "DEV builder base image preflight did not return ready.",
|
|
next: result.nextSteps.length
|
|
? result.nextSteps.join(" ")
|
|
: "Provide an approved local Node 20 or HWLAB DEV builder base image before artifact publish."
|
|
});
|
|
}
|
|
|
|
function commandLine(command, args) {
|
|
return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" ");
|
|
}
|
|
|
|
function shellQuote(value) {
|
|
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
}
|
|
|
|
function dockerfileQuote(value) {
|
|
return JSON.stringify(String(value));
|
|
}
|
|
|
|
function buildkitCacheRef(registryPrefix, serviceId) {
|
|
return `${registryPrefix}/cache/${serviceId}`;
|
|
}
|
|
|
|
function envReuseImageRef(registryPrefix, serviceId, environmentInputHash) {
|
|
const tag = String(environmentInputHash || "unknown").slice(0, 12);
|
|
return `${registryPrefix}/${serviceId}-env:env-${tag}`;
|
|
}
|
|
|
|
function envReuseCacheRef(registryPrefix, serviceId) {
|
|
return `${registryPrefix}/cache/${serviceId}-env`;
|
|
}
|
|
|
|
function buildkitRegistryOption(registryPrefix) {
|
|
const parsed = parseRegistryPrefix(registryPrefix);
|
|
return parsed.host === "127.0.0.1" || parsed.host === "localhost"
|
|
? ",registry.insecure=true"
|
|
: "";
|
|
}
|
|
|
|
function buildkitCacheArgs(args, cacheRef, registryOption) {
|
|
if (args.buildCacheMode === "disabled") return [];
|
|
return [
|
|
"--import-cache", `type=registry,ref=${cacheRef}${registryOption}`,
|
|
"--export-cache", `type=registry,ref=${cacheRef},mode=max,ignore-error=true${registryOption}`
|
|
];
|
|
}
|
|
|
|
function buildkitCacheEvidence(args, cacheRef) {
|
|
return args.buildCacheMode === "disabled"
|
|
? { buildkitCacheMode: "disabled" }
|
|
: { buildkitCacheMode: "registry", buildkitCacheRef: cacheRef };
|
|
}
|
|
|
|
function goBaseCacheRef(registryPrefix) {
|
|
return `${registryPrefix}/cache/hwlab-go-env-base`;
|
|
}
|
|
|
|
function goBaseEvidenceFields(evidence) {
|
|
if (!evidence) return {};
|
|
return {
|
|
goBaseImage: evidence.image,
|
|
goBaseImageStatus: evidence.status,
|
|
goBaseDigest: evidence.digest ?? null,
|
|
goBaseBuildDurationMs: evidence.durationMs ?? null,
|
|
goBaseBuildkitCacheRef: evidence.cacheRef ?? null
|
|
};
|
|
}
|
|
|
|
function buildkitDigestFromMetadata(value) {
|
|
const keys = [
|
|
"containerimage.digest",
|
|
"containerimage.descriptor.digest",
|
|
"image.digest",
|
|
"digest"
|
|
];
|
|
for (const key of keys) {
|
|
const direct = value?.[key];
|
|
if (shaDigestPattern.test(direct ?? "")) return direct;
|
|
}
|
|
const nested = value?.containerimage?.digest ?? value?.containerimage?.descriptor?.digest;
|
|
return shaDigestPattern.test(nested ?? "") ? nested : null;
|
|
}
|
|
|
|
async function buildkitCommandPreflight(command) {
|
|
const result = await run("sh", ["-c", `command -v ${shellQuote(command)}`]);
|
|
return {
|
|
ok: result.code === 0,
|
|
command,
|
|
result
|
|
};
|
|
}
|
|
|
|
async function run(command, args, options = {}) {
|
|
const startedAt = Date.now();
|
|
const child = spawn(command, args, {
|
|
cwd: options.cwd ?? repoRoot,
|
|
env: options.env ?? process.env,
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
|
|
const code = await new Promise((resolve) => {
|
|
child.on("error", () => resolve(127));
|
|
child.on("close", resolve);
|
|
});
|
|
|
|
return {
|
|
command: commandLine(command, args),
|
|
code,
|
|
stdout,
|
|
stderr,
|
|
durationMs: Date.now() - startedAt
|
|
};
|
|
}
|
|
|
|
async function prepareBuildkitEnv(args) {
|
|
const buildEnv = { ...process.env };
|
|
if (!args.buildkitAddr) {
|
|
buildEnv.BUILDKITD_FLAGS = process.env.BUILDKITD_FLAGS || "--oci-worker-no-process-sandbox";
|
|
buildEnv.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR || path.join(os.tmpdir(), "hwlab-buildkit-runtime");
|
|
await mkdir(buildEnv.XDG_RUNTIME_DIR, { recursive: true });
|
|
}
|
|
return buildEnv;
|
|
}
|
|
|
|
function parseRegistryImageRef(image) {
|
|
const value = String(image ?? "").trim();
|
|
const slashIndex = value.indexOf("/");
|
|
assert.ok(slashIndex > 0, `image reference ${value} must include a registry host`);
|
|
const registry = value.slice(0, slashIndex);
|
|
const remainder = value.slice(slashIndex + 1);
|
|
const digestIndex = remainder.indexOf("@");
|
|
if (digestIndex >= 0) {
|
|
return { registry, repository: remainder.slice(0, digestIndex), reference: remainder.slice(digestIndex + 1) };
|
|
}
|
|
const colonIndex = remainder.lastIndexOf(":");
|
|
assert.ok(colonIndex > 0, `image reference ${value} must include an immutable tag`);
|
|
return { registry, repository: remainder.slice(0, colonIndex), reference: remainder.slice(colonIndex + 1) };
|
|
}
|
|
|
|
async function registryManifestExists(image) {
|
|
const { registry, repository, reference } = parseRegistryImageRef(image);
|
|
const protocol = registry.startsWith("127.0.0.1") || registry.startsWith("localhost") ? "http" : "https";
|
|
const url = `${protocol}://${registry}/v2/${repository}/manifests/${reference}`;
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: [
|
|
"application/vnd.oci.image.index.v1+json",
|
|
"application/vnd.oci.image.manifest.v1+json",
|
|
"application/vnd.docker.distribution.manifest.v2+json"
|
|
].join(", ")
|
|
}
|
|
});
|
|
await response.arrayBuffer().catch(() => null);
|
|
return response.status === 200;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function bunExecutable() {
|
|
return process.env.HWLAB_BUN_BINARY || process.env.BUN_BINARY || "bun";
|
|
}
|
|
|
|
async function inspectCloudWebDistFreshness(webRoot) {
|
|
const result = await run(bunExecutable(), ["web/hwlab-cloud-web/scripts/dist-contract.ts", "--inspect", "--root", webRoot]);
|
|
if (result.code !== 0) {
|
|
return {
|
|
status: "blocked",
|
|
distPath: "web/hwlab-cloud-web/dist",
|
|
buildCommand: "cd web/hwlab-cloud-web && bun run build",
|
|
freshnessCommand: "cd web/hwlab-cloud-web && bun run build",
|
|
files: [],
|
|
mismatches: ["dist-contract-inspect"],
|
|
error: tailText(`${result.stdout}\n${result.stderr}`)
|
|
};
|
|
}
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
function assertDevOnlyContracts(catalog, deployManifest) {
|
|
assert.equal(catalog.environment, ENVIRONMENT_DEV, "catalog.environment must be dev");
|
|
assert.equal(catalog.profile, ENVIRONMENT_DEV, "catalog.profile must be dev");
|
|
assert.equal(catalog.namespace, "hwlab-dev", "catalog.namespace must be hwlab-dev");
|
|
assert.deepEqual(catalog.allowedProfiles, [ENVIRONMENT_DEV], "catalog.allowedProfiles must only allow dev");
|
|
assert.deepEqual(catalog.forbiddenProfiles, ["prod"], "catalog.forbiddenProfiles must forbid prod");
|
|
assert.equal(deployManifest.environment, ENVIRONMENT_DEV, "deploy.environment must be dev");
|
|
assert.equal(deployManifest.namespace, "hwlab-dev", "deploy.namespace must be hwlab-dev");
|
|
assert.equal(deployManifest.profiles?.dev?.enabled, true, "deploy dev profile must be enabled");
|
|
assert.equal(deployManifest.profiles?.prod?.enabled, false, "deploy prod profile must stay disabled");
|
|
|
|
const catalogIds = catalog.services.map((service) => service.serviceId);
|
|
const deployIds = deployManifest.services.map((service) => service.serviceId);
|
|
assert.deepEqual(catalogIds, SERVICE_IDS, "catalog services must match frozen DEV service IDs");
|
|
assert.deepEqual(deployIds, SERVICE_IDS, "deploy services must match frozen DEV service IDs");
|
|
|
|
for (const service of [...catalog.services, ...deployManifest.services]) {
|
|
assert.equal(service.profile, ENVIRONMENT_DEV, `${service.serviceId}.profile must be dev`);
|
|
assert.equal(service.namespace, "hwlab-dev", `${service.serviceId}.namespace must be hwlab-dev`);
|
|
assert.ok(!String(service.image).includes("prod"), `${service.serviceId}.image must not target prod`);
|
|
}
|
|
}
|
|
|
|
function laneDeployConfig(deployManifest, lane) {
|
|
return deployManifest?.lanes?.[lane] ?? null;
|
|
}
|
|
|
|
function isRuntimeArtifactLane(lane) {
|
|
return runtimeArtifactLanePattern.test(String(lane ?? ""));
|
|
}
|
|
|
|
function isHttpEndpoint(value) {
|
|
if (typeof value !== "string" || value.trim().length === 0) return false;
|
|
try {
|
|
const url = new URL(value);
|
|
return url.protocol === "http:" || url.protocol === "https:";
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function assertRuntimeLaneContracts(catalog, deployManifest, laneName) {
|
|
const lane = laneDeployConfig(deployManifest, laneName);
|
|
assert.ok(lane && typeof lane === "object", `deploy.lanes.${laneName} must exist for ${laneName} artifact publish`);
|
|
assert.ok(String(lane.namespace ?? "").trim(), `deploy.lanes.${laneName}.namespace must not be empty`);
|
|
assert.ok(isHttpEndpoint(lane.endpoint), `deploy.lanes.${laneName}.endpoint must be a non-empty http(s) URL`);
|
|
const declaredServiceIds = new Set(runtimeLaneServiceIdsFromDeclarations(deployManifest, laneName));
|
|
assert.ok(
|
|
runtimeLaneServiceIdsFromDeploy(deployManifest, laneName).every((serviceId) => declaredServiceIds.has(serviceId)),
|
|
`deploy.lanes.${laneName}.serviceDeclarations must cover envReuseServices`
|
|
);
|
|
assert.equal(catalog.environment, laneName, `catalog.environment must be ${laneName}`);
|
|
assert.equal(catalog.profile, laneName, `catalog.profile must be ${laneName}`);
|
|
assert.equal(catalog.namespace, lane.namespace, `catalog.namespace must be ${lane.namespace}`);
|
|
if (catalog.endpoint != null) assert.ok(isHttpEndpoint(catalog.endpoint), "catalog.endpoint must be a non-empty http(s) URL");
|
|
assert.deepEqual(catalog.allowedProfiles, [laneName], `catalog.allowedProfiles must only allow ${laneName}`);
|
|
assert.deepEqual(catalog.forbiddenProfiles, ["dev", "prod"], "catalog.forbiddenProfiles must forbid dev/prod");
|
|
const catalogIds = (catalog.services ?? []).map((service) => service.serviceId);
|
|
assert.deepEqual(catalogIds, runtimeLaneServiceIdsFromDeploy(deployManifest, laneName), `${laneName} catalog services must match deploy.lanes.${laneName}.envReuseServices`);
|
|
for (const service of catalog.services ?? []) {
|
|
assert.equal(service.profile, laneName, `${service.serviceId}.profile must be ${laneName}`);
|
|
assert.equal(service.namespace, lane.namespace, `${service.serviceId}.namespace must be ${lane.namespace}`);
|
|
assert.ok(!String(service.image).includes("prod"), `${service.serviceId}.image must not target prod`);
|
|
}
|
|
}
|
|
|
|
function assertArtifactContracts(args, catalog, deployManifest) {
|
|
if (isRuntimeArtifactLane(args.lane)) return assertRuntimeLaneContracts(catalog, deployManifest, args.lane);
|
|
return assertDevOnlyContracts(catalog, deployManifest);
|
|
}
|
|
|
|
function imageTagForCommit(args, commitId) {
|
|
return args.imageTagMode === "full" ? commitId : commitId.slice(0, 7);
|
|
}
|
|
|
|
function artifactEnvironment(args) {
|
|
return isRuntimeArtifactLane(args.lane) ? args.lane : ENVIRONMENT_DEV;
|
|
}
|
|
|
|
function artifactCatalogSkeleton({ args, deployManifest, commitId }) {
|
|
const tag = imageTagForCommit(args, commitId);
|
|
const laneConfig = laneDeployConfig(deployManifest, args.lane);
|
|
const laneEndpoint = isRuntimeArtifactLane(args.lane) ? laneConfig?.endpoint : "http://74.48.78.17:16667";
|
|
const namespace = isRuntimeArtifactLane(args.lane) ? laneConfig?.namespace : "hwlab-dev";
|
|
assert.ok(isHttpEndpoint(laneEndpoint), "deploy lane endpoint must be a non-empty http(s) URL");
|
|
assert.ok(String(namespace ?? "").trim(), "deploy lane namespace must not be empty");
|
|
return {
|
|
catalogVersion: "v1",
|
|
kind: "hwlab-artifact-catalog",
|
|
environment: artifactEnvironment(args),
|
|
profile: artifactEnvironment(args),
|
|
namespace,
|
|
endpoint: laneEndpoint,
|
|
commitId: tag,
|
|
artifactState: "contract-skeleton",
|
|
publish: {
|
|
ciPublished: false,
|
|
registryVerified: false,
|
|
provenance: "not_available_until_publish",
|
|
note: `${artifactEnvironment(args)} artifact catalog skeleton initialized without DEV/node fallback.`
|
|
},
|
|
allowedProfiles: [artifactEnvironment(args)],
|
|
forbiddenProfiles: isRuntimeArtifactLane(args.lane) ? ["dev", "prod"] : ["prod"],
|
|
services: serviceIdsForArtifactLane(args, deployManifest).map((serviceId) => artifactCatalogSkeletonService({ args, deployManifest, serviceId, tag, commitId }))
|
|
};
|
|
}
|
|
|
|
function artifactCatalogSkeletonService({ args, deployManifest, serviceId, tag, commitId }) {
|
|
const laneConfig = laneDeployConfig(deployManifest, args.lane);
|
|
const namespace = isRuntimeArtifactLane(args.lane) ? laneConfig?.namespace : "hwlab-dev";
|
|
const base = {
|
|
serviceId,
|
|
commitId: tag,
|
|
sourceCommitId: commitId,
|
|
image: imageRef(args.registryPrefix, serviceId, tag),
|
|
imageTag: tag,
|
|
digest: "not_published",
|
|
publishState: "skeleton-only",
|
|
profile: artifactEnvironment(args),
|
|
namespace,
|
|
healthPath: "/health/live",
|
|
sourceState: "source-present",
|
|
publishEnabled: true,
|
|
artifactRequired: true,
|
|
artifactScope: "required",
|
|
notPublishedReason: "publish_not_run"
|
|
};
|
|
const envReuseServiceIds = envReuseServiceIdsForLane(deployManifest, args.lane);
|
|
if (!envReuseServiceIds.has(serviceId)) return base;
|
|
const bootSh = bootShForService(deployManifest, serviceId, args.lane);
|
|
const environmentImage = envReuseImageRef(args.registryPrefix, serviceId, commitId);
|
|
return {
|
|
...base,
|
|
runtimeMode: v02EnvReuseRuntimeMode,
|
|
envReuse: true,
|
|
image: environmentImage,
|
|
imageTag: imageTagFromReference(environmentImage),
|
|
environmentImage,
|
|
environmentDigest: "not_published",
|
|
environmentInputHash: null,
|
|
codeInputHash: null,
|
|
bootRepo: "git@github.com:pikasTech/HWLAB.git",
|
|
bootCommit: commitId,
|
|
bootSh,
|
|
bootEnvEvidence: bootEnvEvidence({ service: { serviceId, bootCommit: commitId, bootSh }, commitId, environmentImage, environmentDigest: "not_published" })
|
|
};
|
|
}
|
|
|
|
function envReuseServiceIdsForLane(deployManifest, lane) {
|
|
if (!isRuntimeArtifactLane(lane)) return new Set();
|
|
const configured = deployManifest?.lanes?.[lane]?.envReuseServices;
|
|
return new Set(normalizeServiceIdList(configured));
|
|
}
|
|
|
|
function serviceIdsForArtifactLane(args, deployManifest) {
|
|
if (!isRuntimeArtifactLane(args.lane)) return [...SERVICE_IDS];
|
|
return runtimeLaneServiceIdsFromDeploy(deployManifest, args.lane);
|
|
}
|
|
|
|
function applyDeployDefaultServices(args, deployManifest) {
|
|
if (args.servicesExplicit) return;
|
|
args.services = serviceIdsForArtifactLane(args, deployManifest);
|
|
}
|
|
|
|
function runtimeLaneServiceIdsFromDeploy(deployManifest, lane) {
|
|
const serviceIds = normalizeServiceIdList(deployManifest?.lanes?.[lane]?.envReuseServices);
|
|
assert.ok(serviceIds.length > 0, `deploy.lanes.${lane}.envReuseServices must not be empty`);
|
|
return serviceIds;
|
|
}
|
|
|
|
function runtimeLaneServiceIdsFromDeclarations(deployManifest, lane) {
|
|
return normalizeServiceIdList(Object.keys(deployManifest?.lanes?.[lane]?.serviceDeclarations ?? {}));
|
|
}
|
|
|
|
function normalizeServiceIdList(value) {
|
|
return uniquePreserveOrder((Array.isArray(value) ? value : []).map((item) => String(item ?? "").trim()).filter(Boolean));
|
|
}
|
|
|
|
function uniquePreserveOrder(values) {
|
|
const seen = new Set();
|
|
const result = [];
|
|
for (const value of values) {
|
|
if (seen.has(value)) continue;
|
|
seen.add(value);
|
|
result.push(value);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function bootShForService(deployManifest, serviceId, lane) {
|
|
const value = deployManifest?.lanes?.[lane]?.bootScripts?.[serviceId];
|
|
assert.ok(value, `deploy.lanes.${lane}.bootScripts.${serviceId} is required`);
|
|
const text = String(value).replaceAll("\\", "/").replace(/^\.\//u, "");
|
|
assert.ok(text && !text.startsWith("/") && !text.split("/").includes(".."), `${serviceId} boot script must be repo-relative`);
|
|
return text;
|
|
}
|
|
|
|
function normalizeCatalogForLane(catalog, args, deployManifest, commitId) {
|
|
const lane = args?.lane;
|
|
if (!catalog || !isRuntimeArtifactLane(lane)) return catalog;
|
|
const serviceIds = runtimeLaneServiceIdsFromDeploy(deployManifest, lane);
|
|
const allowed = new Set(serviceIds);
|
|
const existingById = new Map((catalog.services ?? [])
|
|
.filter((service) => allowed.has(service.serviceId))
|
|
.map((service) => [service.serviceId, service]));
|
|
const tag = imageTagForCommit(args, commitId);
|
|
return {
|
|
...catalog,
|
|
services: serviceIds.map((serviceId) => existingById.get(serviceId)
|
|
?? artifactCatalogSkeletonService({ args, deployManifest, serviceId, tag, commitId }))
|
|
};
|
|
}
|
|
|
|
function parseRegistryPrefix(prefix) {
|
|
const normalized = prefix.replace(/\/+$/u, "");
|
|
const [hostPort, ...pathParts] = normalized.split("/");
|
|
const host = hostPort.split(":")[0].toLowerCase();
|
|
return {
|
|
normalized,
|
|
hostPort,
|
|
host,
|
|
namespace: pathParts.join("/")
|
|
};
|
|
}
|
|
|
|
function isPrivateHost(host) {
|
|
if (host === "localhost" || host === "127.0.0.1" || host === "::1") return true;
|
|
if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u.test(host)) return true;
|
|
if (/^192\.168\.\d{1,3}\.\d{1,3}$/u.test(host)) return true;
|
|
if (/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/u.test(host)) return true;
|
|
if (host.endsWith(".local") || host.endsWith(".svc") || host.endsWith(".cluster.local")) return true;
|
|
return false;
|
|
}
|
|
|
|
function validateRegistryPrefix(prefix) {
|
|
const parsed = parseRegistryPrefix(prefix);
|
|
assert.ok(parsed.hostPort, "registry prefix must include a registry host");
|
|
assert.ok(parsed.namespace, "registry prefix must include an image namespace, for example 127.0.0.1:5000/hwlab");
|
|
assert.ok(!/prod|production/iu.test(parsed.normalized), "registry prefix must not include prod");
|
|
assert.ok(
|
|
!/(^|\/)(ghcr\.io|docker\.io|index\.docker\.io|quay\.io|gcr\.io|registry-1\.docker\.io)(\/|$)/iu.test(parsed.normalized),
|
|
"registry prefix must not target a third-party registry"
|
|
);
|
|
assert.ok(isPrivateHost(parsed.host), "registry prefix must target localhost or a private/internal host");
|
|
return parsed.normalized;
|
|
}
|
|
|
|
async function gitValue(args) {
|
|
const result = await run("git", args);
|
|
if (result.code !== 0) {
|
|
throw new Error(`${result.command} failed: ${result.stderr.trim() || result.stdout.trim()}`);
|
|
}
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
function repoLabelFromRemote(remoteUrl) {
|
|
const githubMatch = remoteUrl.match(/github\.com[:/](.+?)(?:\.git)?$/iu);
|
|
if (githubMatch) {
|
|
return githubMatch[1];
|
|
}
|
|
|
|
try {
|
|
const url = new URL(remoteUrl);
|
|
url.username = "";
|
|
url.password = "";
|
|
return `${url.host}${url.pathname.replace(/\.git$/u, "")}`;
|
|
} catch {
|
|
return "pikasTech/HWLAB";
|
|
}
|
|
}
|
|
|
|
async function resolveServices(serviceIds, catalog, allowedServiceIds = SERVICE_IDS) {
|
|
return resolveDevArtifactServices(repoRoot, serviceIds, allowedServiceIds, catalog);
|
|
}
|
|
|
|
function dockerfile(baseImage, port, labels = []) {
|
|
const dependencyTimingScript = `started_at=$(node -e "console.log(Date.now())"); status=succeeded; if [ -d /opt/hwlab-node-runtime-base/node_modules ]; then cp -a /opt/hwlab-node-runtime-base/node_modules ./node_modules; fi && if [ -f package-lock.json ]; then npm install --omit=dev --include=optional --ignore-scripts; else npm install --omit=dev --include=optional --ignore-scripts; fi && if [ "\${HWLAB_ARTIFACT_KIND:-}" = "bun-command" ]; then bun_pkg=""; case "$(uname -m)" in x86_64|amd64) bun_pkg="@oven/bun-linux-x64@1.3.13" ;; aarch64|arm64) bun_pkg="@oven/bun-linux-aarch64@1.3.13" ;; *) echo "unsupported bun-command architecture: $(uname -m)" >&2; false ;; esac && npm install --no-save --omit=dev --include=optional --ignore-scripts "$bun_pkg" && test -x "node_modules/\${bun_pkg%@*}/bin/bun" && ln -sf "/app/node_modules/\${bun_pkg%@*}/bin/bun" /usr/local/bin/bun; fi && codex_version="$(node -p "require('./node_modules/@openai/codex/package.json').version")" && case "$(uname -m)" in x86_64|amd64) test -e node_modules/@openai/codex-linux-x64 || npm install --omit=dev --include=optional --ignore-scripts "@openai/codex-linux-x64@npm:@openai/codex@\${codex_version}-linux-x64" ;; aarch64|arm64) test -e node_modules/@openai/codex-linux-arm64 || npm install --omit=dev --include=optional --ignore-scripts "@openai/codex-linux-arm64@npm:@openai/codex@\${codex_version}-linux-arm64" ;; esac || status=failed; finished_at=$(node -e "console.log(Date.now())"); node -e "console.log(JSON.stringify({event:'node-cicd-build-step-timing',stage:'dependency-install',status:process.argv[1],durationMs:Number(process.argv[3])-Number(process.argv[2]),source:'dockerfile-run'}))" "$status" "$started_at" "$finished_at"; test "$status" = succeeded`;
|
|
const runtimeReadinessScript = `set -eu; fail() { echo "hwlab-artifact-runtime-check-failed:$1" >&2; exit 1; }; mkdir -p /workspace /codex-home; rm -rf /workspace/hwlab; ln -s /app /workspace/hwlab; if [ "\${HWLAB_ARTIFACT_KIND:-}" = "bun-command" ]; then test -x /usr/local/bin/bun || fail bun-missing; /usr/local/bin/bun --version >/tmp/hwlab-bun-version.txt || fail bun-version; fi; chmod -R a+rwX /app /workspace /codex-home || fail chmod; test -x /app/node_modules/.bin/codex || fail codex-bin-missing; /app/node_modules/.bin/codex --version >/tmp/hwlab-codex-version.txt || fail codex-version; test -x /app/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex/codex || test -x /app/node_modules/@openai/codex-linux-arm64/vendor/aarch64-unknown-linux-musl/codex/codex || fail codex-native-missing`;
|
|
return [
|
|
`FROM ${baseImage}`,
|
|
"WORKDIR /app",
|
|
...labels.map(([name, value]) => `LABEL ${name}=${dockerfileQuote(value)}`),
|
|
"ARG HWLAB_ARTIFACT_KIND",
|
|
"ENV HWLAB_CODE_AGENT_SKILLS_DIRS=/app/skills",
|
|
"COPY package.json ./package.json",
|
|
"COPY package-lock.json* ./",
|
|
`RUN ${dependencyTimingScript}`,
|
|
"COPY internal ./internal",
|
|
"COPY cmd ./cmd",
|
|
"COPY scripts ./scripts",
|
|
"COPY web ./web",
|
|
"COPY tools ./tools",
|
|
"COPY skills ./skills",
|
|
"COPY .hwlab ./.hwlab",
|
|
"COPY deploy ./deploy",
|
|
"RUN printf '%s\\n' '#!/usr/bin/env sh' 'exec node /app/scripts/run-bun.mjs /app/tools/hwpod-cli.ts \"$@\"' > /usr/local/bin/hwpod && chmod 755 /usr/local/bin/hwpod && printf '%s\\n' '#!/usr/bin/env sh' 'exec node /app/scripts/run-bun.mjs /app/tools/hwpod-ctl.ts \"$@\"' > /usr/local/bin/hwpod-ctl && chmod 755 /usr/local/bin/hwpod-ctl && printf '%s\\n' '#!/usr/bin/env sh' 'exec node /app/scripts/run-bun.mjs /app/tools/hwpod-compiler-cli.ts \"$@\"' > /usr/local/bin/hwpod-compiler && chmod 755 /usr/local/bin/hwpod-compiler",
|
|
`RUN ${runtimeReadinessScript}`,
|
|
"RUN cp /app/internal/dev-entrypoint/artifact-runtime.mjs /usr/local/bin/hwlab-dev-artifact-runtime.mjs && chmod 755 /usr/local/bin/hwlab-dev-artifact-runtime.mjs",
|
|
"ARG HWLAB_ENVIRONMENT",
|
|
"ARG HWLAB_SERVICE_ID",
|
|
"ARG HWLAB_SERVICE_ENTRYPOINT",
|
|
"ARG HWLAB_COMMIT_ID",
|
|
"ARG HWLAB_REVISION",
|
|
"ARG HWLAB_IMAGE",
|
|
"ARG HWLAB_IMAGE_TAG",
|
|
"ARG HWLAB_IMAGE_DIGEST",
|
|
"ARG HWLAB_BUILD_CREATED_AT",
|
|
"ARG HWLAB_BUILD_SOURCE",
|
|
"ARG HWLAB_BUILD_PROVENANCE",
|
|
"ARG PORT",
|
|
"ARG HWLAB_PORT",
|
|
"ENV HWLAB_ENVIRONMENT=$HWLAB_ENVIRONMENT",
|
|
"ENV HWLAB_SERVICE_ID=$HWLAB_SERVICE_ID",
|
|
"ENV HWLAB_ARTIFACT_KIND=$HWLAB_ARTIFACT_KIND",
|
|
"ENV HWLAB_SERVICE_ENTRYPOINT=$HWLAB_SERVICE_ENTRYPOINT",
|
|
"ENV HWLAB_COMMIT_ID=$HWLAB_COMMIT_ID",
|
|
"ENV HWLAB_REVISION=$HWLAB_REVISION",
|
|
"ENV HWLAB_IMAGE=$HWLAB_IMAGE",
|
|
"ENV HWLAB_IMAGE_TAG=$HWLAB_IMAGE_TAG",
|
|
"ENV HWLAB_IMAGE_DIGEST=$HWLAB_IMAGE_DIGEST",
|
|
"ENV HWLAB_BUILD_CREATED_AT=$HWLAB_BUILD_CREATED_AT",
|
|
"ENV HWLAB_BUILD_SOURCE=$HWLAB_BUILD_SOURCE",
|
|
"ENV HWLAB_BUILD_PROVENANCE=$HWLAB_BUILD_PROVENANCE",
|
|
"ENV PORT=$PORT",
|
|
"ENV HWLAB_PORT=$HWLAB_PORT",
|
|
`EXPOSE ${port}`,
|
|
"CMD [\"node\", \"/usr/local/bin/hwlab-dev-artifact-runtime.mjs\"]",
|
|
""
|
|
].join("\n");
|
|
}
|
|
|
|
function envReuseDockerfile(baseImage, labels = [], recipe, service = null) {
|
|
const normalizedRecipe = normalizeEnvReuseRecipe(recipe);
|
|
const runtimeRoot = normalizedRecipe.runtimeNodeModulesPath.replace(/\/node_modules$/u, "");
|
|
const downloadEnvPrefix = shellExportPrefix(downloadEnvPairs(normalizedRecipe.downloadStack));
|
|
const npmConfigScript = npmDownloadConfigScript(normalizedRecipe.downloadStack);
|
|
const goService = service?.runtimeKind === "go-service" || service?.artifactKind === "go-service";
|
|
const goCacheEnabled = Boolean(goService && normalizedRecipe.runtimeGoModCachePath && normalizedRecipe.runtimeGoBuildCachePath);
|
|
const goBaseImageEnabled = Boolean(goCacheEnabled && normalizedRecipe.goBaseImage);
|
|
const effectiveBaseImage = goBaseImageEnabled ? normalizedRecipe.goBaseImage : baseImage;
|
|
const goLauncherPath = "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs";
|
|
const goProxy = normalizedRecipe.downloadStack.goProxy || "https://proxy.golang.org,direct";
|
|
const goEnv = goCacheEnabled ? [
|
|
`ENV GOMODCACHE=${normalizedRecipe.runtimeGoModCachePath}`,
|
|
`ENV GOCACHE=${normalizedRecipe.runtimeGoBuildCachePath}`,
|
|
`ENV GOPROXY=${goProxy}`,
|
|
`ENV GOTOOLCHAIN=${normalizedRecipe.goToolchain || "local"}`
|
|
] : [];
|
|
const goBuildEnvPrefix = goCacheEnabled ? shellExportPrefix([
|
|
["GOMODCACHE", normalizedRecipe.runtimeGoModCachePath],
|
|
["GOCACHE", normalizedRecipe.runtimeGoBuildCachePath],
|
|
["GOPROXY", goProxy],
|
|
["GOTOOLCHAIN", normalizedRecipe.goToolchain || "local"]
|
|
]) : "";
|
|
const goDownloadRetryScript = `retry_go_mod_download() { attempts=6; n=1; while ! go mod download; do if [ "$n" -ge "$attempts" ]; then return 1; fi; echo "go mod download attempt $n/$attempts failed; retrying" >&2; n=$((n + 1)); sleep 5; done; }`;
|
|
const stepTimingHelpers = `emit_hwlab_timing() { stage="$1"; status="$2"; duration_ms="$3"; node -e "console.error(JSON.stringify({event:'node-cicd-build-step-timing',stage:process.argv[1],status:process.argv[2],durationMs:Number(process.argv[3]),source:'dockerfile-run'}))" "$stage" "$status" "$duration_ms"; }; timed_stage() { label="$1"; shift; start="$(node -e "console.log(Date.now())")"; status=succeeded; "$@" || status=failed; end="$(node -e "console.log(Date.now())")"; elapsed=$((end - start)); emit_hwlab_timing "$label" "$status" "$elapsed"; if [ "$elapsed" -ge 120000 ]; then echo "{\"event\":\"env-reuse-warning\",\"warning\":\"stage-over-120s\",\"stage\":\"$label\",\"elapsedMs\":$elapsed}" >&2; fi; test "$status" = succeeded; }; timed_skip() { emit_hwlab_timing "$1" skipped 0; }`;
|
|
const goDownloadScript = goCacheEnabled
|
|
? `RUN ${downloadEnvPrefix}${goBuildEnvPrefix}set -eu; ${stepTimingHelpers}; mkdir -p ${shellQuote(normalizedRecipe.runtimeGoModCachePath)} ${shellQuote(normalizedRecipe.runtimeGoBuildCachePath)}; ${goDownloadRetryScript}; timed_stage "go-mod-download" retry_go_mod_download`
|
|
: null;
|
|
const goReadiness = goCacheEnabled
|
|
? `; mkdir -p ${shellQuote(normalizedRecipe.runtimeGoModCachePath)} ${shellQuote(normalizedRecipe.runtimeGoBuildCachePath)}; command -v go >/dev/null; command -v tini >/dev/null; go version >/tmp/hwlab-env-go-version.txt`
|
|
: "";
|
|
const baseOsPackages = uniquePreserveOrder([...normalizedRecipe.osPackages, "tini"]);
|
|
const goOsPackages = goCacheEnabled ? uniquePreserveOrder(normalizedRecipe.goOsPackages) : [];
|
|
const downloadDependencyScript = `set -eu; ${npmConfigScript}; if [ -f package-lock.json ]; then npm ci --omit=dev --include=optional --ignore-scripts; else npm install --omit=dev --include=optional --ignore-scripts; fi; if command -v bun >/dev/null 2>&1; then bun_path="$(command -v bun)"; if [ "$bun_path" != "/usr/local/bin/bun" ]; then ln -sf "$bun_path" /usr/local/bin/bun; fi; fi; if [ ! -x /usr/local/bin/bun ]; then bun_pkg=""; case "$(uname -m)" in x86_64|amd64) bun_pkg="${normalizedRecipe.downloadStack.bunPackages.x64}@${normalizedRecipe.bunVersion}" ;; aarch64|arm64) bun_pkg="${normalizedRecipe.downloadStack.bunPackages.arm64}@${normalizedRecipe.bunVersion}" ;; *) echo "unsupported bun architecture: $(uname -m)" >&2; false ;; esac; npm install --no-save --omit=dev --include=optional --ignore-scripts "$bun_pkg"; bun_bin="node_modules/\${bun_pkg%@*}/bin/bun"; test -x "$bun_bin"; ln -sf "${runtimeRoot}/$bun_bin" /usr/local/bin/bun; fi`;
|
|
const aptRetryHelpers = `retry_apt_update() { attempts=6; n=1; while ! apt-get update -o Acquire::Retries=3; do if [ "$n" -ge "$attempts" ]; then return 1; fi; echo "apt-get update attempt $n/$attempts failed; retrying" >&2; n=$((n + 1)); rm -rf /var/lib/apt/lists/*; sleep 5; done; }; apt_packages_ready() { for pkg in "$@"; do dpkg-query -W -f='\${Status}' "$pkg" 2>/dev/null | grep -q 'install ok installed' || return 1; done; }; retry_apt_install() { attempts=8; n=1; while true; do apt-get install -o Acquire::Retries=3 -y --no-install-recommends --fix-missing "$@" || true; if apt_packages_ready "$@"; then return 0; fi; if [ "$n" -ge "$attempts" ]; then return 1; fi; echo "apt-get install attempt $n/$attempts incomplete; retrying" >&2; n=$((n + 1)); sleep 5; timed_stage "apt-update-retry" apt-get update -o Acquire::Retries=3 || true; done; }`;
|
|
const aptUpdateScript = `timed_stage "apt-update" retry_apt_update || (for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources; do [ -f "$f" ] || continue; sed -i -e '/^[[:space:]]*deb .* bookworm-updates /d' -e 's/[[:space:]]bookworm-updates//g' -e 's/bookworm-updates[[:space:]]//g' "$f"; done; timed_stage "apt-update-fallback" retry_apt_update)`;
|
|
const aptInstallSection = (label, packages) => {
|
|
const packageArgs = packages.map(shellQuote).join(" ");
|
|
if (!packageArgs) return `timed_skip "apt-install-${label}"`;
|
|
return `if apt_packages_ready ${packageArgs}; then timed_skip "apt-install-${label}"; else ${aptUpdateScript}; timed_stage "apt-install-${label}" retry_apt_install ${packageArgs}; fi`;
|
|
};
|
|
const goPackageSection = goOsPackages.length > 0
|
|
? `if command -v go >/dev/null 2>&1; then timed_skip "apt-install-go"; else ${aptInstallSection("go", goOsPackages)}; fi`
|
|
: `timed_skip "apt-install-go"`;
|
|
const osPackageSections = [aptInstallSection("base", baseOsPackages), goPackageSection].filter(Boolean).join("; ");
|
|
const osPackageScript = goBaseImageEnabled
|
|
? `set -eu; export DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC; ${stepTimingHelpers}; ${aptRetryHelpers}; timed_skip "apt-install-base"; timed_skip "apt-install-go"; ${aptInstallSection("init", ["tini"])}; rm -rf /var/lib/apt/lists/*`
|
|
: `set -eu; export DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC; ${stepTimingHelpers}; ${aptRetryHelpers}; ${osPackageSections}; rm -rf /var/lib/apt/lists/*`;
|
|
const aliasScript = normalizedRecipe.hwpodAliases.map((alias) => `printf '%s\\n' '#!/usr/bin/env sh' 'exec node /workspace/hwlab-boot/repo/scripts/run-bun.mjs /workspace/hwlab-boot/repo/${alias.script} \"$@\"' > /usr/local/bin/${alias.command}; chmod 755 /usr/local/bin/${alias.command}`).join("; ");
|
|
const readinessScript = goCacheEnabled
|
|
? `set -eu; command -v node >/dev/null; command -v git >/dev/null; command -v sh >/dev/null${goReadiness}; test -f /usr/local/bin/hwlab-go-env-reuse-launcher.mjs`
|
|
: `set -eu; command -v node >/dev/null; command -v npm >/dev/null; command -v git >/dev/null; command -v sh >/dev/null; command -v tini >/dev/null; test -d ${shellQuote(normalizedRecipe.runtimeNodeModulesPath)}; test -x /usr/local/bin/bun; /usr/local/bin/bun --version >/tmp/hwlab-env-bun-version.txt; ${aliasScript}`;
|
|
return [
|
|
`FROM ${effectiveBaseImage}`,
|
|
`WORKDIR ${runtimeRoot}`,
|
|
...(goCacheEnabled ? [] : ["COPY package.json ./package.json", "COPY package-lock.json* ./"]),
|
|
`RUN ${downloadEnvPrefix}${osPackageScript}`,
|
|
...(goCacheEnabled ? [] : [`RUN ${downloadEnvPrefix}${downloadDependencyScript}`]),
|
|
...(goCacheEnabled ? ["COPY go.mod ./go.mod", "COPY go.sum* ./"] : []),
|
|
...(goDownloadScript ? [goDownloadScript] : []),
|
|
goCacheEnabled
|
|
? `COPY ${goLauncherPath} /usr/local/bin/hwlab-go-env-reuse-launcher.mjs`
|
|
: `COPY ${normalizedRecipe.launcherPath} /usr/local/bin/hwlab-env-reuse-launcher.ts`,
|
|
`RUN ${stepTimingHelpers}; timed_stage "env-readiness" sh -c ${shellQuote(readinessScript)}`,
|
|
...labels.map(([name, value]) => `LABEL ${name}=${dockerfileQuote(value)}`),
|
|
"ENV HWLAB_RUNTIME_MODE=env-reuse-gitea-checkout",
|
|
...(goCacheEnabled ? [] : [`ENV HWLAB_RUNTIME_NODE_MODULES=${normalizedRecipe.runtimeNodeModulesPath}`]),
|
|
...goEnv,
|
|
"ENV PATH=/usr/local/bin:$PATH",
|
|
"ENTRYPOINT [\"tini\", \"--\"]",
|
|
goCacheEnabled
|
|
? "CMD [\"node\", \"/usr/local/bin/hwlab-go-env-reuse-launcher.mjs\"]"
|
|
: "CMD [\"/usr/local/bin/bun\", \"/usr/local/bin/hwlab-env-reuse-launcher.ts\"]",
|
|
""
|
|
].join("\n");
|
|
}
|
|
|
|
async function copyRepoFileToContext(contextDir, repoRelativePath, { required = true } = {}) {
|
|
const sourcePath = path.join(repoRoot, repoRelativePath);
|
|
const targetPath = path.join(contextDir, repoRelativePath);
|
|
try {
|
|
const content = await readFile(sourcePath);
|
|
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
await writeFile(targetPath, content);
|
|
return true;
|
|
} catch (error) {
|
|
if (!required && error?.code === "ENOENT") return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function prepareEnvReuseBuildContext(tmpDir, normalizedRecipe, goCacheEnabled) {
|
|
const contextDir = path.join(tmpDir, "context");
|
|
await mkdir(contextDir, { recursive: true });
|
|
if (!goCacheEnabled) {
|
|
await copyRepoFileToContext(contextDir, "package.json");
|
|
await copyRepoFileToContext(contextDir, "package-lock.json", { required: false });
|
|
}
|
|
if (goCacheEnabled) {
|
|
await copyRepoFileToContext(contextDir, "go.mod");
|
|
await copyRepoFileToContext(contextDir, "go.sum", { required: false });
|
|
}
|
|
await copyRepoFileToContext(contextDir, goCacheEnabled ? "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs" : normalizedRecipe.launcherPath);
|
|
return contextDir;
|
|
}
|
|
|
|
function normalizeEnvReuseRecipe(recipe) {
|
|
assert.ok(recipe && typeof recipe === "object", "envRecipe must be provided by deploy.lanes.<lane>.envRecipe");
|
|
const normalized = {
|
|
osPackages: normalizeStringList(recipe.osPackages),
|
|
goOsPackages: normalizeStringList(recipe.goOsPackages),
|
|
goBaseImage: String(recipe.goBaseImage ?? "").trim(),
|
|
bunVersion: String(recipe.bunVersion ?? "").trim(),
|
|
launcherPath: normalizeRepoPath(recipe.launcherPath),
|
|
runtimeNodeModulesPath: normalizeAbsolutePath(recipe.runtimeNodeModulesPath),
|
|
runtimeGoModCachePath: normalizeAbsolutePath(recipe.runtimeGoModCachePath),
|
|
runtimeGoBuildCachePath: normalizeAbsolutePath(recipe.runtimeGoBuildCachePath),
|
|
goToolchain: String(recipe.goToolchain ?? "").trim(),
|
|
downloadStack: normalizeDownloadStack(recipe.downloadStack),
|
|
hwpodAliases: normalizeHwpodAliases(recipe.hwpodAliases)
|
|
};
|
|
assert.ok(normalized.osPackages.length > 0, "envRecipe.osPackages must not be empty");
|
|
if (normalized.runtimeGoModCachePath || normalized.runtimeGoBuildCachePath) {
|
|
assert.ok(normalized.goOsPackages.length > 0, "envRecipe.goOsPackages must not be empty when Go cache paths are configured");
|
|
}
|
|
if (normalized.goBaseImage) {
|
|
assert.match(normalized.goBaseImage, /^[^/]+\/.+:[^:@]+$/u, "envRecipe.goBaseImage must include registry/repository:tag");
|
|
}
|
|
assert.match(normalized.bunVersion, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u, "envRecipe.bunVersion must be a semver-like version");
|
|
assert.ok(normalized.launcherPath && !normalized.launcherPath.split("/").includes(".."), "envRecipe.launcherPath must be repo-relative");
|
|
assert.ok(normalized.runtimeNodeModulesPath, "envRecipe.runtimeNodeModulesPath must be absolute");
|
|
assert.ok(normalized.hwpodAliases.length > 0, "envRecipe.hwpodAliases must not be empty");
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeDownloadStack(value) {
|
|
assert.ok(value && typeof value === "object", "envRecipe.downloadStack must be configured in deploy/deploy.yaml");
|
|
const normalized = {
|
|
httpProxy: normalizeOptionalHttpUrl(value.httpProxy),
|
|
httpsProxy: normalizeOptionalHttpUrl(value.httpsProxy),
|
|
noProxy: normalizeStringList(value.noProxy),
|
|
npmRegistry: normalizeOptionalHttpUrl(value.npmRegistry),
|
|
npmFetchTimeoutMs: Number(value.npmFetchTimeoutMs),
|
|
goProxy: String(value.goProxy ?? "").trim(),
|
|
bunPackages: {
|
|
x64: normalizePackageName(value.bunPackages?.x64),
|
|
arm64: normalizePackageName(value.bunPackages?.arm64)
|
|
}
|
|
};
|
|
assert.ok(normalized.npmRegistry, "envRecipe.downloadStack.npmRegistry must be an http(s) URL");
|
|
assert.ok(Number.isInteger(normalized.npmFetchTimeoutMs) && normalized.npmFetchTimeoutMs > 0, "envRecipe.downloadStack.npmFetchTimeoutMs must be a positive integer");
|
|
assert.ok(normalized.bunPackages.x64 && normalized.bunPackages.arm64, "envRecipe.downloadStack.bunPackages.x64/arm64 are required");
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeOptionalHttpUrl(value) {
|
|
const text = String(value ?? "").trim();
|
|
if (!text) return null;
|
|
try {
|
|
const url = new URL(text);
|
|
if (url.protocol === "http:" || url.protocol === "https:") return text;
|
|
} catch {}
|
|
return null;
|
|
}
|
|
|
|
function normalizePackageName(value) {
|
|
const text = String(value ?? "").trim();
|
|
return text && !text.includes(" ") ? text : null;
|
|
}
|
|
|
|
function downloadEnvPairs(stack) {
|
|
return [
|
|
["HTTP_PROXY", stack.httpProxy],
|
|
["http_proxy", stack.httpProxy],
|
|
["HTTPS_PROXY", stack.httpsProxy],
|
|
["https_proxy", stack.httpsProxy],
|
|
["NO_PROXY", stack.noProxy.join(",")],
|
|
["no_proxy", stack.noProxy.join(",")]
|
|
].filter(([, value]) => value);
|
|
}
|
|
|
|
function shellExportPrefix(pairs) {
|
|
return pairs.length === 0 ? "" : `export ${pairs.map(([name, value]) => `${name}=${shellQuote(value)}`).join(" ")}; `;
|
|
}
|
|
|
|
function npmDownloadConfigScript(stack) {
|
|
const commands = [
|
|
`npm config set registry ${shellQuote(stack.npmRegistry)}`,
|
|
`npm config set fetch-timeout ${Number(stack.npmFetchTimeoutMs)}`
|
|
];
|
|
if (stack.httpProxy) commands.push(`npm config set proxy ${shellQuote(stack.httpProxy)}`);
|
|
if (stack.httpsProxy) commands.push(`npm config set https-proxy ${shellQuote(stack.httpsProxy)}`);
|
|
if (stack.noProxy.length > 0) commands.push(`npm config set noproxy ${shellQuote(stack.noProxy.join(","))}`);
|
|
return commands.join("; ");
|
|
}
|
|
|
|
function normalizeStringList(value) {
|
|
const source = Array.isArray(value) ? value : [];
|
|
return source.map((item) => String(item ?? "").trim()).filter(Boolean);
|
|
}
|
|
|
|
function normalizeRepoPath(value) {
|
|
return String(value ?? "").replaceAll("\\", "/").replace(/^\.\//u, "").replace(/^\/+/, "").trim();
|
|
}
|
|
|
|
function normalizeAbsolutePath(value) {
|
|
const text = String(value ?? "").trim();
|
|
return text.startsWith("/") && !text.includes("..") ? text : null;
|
|
}
|
|
|
|
function normalizeHwpodAliases(value) {
|
|
const source = Array.isArray(value) ? value : [];
|
|
return source.map((item) => ({
|
|
command: String(item?.command ?? "").trim(),
|
|
script: normalizeRepoPath(item?.script)
|
|
})).filter((item) => item.command && item.script);
|
|
}
|
|
|
|
function imageRef(registryPrefix, serviceId, tag) {
|
|
return `${registryPrefix}/${serviceId}:${tag}`;
|
|
}
|
|
|
|
function imageRepository(registryPrefix, serviceId) {
|
|
return `${registryPrefix}/${serviceId}`;
|
|
}
|
|
|
|
function tailText(value, maxLength = 2500) {
|
|
const text = value.trim();
|
|
if (text.length <= maxLength) return text;
|
|
return text.slice(text.length - maxLength);
|
|
}
|
|
|
|
function sourceStateBlocker(service) {
|
|
return blocker({
|
|
type: "contract_blocker",
|
|
scope: service.serviceId,
|
|
summary: `${service.serviceId} has invalid sourceState ${service.sourceState ?? "unset"}.`,
|
|
next: `Set ${service.serviceId} sourceState to source-present or intentionally-disabled.`
|
|
});
|
|
}
|
|
|
|
async function preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities, buildServiceIds = new Set() }) {
|
|
const blockers = [];
|
|
let registryPrefix = args.registryPrefix;
|
|
const buildRequired = (args.mode === "build" || args.mode === "publish") && buildServiceIds.size > 0;
|
|
|
|
if (baseImagePreflight.publishUsable) {
|
|
args.baseImage = baseImagePreflight.localTag;
|
|
} else if (buildRequired) {
|
|
blockers.push(baseImagePreflightBlocker(baseImagePreflight));
|
|
}
|
|
|
|
try {
|
|
registryPrefix = validateRegistryPrefix(args.registryPrefix);
|
|
} catch (error) {
|
|
blockers.push(
|
|
blocker({
|
|
type: "safety_blocker",
|
|
scope: "registry",
|
|
summary: error.message,
|
|
next: "Use the node local/internal registry prefix, for example 127.0.0.1:5000/hwlab."
|
|
})
|
|
);
|
|
}
|
|
|
|
try {
|
|
assertArtifactContracts(args, catalog, deployManifest);
|
|
} catch (error) {
|
|
blockers.push(
|
|
blocker({
|
|
type: "contract_blocker",
|
|
scope: `${args.lane}-contract`,
|
|
summary: error.message,
|
|
next: `Fix ${args.catalogPath} and ${args.deployPath} so the selected artifact lane covers the frozen service IDs without crossing namespaces.`
|
|
})
|
|
);
|
|
}
|
|
|
|
const baseImageTag = args.baseImage ? args.baseImage.split(":").at(-1) : "";
|
|
for (const tag of [baseImageTag, args.services.length === 1 ? args.services[0] : ""]) {
|
|
if (mutableTags.has(tag)) {
|
|
blockers.push(
|
|
blocker({
|
|
type: "safety_blocker",
|
|
scope: "tag-policy",
|
|
summary: `mutable tag ${tag} is not allowed for DEV artifact identity`,
|
|
next: "Use the immutable git commit tag generated by this script."
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
if (buildRequired) {
|
|
const buildkitPreflight = await buildkitCommandPreflight(args.buildkitCommand);
|
|
if (!buildkitPreflight.ok) {
|
|
blockers.push(
|
|
blocker({
|
|
type: "environment_blocker",
|
|
scope: "buildkit-command",
|
|
summary: `BuildKit command ${args.buildkitCommand} is not available in the publish task image.`,
|
|
next: "Use the node Tekton BuildKit path that prepares buildctl/buildkitd before running --publish."
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
for (const service of services) {
|
|
if (!sourceStates.has(service.sourceState)) {
|
|
blockers.push(sourceStateBlocker(service));
|
|
} else if (service.sourceState === "intentionally-disabled") {
|
|
blockers.push(
|
|
blocker({
|
|
type: "runtime_blocker",
|
|
scope: service.serviceId,
|
|
summary: `${service.serviceId} is intentionally disabled for MVP artifact publication.`,
|
|
next: `Keep ${service.serviceId} out of the MVP artifact catalog, or add a source entrypoint before publishing it.`
|
|
})
|
|
);
|
|
} else if (service.artifactRequired && service.implementationState === "missing-runtime-entrypoint") {
|
|
blockers.push(
|
|
blocker({
|
|
type: "runtime_blocker",
|
|
scope: service.serviceId,
|
|
summary: `${service.serviceId} has no runtime entrypoint; a health-only placeholder image was not built as a real implementation.`,
|
|
next: `Add cmd/${service.serviceId}/main.mjs, cmd/${service.serviceId}/main.ts, or a dedicated Dockerfile for ${service.serviceId}.`
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
return blockers;
|
|
}
|
|
|
|
function hasFatalBlocker(blockers) {
|
|
return blockers.some((item) => fatalBlockerTypes.has(item.type));
|
|
}
|
|
|
|
async function buildService({ args, repo, commitId, shortCommit, service, buildCreatedAt }) {
|
|
if (service.envReuse) return buildEnvReuseService({ args, repo, commitId, service, buildCreatedAt });
|
|
const tag = shortCommit;
|
|
const ref = imageRef(args.registryPrefix, service.serviceId, tag);
|
|
const buildSource = `${repo}@${commitId}`;
|
|
if (!buildableImplementationStates.has(service.implementationState)) {
|
|
return {
|
|
...service,
|
|
image: ref,
|
|
imageTag: tag,
|
|
buildCreatedAt,
|
|
buildSource,
|
|
status: "blocked_missing_runtime",
|
|
digest: "not_published"
|
|
};
|
|
}
|
|
|
|
if (service.runtimeKind === "cloud-web") {
|
|
const check = await run(bunExecutable(), ["run", "--cwd", "web/hwlab-cloud-web", "check"]);
|
|
if (check.code !== 0) {
|
|
return {
|
|
...service,
|
|
image: ref,
|
|
imageTag: tag,
|
|
buildCreatedAt,
|
|
buildSource,
|
|
status: "build_failed",
|
|
digest: "not_published",
|
|
blocker: blocker({
|
|
type: "environment_blocker",
|
|
scope: service.serviceId,
|
|
summary: "cloud web source check failed before BuildKit image build",
|
|
next: "Run bun run --cwd web/hwlab-cloud-web check, fix TypeScript/unit/build failures, then rerun --publish."
|
|
}),
|
|
logTail: tailText(`${check.stdout}\n${check.stderr}`),
|
|
cloudWebCheckDurationMs: check.durationMs
|
|
};
|
|
}
|
|
|
|
service = {
|
|
...service,
|
|
cloudWebCheckDurationMs: check.durationMs
|
|
};
|
|
}
|
|
|
|
const port = servicePorts.get(service.serviceId) ?? 8080;
|
|
const labels = [
|
|
["org.opencontainers.image.source", "https://github.com/pikasTech/HWLAB"],
|
|
["org.opencontainers.image.revision", commitId],
|
|
["org.opencontainers.image.created", buildCreatedAt],
|
|
["org.opencontainers.image.title", service.serviceId],
|
|
["hwlab.pikastech.local/repo", repo],
|
|
["hwlab.pikastech.local/commit", commitId],
|
|
["hwlab.pikastech.local/build-created-at", buildCreatedAt],
|
|
["hwlab.pikastech.local/build-source", buildSource],
|
|
["hwlab.pikastech.local/service-id", service.serviceId],
|
|
["hwlab.pikastech.local/environment", ENVIRONMENT_DEV]
|
|
];
|
|
const envs = [
|
|
["HWLAB_ENVIRONMENT", ENVIRONMENT_DEV],
|
|
["HWLAB_SERVICE_ID", service.serviceId],
|
|
["HWLAB_ARTIFACT_KIND", service.runtimeKind],
|
|
["HWLAB_SERVICE_ENTRYPOINT", service.entrypoint ?? ""],
|
|
["HWLAB_COMMIT_ID", commitId],
|
|
["HWLAB_REVISION", commitId],
|
|
["HWLAB_IMAGE", ref],
|
|
["HWLAB_IMAGE_TAG", tag],
|
|
["HWLAB_IMAGE_DIGEST", "unknown"],
|
|
["HWLAB_BUILD_CREATED_AT", buildCreatedAt],
|
|
["HWLAB_BUILD_SOURCE", buildSource],
|
|
["HWLAB_BUILD_PROVENANCE", cliEntrypoint],
|
|
["PORT", String(port)],
|
|
["HWLAB_PORT", String(port)]
|
|
];
|
|
assert.equal(args.buildBackend, "buildkit", "artifact publish must use BuildKit only");
|
|
return buildServiceWithBuildkit({ args, service, ref, tag, buildCreatedAt, buildSource, port, labels, envs });
|
|
}
|
|
|
|
async function buildEnvReuseService({ args, repo, commitId, service, buildCreatedAt }) {
|
|
const ref = service.environmentImage ?? envReuseImageRef(args.registryPrefix, service.serviceId, service.environmentInputHash);
|
|
const buildSource = `${repo}@env:${service.environmentInputHash}`;
|
|
const labels = [
|
|
["org.opencontainers.image.source", "https://github.com/pikasTech/HWLAB"],
|
|
["org.opencontainers.image.revision", commitId],
|
|
["org.opencontainers.image.created", buildCreatedAt],
|
|
["org.opencontainers.image.title", `${service.serviceId}-env`],
|
|
["hwlab.pikastech.local/runtime-mode", v02EnvReuseRuntimeMode],
|
|
["hwlab.pikastech.local/service-id", service.serviceId],
|
|
["hwlab.pikastech.local/env-artifact-group", service.envArtifactGroupId ?? ""],
|
|
["hwlab.pikastech.local/env-artifact-build-service", service.envArtifactBuildServiceId ?? service.serviceId],
|
|
["hwlab.pikastech.local/environment-input-hash", service.environmentInputHash],
|
|
["hwlab.pikastech.local/build-created-at", buildCreatedAt]
|
|
];
|
|
const artifact = await buildEnvReuseServiceWithBuildkit({ args, service, ref, buildCreatedAt, buildSource, labels });
|
|
return withEnvReuseArtifactFields({ artifact, service, commitId, buildCreatedAt, buildSource });
|
|
}
|
|
|
|
function goEnvBaseDockerfile(baseImage, recipe) {
|
|
const packages = uniquePreserveOrder([...recipe.osPackages, ...recipe.goOsPackages, "tini"]).map(shellQuote).join(" ");
|
|
const stepTimingHelpers = `emit_hwlab_timing() { stage="$1"; status="$2"; duration_ms="$3"; node -e "console.error(JSON.stringify({event:'node-cicd-build-step-timing',stage:process.argv[1],status:process.argv[2],durationMs:Number(process.argv[3]),source:'dockerfile-run'}))" "$stage" "$status" "$duration_ms"; }; timed_stage() { label="$1"; shift; start="$(node -e "console.log(Date.now())")"; status=succeeded; "$@" || status=failed; end="$(node -e "console.log(Date.now())")"; elapsed=$((end - start)); emit_hwlab_timing "$label" "$status" "$elapsed"; test "$status" = succeeded; }; timed_skip() { emit_hwlab_timing "$1" skipped 0; }`;
|
|
const aptHelpers = `retry_apt_update() { attempts=6; n=1; while ! apt-get update -o Acquire::Retries=3; do if [ "$n" -ge "$attempts" ]; then return 1; fi; echo "apt-get update attempt $n/$attempts failed; retrying" >&2; n=$((n + 1)); rm -rf /var/lib/apt/lists/*; sleep 5; done; }; apt_packages_ready() { for pkg in "$@"; do dpkg-query -W -f='\${Status}' "$pkg" 2>/dev/null | grep -q 'install ok installed' || return 1; done; }; retry_apt_install() { attempts=8; n=1; while true; do apt-get install -o Acquire::Retries=3 -y --no-install-recommends --fix-missing "$@" || true; if apt_packages_ready "$@"; then return 0; fi; if [ "$n" -ge "$attempts" ]; then return 1; fi; echo "apt-get install attempt $n/$attempts incomplete; retrying" >&2; n=$((n + 1)); sleep 5; timed_stage "go-base-apt-update-retry" apt-get update -o Acquire::Retries=3 || true; done; }`;
|
|
const aptUpdateScript = `timed_stage "go-base-apt-update" retry_apt_update || (for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources; do [ -f "$f" ] || continue; sed -i -e '/^[[:space:]]*deb .* bookworm-updates /d' -e 's/[[:space:]]bookworm-updates//g' -e 's/bookworm-updates[[:space:]]//g' "$f"; done; timed_stage "go-base-apt-update-fallback" retry_apt_update)`;
|
|
const installScript = packages
|
|
? `if apt_packages_ready ${packages}; then timed_skip "go-base-apt-install"; else ${aptUpdateScript}; timed_stage "go-base-apt-install" retry_apt_install ${packages}; fi`
|
|
: `timed_skip "go-base-apt-install"`;
|
|
return [
|
|
`FROM ${baseImage}`,
|
|
"WORKDIR /opt/hwlab-env",
|
|
`RUN set -eu; export DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC; ${stepTimingHelpers}; ${aptHelpers}; ${installScript}; command -v go >/dev/null; go version >/tmp/hwlab-go-base-version.txt; rm -rf /var/lib/apt/lists/*`,
|
|
"ENV PATH=/usr/local/bin:$PATH",
|
|
""
|
|
].join("\n");
|
|
}
|
|
|
|
async function ensureGoEnvBaseImage({ args, normalizedRecipe, tmpDir, registryOption, buildkitAddrArgs, buildEnv }) {
|
|
if (!normalizedRecipe.goBaseImage) return null;
|
|
const image = normalizedRecipe.goBaseImage;
|
|
const cacheRef = goBaseCacheRef(args.registryPrefix);
|
|
if (await registryManifestExists(image)) {
|
|
return { image, status: "present", cacheRef, durationMs: 0 };
|
|
}
|
|
const dockerfilePath = path.join(tmpDir, "Dockerfile.go-base");
|
|
const metadataPath = path.join(tmpDir, "metadata.go-base.json");
|
|
await writeFile(dockerfilePath, goEnvBaseDockerfile(args.baseImage, normalizedRecipe));
|
|
const startedAt = Date.now();
|
|
const argsList = [
|
|
...buildkitAddrArgs,
|
|
"build",
|
|
"--frontend", "dockerfile.v0",
|
|
"--local", `context=${tmpDir}`,
|
|
"--local", `dockerfile=${tmpDir}`,
|
|
"--opt", "filename=Dockerfile.go-base",
|
|
"--metadata-file", metadataPath,
|
|
...buildkitCacheArgs(args, cacheRef, registryOption),
|
|
"--output", `type=image,name=${image},push=true${registryOption}`
|
|
];
|
|
if (!args.quietBuild || ciTimingEnabled()) argsList.push("--progress", "plain");
|
|
const result = await run(args.buildkitCommand, argsList, { env: buildEnv });
|
|
emitBuildkitTimingEvents({ service: { serviceId: "hwlab-go-env-base" }, result, timingEnabled: ciTimingEnabled(buildEnv) });
|
|
if (result.code !== 0) {
|
|
return {
|
|
image,
|
|
status: "build_failed",
|
|
cacheRef,
|
|
durationMs: Date.now() - startedAt,
|
|
logTail: tailText(`${result.stdout}\n${result.stderr}`, 12000)
|
|
};
|
|
}
|
|
let metadata = {};
|
|
try {
|
|
metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
|
} catch {}
|
|
return {
|
|
image,
|
|
status: "built",
|
|
digest: buildkitDigestFromMetadata(metadata),
|
|
cacheRef,
|
|
durationMs: Date.now() - startedAt
|
|
};
|
|
}
|
|
|
|
async function buildEnvReuseServiceWithBuildkit({ args, service, ref, buildCreatedAt, buildSource, labels }) {
|
|
const tmpDir = await mkdtemp(path.join(os.tmpdir(), `hwlab-env-reuse-${service.serviceId}-`));
|
|
const normalizedRecipe = normalizeEnvReuseRecipe(service.envReuseRecipe);
|
|
const goService = service?.runtimeKind === "go-service" || service?.artifactKind === "go-service";
|
|
const goCacheEnabled = Boolean(goService && normalizedRecipe.runtimeGoModCachePath && normalizedRecipe.runtimeGoBuildCachePath);
|
|
const contextDir = await prepareEnvReuseBuildContext(tmpDir, normalizedRecipe, goCacheEnabled);
|
|
const metadataPath = path.join(tmpDir, "metadata.json");
|
|
const dockerfilePath = path.join(tmpDir, "Dockerfile");
|
|
const cacheRef = service.envArtifactCacheRef ?? envReuseCacheRef(args.registryPrefix, service.serviceId);
|
|
const registryOption = buildkitRegistryOption(args.registryPrefix);
|
|
const buildkitAddrArgs = args.buildkitAddr ? ["--addr", args.buildkitAddr] : [];
|
|
const startedAt = Date.now();
|
|
const buildEnv = await prepareBuildkitEnv(args);
|
|
let goBaseEvidence = null;
|
|
try {
|
|
goBaseEvidence = goCacheEnabled
|
|
? await ensureGoEnvBaseImage({ args, normalizedRecipe, tmpDir, registryOption, buildkitAddrArgs, buildEnv })
|
|
: null;
|
|
if (goBaseEvidence?.status === "build_failed") {
|
|
return {
|
|
...service,
|
|
image: ref,
|
|
imageTag: imageTagFromReference(ref),
|
|
buildCreatedAt,
|
|
buildSource,
|
|
status: "build_failed",
|
|
digest: "not_published",
|
|
blocker: blocker({
|
|
type: "environment_blocker",
|
|
scope: service.serviceId,
|
|
summary: `BuildKit Go env base image build failed for ${service.serviceId}`,
|
|
next: "Inspect the Go env base BuildKit output and registry/cache availability."
|
|
}),
|
|
logTail: goBaseEvidence.logTail,
|
|
buildBackend: "buildkit-env-reuse",
|
|
...buildkitCacheEvidence(args, cacheRef),
|
|
...goBaseEvidenceFields(goBaseEvidence),
|
|
buildkitBuildDurationMs: Date.now() - startedAt
|
|
};
|
|
}
|
|
await writeFile(dockerfilePath, envReuseDockerfile(args.baseImage, labels, normalizedRecipe, service));
|
|
const argsList = [
|
|
...buildkitAddrArgs,
|
|
"build",
|
|
"--frontend", "dockerfile.v0",
|
|
"--local", `context=${contextDir}`,
|
|
"--local", `dockerfile=${tmpDir}`,
|
|
"--opt", "filename=Dockerfile",
|
|
"--metadata-file", metadataPath,
|
|
...buildkitCacheArgs(args, cacheRef, registryOption),
|
|
"--output", `type=image,name=${ref},push=true${registryOption}`
|
|
];
|
|
if (!args.quietBuild || ciTimingEnabled()) argsList.push("--progress", "plain");
|
|
const result = await run(args.buildkitCommand, argsList, { env: buildEnv });
|
|
emitBuildkitTimingEvents({ service, result, timingEnabled: ciTimingEnabled(buildEnv) });
|
|
if (result.code !== 0) {
|
|
return {
|
|
...service,
|
|
image: ref,
|
|
imageTag: imageTagFromReference(ref),
|
|
buildCreatedAt,
|
|
buildSource,
|
|
status: "build_failed",
|
|
digest: "not_published",
|
|
blocker: blocker({
|
|
type: "environment_blocker",
|
|
scope: service.serviceId,
|
|
summary: `BuildKit env image build failed for ${service.serviceId}`,
|
|
next: "Inspect the env image BuildKit output and base image git/bun availability."
|
|
}),
|
|
logTail: tailText(`${result.stdout}\n${result.stderr}`, 12000),
|
|
buildBackend: "buildkit-env-reuse",
|
|
...buildkitCacheEvidence(args, cacheRef),
|
|
...goBaseEvidenceFields(goBaseEvidence),
|
|
buildkitBuildDurationMs: result.durationMs
|
|
};
|
|
}
|
|
let metadata = {};
|
|
try {
|
|
metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
|
} catch {}
|
|
const digest = buildkitDigestFromMetadata(metadata);
|
|
if (!shaDigestPattern.test(digest ?? "")) {
|
|
return {
|
|
...service,
|
|
image: ref,
|
|
imageTag: imageTagFromReference(ref),
|
|
buildCreatedAt,
|
|
buildSource,
|
|
status: "published_unverified_digest",
|
|
digest: "not_published",
|
|
blocker: blocker({
|
|
type: "environment_blocker",
|
|
scope: service.serviceId,
|
|
summary: `BuildKit completed env image for ${service.serviceId} but did not expose an immutable digest`,
|
|
next: "Inspect BuildKit metadata output before updating the v02 env catalog."
|
|
}),
|
|
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200),
|
|
buildBackend: "buildkit-env-reuse",
|
|
...buildkitCacheEvidence(args, cacheRef),
|
|
...goBaseEvidenceFields(goBaseEvidence),
|
|
buildkitBuildDurationMs: Date.now() - startedAt
|
|
};
|
|
}
|
|
return {
|
|
...service,
|
|
image: ref,
|
|
imageTag: imageTagFromReference(ref),
|
|
buildCreatedAt,
|
|
buildSource,
|
|
status: args.mode === "publish" ? "published" : "built",
|
|
digest,
|
|
repositoryDigest: `${repositoryFromImageRef(ref)}@${digest}`,
|
|
buildBackend: "buildkit-env-reuse",
|
|
...buildkitCacheEvidence(args, cacheRef),
|
|
...goBaseEvidenceFields(goBaseEvidence),
|
|
buildkitBuildDurationMs: Date.now() - startedAt,
|
|
publishDurationMs: args.mode === "publish" ? Date.now() - startedAt : null,
|
|
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200)
|
|
};
|
|
} finally {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function withEnvReuseArtifactFields({ artifact, service, commitId, buildCreatedAt = null, buildSource = null }) {
|
|
const environmentImage = artifact.image ?? service.environmentImage ?? null;
|
|
const environmentDigest = artifact.digest ?? service.environmentDigest ?? "not_published";
|
|
const bootCommit = commitId;
|
|
return {
|
|
...artifact,
|
|
runtimeMode: v02EnvReuseRuntimeMode,
|
|
envReuse: true,
|
|
envChanged: service.envChanged ?? null,
|
|
codeChanged: service.codeChanged ?? null,
|
|
commitId,
|
|
sourceCommitId: commitId,
|
|
environmentImage,
|
|
environmentDigest,
|
|
environmentInputHash: service.environmentInputHash ?? null,
|
|
codeInputHash: service.codeInputHash ?? null,
|
|
envArtifactGroupId: service.envArtifactGroupId ?? null,
|
|
envArtifactGroup: service.envArtifactGroup ?? null,
|
|
envArtifactBuildServiceId: service.envArtifactBuildServiceId ?? service.serviceId,
|
|
envArtifactSourceServiceId: service.envArtifactSourceServiceId ?? service.serviceId,
|
|
envArtifactCacheRef: service.envArtifactCacheRef ?? null,
|
|
sharedEnvBuildSkipped: service.sharedEnvBuildSkipped === true,
|
|
bootRepo: service.bootRepo ?? "git@github.com:pikasTech/HWLAB.git",
|
|
bootCommit,
|
|
bootSh: service.bootSh ?? `deploy/runtime/boot/${service.serviceId}.sh`,
|
|
image: environmentImage,
|
|
imageTag: imageTagFromReference(environmentImage),
|
|
digest: environmentDigest,
|
|
buildCreatedAt: artifact.buildCreatedAt ?? buildCreatedAt,
|
|
buildSource: artifact.buildSource ?? buildSource,
|
|
bootEnvEvidence: bootEnvEvidence({ service: { ...service, bootCommit }, commitId, environmentImage, environmentDigest })
|
|
};
|
|
}
|
|
|
|
function bootEnvEvidence({ service, commitId, environmentImage, environmentDigest }) {
|
|
return {
|
|
runtimeMode: v02EnvReuseRuntimeMode,
|
|
environmentDigest: environmentDigest ?? null,
|
|
environmentImage: environmentImage ?? null,
|
|
bootRepo: service.bootRepo ?? "git@github.com:pikasTech/HWLAB.git",
|
|
bootCommit: service.bootCommit ?? commitId,
|
|
bootSh: service.bootSh ?? `deploy/runtime/boot/${service.serviceId}.sh`,
|
|
env: {
|
|
HWLAB_BOOT_REPO: service.bootRepo ?? "git@github.com:pikasTech/HWLAB.git",
|
|
HWLAB_BOOT_COMMIT: service.bootCommit ?? commitId,
|
|
HWLAB_BOOT_SH: service.bootSh ?? `deploy/runtime/boot/${service.serviceId}.sh`,
|
|
HWLAB_ENVIRONMENT_IMAGE: environmentImage ?? "",
|
|
HWLAB_ENVIRONMENT_DIGEST: environmentDigest ?? ""
|
|
}
|
|
};
|
|
}
|
|
|
|
async function buildServiceWithBuildkit({ args, service, ref, tag, buildCreatedAt, buildSource, port, labels, envs }) {
|
|
const tmpDir = await mkdtemp(path.join(os.tmpdir(), `hwlab-buildkit-${service.serviceId}-`));
|
|
const metadataPath = path.join(tmpDir, "metadata.json");
|
|
const dockerfilePath = path.join(tmpDir, "Dockerfile");
|
|
const cacheRef = buildkitCacheRef(args.registryPrefix, service.serviceId);
|
|
const registryOption = buildkitRegistryOption(args.registryPrefix);
|
|
const buildkitAddrArgs = args.buildkitAddr ? ["--addr", args.buildkitAddr] : [];
|
|
const startedAt = Date.now();
|
|
try {
|
|
await writeFile(dockerfilePath, dockerfile(args.baseImage, port, labels));
|
|
const argsList = [
|
|
...buildkitAddrArgs,
|
|
"build",
|
|
"--frontend", "dockerfile.v0",
|
|
"--local", `context=${repoRoot}`,
|
|
"--local", `dockerfile=${tmpDir}`,
|
|
"--opt", "filename=Dockerfile",
|
|
"--metadata-file", metadataPath,
|
|
...buildkitCacheArgs(args, cacheRef, registryOption),
|
|
"--output", `type=image,name=${ref},push=true${registryOption}`
|
|
];
|
|
if (!args.quietBuild || ciTimingEnabled()) argsList.push("--progress", "plain");
|
|
for (const [name, value] of envs) argsList.push("--opt", `build-arg:${name}=${value}`);
|
|
|
|
const buildEnv = { ...process.env };
|
|
if (!args.buildkitAddr) {
|
|
buildEnv.BUILDKITD_FLAGS = process.env.BUILDKITD_FLAGS || "--oci-worker-no-process-sandbox";
|
|
buildEnv.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR || path.join(os.tmpdir(), "hwlab-buildkit-runtime");
|
|
await mkdir(buildEnv.XDG_RUNTIME_DIR, { recursive: true });
|
|
}
|
|
const result = await run(args.buildkitCommand, argsList, { env: buildEnv });
|
|
emitBuildkitTimingEvents({ service, result, timingEnabled: ciTimingEnabled(buildEnv) });
|
|
if (result.code !== 0) {
|
|
return {
|
|
...service,
|
|
image: ref,
|
|
imageTag: tag,
|
|
buildCreatedAt,
|
|
buildSource,
|
|
status: "build_failed",
|
|
digest: "not_published",
|
|
blocker: blocker({
|
|
type: "environment_blocker",
|
|
scope: service.serviceId,
|
|
summary: `BuildKit build failed for ${service.serviceId}`,
|
|
next: `Inspect the BuildKit output for ${service.serviceId}, then fix its copied source, base image, or registry cache settings.`
|
|
}),
|
|
logTail: tailText(`${result.stdout}\n${result.stderr}`, 12000),
|
|
buildBackend: "buildkit",
|
|
...buildkitCacheEvidence(args, cacheRef),
|
|
buildkitBuildDurationMs: result.durationMs
|
|
};
|
|
}
|
|
|
|
let metadata = {};
|
|
try {
|
|
metadata = JSON.parse(await readFile(metadataPath, "utf8"));
|
|
} catch {}
|
|
const digest = buildkitDigestFromMetadata(metadata);
|
|
if (!shaDigestPattern.test(digest ?? "")) {
|
|
return {
|
|
...service,
|
|
image: ref,
|
|
imageTag: tag,
|
|
buildCreatedAt,
|
|
buildSource,
|
|
status: "published_unverified_digest",
|
|
digest: "not_published",
|
|
blocker: blocker({
|
|
type: "environment_blocker",
|
|
scope: service.serviceId,
|
|
summary: `BuildKit completed for ${service.serviceId} but did not expose an immutable digest`,
|
|
next: "Inspect BuildKit metadata output before updating the artifact catalog."
|
|
}),
|
|
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200),
|
|
buildBackend: "buildkit",
|
|
...buildkitCacheEvidence(args, cacheRef),
|
|
buildkitBuildDurationMs: Date.now() - startedAt
|
|
};
|
|
}
|
|
|
|
return {
|
|
...service,
|
|
image: ref,
|
|
imageTag: tag,
|
|
buildCreatedAt,
|
|
buildSource,
|
|
status: args.mode === "publish" ? "published" : "built",
|
|
digest,
|
|
repositoryDigest: `${repositoryFromImageRef(ref)}@${digest}`,
|
|
buildBackend: "buildkit",
|
|
...buildkitCacheEvidence(args, cacheRef),
|
|
buildkitBuildDurationMs: Date.now() - startedAt,
|
|
publishDurationMs: args.mode === "publish" ? Date.now() - startedAt : null,
|
|
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200)
|
|
};
|
|
} finally {
|
|
await rm(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function runWithInput(command, args, input) {
|
|
const startedAt = Date.now();
|
|
const child = spawn(command, args, {
|
|
cwd: repoRoot,
|
|
env: process.env,
|
|
stdio: ["pipe", "pipe", "pipe"]
|
|
});
|
|
child.stdin.end(input);
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
|
|
const code = await new Promise((resolve) => {
|
|
child.on("error", () => resolve(127));
|
|
child.on("close", resolve);
|
|
});
|
|
|
|
return {
|
|
command: commandLine(command, args),
|
|
code,
|
|
stdout,
|
|
stderr,
|
|
durationMs: Date.now() - startedAt
|
|
};
|
|
}
|
|
|
|
function repositoryFromImageRef(image) {
|
|
const lastColon = image.lastIndexOf(":");
|
|
return lastColon === -1 ? image : image.slice(0, lastColon);
|
|
}
|
|
|
|
const publishReadyStatuses = new Set(["published", "reused"]);
|
|
const buildReadyStatuses = new Set(["built", "published", "published_unverified_digest", "reused"]);
|
|
|
|
function reportStatus(mode, artifacts, blockers) {
|
|
const requiredArtifacts = artifacts.filter((artifact) => artifact.artifactRequired);
|
|
if (mode === "preflight") return blockers.length ? "blocked" : "pass";
|
|
if (requiredArtifacts.some((artifact) => artifact.status === "publish_failed" || artifact.status === "build_failed")) return "failed";
|
|
if (requiredArtifacts.some((artifact) => artifact.status.startsWith("blocked_"))) return "blocked";
|
|
if (mode === "publish" && requiredArtifacts.length > 0 && requiredArtifacts.every((artifact) => publishReadyStatuses.has(artifact.status))) return "published";
|
|
if (mode === "build" && requiredArtifacts.length > 0 && requiredArtifacts.every((artifact) => buildReadyStatuses.has(artifact.status))) return "built";
|
|
return blockers.length ? "blocked" : "pass";
|
|
}
|
|
|
|
function devGateBlockers(blockers) {
|
|
const seen = new Set();
|
|
const result = [];
|
|
for (const item of blockers) {
|
|
const key = `${item.type}::${item.scope}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push({
|
|
type: item.type,
|
|
scope: item.scope,
|
|
status: item.status,
|
|
summary: item.summary
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function artifactNotPublishedReason(artifact, mode, fatalBlocked) {
|
|
if (/^sha256:[a-f0-9]{64}$/u.test(artifact.digest ?? "")) return null;
|
|
if (!artifact.artifactRequired) return artifact.notPublishedReason;
|
|
if (artifact.status === "preflight_only") return fatalBlocked ? "blocked_by_preflight" : "preflight_only";
|
|
if (artifact.status === "built") return "build_only_not_published";
|
|
if (artifact.status === "build_failed") return "build_failed";
|
|
if (artifact.status === "publish_failed") return "publish_failed";
|
|
if (artifact.status === "published_unverified_digest") return "digest_unverified";
|
|
return artifact.notPublishedReason ?? "publish_not_run";
|
|
}
|
|
|
|
function imageTagFromReference(image) {
|
|
const value = String(image ?? "");
|
|
const slashIndex = value.lastIndexOf("/");
|
|
const colonIndex = value.lastIndexOf(":");
|
|
return colonIndex > slashIndex ? value.slice(colonIndex + 1) : null;
|
|
}
|
|
|
|
function repositoryDigestFor(image, digest) {
|
|
return image && shaDigestPattern.test(digest ?? "") ? `${repositoryFromImageRef(image)}@${digest}` : null;
|
|
}
|
|
|
|
function serviceEnvKey(serviceId) {
|
|
return String(serviceId).toUpperCase().replace(/[^A-Z0-9]+/gu, "_");
|
|
}
|
|
|
|
function serviceResultEnvName(serviceId, field) {
|
|
return `HWLAB_SERVICE_RESULT_${serviceEnvKey(serviceId)}_${field}`;
|
|
}
|
|
|
|
function serviceResultValue(env, serviceId, field) {
|
|
const value = env[serviceResultEnvName(serviceId, field)];
|
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
}
|
|
|
|
async function writeTektonResultFiles(dir, report) {
|
|
const service = report?.artifactPublish?.services?.[0];
|
|
if (!service) return;
|
|
const values = {
|
|
"service-id": service.serviceId,
|
|
status: service.status,
|
|
image: service.image,
|
|
"image-tag": service.imageTag,
|
|
digest: service.digest,
|
|
"repository-digest": service.repositoryDigest ?? repositoryDigestFor(service.image, service.digest) ?? "",
|
|
"source-commit-id": service.sourceCommitId,
|
|
"component-input-hash": service.componentInputHash ?? "",
|
|
"environment-input-hash": service.environmentInputHash ?? "",
|
|
"code-input-hash": service.codeInputHash ?? "",
|
|
"runtime-mode": service.runtimeMode ?? "service-image",
|
|
"boot-repo": service.bootRepo ?? "",
|
|
"boot-commit": service.bootCommit ?? "",
|
|
"boot-sh": service.bootSh ?? "",
|
|
"build-created-at": service.buildCreatedAt ?? "",
|
|
"build-backend": report.artifactPublish.buildBackend ?? "unknown",
|
|
"reused-from": service.reusedFrom ?? "",
|
|
"go-base-image": service.goBaseImage ?? "",
|
|
"go-base-image-status": service.goBaseImageStatus ?? "",
|
|
"go-base-digest": service.goBaseDigest ?? "",
|
|
"go-base-build-duration-ms": service.goBaseBuildDurationMs ?? "",
|
|
"go-base-buildkit-cache-ref": service.goBaseBuildkitCacheRef ?? ""
|
|
};
|
|
await mkdir(dir, { recursive: true });
|
|
await Promise.all(Object.entries(values).map(([name, value]) => writeFile(path.join(dir, name), String(value ?? ""))));
|
|
}
|
|
|
|
function artifactRecord({ args, service, commitId, shortCommit, buildCreatedAt = null, buildSource = null, status, digest = "not_published" }) {
|
|
if (service.envReuse) {
|
|
const environmentImage = service.environmentImage ?? envReuseImageRef(args.registryPrefix, service.serviceId, service.environmentInputHash ?? commitId);
|
|
const environmentDigest = service.environmentDigest ?? digest;
|
|
return withEnvReuseArtifactFields({
|
|
artifact: {
|
|
...service,
|
|
commitId,
|
|
sourceCommitId: commitId,
|
|
status,
|
|
image: environmentImage,
|
|
imageTag: imageTagFromReference(environmentImage),
|
|
buildCreatedAt,
|
|
buildSource,
|
|
digest: environmentDigest,
|
|
repositoryDigest: repositoryDigestFor(environmentImage, environmentDigest)
|
|
},
|
|
service,
|
|
commitId,
|
|
buildCreatedAt,
|
|
buildSource
|
|
});
|
|
}
|
|
return {
|
|
...service,
|
|
commitId: shortCommit,
|
|
sourceCommitId: commitId,
|
|
status,
|
|
image: imageRef(args.registryPrefix, service.serviceId, shortCommit),
|
|
imageTag: shortCommit,
|
|
buildCreatedAt,
|
|
buildSource,
|
|
digest,
|
|
repositoryDigest: null
|
|
};
|
|
}
|
|
|
|
function artifactRecordForCatalogReuse({ service, catalogService, commitId }) {
|
|
if (service.envReuse) {
|
|
return artifactRecordForEnvReuseCatalogReuse({ service, catalogService, commitId });
|
|
}
|
|
const image = catalogService?.image ?? null;
|
|
const digest = catalogService?.digest ?? "not_published";
|
|
const imageTag = catalogService?.imageTag ?? imageTagFromReference(image);
|
|
const catalogCommitId = catalogService?.commitId ?? imageTag ?? null;
|
|
const catalogProvenance = serviceImageCatalogProvenance(catalogService);
|
|
if (!image || !imageTag || !shaDigestPattern.test(digest)) {
|
|
return {
|
|
...service,
|
|
commitId: catalogCommitId,
|
|
sourceCommitId: catalogService?.sourceCommitId ?? catalogCommitId ?? commitId,
|
|
status: "blocked_reuse_unavailable",
|
|
image: image ?? null,
|
|
imageTag,
|
|
buildCreatedAt: catalogService?.buildCreatedAt ?? null,
|
|
buildSource: catalogService?.buildSource ?? null,
|
|
digest,
|
|
repositoryDigest: repositoryDigestFor(image, digest),
|
|
...catalogProvenance,
|
|
buildBackend: "reused-catalog",
|
|
reusedFrom: null,
|
|
notPublishedReason: "reuse_catalog_digest_unavailable"
|
|
};
|
|
}
|
|
return {
|
|
...service,
|
|
commitId: catalogCommitId,
|
|
sourceCommitId: catalogService?.sourceCommitId ?? catalogCommitId ?? commitId,
|
|
status: "reused",
|
|
image,
|
|
imageTag,
|
|
buildCreatedAt: catalogService?.buildCreatedAt ?? null,
|
|
buildSource: catalogService?.buildSource ?? null,
|
|
digest,
|
|
repositoryDigest: catalogService?.repositoryDigest ?? repositoryDigestFor(image, digest),
|
|
...catalogProvenance,
|
|
buildBackend: "reused-catalog",
|
|
reusedFrom: service.reuse?.reusedFrom ?? catalogService?.componentInputHash ?? catalogCommitId,
|
|
notPublishedReason: null
|
|
};
|
|
}
|
|
|
|
function serviceImageCatalogProvenance(catalogService) {
|
|
return {
|
|
componentCommitId: catalogService?.componentCommitId ?? null,
|
|
componentInputHash: catalogService?.componentInputHash ?? null,
|
|
dockerfileHash: catalogService?.dockerfileHash ?? null,
|
|
baseImageReference: catalogService?.baseImageReference ?? null,
|
|
baseImageDigest: catalogService?.baseImageDigest ?? null,
|
|
buildArgsHash: catalogService?.buildArgsHash ?? null
|
|
};
|
|
}
|
|
|
|
function artifactRecordForEnvReuseCatalogReuse({ service, catalogService, commitId }) {
|
|
const environmentImage = service.environmentImage ?? catalogService?.environmentImage ?? null;
|
|
const environmentDigest = service.environmentDigest ?? catalogService?.environmentDigest ?? "not_published";
|
|
const imageTag = imageTagFromReference(environmentImage);
|
|
const bootCommit = commitId;
|
|
const base = {
|
|
...service,
|
|
runtimeMode: v02EnvReuseRuntimeMode,
|
|
envReuse: true,
|
|
envChanged: service.envChanged ?? null,
|
|
codeChanged: service.codeChanged ?? null,
|
|
commitId: bootCommit,
|
|
sourceCommitId: bootCommit,
|
|
image: environmentImage,
|
|
imageTag,
|
|
digest: environmentDigest,
|
|
environmentImage,
|
|
environmentDigest,
|
|
environmentInputHash: service.environmentInputHash ?? catalogService?.environmentInputHash ?? null,
|
|
codeInputHash: service.codeInputHash ?? null,
|
|
bootRepo: service.bootRepo ?? catalogService?.bootRepo ?? "git@github.com:pikasTech/HWLAB.git",
|
|
bootCommit,
|
|
bootSh: service.bootSh ?? catalogService?.bootSh ?? `deploy/runtime/boot/${service.serviceId}.sh`,
|
|
buildCreatedAt: catalogService?.buildCreatedAt ?? null,
|
|
buildSource: catalogService?.buildSource ?? null,
|
|
buildBackend: "reused-env-catalog",
|
|
repositoryDigest: service.repositoryDigest ?? repositoryDigestFor(environmentImage, environmentDigest) ?? catalogService?.repositoryDigest ?? null,
|
|
bootEnvEvidence: bootEnvEvidence({ service: { ...service, bootCommit }, commitId, environmentImage, environmentDigest })
|
|
};
|
|
if (!environmentImage || !imageTag || !shaDigestPattern.test(environmentDigest)) {
|
|
return {
|
|
...base,
|
|
status: "blocked_reuse_unavailable",
|
|
reusedFrom: null,
|
|
notPublishedReason: "environment_reuse_digest_unavailable"
|
|
};
|
|
}
|
|
return {
|
|
...base,
|
|
status: "reused",
|
|
reusedFrom: service.reuse?.reusedFrom ?? service.environmentInputHash ?? catalogService?.environmentInputHash ?? catalogService?.componentInputHash ?? imageTag,
|
|
notPublishedReason: null
|
|
};
|
|
}
|
|
|
|
function externalServiceReportPath(dir, serviceId) {
|
|
const base = path.isAbsolute(dir) ? dir : path.join(repoRoot, dir);
|
|
return path.join(base, `${serviceId}.json`);
|
|
}
|
|
|
|
function artifactRecordFromExternalReport({ service, record, reportPath }) {
|
|
const image = record?.image ?? null;
|
|
const digest = record?.digest ?? "not_published";
|
|
const imageTag = record?.imageTag ?? imageTagFromReference(image);
|
|
return {
|
|
...service,
|
|
...record,
|
|
serviceId: service.serviceId,
|
|
runtimeKind: record?.runtimeKind ?? service.runtimeKind,
|
|
implementationState: record?.implementationState ?? service.implementationState,
|
|
sourceState: record?.sourceState ?? service.sourceState,
|
|
entrypoint: record?.entrypoint ?? service.entrypoint,
|
|
publishEnabled: record?.publishEnabled ?? service.publishEnabled,
|
|
artifactRequired: record?.artifactRequired ?? service.artifactRequired,
|
|
artifactScope: record?.artifactScope ?? service.artifactScope,
|
|
image,
|
|
imageTag,
|
|
digest,
|
|
repositoryDigest: record?.repositoryDigest ?? repositoryDigestFor(image, digest),
|
|
externalReportPath: reportPath,
|
|
notPublishedReason: record?.notPublishedReason ?? null
|
|
};
|
|
}
|
|
|
|
function externalArtifactSourceCommitMismatchIsFatal({ artifact, service, commitId }) {
|
|
if (!artifact.sourceCommitId || artifact.sourceCommitId === commitId) return false;
|
|
if (
|
|
artifact.status === "reused" &&
|
|
(artifact.envReuse === true || service?.envReuse === true) &&
|
|
artifact.affected === false &&
|
|
artifact.codeChanged === false &&
|
|
artifact.envChanged === false
|
|
) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function readExternalServiceArtifacts({ dir, services, commitId }) {
|
|
const artifacts = new Map();
|
|
const blockers = [];
|
|
for (const service of services) {
|
|
const buildRequired = serviceRequiresCurrentBuildArtifact(service);
|
|
if (!buildRequired) continue;
|
|
const reportPath = externalServiceReportPath(dir, service.serviceId);
|
|
let report;
|
|
try {
|
|
report = JSON.parse(await readFile(reportPath, "utf8"));
|
|
} catch (error) {
|
|
if (!buildRequired) {
|
|
continue;
|
|
}
|
|
blockers.push(blocker({
|
|
type: "environment_blocker",
|
|
scope: service.serviceId,
|
|
summary: `missing per-service artifact report for ${service.serviceId}: ${error.message}`,
|
|
next: `Ensure the Tekton build-${service.serviceId} TaskRun writes ${reportPath} before collect-artifacts runs.`
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
const record = report?.artifactPublish?.services?.find((item) => item?.serviceId === service.serviceId);
|
|
if (!record) {
|
|
blockers.push(blocker({
|
|
type: "contract_blocker",
|
|
scope: service.serviceId,
|
|
summary: `per-service artifact report ${reportPath} does not contain ${service.serviceId}`,
|
|
next: "Regenerate the service artifact report with --services set to the same service ID."
|
|
}));
|
|
continue;
|
|
}
|
|
|
|
const artifact = artifactRecordFromExternalReport({ service, record, reportPath });
|
|
artifacts.set(service.serviceId, artifact);
|
|
if (!publishReadyStatuses.has(artifact.status) || !shaDigestPattern.test(artifact.digest ?? "")) {
|
|
blockers.push(blocker({
|
|
type: "environment_blocker",
|
|
scope: service.serviceId,
|
|
summary: `per-service artifact report ${reportPath} is not a verified published/reused image for ${service.serviceId}`,
|
|
next: "Inspect the corresponding service TaskRun logs and rerun the failed service build."
|
|
}));
|
|
}
|
|
if (externalArtifactSourceCommitMismatchIsFatal({ artifact, service, commitId })) {
|
|
blockers.push(blocker({
|
|
type: "contract_blocker",
|
|
scope: service.serviceId,
|
|
summary: `per-service artifact report ${reportPath} was produced for ${artifact.sourceCommitId}, expected ${commitId}`,
|
|
next: "Discard stale service reports and rerun the current PipelineRun."
|
|
}));
|
|
}
|
|
}
|
|
return { artifacts, blockers };
|
|
}
|
|
|
|
function artifactRecordFromSharedEnvArtifact({ service, sourceArtifact, commitId }) {
|
|
const environmentImage = sourceArtifact.environmentImage ?? sourceArtifact.image ?? service.environmentImage ?? null;
|
|
const environmentDigest = sourceArtifact.environmentDigest ?? sourceArtifact.digest ?? service.environmentDigest ?? "not_published";
|
|
const artifact = withEnvReuseArtifactFields({
|
|
artifact: {
|
|
...sourceArtifact,
|
|
...service,
|
|
serviceId: service.serviceId,
|
|
status: publishReadyStatuses.has(sourceArtifact.status) ? "reused" : sourceArtifact.status,
|
|
image: environmentImage,
|
|
imageTag: imageTagFromReference(environmentImage),
|
|
digest: environmentDigest,
|
|
repositoryDigest: repositoryDigestFor(environmentImage, environmentDigest),
|
|
buildBackend: "shared-env-artifact",
|
|
reusedFrom: sourceArtifact.serviceId ?? service.envArtifactSourceServiceId ?? service.envArtifactGroupId ?? null,
|
|
notPublishedReason: null
|
|
},
|
|
service,
|
|
commitId,
|
|
buildCreatedAt: sourceArtifact.buildCreatedAt ?? null,
|
|
buildSource: sourceArtifact.buildSource ?? null
|
|
});
|
|
return {
|
|
...artifact,
|
|
envArtifactSourceServiceId: sourceArtifact.serviceId ?? service.envArtifactSourceServiceId ?? null,
|
|
sharedEnvBuildSkipped: true,
|
|
ciReason: uniquePreserveOrder([...(Array.isArray(service.reason) ? service.reason : []), "shared-env-artifact-consumer"])
|
|
};
|
|
}
|
|
|
|
function serviceRequiresCurrentBuildArtifact(service) {
|
|
return service?.buildRequired === true || service?.ciBuildRequired === true;
|
|
}
|
|
|
|
function artifactRecordFromServiceResultsEnv({ service, env }) {
|
|
const status = serviceResultValue(env, service.serviceId, "STATUS");
|
|
if (!status) return null;
|
|
const image = serviceResultValue(env, service.serviceId, "IMAGE");
|
|
const digest = serviceResultValue(env, service.serviceId, "DIGEST") ?? "not_published";
|
|
const imageTag = serviceResultValue(env, service.serviceId, "IMAGE_TAG") ?? imageTagFromReference(image);
|
|
const reusedServiceImage = status === "reused" && service.envReuse !== true;
|
|
const artifact = {
|
|
...service,
|
|
serviceId: service.serviceId,
|
|
status,
|
|
image,
|
|
imageTag,
|
|
digest,
|
|
repositoryDigest: serviceResultValue(env, service.serviceId, "REPOSITORY_DIGEST") ?? repositoryDigestFor(image, digest),
|
|
sourceCommitId: serviceResultValue(env, service.serviceId, "SOURCE_COMMIT_ID"),
|
|
commitId: imageTag,
|
|
buildCreatedAt: serviceResultValue(env, service.serviceId, "BUILD_CREATED_AT"),
|
|
componentInputHash: serviceResultValue(env, service.serviceId, "COMPONENT_INPUT_HASH") ?? (reusedServiceImage ? null : service.componentInputHash ?? null),
|
|
environmentInputHash: serviceResultValue(env, service.serviceId, "ENVIRONMENT_INPUT_HASH") ?? service.environmentInputHash ?? null,
|
|
codeInputHash: serviceResultValue(env, service.serviceId, "CODE_INPUT_HASH") ?? service.codeInputHash ?? null,
|
|
runtimeMode: serviceResultValue(env, service.serviceId, "RUNTIME_MODE") ?? service.runtimeMode ?? "service-image",
|
|
bootRepo: serviceResultValue(env, service.serviceId, "BOOT_REPO") ?? service.bootRepo ?? null,
|
|
bootCommit: serviceResultValue(env, service.serviceId, "BOOT_COMMIT") ?? service.bootCommit ?? null,
|
|
bootSh: serviceResultValue(env, service.serviceId, "BOOT_SH") ?? service.bootSh ?? null,
|
|
reusedFrom: serviceResultValue(env, service.serviceId, "REUSED_FROM"),
|
|
buildBackend: serviceResultValue(env, service.serviceId, "BUILD_BACKEND"),
|
|
...(reusedServiceImage ? {
|
|
componentCommitId: null,
|
|
dockerfileHash: null,
|
|
baseImageReference: null,
|
|
baseImageDigest: null,
|
|
buildArgsHash: null
|
|
} : {}),
|
|
notPublishedReason: null
|
|
};
|
|
if (service.envReuse) {
|
|
return withEnvReuseArtifactFields({ artifact, service, commitId: artifact.sourceCommitId || service.bootCommit || "" });
|
|
}
|
|
return artifact;
|
|
}
|
|
|
|
async function readExternalServiceArtifactsFromEnv({ services, commitId, env = process.env }) {
|
|
const artifacts = new Map();
|
|
const blockers = [];
|
|
for (const service of services) {
|
|
const artifact = artifactRecordFromServiceResultsEnv({ service, env });
|
|
if (!artifact) {
|
|
blockers.push(blocker({
|
|
type: "environment_blocker",
|
|
scope: service.serviceId,
|
|
summary: `missing Tekton result env for ${service.serviceId}`,
|
|
next: `Ensure build-${service.serviceId} writes Tekton results and collect-artifacts passes them as HWLAB_SERVICE_RESULT_${serviceEnvKey(service.serviceId)}_* env vars.`
|
|
}));
|
|
continue;
|
|
}
|
|
artifacts.set(service.serviceId, artifact);
|
|
if (!publishReadyStatuses.has(artifact.status) || !shaDigestPattern.test(artifact.digest ?? "")) {
|
|
blockers.push(blocker({
|
|
type: "environment_blocker",
|
|
scope: service.serviceId,
|
|
summary: `Tekton result for ${service.serviceId} is not a verified published/reused image`,
|
|
next: "Inspect the corresponding build task results and registry digest before collecting artifacts."
|
|
}));
|
|
}
|
|
if (artifact.status === "published" && artifact.sourceCommitId && artifact.sourceCommitId !== commitId) {
|
|
blockers.push(blocker({
|
|
type: "contract_blocker",
|
|
scope: service.serviceId,
|
|
summary: `Tekton result for ${service.serviceId} was produced for ${artifact.sourceCommitId}, expected ${commitId}`,
|
|
next: "Discard stale PipelineRun results and rerun the current source commit."
|
|
}));
|
|
}
|
|
}
|
|
return { artifacts, blockers };
|
|
}
|
|
|
|
function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked, registryCapabilities }) {
|
|
return {
|
|
version: "v2",
|
|
mode,
|
|
sourceCommitId: commitId,
|
|
imageTag: shortCommit,
|
|
registryTarget: args.registryPrefix,
|
|
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
|
|
digestPlaceholder: "not_published",
|
|
services: artifacts.map((artifact) => {
|
|
const image = artifact.image ?? imageRef(args.registryPrefix, artifact.serviceId, shortCommit);
|
|
const digest = artifact.digest ?? "not_published";
|
|
return {
|
|
serviceId: artifact.serviceId,
|
|
enabled: artifact.publishEnabled,
|
|
required: artifact.artifactRequired,
|
|
artifactScope: artifact.artifactScope,
|
|
commitId: artifact.commitId ?? shortCommit,
|
|
sourceCommitId: artifact.sourceCommitId ?? commitId,
|
|
registryTarget: imageRepository(args.registryPrefix, artifact.serviceId),
|
|
image,
|
|
imageTag: artifact.imageTag ?? shortCommit,
|
|
buildCreatedAt: artifact.buildCreatedAt ?? null,
|
|
buildSource: artifact.buildSource ?? null,
|
|
digest,
|
|
repositoryDigest: artifact.repositoryDigest ?? null,
|
|
digestStatus: /^sha256:[a-f0-9]{64}$/u.test(digest) ? "real" : "placeholder",
|
|
status: artifact.status,
|
|
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null,
|
|
publishDurationMs: artifact.publishDurationMs ?? null,
|
|
componentCommitId: artifact.componentCommitId ?? null,
|
|
componentInputHash: artifact.componentInputHash ?? null,
|
|
dockerfileHash: artifact.dockerfileHash ?? null,
|
|
baseImageReference: artifact.baseImageReference ?? null,
|
|
baseImageDigest: artifact.baseImageDigest ?? null,
|
|
buildArgsHash: artifact.buildArgsHash ?? null,
|
|
ciAffected: artifact.ciAffected ?? null,
|
|
ciReason: artifact.ciReason ?? [],
|
|
runtimeMode: artifact.runtimeMode ?? "service-image",
|
|
envReuse: artifact.envReuse === true,
|
|
envChanged: artifact.envChanged ?? null,
|
|
codeChanged: artifact.codeChanged ?? null,
|
|
environmentImage: artifact.environmentImage ?? null,
|
|
environmentDigest: artifact.environmentDigest ?? null,
|
|
environmentInputHash: artifact.environmentInputHash ?? null,
|
|
codeInputHash: artifact.codeInputHash ?? null,
|
|
bootRepo: artifact.bootRepo ?? null,
|
|
bootCommit: artifact.bootCommit ?? null,
|
|
bootSh: artifact.bootSh ?? null,
|
|
bootEnvEvidence: artifact.bootEnvEvidence ?? null,
|
|
reuse: artifact.reuse ?? null,
|
|
reusedFrom: artifact.reusedFrom ?? null,
|
|
notPublishedReason: artifactNotPublishedReason(artifact, mode, fatalBlocked)
|
|
};
|
|
})
|
|
};
|
|
}
|
|
|
|
function plannerFieldsFor(servicePlan) {
|
|
if (!servicePlan) return {};
|
|
return {
|
|
componentCommitId: servicePlan.componentCommitId ?? null,
|
|
componentInputHash: servicePlan.componentInputHash ?? null,
|
|
runtimeMode: servicePlan.runtimeMode ?? "service-image",
|
|
envReuse: servicePlan.envReuse === true,
|
|
envReuseRecipe: servicePlan.envReuseRecipe ?? null,
|
|
envChanged: servicePlan.envChanged ?? null,
|
|
codeChanged: servicePlan.codeChanged ?? null,
|
|
environmentInputHash: servicePlan.environmentInputHash ?? null,
|
|
codeInputHash: servicePlan.codeInputHash ?? null,
|
|
bootRepo: servicePlan.bootRepo ?? null,
|
|
bootCommit: servicePlan.bootCommit ?? null,
|
|
bootSh: servicePlan.bootSh ?? null,
|
|
envArtifactGroupId: servicePlan.envArtifactGroupId ?? null,
|
|
envArtifactGroup: servicePlan.envArtifactGroup ?? null,
|
|
envArtifactBuildServiceId: servicePlan.envArtifactBuildServiceId ?? null,
|
|
envArtifactSourceServiceId: servicePlan.envArtifactSourceServiceId ?? null,
|
|
envArtifactCacheRef: servicePlan.envArtifactCacheRef ?? null,
|
|
envArtifactGroupBuildRequired: servicePlan.envArtifactGroupBuildRequired === true,
|
|
sharedEnvBuildSkipped: servicePlan.sharedEnvBuildSkipped === true,
|
|
buildRequired: servicePlan.buildRequired ?? servicePlan.affected ?? false,
|
|
environmentImage: servicePlan.environmentImage ?? null,
|
|
environmentDigest: servicePlan.environmentDigest ?? null,
|
|
dockerfileHash: servicePlan.dockerfileHash ?? null,
|
|
baseImageReference: servicePlan.baseImageReference ?? null,
|
|
baseImageDigest: servicePlan.baseImageDigest ?? null,
|
|
buildArgsHash: servicePlan.buildArgsHash ?? null,
|
|
ciAffected: servicePlan.affected ?? false,
|
|
ciReason: servicePlan.reason ?? [],
|
|
reuse: servicePlan.reuse ?? null
|
|
};
|
|
}
|
|
|
|
function enrichServicesWithCiPlan(services, ciPlan) {
|
|
const byServiceId = new Map((ciPlan?.services ?? []).map((service) => [service.serviceId, service]));
|
|
return services.map((service) => ({
|
|
...service,
|
|
...plannerFieldsFor(byServiceId.get(service.serviceId))
|
|
}));
|
|
}
|
|
|
|
function ciPlanReportSummary(ciPlan) {
|
|
if (!ciPlan) return null;
|
|
return {
|
|
planVersion: ciPlan.planVersion,
|
|
sourceCommitId: ciPlan.sourceCommitId,
|
|
shortCommitId: ciPlan.shortCommitId,
|
|
baseRef: ciPlan.baseRef,
|
|
targetRef: ciPlan.targetRef,
|
|
compatibility: ciPlan.compatibility,
|
|
changedPathSummary: ciPlan.changedPathSummary,
|
|
imageBuildRequired: ciPlan.imageBuildRequired,
|
|
artifactCatalog: ciPlan.artifactCatalog,
|
|
affectedServices: ciPlan.affectedServices,
|
|
rolloutServices: ciPlan.rolloutServices ?? ciPlan.affectedServices,
|
|
buildServices: ciPlan.buildServices ?? ciPlan.affectedServices,
|
|
rolloutWithoutImageBuildServices: ciPlan.rolloutWithoutImageBuildServices ?? [],
|
|
reusedServices: ciPlan.reusedServices,
|
|
serviceReusedCount: ciPlan.serviceReusedCount,
|
|
imageBuildSkippedServices: ciPlan.imageBuildSkippedServices,
|
|
buildSkippedCount: ciPlan.buildSkippedCount,
|
|
ciCdPlan: ciPlan.ciCdPlan
|
|
};
|
|
}
|
|
|
|
function sumDurations(artifacts, field) {
|
|
return artifacts.reduce((sum, artifact) => sum + (Number.isFinite(artifact[field]) ? artifact[field] : 0), 0);
|
|
}
|
|
|
|
async function mapWithConcurrency(items, concurrency, worker) {
|
|
const results = new Array(items.length);
|
|
let nextIndex = 0;
|
|
const workerCount = Math.min(Math.max(concurrency, 1), items.length);
|
|
await Promise.all(Array.from({ length: workerCount }, async () => {
|
|
while (nextIndex < items.length) {
|
|
const index = nextIndex;
|
|
nextIndex += 1;
|
|
results[index] = await worker(items[index], index);
|
|
}
|
|
}));
|
|
return results;
|
|
}
|
|
|
|
function createReport({ args, repo, commitId, shortCommit, mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer, ciPlan }) {
|
|
const status = reportStatus(mode, artifacts, blockers);
|
|
const fatalBlocked = hasFatalBlocker(blockers);
|
|
const requiredArtifacts = artifacts.filter((artifact) => artifact.artifactRequired);
|
|
const serviceInventory = serviceInventoryFromServices(services);
|
|
const publishPlan = publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked, registryCapabilities });
|
|
const publishedCount = requiredArtifacts.filter((artifact) => artifact.status === "published").length;
|
|
const reusedCount = requiredArtifacts.filter((artifact) => artifact.status === "reused").length;
|
|
const builtCount = requiredArtifacts.filter((artifact) => buildReadyStatuses.has(artifact.status)).length;
|
|
const environment = artifactEnvironment(args);
|
|
|
|
return {
|
|
$schema: "https://hwlab.pikastech.local/schemas/artifact-publish.schema.json",
|
|
$id: `https://hwlab.pikastech.local/${args.lane}-cicd/artifacts.json`,
|
|
reportVersion: "v1",
|
|
issue: args.lane === "v02" ? "pikasTech/HWLAB#530" : "pikasTech/HWLAB#35",
|
|
taskId: `${args.lane}-artifact-publish`,
|
|
commitId: shortCommit,
|
|
acceptanceLevel: `${environment}_artifact_publish`,
|
|
lane: args.lane,
|
|
devOnly: args.lane !== "v02",
|
|
prodDisabled: true,
|
|
reportLifecycle: activeReportLifecycle(`Current ${environment} artifact publish report; it is not runtime acceptance evidence.`),
|
|
sourceContract: {
|
|
status: fatalBlocked ? "blocked" : "pass",
|
|
documents: [
|
|
"docs/reference/MVP-e2e-acceptance.md",
|
|
"docs/reference/spec-v02-documentation-governance.md",
|
|
"docs/reference/node-gitops-cicd.md",
|
|
"docs/reference/spec-v02-cicd.md"
|
|
],
|
|
summary: `${environment} artifact publish evidence; runtime acceptance stays separate from artifact publication.`
|
|
},
|
|
validationCommands: [
|
|
"node --check scripts/artifact-publish.mjs",
|
|
"node --check scripts/artifact-publish.mjs",
|
|
"node --check scripts/src/dev-artifact-services.mjs",
|
|
"node --check scripts/src/registry-capabilities.mjs",
|
|
"node --check scripts/preflight-dev-base-image.mjs",
|
|
"node --check scripts/src/dev-base-image-preflight.mjs",
|
|
"node --check scripts/dev-runtime-base-image.mjs",
|
|
"node --test scripts/refresh-artifact-catalog.test.mjs",
|
|
"node scripts/preflight-dev-base-image.mjs",
|
|
"node scripts/artifact-publish.mjs --preflight --no-report",
|
|
"node --check scripts/validate-dev-gate-report.mjs",
|
|
"node scripts/validate-dev-gate-report.mjs"
|
|
],
|
|
localSmoke: {
|
|
status: "not_run",
|
|
commands: [],
|
|
evidence: [
|
|
"This report records artifact build/publish state only; local smoke remains a separate gate."
|
|
],
|
|
summary: "Local smoke is not implied by artifact publication."
|
|
},
|
|
dryRun: {
|
|
status: "not_run",
|
|
commands: [],
|
|
evidence: [
|
|
"This report records DEV artifact build/publish state only; hwlab-cli is not an artifact service."
|
|
],
|
|
summary: "DEV deploy dry-run is not implied by artifact publication."
|
|
},
|
|
devPreconditions: {
|
|
status: fatalBlocked ? "blocked" : "pass",
|
|
requirements: [
|
|
"Registry prefix is localhost or private/internal only.",
|
|
"DEV builder base image preflight returns ready before build or publish.",
|
|
`Environment remains ${environment} and namespace remains ${args.lane === "v02" ? "hwlab-v02" : "hwlab-dev"}.`,
|
|
"PROD profile remains disabled.",
|
|
"No secret or token material is required by this workflow."
|
|
],
|
|
summary: fatalBlocked
|
|
? `Fatal preconditions blocked a full ${environment} artifact publish.`
|
|
: `Fatal ${environment} artifact preconditions passed.`
|
|
},
|
|
blockers: devGateBlockers(blockers),
|
|
artifactPublish: {
|
|
issue: args.lane === "v02" ? "pikasTech/HWLAB#530" : "pikasTech/HWLAB#35",
|
|
status,
|
|
mode,
|
|
lane: args.lane,
|
|
catalogPath: args.catalogPath,
|
|
imageTagMode: args.imageTagMode,
|
|
repo,
|
|
sourceCommitId: commitId,
|
|
registryPrefix: args.registryPrefix,
|
|
buildBackend: args.buildBackend,
|
|
buildkitCommand: args.buildkitCommand,
|
|
buildkitAddr: args.buildkitAddr ?? null,
|
|
buildCacheMode: args.buildCacheMode,
|
|
producer,
|
|
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
|
|
baseImage: args.baseImage ?? null,
|
|
selectedServiceIds: args.services,
|
|
affectedOnly: args.affectedOnly,
|
|
externalServiceReportDir: args.externalServiceReportDir,
|
|
externalServiceResultsEnv: args.externalServiceResultsEnv,
|
|
quietBuild: args.quietBuild,
|
|
concurrency: args.concurrency,
|
|
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
|
|
environment,
|
|
buildCreatedAt: artifacts.find((artifact) => artifact.buildCreatedAt)?.buildCreatedAt ?? null,
|
|
serviceInventory,
|
|
ciPlan: ciPlanReportSummary(ciPlan),
|
|
publishPlan,
|
|
serviceCount: artifacts.length,
|
|
requiredServiceCount: serviceInventory.requiredServiceCount,
|
|
disabledServiceCount: serviceInventory.disabledServiceCount,
|
|
builtCount,
|
|
publishedCount,
|
|
reusedCount,
|
|
timings: {
|
|
buildkitBuildDurationMs: sumDurations(artifacts, "buildkitBuildDurationMs"),
|
|
cloudWebBuildDurationMs: sumDurations(artifacts, "cloudWebBuildDurationMs"),
|
|
publishDurationMs: sumDurations(artifacts, "publishDurationMs")
|
|
},
|
|
generatedAt: new Date().toISOString(),
|
|
services: artifacts.map((artifact) => ({
|
|
serviceId: artifact.serviceId,
|
|
status: artifact.status,
|
|
commitId: artifact.commitId ?? shortCommit,
|
|
sourceCommitId: artifact.sourceCommitId ?? commitId,
|
|
image: artifact.image ?? imageRef(args.registryPrefix, artifact.serviceId, shortCommit),
|
|
imageTag: artifact.imageTag ?? shortCommit,
|
|
buildCreatedAt: artifact.buildCreatedAt ?? null,
|
|
buildSource: artifact.buildSource ?? null,
|
|
digest: artifact.digest ?? "not_published",
|
|
repositoryDigest: artifact.repositoryDigest ?? null,
|
|
runtimeKind: artifact.runtimeKind,
|
|
distFreshness: artifact.distFreshness,
|
|
implementationState: artifact.implementationState,
|
|
sourceState: artifact.sourceState,
|
|
entrypoint: artifact.entrypoint,
|
|
publishEnabled: artifact.publishEnabled,
|
|
artifactRequired: artifact.artifactRequired,
|
|
artifactScope: artifact.artifactScope,
|
|
notPublishedReason: artifactNotPublishedReason(artifact, mode, fatalBlocked),
|
|
localImageId: artifact.localImageId,
|
|
buildkitBuildDurationMs: artifact.buildkitBuildDurationMs ?? null,
|
|
buildBackend: artifact.buildBackend ?? args.buildBackend,
|
|
buildkitCacheMode: artifact.buildkitCacheMode ?? args.buildCacheMode,
|
|
buildkitCacheRef: artifact.buildkitCacheRef ?? null,
|
|
goBaseImage: artifact.goBaseImage ?? null,
|
|
goBaseImageStatus: artifact.goBaseImageStatus ?? null,
|
|
goBaseDigest: artifact.goBaseDigest ?? null,
|
|
goBaseBuildDurationMs: artifact.goBaseBuildDurationMs ?? null,
|
|
goBaseBuildkitCacheRef: artifact.goBaseBuildkitCacheRef ?? null,
|
|
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null,
|
|
publishDurationMs: artifact.publishDurationMs ?? null,
|
|
componentCommitId: artifact.componentCommitId ?? null,
|
|
componentInputHash: artifact.componentInputHash ?? null,
|
|
dockerfileHash: artifact.dockerfileHash ?? null,
|
|
baseImageReference: artifact.baseImageReference ?? null,
|
|
baseImageDigest: artifact.baseImageDigest ?? null,
|
|
buildArgsHash: artifact.buildArgsHash ?? null,
|
|
ciAffected: artifact.ciAffected ?? null,
|
|
ciReason: artifact.ciReason ?? [],
|
|
runtimeMode: artifact.runtimeMode ?? "service-image",
|
|
envReuse: artifact.envReuse === true,
|
|
envChanged: artifact.envChanged ?? null,
|
|
codeChanged: artifact.codeChanged ?? null,
|
|
environmentImage: artifact.environmentImage ?? null,
|
|
environmentDigest: artifact.environmentDigest ?? null,
|
|
environmentInputHash: artifact.environmentInputHash ?? null,
|
|
codeInputHash: artifact.codeInputHash ?? null,
|
|
bootRepo: artifact.bootRepo ?? null,
|
|
bootCommit: artifact.bootCommit ?? null,
|
|
bootSh: artifact.bootSh ?? null,
|
|
bootEnvEvidence: artifact.bootEnvEvidence ?? null,
|
|
reuse: artifact.reuse ?? null,
|
|
reusedFrom: artifact.reusedFrom ?? null,
|
|
logTail: artifact.logTail ?? null,
|
|
pushLogTail: artifact.pushLogTail ?? null
|
|
})),
|
|
blockers
|
|
},
|
|
notes: `${environment} artifact report. Do not treat runtime placeholder images as real service implementations.`
|
|
};
|
|
}
|
|
|
|
async function writeReport(relativePath, report) {
|
|
const absolutePath = ensureNotRepoReportsPath(repoRoot, relativePath, "--report");
|
|
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
await writeFile(absolutePath, `${JSON.stringify(report, null, 2)}\n`);
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
if (args.help) {
|
|
printHelp();
|
|
return;
|
|
}
|
|
if (args.mode === "publish") {
|
|
const identityGuard = requireArtifactPublishIdentity({
|
|
script: cliEntrypoint,
|
|
mode: "--publish"
|
|
});
|
|
if (identityGuard) {
|
|
console.log(JSON.stringify(identityGuard, null, 2));
|
|
process.exitCode = 2;
|
|
return;
|
|
}
|
|
}
|
|
|
|
args.registryPrefix = validateRegistryPrefix(args.registryPrefix);
|
|
const [baseImagePreflight, registryCapabilities] = await Promise.all([
|
|
buildkitBaseImagePreflight(args.baseImage),
|
|
probeRegistryCapabilities({
|
|
registryPrefix: args.registryPrefix
|
|
})
|
|
]);
|
|
if (baseImagePreflight.publishUsable) {
|
|
args.baseImage = baseImagePreflight.localTag;
|
|
}
|
|
const [deployManifest, commitId, remoteUrl] = await Promise.all([
|
|
readConfig(args.deployPath),
|
|
gitValue(["rev-parse", "HEAD"]),
|
|
gitValue(["remote", "get-url", "origin"]).catch(() => "git@github.com:pikasTech/HWLAB.git")
|
|
]);
|
|
applyDeployDefaultServices(args, deployManifest);
|
|
let catalog = await readConfigIfPresent(args.catalogPath, null);
|
|
const catalogWasMissing = !catalog;
|
|
if (!catalog && isRuntimeArtifactLane(args.lane)) catalog = artifactCatalogSkeleton({ args, deployManifest, commitId });
|
|
catalog = normalizeCatalogForLane(catalog, args, deployManifest, commitId);
|
|
assert.ok(catalog, `${args.catalogPath} is required for ${args.lane} artifact publish`);
|
|
const shortCommit = imageTagForCommit(args, commitId);
|
|
let effectiveCatalogPath = args.catalogPath;
|
|
if (catalogWasMissing && isRuntimeArtifactLane(args.lane)) {
|
|
effectiveCatalogPath = path.join(os.tmpdir(), `hwlab-${args.lane}-artifact-catalog-${process.pid}.json`);
|
|
await writeFile(effectiveCatalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
|
|
}
|
|
const buildCreatedAt = new Date().toISOString();
|
|
const repo = repoLabelFromRemote(remoteUrl);
|
|
const producer = artifactProducer(process.env);
|
|
const computedCiPlan = await createCiPlan({
|
|
repoRoot,
|
|
lane: args.lane,
|
|
targetRef: "HEAD",
|
|
deployConfigPath: args.deployPath,
|
|
artifactCatalogPath: effectiveCatalogPath,
|
|
registryPrefix: args.registryPrefix,
|
|
baseImage: args.baseImage ?? undefined,
|
|
...(args.servicesExplicit ? { services: args.services } : {})
|
|
});
|
|
const ciPlanOverride = await readOptionalCiPlan(args.ciPlanPath);
|
|
const ciPlan = ciPlanOverride ?? computedCiPlan;
|
|
const allowedServiceIds = isRuntimeArtifactLane(args.lane) ? runtimeLaneServiceIdsFromDeploy(deployManifest, args.lane) : SERVICE_IDS;
|
|
const services = enrichServicesWithCiPlan(await resolveServices(args.services, catalog, allowedServiceIds), ciPlan);
|
|
const catalogByServiceId = new Map((catalog?.services ?? []).map((service) => [service.serviceId, service]));
|
|
const affectedServiceIds = new Set(ciPlan?.affectedServices ?? []);
|
|
const plannedBuildServices = Array.isArray(ciPlan?.buildServices) ? ciPlan.buildServices : null;
|
|
const buildServiceIds = new Set(plannedBuildServices ?? services
|
|
.filter((service) => service.artifactRequired && (service.envReuse ? service.envChanged === true : affectedServiceIds.has(service.serviceId)))
|
|
.map((service) => service.serviceId));
|
|
const currentBuildArtifactServices = services.filter((service) => buildServiceIds.has(service.serviceId) && serviceRequiresCurrentBuildArtifact(service));
|
|
|
|
const preflightBuildServiceIds = args.externalServiceReportDir || args.externalServiceResultsEnv ? new Set() : buildServiceIds;
|
|
let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities, buildServiceIds: preflightBuildServiceIds });
|
|
let artifacts = services.map((service) => artifactRecord({
|
|
args,
|
|
service,
|
|
commitId,
|
|
shortCommit,
|
|
status: service.artifactRequired ? "preflight_only" : "not_required"
|
|
}));
|
|
let externalArtifacts = new Map();
|
|
if (args.mode === "build" || args.mode === "publish") {
|
|
if (args.externalServiceReportDir && currentBuildArtifactServices.length > 0) {
|
|
const externalResult = await readExternalServiceArtifacts({
|
|
dir: args.externalServiceReportDir,
|
|
services: currentBuildArtifactServices,
|
|
commitId
|
|
});
|
|
externalArtifacts = externalResult.artifacts;
|
|
blockers.push(...externalResult.blockers);
|
|
}
|
|
if (args.externalServiceResultsEnv) {
|
|
const externalResult = await readExternalServiceArtifactsFromEnv({
|
|
services: services.filter((service) => service.artifactRequired),
|
|
commitId
|
|
});
|
|
externalArtifacts = new Map([...externalArtifacts, ...externalResult.artifacts]);
|
|
blockers.push(...externalResult.blockers);
|
|
}
|
|
artifacts = services.map((service) => {
|
|
if (externalArtifacts.has(service.serviceId)) {
|
|
return externalArtifacts.get(service.serviceId);
|
|
}
|
|
const sharedEnvSourceServiceId = service.envReuse === true && service.envArtifactSourceServiceId && service.envArtifactSourceServiceId !== service.serviceId
|
|
? service.envArtifactSourceServiceId
|
|
: null;
|
|
if (sharedEnvSourceServiceId && externalArtifacts.has(sharedEnvSourceServiceId)) {
|
|
const sharedArtifact = artifactRecordFromSharedEnvArtifact({
|
|
service,
|
|
sourceArtifact: externalArtifacts.get(sharedEnvSourceServiceId),
|
|
commitId
|
|
});
|
|
emit("artifact_reuse", {
|
|
serviceId: service.serviceId,
|
|
status: sharedArtifact.status,
|
|
image: sharedArtifact.image,
|
|
digest: sharedArtifact.digest,
|
|
reusedFrom: sharedArtifact.reusedFrom ?? null,
|
|
envArtifactGroupId: sharedArtifact.envArtifactGroupId ?? null,
|
|
envArtifactSourceServiceId: sharedArtifact.envArtifactSourceServiceId ?? null
|
|
});
|
|
return sharedArtifact;
|
|
}
|
|
if (!service.artifactRequired || (buildServiceIds.has(service.serviceId) && serviceRequiresCurrentBuildArtifact(service))) {
|
|
return artifactRecord({
|
|
args,
|
|
service,
|
|
commitId,
|
|
shortCommit,
|
|
status: service.artifactRequired ? "preflight_only" : "not_required"
|
|
});
|
|
}
|
|
const reused = artifactRecordForCatalogReuse({
|
|
service,
|
|
catalogService: catalogByServiceId.get(service.serviceId),
|
|
commitId
|
|
});
|
|
emit("artifact_reuse", {
|
|
serviceId: service.serviceId,
|
|
status: reused.status,
|
|
image: reused.image,
|
|
digest: reused.digest,
|
|
reusedFrom: reused.reusedFrom ?? null
|
|
});
|
|
if (reused.status.startsWith("blocked_")) {
|
|
blockers.push(blocker({
|
|
type: "environment_blocker",
|
|
scope: service.serviceId,
|
|
summary: `${service.serviceId} cannot be reused because ${args.catalogPath} has no verified digest`,
|
|
next: "Refresh the service catalog with a verified sha256 digest for this service, or create a service-scoped source change so the planner publishes it through the per-service BuildKit task."
|
|
}));
|
|
}
|
|
return reused;
|
|
});
|
|
}
|
|
|
|
if ((args.mode === "build" || args.mode === "publish") && hasFatalBlocker(blockers)) {
|
|
emit("publish_blocked", {
|
|
fatalBlockers: blockers.filter((item) => fatalBlockerTypes.has(item.type)).map((item) => item.scope)
|
|
});
|
|
} else if (args.mode === "build" || args.mode === "publish") {
|
|
const artifactByServiceId = new Map(artifacts.map((artifact) => [artifact.serviceId, artifact]));
|
|
await mapWithConcurrency(services.filter((item) => buildServiceIds.has(item.serviceId) && serviceRequiresCurrentBuildArtifact(item) && !externalArtifacts.has(item.serviceId)), args.concurrency, async (service) => {
|
|
emit("build_start", { serviceId: service.serviceId, image: imageRef(args.registryPrefix, service.serviceId, shortCommit) });
|
|
const artifact = await buildService({ args, repo, commitId, shortCommit, service, buildCreatedAt });
|
|
artifactByServiceId.set(service.serviceId, artifact);
|
|
if (artifact.blocker) blockers.push(artifact.blocker);
|
|
emit("build_done", {
|
|
serviceId: service.serviceId,
|
|
status: artifact.status,
|
|
image: artifact.image,
|
|
buildkitBuildDurationMs: artifact.buildkitBuildDurationMs ?? null,
|
|
buildBackend: artifact.buildBackend ?? args.buildBackend,
|
|
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null
|
|
});
|
|
});
|
|
artifacts = services.map((service) => artifactByServiceId.get(service.serviceId));
|
|
}
|
|
|
|
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities, producer, ciPlan });
|
|
if (args.tektonResultsDir) {
|
|
await writeTektonResultFiles(args.tektonResultsDir, report);
|
|
}
|
|
if (args.emitReport) {
|
|
await writeReport(args.reportPath, report);
|
|
}
|
|
|
|
console.log(JSON.stringify({
|
|
status: report.artifactPublish.status,
|
|
mode: args.mode,
|
|
reportPath: args.emitReport ? args.reportPath : null,
|
|
sourceCommitId: commitId,
|
|
registryPrefix: args.registryPrefix,
|
|
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
|
|
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
|
|
selectedServiceIds: report.artifactPublish.selectedServiceIds,
|
|
affectedOnly: report.artifactPublish.affectedOnly,
|
|
ciPlan: report.artifactPublish.ciPlan,
|
|
concurrency: report.artifactPublish.concurrency,
|
|
builtCount: report.artifactPublish.builtCount,
|
|
publishedCount: report.artifactPublish.publishedCount,
|
|
reusedCount: report.artifactPublish.reusedCount,
|
|
timings: report.artifactPublish.timings,
|
|
publishPlan: report.artifactPublish.publishPlan,
|
|
services: report.artifactPublish.services.map((service) => ({
|
|
serviceId: service.serviceId,
|
|
status: service.status,
|
|
commitId: service.commitId,
|
|
sourceCommitId: service.sourceCommitId,
|
|
image: service.image,
|
|
imageTag: service.imageTag,
|
|
buildCreatedAt: service.buildCreatedAt,
|
|
digest: service.digest,
|
|
distFreshness: service.distFreshness,
|
|
buildkitBuildDurationMs: service.buildkitBuildDurationMs,
|
|
buildBackend: service.buildBackend,
|
|
buildkitCacheMode: service.buildkitCacheMode,
|
|
buildkitCacheRef: service.buildkitCacheRef,
|
|
cloudWebBuildDurationMs: service.cloudWebBuildDurationMs,
|
|
publishDurationMs: service.publishDurationMs,
|
|
reusedFrom: service.reusedFrom,
|
|
logTail: service.logTail,
|
|
pushLogTail: service.pushLogTail,
|
|
notPublishedReason: service.notPublishedReason
|
|
})),
|
|
blockers
|
|
}, null, 2));
|
|
|
|
if (hasFatalBlocker(blockers)) {
|
|
process.exitCode = 2;
|
|
} else if ((args.mode === "build" || args.mode === "publish") && artifacts.some((artifact) => artifact.status.endsWith("_failed"))) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(JSON.stringify({
|
|
status: "failed",
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}, null, 2));
|
|
process.exitCode = 1;
|
|
});
|