#!/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"; import { resolveDevArtifactServices, serviceInventoryFromServices } from "./src/dev-artifact-services.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 defaultRegistryPrefix = process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab"; 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, registryPrefix = defaultRegistryPrefix) { return `${registryPrefix.replace(/\/+$/u, "")}/${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.serviceCount, SERVICE_IDS.length, "publish report must cover every frozen service"); assert.ok(artifactPublish.publishPlan && typeof artifactPublish.publishPlan === "object", "publish report missing artifactPublish.publishPlan"); assert.equal(artifactPublish.publishPlan.version, "v2", "publish report plan version must be v2"); const requiredServiceIds = artifactPublish.publishPlan.services .filter((service) => service.required) .map((service) => service.serviceId); const disabledServiceIds = artifactPublish.publishPlan.services .filter((service) => !service.required) .map((service) => service.serviceId); assert.deepEqual( [...requiredServiceIds, ...disabledServiceIds].sort(), [...SERVICE_IDS].sort(), "publish plan must cover every frozen service" ); assert.equal( artifactPublish.publishedCount, requiredServiceIds.length, "publish report must publish every required enabled 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`); const image = parseTaggedImage(service.image, service.serviceId); assert.equal(image.tag, target.shortCommitId, `${service.serviceId} image tag must match target short commit`); if (!requiredServiceIds.includes(service.serviceId)) { assert.equal(service.artifactRequired, false, `${service.serviceId} disabled service must not be required`); assert.equal(service.digest, "not_published", `${service.serviceId} disabled digest must stay not_published`); assert.equal(service.notPublishedReason?.startsWith("disabled_"), true, `${service.serviceId} disabled service must state a disabled reason`); continue; } assert.equal(service.status, "published", `${service.serviceId} status must be published`); assert.equal(service.artifactRequired, true, `${service.serviceId} required service must state artifactRequired=true`); assert.match(service.digest, digestPattern, `${service.serviceId} digest must be a sha256 registry digest`); 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()], requiredServiceIds, "publish report services must publish required service IDs in order"); return { records, requiredServiceIds, disabledServiceIds }; } 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, serviceInventory, provenancePath, registryPrefix }) { const published = Boolean(publishRecords); const records = publishRecords?.records ?? null; const requiredIds = new Set(publishRecords?.requiredServiceIds ?? serviceInventory.requiredServiceIds); const catalogByService = new Map(catalog.services.map((service) => [service.serviceId, service])); const deployByService = new Map(deploy.services.map((service) => [service.serviceId, service])); const inventoryByService = new Map(serviceInventory.services.map((service) => [service.serviceId, service])); const refreshedServices = []; deploy.commitId = target.shortCommitId; catalog.commitId = target.shortCommitId; catalog.artifactState = published ? "published" : "contract-skeleton"; catalog.serviceInventory = serviceInventory; 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 inventory = inventoryByService.get(serviceId); const required = requiredIds.has(serviceId); const publishRecord = records?.get(serviceId) ?? null; const image = publishRecord?.image ?? targetImage(serviceId, target.shortCommitId, registryPrefix); 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 = publishRecord ? "published" : "skeleton-only"; catalogService.publishEnabled = inventory?.publishEnabled ?? required; catalogService.artifactRequired = required; catalogService.artifactScope = required ? "required" : "disabled"; catalogService.notPublishedReason = publishRecord ? null : inventory?.disabledReason ?? (published ? "not_required" : "publish_not_run"); refreshedServices.push({ serviceId, image, imageTag, digest: catalogService.digest, publishState: catalogService.publishState, artifactRequired: catalogService.artifactRequired, notPublishedReason: catalogService.notPublishedReason }); } 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 serviceInventory = publishReport?.artifactPublish?.serviceInventory ?? serviceInventoryFromServices( await resolveDevArtifactServices(repoRoot, SERVICE_IDS, SERVICE_IDS) ); const services = refreshDocuments({ deploy, catalog, workloads, target, publishRecords, serviceInventory, provenancePath: args.publishReportPath ?? defaultPublishReportPath, registryPrefix: publishReport?.artifactPublish?.registryPrefix ?? defaultRegistryPrefix }); 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?.records.size ?? 0, requiredServiceCount: serviceInventory.requiredServiceCount, disabledServiceCount: serviceInventory.disabledServiceCount, notPublishedCount: services.filter((service) => service.digest === "not_published").length, digestPolicy: publishRecords ? "catalog digests copied only for required services from a published DEV artifact report; disabled services stay not_published" : "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; });