1627 lines
59 KiB
JavaScript
1627 lines
59 KiB
JavaScript
#!/usr/bin/env node
|
|
import assert from "node:assert/strict";
|
|
import { spawn } from "node:child_process";
|
|
import {
|
|
mkdir,
|
|
readFile,
|
|
writeFile
|
|
} from "node:fs/promises";
|
|
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 {
|
|
BUILDABLE_IMPLEMENTATION_STATES,
|
|
resolveDevArtifactServices,
|
|
serviceInventoryFromServices
|
|
} from "./src/dev-artifact-services.mjs";
|
|
import {
|
|
devArtifactProducer,
|
|
requireDevArtifactSideEffectEnv
|
|
} from "./src/dev-cd-transaction-guard.mjs";
|
|
import { runDevBaseImagePreflight } from "./src/dev-base-image-preflight.mjs";
|
|
import { probeRegistryCapabilities } from "./src/registry-capabilities.mjs";
|
|
import { inspectCloudWebDistFreshness } from "../web/hwlab-cloud-web/scripts/dist-contract.mjs";
|
|
import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
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 catalogPath = "deploy/artifact-catalog.dev.json";
|
|
const deployPath = "deploy/deploy.json";
|
|
|
|
const mutableTags = new Set(["latest", "dev", "main", "master", "prod", "production"]);
|
|
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 servicePorts = new Map([
|
|
["hwlab-cloud-api", 6667],
|
|
["hwlab-cloud-web", 8080],
|
|
["hwlab-agent-mgr", 7410],
|
|
["hwlab-agent-worker", 7411],
|
|
["hwlab-gateway", 7001],
|
|
["hwlab-gateway-simu", 7101],
|
|
["hwlab-box-simu", 7201],
|
|
["hwlab-patch-panel", 7301],
|
|
["hwlab-router", 7401],
|
|
["hwlab-tunnel-client", 7402],
|
|
["hwlab-edge-proxy", 6667],
|
|
["hwlab-cli", 7420],
|
|
["hwlab-agent-skills", 7430]
|
|
]);
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
mode: "preflight",
|
|
registryPrefix: defaultRegistryPrefix,
|
|
baseImage: defaultBaseImage,
|
|
reportPath: defaultReportPath,
|
|
services: [...SERVICE_IDS],
|
|
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 === "--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);
|
|
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}`);
|
|
}
|
|
|
|
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 printHelp() {
|
|
console.log([
|
|
"usage: node scripts/dev-artifact-publish.mjs [--preflight|--build|--publish]",
|
|
"",
|
|
"DEV-only artifact workflow for the D601 local/internal registry.",
|
|
"",
|
|
"options:",
|
|
" --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; default: all frozen DEV services",
|
|
` --report PATH default: ${defaultReportPath}`,
|
|
" --no-report print JSON without updating the temporary artifact JSON",
|
|
" --quiet-build keep docker build quiet; default keeps build output visible",
|
|
" --concurrency N parallel service build/push workers; default: 4, max: 8"
|
|
].join("\n"));
|
|
}
|
|
|
|
async function readJson(relativePath) {
|
|
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
|
|
}
|
|
|
|
function emit(event, payload = {}) {
|
|
process.stderr.write(`${JSON.stringify({ event, at: new Date().toISOString(), ...payload })}\n`);
|
|
}
|
|
|
|
function blocker({ type, scope, summary, next }) {
|
|
return {
|
|
type,
|
|
scope,
|
|
status: "open",
|
|
summary,
|
|
next
|
|
};
|
|
}
|
|
|
|
function preflightEnvForBaseImage(baseImage) {
|
|
if (!baseImage) {
|
|
return process.env;
|
|
}
|
|
return {
|
|
...process.env,
|
|
HWLAB_DEV_BASE_IMAGE: baseImage
|
|
};
|
|
}
|
|
|
|
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(" ");
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
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 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) {
|
|
return resolveDevArtifactServices(repoRoot, serviceIds, SERVICE_IDS, catalog);
|
|
}
|
|
|
|
function runtimeScriptBase64() {
|
|
const source = String.raw`
|
|
import { createServer } from "node:http";
|
|
import http from "node:http";
|
|
import { spawn } from "node:child_process";
|
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
import { chmodSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
|
|
import { readFile, stat } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
const serviceId = process.env.HWLAB_SERVICE_ID || "hwlab-unknown";
|
|
const environment = process.env.HWLAB_ENVIRONMENT || "dev";
|
|
const runtimeKind = process.env.HWLAB_ARTIFACT_KIND || "health-placeholder";
|
|
const entrypoint = process.env.HWLAB_SERVICE_ENTRYPOINT || "";
|
|
const port = Number.parseInt(process.env.PORT || process.env.HWLAB_PORT || "8080", 10);
|
|
const cloudApiBaseUrl = process.env.HWLAB_API_BASE_URL || "";
|
|
const cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS, 180000, {
|
|
min: 1000,
|
|
max: 300000
|
|
});
|
|
const authUsername = nonEmptyEnv("HWLAB_CLOUD_WEB_AUTH_USERNAME", "admin");
|
|
const authPassword = nonEmptyEnv("HWLAB_CLOUD_WEB_AUTH_PASSWORD", "hwlab2026");
|
|
const authSessionTtlMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_AUTH_SESSION_TTL_MS, 12 * 60 * 60 * 1000, {
|
|
min: 60 * 1000,
|
|
max: 7 * 24 * 60 * 60 * 1000
|
|
});
|
|
const authCookieName = "hwlab_cloud_web_session";
|
|
const authSessions = new Map();
|
|
const readOnlyRpcMethods = new Set([
|
|
"system.health",
|
|
"cloud.adapter.describe",
|
|
"audit.event.query",
|
|
"evidence.record.query"
|
|
]);
|
|
|
|
function ensureCodeAgentRuntimeBase() {
|
|
if (serviceId !== "hwlab-cloud-api") return;
|
|
const workspace = process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || process.env.HWLAB_CODE_AGENT_WORKSPACE || "/workspace/hwlab";
|
|
const codexHome = process.env.CODEX_HOME || process.env.HWLAB_CODE_AGENT_CODEX_HOME || "/codex-home";
|
|
process.env.HWLAB_CODE_AGENT_WORKSPACE = process.env.HWLAB_CODE_AGENT_WORKSPACE || workspace;
|
|
process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE = process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || workspace;
|
|
process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX = process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX || "workspace-write";
|
|
process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED || "1";
|
|
process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR || "repo-owned";
|
|
process.env.HWLAB_CODE_AGENT_PROVIDER = process.env.HWLAB_CODE_AGENT_PROVIDER || "codex-stdio";
|
|
process.env.CODEX_HOME = codexHome;
|
|
process.env.HWLAB_CODE_AGENT_CODEX_COMMAND = process.env.HWLAB_CODE_AGENT_CODEX_COMMAND || "/app/node_modules/.bin/codex";
|
|
process.env.HWLAB_CODE_AGENT_SKILLS_DIRS = process.env.HWLAB_CODE_AGENT_SKILLS_DIRS || "/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills";
|
|
|
|
mkdirSync(path.dirname(workspace), { recursive: true });
|
|
if (!existsSync(workspace)) {
|
|
if (workspace === "/workspace/hwlab" && existsSync("/app")) {
|
|
symlinkSync("/app", workspace, "dir");
|
|
} else {
|
|
mkdirSync(workspace, { recursive: true });
|
|
}
|
|
}
|
|
mkdirSync(codexHome, { recursive: true });
|
|
for (const target of [workspace, codexHome]) {
|
|
try {
|
|
const info = lstatSync(target);
|
|
chmodSync(info.isSymbolicLink() ? path.resolve(target) : target, 0o777);
|
|
} catch {
|
|
// Runtime readiness reports the concrete blocker without exposing secret values.
|
|
}
|
|
}
|
|
}
|
|
|
|
ensureCodeAgentRuntimeBase();
|
|
|
|
function sendJson(response, statusCode, body) {
|
|
const payload = JSON.stringify(body, null, 2);
|
|
response.writeHead(statusCode, {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"content-length": Buffer.byteLength(payload)
|
|
});
|
|
response.end(payload);
|
|
}
|
|
|
|
function nonEmptyEnv(name, fallback) {
|
|
const value = process.env[name];
|
|
return typeof value === "string" && value.trim() ? value.trim() : fallback;
|
|
}
|
|
|
|
function healthPayload() {
|
|
const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown";
|
|
const imageReference = process.env.HWLAB_IMAGE || "unknown";
|
|
const imageTag = process.env.HWLAB_IMAGE_TAG || imageTagFromReference(imageReference) || shortRevision(commitId);
|
|
const buildCreatedAt = normalizeIsoTimestamp(
|
|
process.env.HWLAB_BUILD_CREATED_AT ||
|
|
process.env.HWLAB_IMAGE_CREATED_AT ||
|
|
process.env.SOURCE_DATE_EPOCH ||
|
|
process.env.BUILD_DATE
|
|
);
|
|
const buildSource = process.env.HWLAB_BUILD_SOURCE || "runtime-env";
|
|
return {
|
|
serviceId,
|
|
environment,
|
|
status: "ok",
|
|
artifactKind: runtimeKind,
|
|
revision: process.env.HWLAB_REVISION || commitId,
|
|
commit: {
|
|
id: commitId,
|
|
source: buildSource
|
|
},
|
|
image: {
|
|
reference: imageReference,
|
|
tag: imageTag,
|
|
digest: process.env.HWLAB_IMAGE_DIGEST || "unknown"
|
|
},
|
|
build: {
|
|
createdAt: buildCreatedAt,
|
|
source: buildSource,
|
|
provenance: process.env.HWLAB_BUILD_PROVENANCE || null,
|
|
metadataSource: buildCreatedAt ? "runtime-env:HWLAB_BUILD_CREATED_AT" : "unavailable",
|
|
unavailableReason: buildCreatedAt ? null : "HWLAB_BUILD_CREATED_AT missing from runtime environment"
|
|
}
|
|
};
|
|
}
|
|
|
|
function normalizeIsoTimestamp(value) {
|
|
const source = String(value || "").trim();
|
|
if (!source) return null;
|
|
if (/^\d+$/u.test(source)) {
|
|
const seconds = Number.parseInt(source, 10);
|
|
if (Number.isSafeInteger(seconds)) {
|
|
const date = new Date(seconds * 1000);
|
|
if (Number.isFinite(date.getTime())) return date.toISOString();
|
|
}
|
|
}
|
|
const date = new Date(source);
|
|
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
|
}
|
|
|
|
function imageTagFromReference(imageReference) {
|
|
const source = String(imageReference || "").trim();
|
|
const slashIndex = source.lastIndexOf("/");
|
|
const colonIndex = source.lastIndexOf(":");
|
|
if (colonIndex <= slashIndex) return null;
|
|
return source.slice(colonIndex + 1) || null;
|
|
}
|
|
|
|
function shortRevision(value) {
|
|
const source = String(value || "").trim();
|
|
return source.length >= 7 ? source.slice(0, 7) : source || "unknown";
|
|
}
|
|
|
|
function runNodeEntrypoint(file) {
|
|
const child = spawn(process.execPath, [file], {
|
|
cwd: "/app",
|
|
env: process.env,
|
|
stdio: "inherit"
|
|
});
|
|
child.on("exit", (code, signal) => {
|
|
if (signal) process.kill(process.pid, signal);
|
|
else process.exit(code ?? 0);
|
|
});
|
|
}
|
|
|
|
function cookieValue(request, name) {
|
|
const raw = request.headers.cookie || "";
|
|
for (const part of raw.split(";")) {
|
|
const [key, ...rest] = part.trim().split("=");
|
|
if (key === name) return decodeURIComponent(rest.join("=") || "");
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function authCookie(token, expiresAt) {
|
|
const maxAgeSeconds = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
|
|
return authCookieName + "=" + encodeURIComponent(token) + "; Path=/; HttpOnly; SameSite=Lax; Max-Age=" + maxAgeSeconds;
|
|
}
|
|
|
|
function clearAuthCookie() {
|
|
return authCookieName + "=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
|
|
}
|
|
|
|
function activeAuthSession(request) {
|
|
const token = cookieValue(request, authCookieName);
|
|
if (!token) return null;
|
|
const session = authSessions.get(token);
|
|
if (!session) return null;
|
|
if (session.expiresAt <= Date.now()) {
|
|
authSessions.delete(token);
|
|
return null;
|
|
}
|
|
return { token, ...session };
|
|
}
|
|
|
|
function cleanupExpiredAuthSessions() {
|
|
const now = Date.now();
|
|
for (const [token, session] of authSessions) {
|
|
if (session.expiresAt <= now) authSessions.delete(token);
|
|
}
|
|
}
|
|
|
|
function constantTimeEquals(a, b) {
|
|
const left = Buffer.from(String(a));
|
|
const right = Buffer.from(String(b));
|
|
if (left.length !== right.length) return false;
|
|
return timingSafeEqual(left, right);
|
|
}
|
|
|
|
async function handleCloudWebAuth(request, response, url) {
|
|
if (url.pathname === "/auth/session" && request.method === "GET") {
|
|
const session = activeAuthSession(request);
|
|
sendJson(response, 200, session ? {
|
|
authenticated: true,
|
|
user: { username: session.username },
|
|
expiresAt: new Date(session.expiresAt).toISOString()
|
|
} : { authenticated: false });
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === "/auth/login" && request.method === "POST") {
|
|
let body = {};
|
|
try {
|
|
body = JSON.parse(await readRequestBody(request) || "{}");
|
|
} catch {
|
|
sendJson(response, 400, { authenticated: false, error: "invalid_request" });
|
|
return true;
|
|
}
|
|
const username = typeof body.username === "string" ? body.username.trim() : "";
|
|
const password = typeof body.password === "string" ? body.password : "";
|
|
if (!constantTimeEquals(username, authUsername) || !constantTimeEquals(password, authPassword)) {
|
|
sendJson(response, 401, { authenticated: false, error: "invalid_credentials" });
|
|
return true;
|
|
}
|
|
cleanupExpiredAuthSessions();
|
|
const token = randomBytes(32).toString("hex");
|
|
const expiresAt = Date.now() + authSessionTtlMs;
|
|
authSessions.set(token, { username: authUsername, expiresAt });
|
|
const payload = JSON.stringify({
|
|
authenticated: true,
|
|
user: { username: authUsername },
|
|
expiresAt: new Date(expiresAt).toISOString()
|
|
}, null, 2);
|
|
response.writeHead(200, {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"content-length": Buffer.byteLength(payload),
|
|
"set-cookie": authCookie(token, expiresAt)
|
|
});
|
|
response.end(payload);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === "/auth/logout" && request.method === "POST") {
|
|
const session = activeAuthSession(request);
|
|
if (session) authSessions.delete(session.token);
|
|
const payload = JSON.stringify({ authenticated: false }, null, 2);
|
|
response.writeHead(200, {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"content-length": Buffer.byteLength(payload),
|
|
"set-cookie": clearAuthCookie()
|
|
});
|
|
response.end(payload);
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname.startsWith("/auth/")) {
|
|
sendJson(response, 404, { authenticated: false, error: "not_found" });
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function readRequestBody(request) {
|
|
let body = "";
|
|
for await (const chunk of request) {
|
|
body += chunk;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function copyHeaders(headers) {
|
|
const result = {};
|
|
for (const [key, value] of Object.entries(headers)) {
|
|
if (value === undefined) continue;
|
|
if (key.toLowerCase() === "transfer-encoding") continue;
|
|
if (key.toLowerCase() === "content-length") continue;
|
|
result[key] = value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function parseTimeout(value, fallback, { min, max }) {
|
|
const parsed = Number.parseInt(value || "", 10);
|
|
if (!Number.isInteger(parsed)) return fallback;
|
|
return Math.min(Math.max(parsed, min), max);
|
|
}
|
|
|
|
function requestUpstream(target, request, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
upstream.destroy(new Error("cloud api proxy timed out after " + cloudApiProxyTimeoutMs + "ms"));
|
|
}, cloudApiProxyTimeoutMs);
|
|
const upstream = http.request(
|
|
target,
|
|
{
|
|
method: request.method,
|
|
headers: {
|
|
accept: request.headers.accept || "application/json",
|
|
"content-type": request.headers["content-type"] || "application/json",
|
|
"content-length": Buffer.byteLength(body)
|
|
}
|
|
},
|
|
(upstreamResponse) => {
|
|
const chunks = [];
|
|
upstreamResponse.on("data", (chunk) => chunks.push(chunk));
|
|
upstreamResponse.on("end", () => {
|
|
clearTimeout(timeout);
|
|
resolve({
|
|
statusCode: upstreamResponse.statusCode || 502,
|
|
headers: upstreamResponse.headers,
|
|
body: Buffer.concat(chunks)
|
|
});
|
|
});
|
|
}
|
|
);
|
|
upstream.on("error", (error) => {
|
|
clearTimeout(timeout);
|
|
reject(error);
|
|
});
|
|
if (body) upstream.write(body);
|
|
upstream.end();
|
|
});
|
|
}
|
|
|
|
async function proxyCloudApi(request, response, url) {
|
|
if (!cloudApiBaseUrl) {
|
|
sendJson(response, 503, {
|
|
error: "cloud_api_base_url_missing",
|
|
serviceId,
|
|
path: url.pathname
|
|
});
|
|
return;
|
|
}
|
|
|
|
let body = "";
|
|
if (request.method !== "GET") {
|
|
body = await readRequestBody(request);
|
|
}
|
|
|
|
if (url.pathname === "/json-rpc") {
|
|
try {
|
|
const envelope = body ? JSON.parse(body) : {};
|
|
if (!readOnlyRpcMethods.has(envelope.method)) {
|
|
sendJson(response, 403, {
|
|
error: "readonly_rpc_required",
|
|
serviceId,
|
|
method: envelope.method || null
|
|
});
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
sendJson(response, 400, {
|
|
error: "invalid_json",
|
|
serviceId,
|
|
reason: error.message
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const target = new URL(url.pathname + url.search, cloudApiBaseUrl);
|
|
try {
|
|
const upstream = await requestUpstream(target, request, request.method === "GET" ? "" : body);
|
|
response.writeHead(upstream.statusCode, {
|
|
...copyHeaders(upstream.headers),
|
|
"content-length": upstream.body.length
|
|
});
|
|
response.end(upstream.body);
|
|
} catch (error) {
|
|
const timedOut = /timed out after \d+ms/iu.test(error.message);
|
|
const code = timedOut ? "cloud_api_proxy_timeout" : "cloud_api_proxy_failed";
|
|
const category = timedOut ? "timeout" : "proxy";
|
|
const traceId = request.headers["x-trace-id"] || null;
|
|
const userMessage = timedOut
|
|
? "Code Agent 代理等待 cloud-api 超过 " + cloudApiProxyTimeoutMs + "ms;输入已保留,可稍后重试。"
|
|
: "Code Agent 代理暂时无法连接 cloud-api;输入已保留,可稍后重试。";
|
|
sendJson(response, 502, {
|
|
status: "failed",
|
|
error: {
|
|
code,
|
|
layer: "proxy",
|
|
category,
|
|
retryable: true,
|
|
userMessage,
|
|
message: userMessage,
|
|
timeoutMs: timedOut ? cloudApiProxyTimeoutMs : null,
|
|
traceId,
|
|
route: url.pathname,
|
|
blocker: {
|
|
code,
|
|
layer: "proxy",
|
|
category,
|
|
retryable: true,
|
|
summary: userMessage,
|
|
userMessage,
|
|
traceId,
|
|
route: url.pathname
|
|
}
|
|
},
|
|
serviceId,
|
|
target: target.href.replace(/\/\/[^/@]*@/u, "//***@"),
|
|
traceId,
|
|
reason: userMessage
|
|
});
|
|
}
|
|
}
|
|
|
|
async function serveCloudWeb() {
|
|
const roots = ["/app/web/hwlab-cloud-web/dist", "/app/web/hwlab-cloud-web"];
|
|
const server = createServer(async (request, response) => {
|
|
const url = new URL(request.url || "/", "http://hwlab-cloud-web.local");
|
|
if (url.pathname === "/health/live" || url.pathname === "/health") {
|
|
sendJson(response, 200, healthPayload());
|
|
return;
|
|
}
|
|
if (await handleCloudWebAuth(request, response, url)) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(request.method === "GET" && (url.pathname === "/v1" || url.pathname === "/v1/m3/status" || url.pathname.startsWith("/v1/"))) ||
|
|
(request.method === "POST" && (url.pathname === "/json-rpc" || url.pathname === "/v1/agent/chat" || url.pathname === "/v1/m3/io"))
|
|
) {
|
|
if (!activeAuthSession(request)) {
|
|
sendJson(response, 401, {
|
|
status: "failed",
|
|
error: "auth_required",
|
|
serviceId
|
|
});
|
|
return;
|
|
}
|
|
await proxyCloudApi(request, response, url);
|
|
return;
|
|
}
|
|
|
|
const routePath = url.pathname.replace(/\/+$/, "") || "/";
|
|
const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" || routePath === "/help" ? "index.html" : url.pathname.slice(1);
|
|
for (const root of roots) {
|
|
const candidate = path.resolve(root, relativePath);
|
|
if (!candidate.startsWith(root)) continue;
|
|
try {
|
|
const info = await stat(candidate);
|
|
if (!info.isFile()) continue;
|
|
const body = await readFile(candidate);
|
|
response.writeHead(200, {
|
|
"content-type": contentType(candidate),
|
|
"content-length": body.length
|
|
});
|
|
response.end(body);
|
|
return;
|
|
} catch {
|
|
// Try the next root.
|
|
}
|
|
}
|
|
|
|
sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname });
|
|
});
|
|
server.listen(port, "0.0.0.0", () => {
|
|
process.stdout.write(JSON.stringify({ serviceId, environment, status: "listening", port }) + "\n");
|
|
});
|
|
}
|
|
|
|
function contentType(filePath) {
|
|
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
|
|
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
|
|
if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
function serveHealthOnly() {
|
|
const server = createServer((request, response) => {
|
|
const url = new URL(request.url || "/", "http://hwlab-artifact.local");
|
|
if (url.pathname === "/health/live" || url.pathname === "/health") {
|
|
sendJson(response, 200, healthPayload());
|
|
return;
|
|
}
|
|
sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname });
|
|
});
|
|
server.listen(port, "0.0.0.0", () => {
|
|
process.stdout.write(JSON.stringify({ serviceId, environment, status: "listening", port }) + "\n");
|
|
});
|
|
}
|
|
|
|
if (runtimeKind === "node-command" && entrypoint) {
|
|
runNodeEntrypoint(entrypoint);
|
|
} else if (runtimeKind === "cloud-web") {
|
|
await serveCloudWeb();
|
|
} else {
|
|
serveHealthOnly();
|
|
}
|
|
`;
|
|
return Buffer.from(source, "utf8").toString("base64");
|
|
}
|
|
|
|
function dockerfile(baseImage, port) {
|
|
return [
|
|
`FROM ${baseImage}`,
|
|
"WORKDIR /app",
|
|
"ENV CODEX_HOME=/codex-home",
|
|
"ENV HWLAB_CODE_AGENT_CODEX_HOME=/codex-home",
|
|
"ENV HWLAB_CODE_AGENT_WORKSPACE=/workspace/hwlab",
|
|
"ENV HWLAB_CODE_AGENT_CODEX_WORKSPACE=/workspace/hwlab",
|
|
"ENV HWLAB_CODE_AGENT_CODEX_SANDBOX=workspace-write",
|
|
"ENV HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED=1",
|
|
"ENV HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR=repo-owned",
|
|
"ENV HWLAB_CODE_AGENT_CODEX_COMMAND=/app/node_modules/.bin/codex",
|
|
"ENV HWLAB_CODE_AGENT_PROVIDER=codex-stdio",
|
|
"ENV HWLAB_CODE_AGENT_SKILLS_DIRS=/app/skills:/root/.agents/skills:/home/ubuntu/.agents/skills",
|
|
"COPY package.json ./package.json",
|
|
"COPY package-lock.json* ./",
|
|
"RUN if [ -d /opt/hwlab-node-runtime-base/node_modules ]; then cp -a /opt/hwlab-node-runtime-base/node_modules ./node_modules; elif [ -f package-lock.json ]; then npm ci --omit=dev --ignore-scripts; else npm install --omit=dev --ignore-scripts; fi",
|
|
"COPY internal ./internal",
|
|
"COPY cmd ./cmd",
|
|
"COPY scripts ./scripts",
|
|
"COPY web ./web",
|
|
"COPY tools ./tools",
|
|
"COPY skills ./skills",
|
|
"COPY deploy ./deploy",
|
|
"RUN mkdir -p /workspace /codex-home && rm -rf /workspace/hwlab && ln -s /app /workspace/hwlab && chmod -R a+rwX /app /workspace /codex-home && test -x /app/node_modules/.bin/codex && /app/node_modules/.bin/codex --version >/tmp/hwlab-codex-version.txt && (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)",
|
|
`RUN node -e "const fs=require('node:fs'); fs.writeFileSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', Buffer.from('${runtimeScriptBase64()}', 'base64')); fs.chmodSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', 0o755);"`,
|
|
"ARG HWLAB_ENVIRONMENT",
|
|
"ARG HWLAB_SERVICE_ID",
|
|
"ARG HWLAB_ARTIFACT_KIND",
|
|
"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 imageRef(registryPrefix, serviceId, tag) {
|
|
return `${registryPrefix}/${serviceId}:${tag}`;
|
|
}
|
|
|
|
function imageRepository(registryPrefix, serviceId) {
|
|
return `${registryPrefix}/${serviceId}`;
|
|
}
|
|
|
|
function inspectDigest(pushOutput) {
|
|
const matches = [...pushOutput.matchAll(/digest:\s+(sha256:[a-f0-9]{64})/giu)];
|
|
if (!matches.length) return null;
|
|
return matches[matches.length - 1][1];
|
|
}
|
|
|
|
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 }) {
|
|
const blockers = [];
|
|
let registryPrefix = args.registryPrefix;
|
|
|
|
if (baseImagePreflight.publishUsable) {
|
|
args.baseImage = baseImagePreflight.localTag;
|
|
} else {
|
|
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 D601 local/internal registry prefix, for example 127.0.0.1:5000/hwlab."
|
|
})
|
|
);
|
|
}
|
|
|
|
try {
|
|
assertDevOnlyContracts(catalog, deployManifest);
|
|
} catch (error) {
|
|
blockers.push(
|
|
blocker({
|
|
type: "contract_blocker",
|
|
scope: "dev-contract",
|
|
summary: error.message,
|
|
next: "Fix deploy/artifact-catalog.dev.json and deploy/deploy.json so both remain DEV-only and cover the frozen service IDs."
|
|
})
|
|
);
|
|
}
|
|
|
|
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 (registryCapabilities?.dimensions?.dockerDaemonPushAccess?.status === "blocked") {
|
|
blockers.push(
|
|
blocker({
|
|
type: "network_blocker",
|
|
scope: "docker-daemon-push-access",
|
|
summary: registryCapabilities.dimensions.dockerDaemonPushAccess.summary,
|
|
next: "Restore Docker daemon access to the D601 local/internal registry 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 cmd/${service.serviceId}/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation.`,
|
|
next: `Add cmd/${service.serviceId}/main.mjs or a dedicated Dockerfile for ${service.serviceId}.`
|
|
})
|
|
);
|
|
}
|
|
if (service.serviceId === "hwlab-cli" && service.implementationState === "library-only") {
|
|
blockers.push(
|
|
blocker({
|
|
type: "runtime_blocker",
|
|
scope: service.serviceId,
|
|
summary: "tools/hwlab-cli declares bin/hwlab-cli.mjs but the bin file is absent; a health-only placeholder image was not built as a real CLI artifact.",
|
|
next: "Add tools/hwlab-cli/bin/hwlab-cli.mjs that calls tools/hwlab-cli/lib/cli.mjs."
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
return blockers;
|
|
}
|
|
|
|
function hasFatalBlocker(blockers) {
|
|
return blockers.some((item) => fatalBlockerTypes.has(item.type));
|
|
}
|
|
|
|
async function buildService({ args, repo, commitId, shortCommit, 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 build = await run(process.execPath, ["web/hwlab-cloud-web/scripts/build.mjs"]);
|
|
if (build.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 dist build failed before Docker build",
|
|
next: "Run node web/hwlab-cloud-web/scripts/build.mjs, fix the static asset build, then rerun --publish."
|
|
}),
|
|
logTail: tailText(`${build.stdout}\n${build.stderr}`),
|
|
cloudWebBuildDurationMs: build.durationMs
|
|
};
|
|
}
|
|
|
|
const distFreshness = await inspectCloudWebDistFreshness(path.join(repoRoot, "web/hwlab-cloud-web"));
|
|
if (distFreshness.status !== "pass") {
|
|
return {
|
|
...service,
|
|
image: ref,
|
|
imageTag: tag,
|
|
buildCreatedAt,
|
|
buildSource,
|
|
status: "build_failed",
|
|
digest: "not_published",
|
|
distFreshness,
|
|
blocker: blocker({
|
|
type: "contract_blocker",
|
|
scope: service.serviceId,
|
|
summary: `cloud web dist is stale or incomplete before Docker build: ${distFreshness.mismatches.join(", ")}`,
|
|
next: "Run node web/hwlab-cloud-web/scripts/build.mjs and node web/hwlab-cloud-web/scripts/check.mjs before publishing hwlab-cloud-web."
|
|
})
|
|
};
|
|
}
|
|
|
|
service = {
|
|
...service,
|
|
distFreshness,
|
|
cloudWebBuildDurationMs: build.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", "scripts/dev-artifact-publish.mjs"],
|
|
["PORT", String(port)],
|
|
["HWLAB_PORT", String(port)]
|
|
];
|
|
const argsList = [
|
|
"build",
|
|
"--pull=false"
|
|
];
|
|
if (args.quietBuild) argsList.push("--quiet");
|
|
|
|
for (const [name, value] of labels) {
|
|
argsList.push("--label", `${name}=${value}`);
|
|
}
|
|
for (const [name, value] of envs) {
|
|
argsList.push("--build-arg", `${name}=${value}`);
|
|
}
|
|
|
|
argsList.push("-t", ref, "-f", "-", ".");
|
|
const result = await runWithInput("docker", argsList, dockerfile(args.baseImage, port));
|
|
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: `docker build failed for ${service.serviceId}`,
|
|
next: `Inspect the Docker build output for ${service.serviceId}, then fix its copied source or base image.`
|
|
}),
|
|
logTail: tailText(`${result.stdout}\n${result.stderr}`),
|
|
dockerBuildDurationMs: result.durationMs
|
|
};
|
|
}
|
|
const imageInspect = await run("docker", ["image", "inspect", "--format", "{{.Id}}", ref]);
|
|
|
|
return {
|
|
...service,
|
|
image: ref,
|
|
imageTag: tag,
|
|
buildCreatedAt,
|
|
buildSource,
|
|
status: "built",
|
|
localImageId: imageInspect.code === 0 ? imageInspect.stdout.trim() : null,
|
|
dockerBuildDurationMs: result.durationMs,
|
|
dockerImageInspectDurationMs: imageInspect.durationMs,
|
|
dockerImageInspectStatus: imageInspect.code === 0 ? "pass" : "failed",
|
|
dockerBuildLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200),
|
|
digest: "not_published"
|
|
};
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|
|
|
|
async function publishService(artifact) {
|
|
if (artifact.status !== "built") {
|
|
return artifact;
|
|
}
|
|
|
|
const result = await run("docker", ["push", artifact.image]);
|
|
if (result.code !== 0) {
|
|
return {
|
|
...artifact,
|
|
status: "publish_failed",
|
|
blocker: blocker({
|
|
type: "network_blocker",
|
|
scope: artifact.serviceId,
|
|
summary: `docker push failed for ${artifact.image}`,
|
|
next: "Verify the D601 local/internal registry is reachable from the Docker daemon and rerun --publish."
|
|
}),
|
|
logTail: tailText(`${result.stdout}\n${result.stderr}`),
|
|
publishDurationMs: result.durationMs
|
|
};
|
|
}
|
|
|
|
const digest = inspectDigest(`${result.stdout}\n${result.stderr}`);
|
|
if (!digest) {
|
|
return {
|
|
...artifact,
|
|
status: "published_unverified_digest",
|
|
digest: "not_published",
|
|
blocker: blocker({
|
|
type: "environment_blocker",
|
|
scope: artifact.serviceId,
|
|
summary: `docker push completed for ${artifact.image} but did not return an immutable digest`,
|
|
next: "Verify registry digest reporting, then rerun --publish; do not update the catalog with a digest until a sha256 registry digest is observed."
|
|
}),
|
|
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200),
|
|
publishDurationMs: result.durationMs
|
|
};
|
|
}
|
|
|
|
return {
|
|
...artifact,
|
|
status: "published",
|
|
digest,
|
|
repositoryDigest: `${repositoryFromImageRef(artifact.image)}@${digest}`,
|
|
pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200),
|
|
publishDurationMs: result.durationMs
|
|
};
|
|
}
|
|
|
|
function repositoryFromImageRef(image) {
|
|
const lastColon = image.lastIndexOf(":");
|
|
return lastColon === -1 ? image : image.slice(0, lastColon);
|
|
}
|
|
|
|
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) => artifact.status === "published")) return "published";
|
|
if (mode === "build" && requiredArtifacts.length > 0 && requiredArtifacts.every((artifact) => artifact.status === "built")) 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 artifactRecord({ args, service, commitId, shortCommit, buildCreatedAt = null, buildSource = null, status, digest = "not_published" }) {
|
|
return {
|
|
...service,
|
|
sourceCommitId: commitId,
|
|
status,
|
|
image: imageRef(args.registryPrefix, service.serviceId, shortCommit),
|
|
imageTag: shortCommit,
|
|
buildCreatedAt,
|
|
buildSource,
|
|
digest,
|
|
repositoryDigest: null
|
|
};
|
|
}
|
|
|
|
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,
|
|
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,
|
|
dockerBuildDurationMs: artifact.dockerBuildDurationMs ?? null,
|
|
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null,
|
|
publishDurationMs: artifact.publishDurationMs ?? null,
|
|
notPublishedReason: artifactNotPublishedReason(artifact, mode, fatalBlocked)
|
|
};
|
|
})
|
|
};
|
|
}
|
|
|
|
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 }) {
|
|
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 builtCount = requiredArtifacts.filter((artifact) => ["built", "published", "published_unverified_digest"].includes(artifact.status)).length;
|
|
|
|
return {
|
|
$schema: "https://hwlab.pikastech.local/schemas/dev-artifact-publish.schema.json",
|
|
$id: "https://hwlab.pikastech.local/dev-cd/dev-artifacts.json",
|
|
reportVersion: "v1",
|
|
issue: "pikasTech/HWLAB#35",
|
|
taskId: "dev-artifact-publish",
|
|
commitId: shortCommit,
|
|
acceptanceLevel: "dev_artifact_publish",
|
|
devOnly: true,
|
|
prodDisabled: true,
|
|
reportLifecycle: activeReportLifecycle("Current DEV artifact publish report; it is not a DEV-LIVE runtime substitute."),
|
|
sourceContract: {
|
|
status: fatalBlocked ? "blocked" : "pass",
|
|
documents: [
|
|
"docs/dev-acceptance-matrix.md",
|
|
"docs/m0-contract-audit.md",
|
|
"docs/artifact-catalog.md",
|
|
"docs/dev-artifact-publish.md",
|
|
"docs/dev-base-image-preflight.md"
|
|
],
|
|
summary: "DEV artifact publish evidence for pikasTech/HWLAB#35, stored in the DEV gate report envelope."
|
|
},
|
|
validationCommands: [
|
|
"node --check scripts/dev-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/dev-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: [
|
|
"node scripts/m1-contract-smoke.mjs"
|
|
],
|
|
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: [
|
|
"node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run"
|
|
],
|
|
evidence: [
|
|
"This report records DEV artifact build/publish state only; deployment dry-run remains separate."
|
|
],
|
|
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 dev and namespace remains hwlab-dev.",
|
|
"PROD profile remains disabled.",
|
|
"No secret or token material is required by this workflow."
|
|
],
|
|
summary: fatalBlocked
|
|
? "Fatal preconditions blocked a full DEV artifact publish."
|
|
: "Fatal DEV-only artifact preconditions passed."
|
|
},
|
|
blockers: devGateBlockers(blockers),
|
|
artifactPublish: {
|
|
issue: "pikasTech/HWLAB#35",
|
|
status,
|
|
mode,
|
|
repo,
|
|
sourceCommitId: commitId,
|
|
registryPrefix: args.registryPrefix,
|
|
producer,
|
|
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
|
|
baseImage: args.baseImage ?? null,
|
|
selectedServiceIds: args.services,
|
|
quietBuild: args.quietBuild,
|
|
concurrency: args.concurrency,
|
|
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
|
|
environment: ENVIRONMENT_DEV,
|
|
buildCreatedAt: artifacts.find((artifact) => artifact.buildCreatedAt)?.buildCreatedAt ?? null,
|
|
serviceInventory,
|
|
publishPlan,
|
|
serviceCount: artifacts.length,
|
|
requiredServiceCount: serviceInventory.requiredServiceCount,
|
|
disabledServiceCount: serviceInventory.disabledServiceCount,
|
|
builtCount,
|
|
publishedCount,
|
|
timings: {
|
|
dockerBuildDurationMs: sumDurations(artifacts, "dockerBuildDurationMs"),
|
|
cloudWebBuildDurationMs: sumDurations(artifacts, "cloudWebBuildDurationMs"),
|
|
publishDurationMs: sumDurations(artifacts, "publishDurationMs")
|
|
},
|
|
generatedAt: new Date().toISOString(),
|
|
services: artifacts.map((artifact) => ({
|
|
serviceId: artifact.serviceId,
|
|
status: artifact.status,
|
|
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,
|
|
dockerBuildDurationMs: artifact.dockerBuildDurationMs ?? null,
|
|
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null,
|
|
publishDurationMs: artifact.publishDurationMs ?? null,
|
|
dockerBuildLogTail: artifact.dockerBuildLogTail ?? null,
|
|
logTail: artifact.logTail ?? null,
|
|
pushLogTail: artifact.pushLogTail ?? null
|
|
})),
|
|
blockers
|
|
},
|
|
notes: "DEV-only 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 transactionGuard = requireDevArtifactSideEffectEnv({
|
|
script: "scripts/dev-artifact-publish.mjs",
|
|
mode: "--publish"
|
|
});
|
|
if (transactionGuard) {
|
|
console.log(JSON.stringify(transactionGuard, null, 2));
|
|
process.exitCode = 2;
|
|
return;
|
|
}
|
|
}
|
|
|
|
args.registryPrefix = validateRegistryPrefix(args.registryPrefix);
|
|
const [baseImagePreflight, registryCapabilities] = await Promise.all([
|
|
runDevBaseImagePreflight({
|
|
env: preflightEnvForBaseImage(args.baseImage)
|
|
}),
|
|
probeRegistryCapabilities({
|
|
registryPrefix: args.registryPrefix
|
|
})
|
|
]);
|
|
if (baseImagePreflight.publishUsable) {
|
|
args.baseImage = baseImagePreflight.localTag;
|
|
}
|
|
const [catalog, deployManifest, commitId, remoteUrl] = await Promise.all([
|
|
readJson(catalogPath),
|
|
readJson(deployPath),
|
|
gitValue(["rev-parse", "HEAD"]),
|
|
gitValue(["remote", "get-url", "origin"]).catch(() => "git@github.com:pikasTech/HWLAB.git")
|
|
]);
|
|
const shortCommit = commitId.slice(0, 7);
|
|
const buildCreatedAt = new Date().toISOString();
|
|
const repo = repoLabelFromRemote(remoteUrl);
|
|
const producer = devArtifactProducer(process.env);
|
|
const services = await resolveServices(args.services, catalog);
|
|
|
|
let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities });
|
|
let artifacts = services.map((service) => artifactRecord({
|
|
args,
|
|
service,
|
|
commitId,
|
|
shortCommit,
|
|
status: service.artifactRequired ? "preflight_only" : "not_required"
|
|
}));
|
|
|
|
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) => item.artifactRequired), 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,
|
|
dockerBuildDurationMs: artifact.dockerBuildDurationMs ?? null,
|
|
cloudWebBuildDurationMs: artifact.cloudWebBuildDurationMs ?? null
|
|
});
|
|
|
|
if (args.mode === "publish" && artifact.status === "built") {
|
|
emit("publish_start", { serviceId: service.serviceId, image: artifact.image });
|
|
const published = await publishService(artifact);
|
|
artifactByServiceId.set(service.serviceId, published);
|
|
if (published.blocker) blockers.push(published.blocker);
|
|
emit("publish_done", {
|
|
serviceId: service.serviceId,
|
|
status: published.status,
|
|
image: published.image,
|
|
digest: published.digest,
|
|
publishDurationMs: published.publishDurationMs ?? 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 });
|
|
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,
|
|
concurrency: report.artifactPublish.concurrency,
|
|
timings: report.artifactPublish.timings,
|
|
publishPlan: report.artifactPublish.publishPlan,
|
|
services: report.artifactPublish.services.map((service) => ({
|
|
serviceId: service.serviceId,
|
|
status: service.status,
|
|
image: service.image,
|
|
imageTag: service.imageTag,
|
|
buildCreatedAt: service.buildCreatedAt,
|
|
digest: service.digest,
|
|
distFreshness: service.distFreshness,
|
|
dockerBuildDurationMs: service.dockerBuildDurationMs,
|
|
cloudWebBuildDurationMs: service.cloudWebBuildDurationMs,
|
|
publishDurationMs: service.publishDurationMs,
|
|
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;
|
|
});
|