Files
pikasTech-HWLAB/scripts/refresh-artifact-catalog.mjs
T
2026-05-21 18:00:33 +00:00

312 lines
13 KiB
JavaScript

#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const catalogPath = "deploy/artifact-catalog.dev.json";
const deployPath = "deploy/deploy.json";
const workloadsPath = "deploy/k8s/base/workloads.yaml";
const defaultPublishReportPath = "reports/dev-gate/dev-artifacts.json";
const digestPattern = /^sha256:[a-f0-9]{64}$/;
const commitPattern = /^[a-f0-9]{7,40}$/;
function parseArgs(argv) {
const args = {
targetRef: "HEAD",
publishReportPath: null,
blocked: false,
write: true
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--target-ref") {
args.targetRef = readOption(argv, ++index, arg);
} else if (arg === "--publish-report") {
args.publishReportPath = readOption(argv, ++index, arg);
} else if (arg === "--blocked") {
args.blocked = true;
} else if (arg === "--no-write") {
args.write = false;
} else if (arg === "--help" || arg === "-h") {
args.help = true;
} else {
throw new Error(`unknown argument ${arg}`);
}
}
if (!args.help) {
assert.notEqual(args.blocked && Boolean(args.publishReportPath), true, "--blocked and --publish-report are mutually exclusive");
assert.ok(args.blocked || args.publishReportPath, "choose --blocked or --publish-report reports/dev-gate/dev-artifacts.json");
}
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 usage() {
return [
"usage: node scripts/refresh-artifact-catalog.mjs --target-ref REF (--blocked|--publish-report PATH) [--no-write]",
"",
"Refresh DEV deploy/catalog artifact identity without faking digest evidence.",
"",
"examples:",
" node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked",
` node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --publish-report ${defaultPublishReportPath}`
].join("\n");
}
async function readJson(relativePath) {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
}
async function writeJson(relativePath, value) {
await writeFile(path.join(repoRoot, relativePath), `${JSON.stringify(value, null, 2)}\n`);
}
async function gitValue(args) {
const result = await execFileAsync("git", args, {
cwd: repoRoot,
timeout: 5000,
maxBuffer: 1024 * 1024
});
return result.stdout.trim();
}
async function resolveTarget(ref) {
const commitId = await gitValue(["rev-parse", ref]);
const shortCommitId = await gitValue(["rev-parse", "--short=7", ref]);
assert.match(commitId, /^[a-f0-9]{40}$/, `target ref ${ref} must resolve to a full SHA`);
return { ref, commitId, shortCommitId };
}
function parseTaggedImage(image, context) {
assert.equal(typeof image, "string", `${context} image must be a string`);
assert.equal(image.includes("@"), false, `${context} image must not include a digest suffix`);
assert.ok(!/prod|production/iu.test(image), `${context} image must not target prod`);
const slashIndex = image.lastIndexOf("/");
const colonIndex = image.lastIndexOf(":");
assert.ok(colonIndex > slashIndex, `${context} image must be a tagged image reference`);
return {
repository: image.slice(0, colonIndex),
tag: image.slice(colonIndex + 1)
};
}
function targetImage(serviceId, shortCommitId) {
return `ghcr.io/pikastech/${serviceId}:${shortCommitId}`;
}
function commitMatchesTarget(value, target) {
return value === target.commitId || value === target.shortCommitId;
}
function assertDevOnlyDeployAndCatalog(deploy, catalog) {
assert.equal(deploy.environment, ENVIRONMENT_DEV, "deploy environment must be dev");
assert.equal(deploy.namespace, "hwlab-dev", "deploy namespace must be hwlab-dev");
assert.equal(deploy.endpoint, DEV_ENDPOINT, "deploy endpoint must stay frozen");
assert.equal(deploy.profiles?.dev?.enabled, true, "deploy dev profile must be enabled");
assert.equal(deploy.profiles?.prod?.enabled, false, "deploy prod profile must stay disabled");
assert.deepEqual((deploy.services ?? []).map((service) => service.serviceId), SERVICE_IDS, "deploy services must cover frozen service IDs in order");
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.equal(catalog.endpoint, DEV_ENDPOINT, "catalog endpoint must stay frozen");
assert.deepEqual(catalog.allowedProfiles, [ENVIRONMENT_DEV], "catalog allowedProfiles must only allow dev");
assert.deepEqual(catalog.forbiddenProfiles, ["prod"], "catalog forbiddenProfiles must forbid prod");
assert.deepEqual((catalog.services ?? []).map((service) => service.serviceId), SERVICE_IDS, "catalog services must cover frozen service IDs in order");
}
function publishRecordsFromReport(report, target) {
const artifactPublish = report.artifactPublish;
assert.ok(artifactPublish && typeof artifactPublish === "object", "publish report missing artifactPublish");
assert.equal(artifactPublish.status, "published", "publish report artifactPublish.status must be published");
assert.ok(commitMatchesTarget(artifactPublish.sourceCommitId, target), "publish report sourceCommitId must match target ref");
assert.equal(artifactPublish.publishedCount, SERVICE_IDS.length, "publish report must publish every frozen service");
assert.equal(artifactPublish.serviceCount, SERVICE_IDS.length, "publish report must cover every frozen service");
const records = new Map();
for (const service of artifactPublish.services ?? []) {
assert.ok(SERVICE_IDS.includes(service.serviceId), `unknown service ${service.serviceId} in publish report`);
assert.equal(service.status, "published", `${service.serviceId} status must be published`);
assert.match(service.digest, digestPattern, `${service.serviceId} digest must be a sha256 registry digest`);
const image = parseTaggedImage(service.image, service.serviceId);
assert.equal(image.tag, target.shortCommitId, `${service.serviceId} image tag must match target short commit`);
records.set(service.serviceId, {
serviceId: service.serviceId,
image: service.image,
imageTag: image.tag,
digest: service.digest,
repositoryDigest: service.repositoryDigest ?? `${image.repository}@${service.digest}`
});
}
assert.deepEqual([...records.keys()], SERVICE_IDS, "publish report services must cover frozen service IDs in order");
return records;
}
function updateEnvObject(env, service, target, publishRecord) {
if (!env || typeof env !== "object" || Array.isArray(env)) return;
if (Object.hasOwn(env, "HWLAB_COMMIT_ID")) env.HWLAB_COMMIT_ID = target.shortCommitId;
if (Object.hasOwn(env, "HWLAB_IMAGE")) env.HWLAB_IMAGE = service.image;
if (Object.hasOwn(env, "HWLAB_IMAGE_TAG")) env.HWLAB_IMAGE_TAG = service.imageTag;
if (Object.hasOwn(env, "HWLAB_SKILLS_COMMIT_ID")) env.HWLAB_SKILLS_COMMIT_ID = target.shortCommitId;
if (Object.hasOwn(env, "HWLAB_IMAGE_DIGEST")) env.HWLAB_IMAGE_DIGEST = publishRecord?.digest ?? "not_published";
}
function serviceIdForWorkload(item, container) {
return (
item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
item?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
container?.name
);
}
function updateEnvList(envList, service, target, publishRecord) {
if (!Array.isArray(envList)) return;
for (const entry of envList) {
if (entry.name === "HWLAB_COMMIT_ID") entry.value = target.shortCommitId;
if (entry.name === "HWLAB_IMAGE") entry.value = service.image;
if (entry.name === "HWLAB_IMAGE_TAG") entry.value = service.imageTag;
if (entry.name === "HWLAB_SKILLS_COMMIT_ID") entry.value = target.shortCommitId;
if (entry.name === "HWLAB_IMAGE_DIGEST") entry.value = publishRecord?.digest ?? "not_published";
}
}
function workloadContainers(item) {
return item?.spec?.template?.spec?.containers ?? [];
}
function refreshDocuments({ deploy, catalog, workloads, target, publishRecords, provenancePath }) {
const published = Boolean(publishRecords);
const catalogByService = new Map(catalog.services.map((service) => [service.serviceId, service]));
const deployByService = new Map(deploy.services.map((service) => [service.serviceId, service]));
const refreshedServices = [];
deploy.commitId = target.shortCommitId;
catalog.commitId = target.shortCommitId;
catalog.artifactState = published ? "published" : "contract-skeleton";
catalog.publish = {
...catalog.publish,
ciPublished: published,
registryVerified: published,
provenance: published ? provenancePath : "not_available_until_publish",
note: published
? "Digest fields were copied from a successful DEV artifact publish report for this source commit."
: "Artifact identity was refreshed to this source commit, but no publish report proved registry digests."
};
for (const serviceId of SERVICE_IDS) {
const deployService = deployByService.get(serviceId);
const catalogService = catalogByService.get(serviceId);
assert.ok(deployService, `${serviceId} missing from deploy manifest`);
assert.ok(catalogService, `${serviceId} missing from catalog`);
const publishRecord = publishRecords?.get(serviceId) ?? null;
const image = publishRecord?.image ?? targetImage(serviceId, target.shortCommitId);
const imageTag = publishRecord?.imageTag ?? target.shortCommitId;
deployService.image = image;
updateEnvObject(deployService.env, { image, imageTag }, target, publishRecord);
catalogService.commitId = target.shortCommitId;
catalogService.image = image;
catalogService.imageTag = imageTag;
catalogService.digest = publishRecord?.digest ?? "not_published";
catalogService.publishState = published ? "published" : "skeleton-only";
refreshedServices.push({
serviceId,
image,
imageTag,
digest: catalogService.digest,
publishState: catalogService.publishState
});
}
for (const item of workloads.items ?? []) {
for (const container of workloadContainers(item)) {
const serviceId = serviceIdForWorkload(item, container);
const service = catalogByService.get(serviceId);
if (!service) continue;
container.image = service.image;
updateEnvList(container.env, service, target, publishRecords?.get(serviceId) ?? null);
}
}
return refreshedServices;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
return;
}
const target = await resolveTarget(args.targetRef);
const [deploy, catalog, workloads, publishReport] = await Promise.all([
readJson(deployPath),
readJson(catalogPath),
readJson(workloadsPath),
args.publishReportPath ? readJson(args.publishReportPath) : Promise.resolve(null)
]);
assertDevOnlyDeployAndCatalog(deploy, catalog);
const publishRecords = publishReport ? publishRecordsFromReport(publishReport, target) : null;
const services = refreshDocuments({
deploy,
catalog,
workloads,
target,
publishRecords,
provenancePath: args.publishReportPath ?? defaultPublishReportPath
});
if (args.write) {
await Promise.all([
writeJson(deployPath, deploy),
writeJson(catalogPath, catalog),
writeJson(workloadsPath, workloads)
]);
}
console.log(JSON.stringify({
status: publishRecords ? "published" : "blocked",
targetRef: target.ref,
sourceCommitId: target.commitId,
artifactCommitId: target.shortCommitId,
wrote: args.write ? [deployPath, catalogPath, workloadsPath] : [],
ciPublished: Boolean(publishRecords),
registryVerified: Boolean(publishRecords),
publishedCount: publishRecords?.size ?? 0,
notPublishedCount: publishRecords ? 0 : services.length,
digestPolicy: publishRecords
? "catalog digests copied only from a published DEV artifact report"
: "all catalog digests remain not_published",
services
}, null, 2));
}
main().catch((error) => {
console.error(JSON.stringify({
status: "failed",
error: error instanceof Error ? error.message : String(error)
}, null, 2));
process.exitCode = 1;
});