feat: update caserun aggregation and v02 deploy config

This commit is contained in:
Codex Agent
2026-06-08 12:20:34 +08:00
parent d07565c6f1
commit 916838bde4
43 changed files with 1836 additions and 395 deletions
+112 -41
View File
@@ -22,6 +22,7 @@ import {
} 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";
@@ -35,7 +36,7 @@ const v02EnvReuseRuntimeMode = "env-reuse-git-mirror-checkout";
const defaultBuildkitCommand = process.env.HWLAB_BUILDKIT_BUILDCTL || "buildctl-daemonless.sh";
const defaultBuildkitAddr = process.env.HWLAB_BUILDKIT_ADDR || null;
const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json";
const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.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;
@@ -66,18 +67,6 @@ const servicePorts = new Map([
["hwlab-agent-skills", 7430]
]);
const v02RuntimeServiceIds = Object.freeze([
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-gateway",
"hwlab-edge-proxy",
"hwlab-agent-skills"
]);
function serviceIdsForLane(lane) {
return lane === "v02" ? v02RuntimeServiceIds : SERVICE_IDS;
}
function parseArgs(argv) {
const args = {
mode: "preflight",
@@ -109,7 +98,7 @@ function parseArgs(argv) {
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-json") args.deployPath = 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);
@@ -133,7 +122,7 @@ function parseArgs(argv) {
assert.ok(["g14", "v02"].includes(args.lane), `unknown artifact lane ${args.lane}`);
assert.ok(["short", "full"].includes(args.imageTagMode), `unknown image tag mode ${args.imageTagMode}`);
if (!args.servicesExplicit) args.services = [...serviceIdsForLane(args.lane)];
if (!args.servicesExplicit && args.lane !== "v02") args.services = [...SERVICE_IDS];
return args;
}
@@ -210,11 +199,11 @@ function printHelp() {
"options:",
" --lane LANE g14 or v02; default: g14",
` --catalog-path PATH default: ${defaultCatalogPath}`,
` --deploy-json PATH default: ${defaultDeployPath}`,
` --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; default: all frozen DEV services",
" --services LIST comma-separated service IDs; v02 default comes from deploy.lanes.v02.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",
@@ -228,13 +217,13 @@ function printHelp() {
].join("\n"));
}
async function readJson(relativePath) {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
async function readConfig(relativePath) {
return readStructuredFile(repoRoot, relativePath);
}
async function readJsonIfPresent(relativePath, fallback = null) {
async function readConfigIfPresent(relativePath, fallback = null) {
try {
return await readJson(relativePath);
return await readConfig(relativePath);
} catch (error) {
if (error?.code === "ENOENT") return fallback;
throw error;
@@ -593,6 +582,11 @@ function assertV02Contracts(catalog, deployManifest) {
assert.ok(lane && typeof lane === "object", "deploy.lanes.v02 must exist for v02 artifact publish");
assert.equal(lane.namespace, "hwlab-v02", "deploy.lanes.v02.namespace must be hwlab-v02");
assert.ok(isHttpEndpoint(lane.endpoint), "deploy.lanes.v02.endpoint must be a non-empty http(s) URL");
const declaredServiceIds = new Set(v02ServiceIdsFromDeclarations(deployManifest));
assert.ok(
v02ServiceIdsFromDeploy(deployManifest).every((serviceId) => declaredServiceIds.has(serviceId)),
"deploy.lanes.v02.serviceDeclarations must cover envReuseServices"
);
assert.equal(catalog.environment, "v02", "catalog.environment must be v02");
assert.equal(catalog.profile, "v02", "catalog.profile must be v02");
assert.equal(catalog.namespace, "hwlab-v02", "catalog.namespace must be hwlab-v02");
@@ -600,7 +594,7 @@ function assertV02Contracts(catalog, deployManifest) {
assert.deepEqual(catalog.allowedProfiles, ["v02"], "catalog.allowedProfiles must only allow v02");
assert.deepEqual(catalog.forbiddenProfiles, ["dev", "prod"], "catalog.forbiddenProfiles must forbid dev/prod");
const catalogIds = (catalog.services ?? []).map((service) => service.serviceId);
assert.deepEqual(catalogIds, serviceIdsForLane("v02"), "v02 catalog services must match retained service IDs");
assert.deepEqual(catalogIds, v02ServiceIdsFromDeploy(deployManifest), "v02 catalog services must match deploy.lanes.v02.envReuseServices");
for (const service of catalog.services ?? []) {
assert.equal(service.profile, "v02", `${service.serviceId}.profile must be v02`);
assert.equal(service.namespace, "hwlab-v02", `${service.serviceId}.namespace must be hwlab-v02`);
@@ -642,7 +636,7 @@ function artifactCatalogSkeleton({ args, deployManifest, commitId }) {
},
allowedProfiles: [artifactEnvironment(args)],
forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"],
services: serviceIdsForLane(args.lane).map((serviceId) => artifactCatalogSkeletonService({ args, deployManifest, serviceId, tag, commitId }))
services: serviceIdsForArtifactLane(args, deployManifest).map((serviceId) => artifactCatalogSkeletonService({ args, deployManifest, serviceId, tag, commitId }))
};
}
@@ -688,20 +682,55 @@ function artifactCatalogSkeletonService({ args, deployManifest, serviceId, tag,
function envReuseServiceIdsForLane(deployManifest, lane) {
if (lane !== "v02") return new Set();
const configured = deployManifest?.lanes?.v02?.envReuseServices;
const allowed = new Set(serviceIdsForLane(lane));
return new Set((Array.isArray(configured) ? configured : []).filter((serviceId) => allowed.has(serviceId)));
return new Set(normalizeServiceIdList(configured));
}
function serviceIdsForArtifactLane(args, deployManifest) {
if (args.lane !== "v02") return [...SERVICE_IDS];
return v02ServiceIdsFromDeploy(deployManifest);
}
function applyDeployDefaultServices(args, deployManifest) {
if (args.servicesExplicit) return;
args.services = serviceIdsForArtifactLane(args, deployManifest);
}
function v02ServiceIdsFromDeploy(deployManifest) {
const serviceIds = normalizeServiceIdList(deployManifest?.lanes?.v02?.envReuseServices);
assert.ok(serviceIds.length > 0, "deploy.lanes.v02.envReuseServices must not be empty");
return serviceIds;
}
function v02ServiceIdsFromDeclarations(deployManifest) {
return normalizeServiceIdList(Object.keys(deployManifest?.lanes?.v02?.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) {
const value = deployManifest?.lanes?.v02?.bootScripts?.[serviceId] || `deploy/runtime/boot/${serviceId}.sh`;
const value = deployManifest?.lanes?.v02?.bootScripts?.[serviceId];
assert.ok(value, `deploy.lanes.v02.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, lane) {
function normalizeCatalogForLane(catalog, lane, deployManifest) {
if (!catalog || lane !== "v02") return catalog;
const allowed = new Set(serviceIdsForLane(lane));
const allowed = new Set(v02ServiceIdsFromDeploy(deployManifest));
return {
...catalog,
services: (catalog.services ?? []).filter((service) => allowed.has(service.serviceId))
@@ -826,28 +855,69 @@ function dockerfile(baseImage, port, labels = []) {
].join("\n");
}
function envReuseDockerfile(baseImage, labels = []) {
const osPackageScript = "set -eu; apt-get update; apt-get install -y --no-install-recommends ca-certificates git; rm -rf /var/lib/apt/lists/*";
const dependencyScript = "set -eu; 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=\"@oven/bun-linux-x64@1.3.13\" ;; aarch64|arm64) bun_pkg=\"@oven/bun-linux-aarch64@1.3.13\" ;; *) 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 \"/opt/hwlab-env/$bun_bin\" /usr/local/bin/bun; fi";
const readinessScript = "set -eu; command -v node >/dev/null; command -v npm >/dev/null; command -v git >/dev/null; command -v sh >/dev/null; test -d /opt/hwlab-env/node_modules; test -x /usr/local/bin/bun; /usr/local/bin/bun --version >/tmp/hwlab-env-bun-version.txt; printf '%s\\n' '#!/usr/bin/env sh' 'exec node /workspace/hwlab-boot/repo/scripts/run-bun.mjs /workspace/hwlab-boot/repo/tools/hwpod-cli.ts \"$@\"' > /usr/local/bin/hwpod; chmod 755 /usr/local/bin/hwpod; printf '%s\\n' '#!/usr/bin/env sh' 'exec node /workspace/hwlab-boot/repo/scripts/run-bun.mjs /workspace/hwlab-boot/repo/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 /workspace/hwlab-boot/repo/scripts/run-bun.mjs /workspace/hwlab-boot/repo/tools/hwpod-compiler-cli.ts \"$@\"' > /usr/local/bin/hwpod-compiler; chmod 755 /usr/local/bin/hwpod-compiler";
function envReuseDockerfile(baseImage, labels = [], recipe) {
const normalizedRecipe = normalizeEnvReuseRecipe(recipe);
const osPackageScript = `set -eu; apt-get update; apt-get install -y --no-install-recommends ${normalizedRecipe.osPackages.map(shellQuote).join(" ")}; rm -rf /var/lib/apt/lists/*`;
const dependencyScript = `set -eu; 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=\"@oven/bun-linux-x64@${normalizedRecipe.bunVersion}\" ;; aarch64|arm64) bun_pkg=\"@oven/bun-linux-aarch64@${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 \"${normalizedRecipe.runtimeNodeModulesPath}/$bun_bin\" /usr/local/bin/bun; fi`;
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 = `set -eu; command -v node >/dev/null; command -v npm >/dev/null; command -v git >/dev/null; command -v sh >/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 ${baseImage}`,
"WORKDIR /opt/hwlab-env",
`WORKDIR ${normalizedRecipe.runtimeNodeModulesPath.replace(/\/node_modules$/u, "")}`,
...labels.map(([name, value]) => `LABEL ${name}=${dockerfileQuote(value)}`),
"COPY package.json ./package.json",
"COPY package-lock.json* ./",
`RUN ${osPackageScript}`,
`RUN ${dependencyScript}`,
"COPY deploy/runtime/launcher/hwlab-env-reuse-launcher.ts /usr/local/bin/hwlab-env-reuse-launcher.ts",
`COPY ${normalizedRecipe.launcherPath} /usr/local/bin/hwlab-env-reuse-launcher.ts`,
`RUN ${readinessScript}`,
"ENV HWLAB_RUNTIME_MODE=env-reuse-git-mirror-checkout",
"ENV HWLAB_RUNTIME_NODE_MODULES=/opt/hwlab-env/node_modules",
`ENV HWLAB_RUNTIME_NODE_MODULES=${normalizedRecipe.runtimeNodeModulesPath}`,
"ENV PATH=/usr/local/bin:$PATH",
"CMD [\"/usr/local/bin/bun\", \"/usr/local/bin/hwlab-env-reuse-launcher.ts\"]",
""
].join("\n");
}
function normalizeEnvReuseRecipe(recipe) {
assert.ok(recipe && typeof recipe === "object", "envRecipe must be provided by deploy.lanes.v02.envRecipe");
const normalized = {
osPackages: normalizeStringList(recipe.osPackages),
bunVersion: String(recipe.bunVersion ?? "").trim(),
launcherPath: normalizeRepoPath(recipe.launcherPath),
runtimeNodeModulesPath: normalizeAbsolutePath(recipe.runtimeNodeModulesPath),
hwpodAliases: normalizeHwpodAliases(recipe.hwpodAliases)
};
assert.ok(normalized.osPackages.length > 0, "envRecipe.osPackages must not be empty");
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 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}`;
}
@@ -1071,7 +1141,7 @@ async function buildEnvReuseServiceWithBuildkit({ args, service, ref, buildCreat
const buildkitAddrArgs = args.buildkitAddr ? ["--addr", args.buildkitAddr] : [];
const startedAt = Date.now();
try {
await writeFile(dockerfilePath, envReuseDockerfile(args.baseImage, labels));
await writeFile(dockerfilePath, envReuseDockerfile(args.baseImage, labels, service.envReuseRecipe));
const argsList = [
...buildkitAddrArgs,
"build",
@@ -2076,14 +2146,15 @@ async function main() {
args.baseImage = baseImagePreflight.localTag;
}
const [deployManifest, commitId, remoteUrl] = await Promise.all([
readJson(args.deployPath),
readConfig(args.deployPath),
gitValue(["rev-parse", "HEAD"]),
gitValue(["remote", "get-url", "origin"]).catch(() => "git@github.com:pikasTech/HWLAB.git")
]);
let catalog = await readJsonIfPresent(args.catalogPath, null);
applyDeployDefaultServices(args, deployManifest);
let catalog = await readConfigIfPresent(args.catalogPath, null);
const catalogWasMissing = !catalog;
if (!catalog && args.lane === "v02") catalog = artifactCatalogSkeleton({ args, deployManifest, commitId });
catalog = normalizeCatalogForLane(catalog, args.lane);
catalog = normalizeCatalogForLane(catalog, args.lane, deployManifest);
assert.ok(catalog, `${args.catalogPath} is required for ${args.lane} artifact publish`);
const shortCommit = imageTagForCommit(args, commitId);
let effectiveCatalogPath = args.catalogPath;
@@ -2098,11 +2169,11 @@ async function main() {
repoRoot,
lane: args.lane,
targetRef: "HEAD",
deployJsonPath: args.deployPath,
deployConfigPath: args.deployPath,
artifactCatalogPath: effectiveCatalogPath,
registryPrefix: args.registryPrefix,
baseImage: args.baseImage ?? undefined,
services: args.services
...(args.servicesExplicit ? { services: args.services } : {})
});
const services = enrichServicesWithCiPlan(await resolveServices(args.services, catalog), ciPlan);
const catalogByServiceId = new Map((catalog?.services ?? []).map((service) => [service.serviceId, service]));
+6 -5
View File
@@ -7,6 +7,7 @@ import test from "node:test";
import { promisify } from "node:util";
import { buildDesiredStatePlan } from "./src/deploy-desired-state-plan.mjs";
import { writeStructuredFile } from "./src/structured-config.mjs";
const execFileAsync = promisify(execFile);
@@ -316,7 +317,7 @@ async function makeFixture({
}
]
};
await writeFile(path.join(root, "deploy/deploy.json"), `${JSON.stringify(deploy, null, 2)}\n`);
await writeStructuredFile(root, "deploy/deploy.yaml", deploy);
await writeFile(path.join(root, "deploy/artifact-catalog.dev.json"), `${JSON.stringify(catalog, null, 2)}\n`);
await writeFile(path.join(root, "deploy/k8s/base/workloads.yaml"), `${JSON.stringify(workloads, null, 2)}\n`);
return root;
@@ -463,7 +464,7 @@ test("blocks when cloud-api DB SSL mode drifts back to require", async () => {
assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "cloud_api_db_contract_mismatch"));
});
test("blocks when generated runtime identity leaks into deploy.json", async () => {
test("blocks when generated runtime identity leaks into deploy.yaml", async () => {
const repoRoot = await makeFixture({
serviceId: "hwlab-cloud-web",
deployEnvMirrors: true,
@@ -474,7 +475,7 @@ test("blocks when generated runtime identity leaks into deploy.json", async () =
assert.equal(plan.summary.blockers, 3);
assert.ok(plan.diagnostics.some((diagnostic) =>
diagnostic.code === "generated_artifact_identity" &&
diagnostic.path === "deploy/deploy.json.services.hwlab-cloud-web.env.HWLAB_COMMIT_ID"
diagnostic.path === "deploy/deploy.yaml.services.hwlab-cloud-web.env.HWLAB_COMMIT_ID"
));
assert.ok(plan.diagnostics.some((diagnostic) =>
diagnostic.code === "generated_artifact_identity" &&
@@ -541,7 +542,7 @@ test("target check accepts accepted first-parent main on the checked-out merge c
await git(repoRoot, ["checkout", "-b", "desired-state-refresh"]);
for (const relativePath of [
"deploy/deploy.json",
"deploy/deploy.yaml",
"deploy/artifact-catalog.dev.json",
"deploy/k8s/base/workloads.yaml"
]) {
@@ -600,7 +601,7 @@ test("ignores generated workload mirror drift because render owns runtime identi
assert.deepEqual(plan.diagnostics, []);
});
test("blocks on skills commit mirror leak in deploy.json", async () => {
test("blocks on skills commit mirror leak in deploy.yaml", async () => {
const repoRoot = await makeFixture({ deployEnvMirrors: true, deploySkillsCommitId: "badcafe" });
const plan = await buildDesiredStatePlan({ repoRoot });
assert.equal(plan.status, "blocked");
+5 -4
View File
@@ -12,6 +12,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs";
import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.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 defaultReportPath = tempReportPath("dev-m3-hardware-loop.json");
@@ -382,9 +383,9 @@ function serviceByName(services) {
async function collectReadOnlySupplementalEvidence() {
const [deploy, workloads, services] = await Promise.all([
readFile(path.join(repoRoot, "deploy/deploy.json"), "utf8").then(JSON.parse),
readFile(path.join(repoRoot, deployWorkloadsPath), "utf8").then(JSON.parse),
readFile(path.join(repoRoot, deployServicesPath), "utf8").then(JSON.parse)
readStructuredFile(repoRoot, "deploy/deploy.yaml"),
readStructuredFile(repoRoot, deployWorkloadsPath),
readStructuredFile(repoRoot, deployServicesPath)
]);
const targets = ["hwlab-box-simu", "hwlab-gateway-simu", "hwlab-patch-panel"];
const observed = listItems(workloads)
@@ -468,7 +469,7 @@ async function collectReadOnlySupplementalEvidence() {
id: "deploy-skeleton-m3-cardinality",
status: manifestReadyForM3 ? "manifest-ready" : "gap",
blockerClass: manifestReadyForM3 ? null : "runtime_blocker",
source: ["deploy/deploy.json", deployWorkloadsPath, deployServicesPath],
source: ["deploy/deploy.yaml", deployWorkloadsPath, deployServicesPath],
summary: manifestReadyForM3
? "Checked-in DEV deploy skeleton declares distinct indexed M3 identities for two box simulators, two gateway simulators, one patch panel, and the M3 DO1 -> DI1 endpoint map."
: "Read-only static comparison found the checked-in DEV deploy skeleton does not declare distinct indexed M3 simulator identities and patch-panel M3 wiring; proving live DEV still requires a separate deploy/runtime task or authorized runtime observation.",
+4 -4
View File
@@ -17,7 +17,7 @@ function parseArgs(argv) {
const arg = argv[index];
if (arg === "--base-ref") parsed.baseRef = readOption(argv, ++index, arg);
else if (arg === "--target-ref") parsed.targetRef = readOption(argv, ++index, arg);
else if (arg === "--deploy-json") parsed.deployJsonPath = readOption(argv, ++index, arg);
else if (arg === "--deploy-config") parsed.deployConfigPath = readOption(argv, ++index, arg);
else if (arg === "--artifact-catalog") parsed.artifactCatalogPath = readOption(argv, ++index, arg);
else if (arg === "--lane") parsed.lane = readOption(argv, ++index, arg);
else if (arg === "--registry-prefix") parsed.registryPrefix = readOption(argv, ++index, arg);
@@ -38,13 +38,13 @@ function readOption(argv, index, name) {
function usage() {
return [
"usage: node scripts/g14-ci-plan.mjs [--base-ref REF] [--target-ref REF] [--pretty]",
"usage: node scripts/g14-ci-plan.mjs [--lane v02] [--base-ref REF] [--target-ref REF] [--pretty]",
"",
"Plan G14 monorepo image builds without mutating CI/CD state.",
"The planner reads current deploy/deploy.json and the built-in G14 CI component model by default.",
"For v02 env reuse, service component boundaries and env recipe must come from deploy/deploy.yaml.",
"",
"examples:",
" node scripts/g14-ci-plan.mjs --base-ref HEAD~1 --target-ref HEAD --pretty",
" node scripts/g14-ci-plan.mjs --lane v02 --base-ref HEAD~1 --target-ref HEAD --pretty",
" node scripts/g14-ci-plan.mjs --services hwlab-cloud-api,hwlab-cloud-web --pretty"
].join("\n");
}
+184 -39
View File
@@ -10,9 +10,11 @@ import {
classifyGlobalChange,
componentModelsForServices,
createG14CiPlan,
envReuseRecipeForLane,
matchingPaths,
packageRuntimeFieldsChanged
} from "./src/g14-ci-plan-lib.mjs";
import { readStructuredFile, writeStructuredFile } from "./src/structured-config.mjs";
const execFileAsync = promisify(execFile);
@@ -30,12 +32,12 @@ test("G14 CI planner maps bridge changes to cloud-api only", async () => {
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "change bridge"]);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]);
assert.equal(plan.reusedServices.includes("hwlab-cloud-web"), true);
const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
assert.equal(cloudApi.affected, true);
assert.deepEqual(cloudApi.reason, ["component-path-changed"]);
assert.deepEqual(cloudApi.reason, ["code-input-changed", "component-path-changed"]);
});
test("G14 CI planner treats docs-only changes as no image build", async () => {
@@ -45,7 +47,7 @@ test("G14 CI planner treats docs-only changes as no image build", async () => {
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "change docs"]);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, []);
assert.equal(plan.imageBuildRequired, false);
assert.equal(plan.changedPathSummary.docsOnly, true);
@@ -58,10 +60,11 @@ test("planner rebuilds when catalog digest is missing instead of blocking reuse"
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "change docs"]);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]);
assert.equal(plan.imageBuildRequired, true);
assert.equal(plan.services[0].reason.includes("catalog-digest-missing"), true);
assert.equal(plan.services.every((service) => service.envChanged === true), true);
assert.equal(plan.services.every((service) => service.reuse.status === "not-reused"), true);
});
test("v02 planner rolls cloud-api service code changes without rebuilding service image", async () => {
@@ -140,8 +143,9 @@ test("artifact reuse paths preserve catalog provenance instead of current planne
assert.doesNotMatch(gitopsRender, /"component-input-hash": planned\.componentInputHash \|\| service\.componentInputHash \|\| ""/u);
});
test("component model uses built-in service paths", () => {
const models = componentModelsForServices(["hwlab-cloud-api"]);
test("component model uses service paths declared in deploy json", () => {
const deploy = createDeployFixture();
const models = componentModelsForServices(["hwlab-cloud-api"], deploy.lanes.v02);
assert.deepEqual(models[0].componentPaths, [
"cmd/hwlab-cloud-api/",
"cmd/hwlab-codex-api-responses-forwarder/",
@@ -159,6 +163,50 @@ test("component model uses built-in service paths", () => {
assert.deepEqual(matchingPaths(["cmd/hwlab-cloud-api/main.ts"], models[0].componentPaths), ["cmd/hwlab-cloud-api/main.ts"]);
});
test("v02 planner rejects missing service declarations instead of falling back to an internal model", async () => {
const repo = await createFixtureRepo();
const deployPath = path.join(repo, "deploy/deploy.yaml");
const deploy = await readStructuredFile(repo, deployPath);
delete deploy.lanes.v02.serviceDeclarations;
await writeStructuredFile(repo, deployPath, deploy);
await git(repo, ["add", "deploy/deploy.yaml"]);
await git(repo, ["commit", "-m", "remove v02 declarations"]);
await assert.rejects(
() => createG14CiPlan({
repoRoot: repo,
lane: "v02",
baseRef: "HEAD~1",
targetRef: "HEAD",
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
services: ["hwlab-cloud-api"]
}),
/deploy\.lanes\.v02\.serviceDeclarations is required/u
);
});
test("v02 planner rejects missing boot script declarations instead of guessing paths", async () => {
const repo = await createFixtureRepo();
const deployPath = path.join(repo, "deploy/deploy.yaml");
const deploy = await readStructuredFile(repo, deployPath);
delete deploy.lanes.v02.bootScripts["hwlab-cloud-api"];
await writeStructuredFile(repo, deployPath, deploy);
await git(repo, ["add", "deploy/deploy.yaml"]);
await git(repo, ["commit", "-m", "remove v02 cloud-api boot script"]);
await assert.rejects(
() => createG14CiPlan({
repoRoot: repo,
lane: "v02",
baseRef: "HEAD~1",
targetRef: "HEAD",
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
services: ["hwlab-cloud-api"]
}),
/deploy\.lanes\.v02\.bootScripts\.hwlab-cloud-api is required/u
);
});
test("v02 planner keeps hwpod-spec changes out of runtime service env reuse", async () => {
const repo = await createFixtureRepo();
await writeFile(path.join(repo, ".hwlab/hwpod-spec.yaml"), "kind: Hwpod\nmetadata:\n name: changed-preinstalled\nspec:\n nodeBinding:\n nodeId: node-changed\n workspace:\n path: F:\\\\Work\\\\D601-HWLAB\n targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n", "utf8");
@@ -232,11 +280,11 @@ test("planner scopes cloud-web runtime changes to cloud-web", async () => {
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "change cloud web runtime"]);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-web"]);
assert.equal(plan.services.some((service) => service.serviceId === "hwlab-agent-worker"), false);
const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web");
assert.deepEqual(cloudWeb.reason, ["component-path-changed"]);
assert.deepEqual(cloudWeb.reason, ["code-input-changed", "component-path-changed"]);
});
test("v02 planner treats shared trace renderer changes as cloud-web code rollout inputs", async () => {
@@ -312,7 +360,7 @@ test("planner ignores package script cleanup for runtime images", async () => {
await git(repo, ["commit", "-m", "change package scripts"]);
assert.equal(await packageRuntimeFieldsChanged(repo, "HEAD~1", "HEAD"), false);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, []);
assert.deepEqual(plan.buildServices, []);
assert.equal(plan.imageBuildRequired, false);
@@ -340,24 +388,11 @@ test("planner treats dependency changes as runtime image inputs", async () => {
await git(repo, ["commit", "-m", "change dependencies"]);
assert.equal(await packageRuntimeFieldsChanged(repo, "HEAD~1", "HEAD"), true);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]);
assert.equal(plan.imageBuildRequired, true);
});
test("planner remains compatible with deploy.k3s.serviceMappings shape", async () => {
const repo = await createFixtureRepo({ deployServices: false, k3sServiceMappings: true });
await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge v2');\n");
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "change bridge"]);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
assert.equal(plan.compatibility.currentRenderCompatible, true);
assert.equal(plan.compatibility.serviceIdSource, "deploy.k3s.serviceMappings");
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]);
assert.equal(plan.inputs.serviceCount, 2);
});
test("global change classifier distinguishes gitops-only and test-only", () => {
assert.equal(classifyGlobalChange(["deploy/gitops/g14/runtime-dev/workloads.yaml"]).gitopsOnly, true);
assert.equal(classifyGlobalChange(["cmd/hwlab-cloud-api/runtime-options.test.ts"]).testOnly, true);
@@ -556,10 +591,27 @@ test("v02 planner scopes hwpod skill bundle changes to the skills service", asyn
assert.deepEqual(skillsService.reason, ["code-input-changed", "component-path-changed"]);
});
test("v02 planner reads env reuse declarations and recipe from deploy json", async () => {
const deploy = createDeployFixture();
deploy.lanes.v02.serviceDeclarations["hwlab-cloud-web"].componentPaths.push("custom/web-plugin/");
deploy.lanes.v02.envRecipe.bunVersion = "1.3.14";
deploy.lanes.v02.envRecipe.additionalEnvPaths.push("custom/env-tool.mjs");
const models = componentModelsForServices(["hwlab-cloud-web"], deploy.lanes.v02);
assert.equal(models[0].runtimeKind, "cloud-web");
assert.equal(models[0].entrypoint, "web/hwlab-cloud-web/index.html");
assert.equal(models[0].componentPaths.includes("custom/web-plugin/"), true);
assert.equal(models[0].buildSystemPaths.includes("custom/env-tool.mjs"), true);
const recipe = envReuseRecipeForLane(deploy, "v02");
assert.equal(recipe.bunVersion, "1.3.14");
assert.equal(recipe.launcherInputPaths.includes("deploy/runtime/launcher/hwlab-env-reuse-launcher.ts"), true);
assert.equal(recipe.launcherInputPaths.includes("custom/env-tool.mjs"), true);
});
async function createFixtureRepo(options = {}) {
const deployServices = options.deployServices !== false;
const k3sServiceMappings = options.k3sServiceMappings === true;
const serviceIds = options.serviceIds ?? ["hwlab-cloud-api", "hwlab-cloud-web"];
const laneServiceIds = options.laneServiceIds ?? V02_RUNTIME_SERVICE_IDS;
const repo = await mkdtemp(path.join(os.tmpdir(), "hwlab-g14-ci-plan-"));
await mkdir(path.join(repo, "cmd/hwlab-cloud-api"), { recursive: true });
await mkdir(path.join(repo, "cmd/hwlab-codex-api-responses-forwarder"), { recursive: true });
@@ -578,7 +630,7 @@ async function createFixtureRepo(options = {}) {
await mkdir(path.join(repo, "deploy/runtime/boot"), { recursive: true });
await mkdir(path.join(repo, "deploy/runtime/launcher"), { recursive: true });
await mkdir(path.join(repo, "deploy"), { recursive: true });
await writeFile(path.join(repo, "deploy/deploy.json"), JSON.stringify(createDeployFixture({ deployServices, k3sServiceMappings, serviceIds }), null, 2));
await writeStructuredFile(repo, "deploy/deploy.yaml", createDeployFixture({ serviceIds: laneServiceIds }));
const catalogMode = options.catalog ?? "ready";
await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module" }, null, 2));
await writeFile(path.join(repo, "package-lock.json"), JSON.stringify({ lockfileVersion: 3 }, null, 2));
@@ -642,43 +694,136 @@ async function refreshFixtureCatalogForHead(repo) {
const services = ["hwlab-cloud-api", "hwlab-cloud-web"];
const plan = await createG14CiPlan({
repoRoot: repo,
lane: "v02",
baseRef: "HEAD",
targetRef: "HEAD",
artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
services
});
const componentInputHashes = Object.fromEntries(plan.services.map((service) => [service.serviceId, service.componentInputHash]));
const catalog = createCatalogFixture("ready", { sourceCommitId, componentInputHashes });
const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(plan, { sourceCommitId, serviceIds: services }));
await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(catalog, null, 2));
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(catalog, null, 2));
await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json"]);
await git(repo, ["commit", "-m", "refresh fixture artifact catalogs"]);
}
function createDeployFixture({ deployServices, k3sServiceMappings, serviceIds = ["hwlab-cloud-api", "hwlab-cloud-web"] }) {
function planV02CoreServices(repo, options) {
return createG14CiPlan({
repoRoot: repo,
lane: "v02",
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
services: ["hwlab-cloud-api", "hwlab-cloud-web"],
...options
});
}
function createDeployFixture({ serviceIds = V02_RUNTIME_SERVICE_IDS } = {}) {
const deploy = {
lanes: {
v02: {
sourceRepo: "git@github.com:pikasTech/HWLAB.git",
envReuseServices: [...V02_RUNTIME_SERVICE_IDS],
envReuseServices: [...serviceIds],
bootScripts: {
"hwlab-cloud-api": "deploy/runtime/boot/hwlab-cloud-api.sh",
"hwlab-cloud-web": "deploy/runtime/boot/hwlab-cloud-web.sh",
"hwlab-gateway": "deploy/runtime/boot/hwlab-gateway.sh",
"hwlab-edge-proxy": "deploy/runtime/boot/hwlab-edge-proxy.sh",
"hwlab-agent-skills": "deploy/runtime/boot/hwlab-agent-skills.sh"
},
serviceDeclarations: {
"hwlab-cloud-api": {
runtimeKind: "bun-command",
entrypoint: "cmd/hwlab-cloud-api/main.ts",
artifactKind: "bun-command",
healthPath: "/health/live",
healthPort: 6667,
componentPaths: [
"cmd/hwlab-cloud-api/",
"cmd/hwlab-codex-api-responses-forwarder/",
"cmd/hwlab-deepseek-responses-bridge/",
"deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh",
"deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh",
"internal/cloud/",
"internal/db/",
"internal/audit/",
"internal/agent/agentrun-dispatch.mjs",
"internal/agent/prompts/",
"skills/hwlab-agent-runtime/"
],
env: {},
observable: true
},
"hwlab-cloud-web": {
runtimeKind: "cloud-web",
entrypoint: "web/hwlab-cloud-web/index.html",
artifactKind: "cloud-web",
healthPath: "/health/live",
healthPort: 8080,
componentPaths: [
"web/hwlab-cloud-web/",
"internal/dev-entrypoint/cloud-web-runtime.mjs",
"internal/dev-entrypoint/cloud-web-proxy.mjs",
"internal/dev-entrypoint/cloud-web-routes.mjs",
"tools/src/hwlab-cli/trace-renderer.ts"
],
env: {},
observable: true
},
"hwlab-gateway": {
runtimeKind: "bun-command",
entrypoint: "cmd/hwlab-gateway/main.ts",
artifactKind: "bun-command",
healthPath: "/health/live",
healthPort: 7001,
componentPaths: ["cmd/hwlab-gateway/", "internal/sim/"],
env: {},
observable: false
},
"hwlab-edge-proxy": {
runtimeKind: "bun-command",
entrypoint: "cmd/hwlab-edge-proxy/main.ts",
artifactKind: "bun-command",
healthPath: "/health",
healthPort: 6667,
componentPaths: ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"],
env: {},
observable: true
},
"hwlab-agent-skills": {
runtimeKind: "skills-bundle",
entrypoint: "skills/hwlab-agent-runtime/SKILL.md",
artifactKind: "skills-bundle",
healthPath: "/health/live",
healthPort: 7430,
componentPaths: ["skills/"],
env: {},
observable: true
}
},
envRecipe: {
osPackages: ["ca-certificates", "git"],
bunVersion: "1.3.13",
launcherPath: "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts",
runtimeNodeModulesPath: "/opt/hwlab-env/node_modules",
additionalEnvPaths: [
"internal/dev-entrypoint/artifact-runtime.mjs",
"scripts/artifact-publish.mjs",
"scripts/g14-artifact-publish.mjs",
"scripts/src/dev-artifact-services.mjs"
],
hwpodAliases: [
{ command: "hwpod", script: "tools/hwpod-cli.ts" },
{ command: "hwpod-ctl", script: "tools/hwpod-ctl.ts" },
{ command: "hwpod-compiler", script: "tools/hwpod-compiler-cli.ts" }
]
},
bootConfig: {
template: "default",
overrides: {}
}
}
}
};
if (deployServices) {
deploy.services = serviceIds.map((serviceId) => ({ serviceId }));
}
if (k3sServiceMappings) {
deploy.k3s = {
serviceMappings: serviceIds.map((serviceId) => ({ serviceId, name: serviceId }))
};
}
return deploy;
}
+88 -59
View File
@@ -7,6 +7,8 @@ import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { readStructuredFile } from "./src/structured-config.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultOutDir = "deploy/gitops/g14";
@@ -37,21 +39,9 @@ const defaultServiceIds = Object.freeze([
"hwlab-edge-proxy",
"hwlab-agent-skills"
]);
const v02RuntimeServiceIds = Object.freeze([
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-gateway",
"hwlab-edge-proxy",
"hwlab-agent-skills"
]);
const v02ObservableServices = Object.freeze([
{ serviceId: "hwlab-cloud-api", port: 6667, healthPath: "/health/live" },
{ serviceId: "hwlab-cloud-web", port: 8080, healthPath: "/health/live" },
{ serviceId: "hwlab-edge-proxy", port: 6667, healthPath: "/health" },
{ serviceId: "hwlab-agent-skills", port: 7430, healthPath: "/health/live" },
const v02ExtraObservableServices = Object.freeze([
{ serviceId: "hwlab-deepseek-proxy", port: 4000, healthPath: "/health/live" }
]);
const v02ObservableServiceIds = new Set(v02ObservableServices.map((item) => item.serviceId));
const v02RemovedServiceIds = new Set([
"hwlab-agent-mgr",
"hwlab-agent-worker",
@@ -275,7 +265,7 @@ function usage() {
return [
"usage: node scripts/g14-gitops-render.mjs [options]",
"",
"Render G14-only Kubernetes-native CI/CD manifests from built-in Tekton CI primitives, deploy/deploy.json, and deploy/k8s/*.",
"Render G14-only Kubernetes-native CI/CD manifests from built-in Tekton CI primitives, deploy/deploy.yaml, and deploy/k8s/*.",
"",
"options:",
" --lane LANE g14 or v02; default: g14",
@@ -304,7 +294,7 @@ function generatedPath(filePath) {
}
async function readJson(relativePath) {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
return readStructuredFile(repoRoot, relativePath);
}
async function readJsonIfPresent(relativePath, fallback = null) {
@@ -441,7 +431,8 @@ function v02EnvReuseEnabled(catalog, serviceId, envReuseServiceIds = null) {
}
function bootShForService(deployService, serviceId) {
const value = deployService?.bootSh || deployService?.bootScript || `deploy/runtime/boot/${serviceId}.sh`;
assert.ok(deployService?.bootSh, `deploy.lanes.v02.bootScripts.${serviceId} is required`);
const value = deployService.bootSh;
const text = String(value).replaceAll("\\", "/").replace(/^\.\//u, "");
assert.ok(text && !text.startsWith("/") && !text.split("/").includes(".."), `${serviceId} boot script must be repo-relative`);
return text;
@@ -450,7 +441,7 @@ function bootShForService(deployService, serviceId) {
function envReuseServiceIdsForLane(deploy, lane) {
if (lane !== "v02") return new Set();
const configured = deploy?.lanes?.v02?.envReuseServices;
const allowed = new Set(serviceIdsForLane(lane));
const allowed = new Set(serviceIdsForLane(lane, deploy));
return new Set((Array.isArray(configured) ? configured : []).filter((serviceId) => allowed.has(serviceId)));
}
@@ -575,20 +566,56 @@ function artifactEndpoint(args) {
return args.lane === "v02" ? defaultV02RuntimeEndpoint : defaultRuntimeEndpoint;
}
function serviceIdsForLane(lane) {
return lane === "v02" ? v02RuntimeServiceIds : defaultServiceIds;
function serviceIdsForLane(lane, deploy = null) {
return lane === "v02" ? v02ServiceIdsFromDeploy(deploy) : defaultServiceIds;
}
function serviceIdsForProfile(profile) {
return profile === "v02" ? v02RuntimeServiceIds : defaultServiceIds;
function serviceIdsForProfile(profile, deploy = null) {
return profile === "v02" ? v02ServiceIdsFromDeploy(deploy) : defaultServiceIds;
}
function v02ObservableService(serviceId) {
return v02ObservableServices.find((item) => item.serviceId === serviceId) ?? null;
function v02ServiceIdsFromDeploy(deploy) {
const serviceIds = uniquePreserveOrder((Array.isArray(deploy?.lanes?.v02?.envReuseServices) ? deploy.lanes.v02.envReuseServices : [])
.map((item) => String(item ?? "").trim())
.filter(Boolean));
assert.ok(serviceIds.length > 0, "deploy.lanes.v02.envReuseServices must not be empty");
return serviceIds;
}
function servicesParamForLane(lane) {
return serviceIdsForLane(lane).join(",");
function v02ObservableServicesForDeploy(deploy) {
const declarations = deploy?.lanes?.v02?.serviceDeclarations ?? {};
const serviceIds = new Set(v02ServiceIdsFromDeploy(deploy));
const declared = Object.entries(declarations)
.filter(([serviceId, declaration]) => serviceIds.has(serviceId) && declaration?.observable !== false && Number.isInteger(declaration?.healthPort))
.map(([serviceId, declaration]) => ({
serviceId,
port: declaration.healthPort,
healthPath: declaration.healthPath || "/health/live"
}));
return [...declared, ...v02ExtraObservableServices];
}
function v02ObservableService(deploy, serviceId) {
return v02ObservableServicesForDeploy(deploy).find((item) => item.serviceId === serviceId) ?? null;
}
function v02ObservableServiceIdsForDeploy(deploy) {
return new Set(v02ObservableServicesForDeploy(deploy).map((item) => item.serviceId));
}
function servicesParamForLane(lane, deploy = null) {
return serviceIdsForLane(lane, deploy).join(",");
}
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 isV02RemovedServiceId(serviceId) {
@@ -616,7 +643,7 @@ function artifactCatalogSkeleton({ args, deploy, source }) {
},
allowedProfiles: [environment],
forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"],
services: serviceIdsForLane(args.lane).map((serviceId) => artifactCatalogSkeletonService({ args, deploy, source, serviceId, environment, namespace, tag }))
services: serviceIdsForLane(args.lane, deploy).map((serviceId) => artifactCatalogSkeletonService({ args, deploy, source, serviceId, environment, namespace, tag }))
};
}
@@ -817,7 +844,7 @@ function removeV02RepoOwnedCodeAgentPodConfig(podSpec) {
}
function deployServicesForProfile(deploy, profile) {
const allowed = new Set(serviceIdsForProfile(profile));
const allowed = new Set(serviceIdsForProfile(profile, deploy));
const services = new Map((deploy.services ?? [])
.filter((service) => allowed.has(service.serviceId))
.map((service) => [service.serviceId, cloneJson(service)]));
@@ -850,8 +877,8 @@ function v02MetricsSidecarAnnotations(metricsSidecarSha256) {
return metricsSidecarSha256 ? { "hwlab.pikastech.local/metrics-sidecar-sha256": metricsSidecarSha256 } : {};
}
function v02MetricsSidecarContainer({ serviceId, namespace, gitopsTarget }) {
const service = v02ObservableService(serviceId);
function v02MetricsSidecarContainer({ deploy, serviceId, namespace, gitopsTarget }) {
const service = v02ObservableService(deploy, serviceId);
assert.ok(service, `unknown v0.2 observable service ${serviceId}`);
const extraMetricsEnv = serviceId === "hwlab-cloud-api"
? [
@@ -904,6 +931,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch =
const gitopsTarget = gitopsTargetForProfile(profile);
const digestPin = profile === "v02";
const envReuseServiceIds = envReuseServiceIdsForLane(deploy, profile);
const v02ObservableServiceIds = profile === "v02" ? v02ObservableServiceIdsForDeploy(deploy) : new Set();
const stableRuntimeLabels = {
"app.kubernetes.io/part-of": "hwlab",
"hwlab.pikastech.local/environment": profileLabel,
@@ -948,7 +976,7 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch =
label(item.metadata, v02MetricsLabels(templateServiceId));
label(podTemplate.metadata, v02MetricsLabels(templateServiceId));
annotate(podTemplate.metadata, v02MetricsSidecarAnnotations(metricsSidecarSha256));
upsertV02MetricsSidecar(podTemplate.spec, { serviceId: templateServiceId, namespace, gitopsTarget });
upsertV02MetricsSidecar(podTemplate.spec, { deploy, serviceId: templateServiceId, namespace, gitopsTarget });
}
if ((item.kind === "Deployment" || item.kind === "StatefulSet") && item.spec?.selector?.matchLabels) {
item.spec.selector.matchLabels = {
@@ -1097,7 +1125,8 @@ function transformListNamespace(value, namespace, labels, annotations) {
return result;
}
function transformServices({ services, namespace, labels, annotations, profile = "dev" }) {
function transformServices({ services, namespace, labels, annotations, profile = "dev", deploy = null }) {
const v02ObservableServiceIds = profile === "v02" ? v02ObservableServiceIdsForDeploy(deploy) : new Set();
const result = transformListNamespace(services, namespace, labels, annotations);
if (result.kind === "List") {
result.items = (result.items ?? []).filter((item) => !(profile === "v02" && isV02RemovedServiceId(serviceIdForWorkload(item, null))));
@@ -1111,13 +1140,13 @@ function transformServices({ services, namespace, labels, annotations, profile =
return result;
}
function observabilityManifest({ namespace, labels, annotations, metricsSidecarScript }) {
function observabilityManifest({ deploy, namespace, labels, annotations, metricsSidecarScript }) {
const baseLabels = {
...labels,
"app.kubernetes.io/part-of": "hwlab",
"hwlab.pikastech.local/monitoring": "enabled"
};
const serviceMonitors = v02ObservableServices.map((service) => ({
const serviceMonitors = v02ObservableServicesForDeploy(deploy).map((service) => ({
apiVersion: "monitoring.coreos.com/v1",
kind: "ServiceMonitor",
metadata: {
@@ -1463,7 +1492,7 @@ function prepareSourceScript() {
" git_timed catalog-fetch 90 git fetch --depth 1 gitops-catalog \"$(params.gitops-branch)\" >/dev/null || true",
" if git cat-file -e FETCH_HEAD:\"$catalog_path\" 2>/dev/null; then",
" git show FETCH_HEAD:\"$catalog_path\" > /tmp/hwlab-gitops-artifact-catalog.json",
" if node -e 'const fs=require(\"fs\"); const ids=(doc)=>(doc.services||[]).map((s)=>s.serviceId); const selected=(process.argv[3]||\"\").split(\",\").filter(Boolean); const gitops=JSON.parse(fs.readFileSync(process.argv[1],\"utf8\")); const deploy=JSON.parse(fs.readFileSync(process.argv[2],\"utf8\")); const expected=selected.length ? selected : ids(deploy); if (JSON.stringify(ids(gitops)) !== JSON.stringify(expected)) process.exit(42);' /tmp/hwlab-gitops-artifact-catalog.json deploy/deploy.json \"$(params.services)\"; then",
" if node --input-type=module -e 'import fs from \"node:fs\"; import { readStructuredFile } from \"./scripts/src/structured-config.mjs\"; const ids=(doc)=>(doc.services||[]).map((s)=>s.serviceId); const selected=(process.argv[3]||\"\").split(\",\").filter(Boolean); const gitops=JSON.parse(fs.readFileSync(process.argv[1],\"utf8\")); const deploy=await readStructuredFile(process.cwd(),process.argv[2]); const expected=selected.length ? selected : ids(deploy); if (JSON.stringify(ids(gitops)) !== JSON.stringify(expected)) process.exit(42);' /tmp/hwlab-gitops-artifact-catalog.json deploy/deploy.yaml \"$(params.services)\"; then",
" cp /tmp/hwlab-gitops-artifact-catalog.json \"$catalog_path\"",
" echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"loaded\",\"branch\":\"'\"$(params.gitops-branch)\"'\"}'",
" else",
@@ -1476,11 +1505,11 @@ function prepareSourceScript() {
" echo '{\"event\":\"gitops-artifact-catalog\",\"phase\":\"prepare-source\",\"status\":\"seed\",\"reason\":\"gitops-branch-missing\"}'",
"fi",
"if [ ! -s \"$catalog_path\" ] && [ \"$(params.lane)\" = \"v02\" ]; then",
" node - \"$catalog_path\" \"$(params.revision)\" \"$(params.registry-prefix)\" \"$(params.image-tag-mode)\" \"$(params.services)\" <<'NODE'",
"const fs = require('node:fs');",
"const path = require('node:path');",
" node --input-type=module - \"$catalog_path\" \"$(params.revision)\" \"$(params.registry-prefix)\" \"$(params.image-tag-mode)\" \"$(params.services)\" <<'NODE'",
"import fs from 'node:fs';",
"import { readStructuredFile } from './scripts/src/structured-config.mjs';",
"const [catalogPath, revision, registryPrefix, imageTagMode, selectedServices] = process.argv.slice(2);",
"const deploy = JSON.parse(fs.readFileSync('deploy/deploy.json', 'utf8'));",
"const deploy = await readStructuredFile(process.cwd(), 'deploy/deploy.yaml');",
"const tag = imageTagMode === 'full' ? revision : revision.slice(0, 7);",
"const namespace = 'hwlab-v02';",
"const selected = (selectedServices || '').split(',').filter(Boolean);",
@@ -1551,7 +1580,7 @@ echo '{"event":"ci-base-image","phase":"plan-artifacts","image":"${ciToolsRunner
for tool in node git; do command -v "$tool" >/dev/null 2>&1 || { echo '{"event":"ci-base-image","phase":"plan-artifacts","ok":false,"reason":"missing-tool","tool":"'"$tool"'"}'; exit 31; }; done
cd /workspace/source/repo
test "$(git rev-parse HEAD)" = "$(params.revision)"
node scripts/g14-ci-plan.mjs --lane "$(params.lane)" --target-ref HEAD --deploy-json deploy/deploy.json --artifact-catalog "$(params.catalog-path)" --registry-prefix "$(params.registry-prefix)" --services "$(params.services)" > /workspace/source/ci-plan.json
node scripts/g14-ci-plan.mjs --lane "$(params.lane)" --target-ref HEAD --deploy-config deploy/deploy.yaml --artifact-catalog "$(params.catalog-path)" --registry-prefix "$(params.registry-prefix)" --services "$(params.services)" > /workspace/source/ci-plan.json
node - <<'NODE'
const fs = require("node:fs");
const plan = JSON.parse(fs.readFileSync("/workspace/source/ci-plan.json", "utf8"));
@@ -1615,7 +1644,7 @@ cd /workspace/source/repo
test "$(git rev-parse HEAD)" = "$(params.revision)"
export HWLAB_ARTIFACT_LANE="$(params.lane)"
export HWLAB_ARTIFACT_CATALOG_PATH="$(params.catalog-path)"
export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.json"
export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.yaml"
export HWLAB_ARTIFACT_IMAGE_TAG_MODE="$(params.image-tag-mode)"
mkdir -p /workspace/source/service-results
if [ -s /workspace/source/affected-services.json ] && ! node -e 'const fs=require("node:fs"); const p=JSON.parse(fs.readFileSync("/workspace/source/affected-services.json","utf8")); const service=process.argv[1]; const item=(p.entries||[]).find((entry)=>entry.serviceId===service); process.exit(item && item.affected ? 0 : 1);' "$(params.service-id)"; then
@@ -1694,7 +1723,7 @@ if [ "$buildkit_ready" != "1" ]; then
echo '{"event":"buildkit-sidecar-not-ready","serviceId":"'"$(params.service-id)"'","addr":"'"$HWLAB_BUILDKIT_ADDR"'"}' >&2
exit 1
fi
node scripts/g14-artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-json deploy/deploy.json --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --report "/workspace/source/service-results/$(params.service-id).json" --tekton-results-dir /tekton/results --quiet-build --concurrency 1 --services "$(params.service-id)" --buildkit-command /workspace/buildkit-bin/buildctl --buildkit-addr "$HWLAB_BUILDKIT_ADDR"
node scripts/g14-artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --report "/workspace/source/service-results/$(params.service-id).json" --tekton-results-dir /tekton/results --quiet-build --concurrency 1 --services "$(params.service-id)" --buildkit-command /workspace/buildkit-bin/buildctl --buildkit-addr "$HWLAB_BUILDKIT_ADDR"
`;
}
@@ -1712,10 +1741,10 @@ export HWLAB_DEV_REGISTRY_K8S_NATIVE=1
if [ -n "$(params.base-image)" ]; then export HWLAB_DEV_BASE_IMAGE="$(params.base-image)"; fi
export HWLAB_ARTIFACT_LANE="$(params.lane)"
export HWLAB_ARTIFACT_CATALOG_PATH="$(params.catalog-path)"
export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.json"
export HWLAB_DEPLOY_MANIFEST_PATH="deploy/deploy.yaml"
export HWLAB_ARTIFACT_IMAGE_TAG_MODE="$(params.image-tag-mode)"
node scripts/g14-artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-json deploy/deploy.json --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --report /workspace/source/dev-artifacts.json --quiet-build --concurrency 1 --services "$(params.services)" --external-service-report-dir /workspace/source/service-results
node scripts/refresh-artifact-catalog.mjs --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-json deploy/deploy.json --image-tag-mode "$(params.image-tag-mode)" --target-ref HEAD --publish-report /workspace/source/dev-artifacts.json --no-write
node scripts/g14-artifact-publish.mjs --publish --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --registry-prefix "$(params.registry-prefix)" --report /workspace/source/dev-artifacts.json --quiet-build --concurrency 1 --services "$(params.services)" --external-service-report-dir /workspace/source/service-results
node scripts/refresh-artifact-catalog.mjs --lane "$(params.lane)" --catalog-path "$(params.catalog-path)" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --target-ref HEAD --publish-report /workspace/source/dev-artifacts.json --no-write
`;
}
@@ -1862,7 +1891,7 @@ NODE
}
if [ -s /workspace/source/dev-artifacts.json ]; then
node scripts/refresh-artifact-catalog.mjs --lane "$lane" --catalog-path "$catalog_path" --deploy-json deploy/deploy.json --image-tag-mode "$(params.image-tag-mode)" --target-ref "$(params.revision)" --publish-report /workspace/source/dev-artifacts.json --write
node scripts/refresh-artifact-catalog.mjs --lane "$lane" --catalog-path "$catalog_path" --deploy-config deploy/deploy.yaml --image-tag-mode "$(params.image-tag-mode)" --target-ref "$(params.revision)" --publish-report /workspace/source/dev-artifacts.json --write
fi
gitops_render_started_ms="$(ci_now_ms)"
node scripts/g14-gitops-render.mjs --lane "$lane" --catalog-path "$catalog_path" --image-tag-mode "$(params.image-tag-mode)" --source-revision "$(params.revision)" --source-repo "$(params.git-url)" --source-branch "$(params.source-branch)" --gitops-branch "$(params.gitops-branch)" --registry-prefix "$(params.registry-prefix)" --use-deploy-images
@@ -2961,8 +2990,8 @@ function runtimeReadyTask({ profile = "dev" } = {}) {
};
}
function imagePublishTaskSet(args = { lane: "g14" }) {
const serviceIds = serviceIdsForLane(args.lane);
function imagePublishTaskSet(args = { lane: "g14" }, deploy = null) {
const serviceIds = serviceIdsForLane(args.lane, deploy);
const buildTasks = serviceIds.map((serviceId) => perServiceBuildTask(serviceId, {
runAfter: ["plan-artifacts"]
}));
@@ -2973,7 +3002,7 @@ function imagePublishTaskSet(args = { lane: "g14" }) {
];
}
function tektonPipeline(args = { lane: "g14" }) {
function tektonPipeline(args = { lane: "g14" }, deploy = null) {
const settings = ciLaneSettings(args);
const runtimePath = `deploy/gitops/g14/${runtimePathForProfile(settings.profile)}`;
return {
@@ -3005,7 +3034,7 @@ function tektonPipeline(args = { lane: "g14" }) {
{ name: "runtime-path", type: "string", default: runtimePath },
{ name: "revision", type: "string", description: "Full git commit SHA; the only source truth for image tags." },
{ name: "registry-prefix", type: "string", default: defaultRegistryPrefix },
{ name: "services", type: "string", default: servicesParamForLane(args.lane) },
{ name: "services", type: "string", default: servicesParamForLane(args.lane, deploy) },
{ name: "base-image", type: "string", default: defaultDevBaseImage }
],
workspaces: [
@@ -3049,7 +3078,7 @@ function tektonPipeline(args = { lane: "g14" }) {
]
},
...primitiveValidationTasks.map(primitiveValidationTask),
...imagePublishTaskSet(args),
...imagePublishTaskSet(args, deploy),
{
name: "gitops-promote",
runAfter: ["collect-artifacts"],
@@ -3203,7 +3232,7 @@ function tektonPollerCronJob(args) {
{ name: "SOURCE_BRANCH", value: args.sourceBranch },
{ name: "GITOPS_BRANCH", value: args.gitopsBranch },
{ name: "REGISTRY_PREFIX", value: args.registryPrefix },
{ name: "SERVICES", value: servicesParamForLane(args.lane) },
{ name: "SERVICES", value: servicesParamForLane(args.lane, deploy) },
{ name: "BASE_IMAGE", value: defaultDevBaseImage }
],
volumeMounts: [{ name: "git-ssh", mountPath: "/workspace/git-ssh", readOnly: true }],
@@ -4204,7 +4233,7 @@ function deepSeekProxyManifest({ profile = "dev", source, sourceBranch = "G14",
{ name: "moonbridge-config", mountPath: "/config", readOnly: true },
{ name: "moonbridge-data", mountPath: "/data" }
]
}, ...(profile === "v02" ? [v02MetricsSidecarContainer({ serviceId: "hwlab-deepseek-proxy", namespace, gitopsTarget: gitopsTargetForProfile(profile) })] : [])],
}, ...(profile === "v02" ? [v02MetricsSidecarContainer({ deploy, serviceId: "hwlab-deepseek-proxy", namespace, gitopsTarget: gitopsTargetForProfile(profile) })] : [])],
volumes: [
{ name: "moonbridge-scripts", configMap: { name: "hwlab-deepseek-proxy-config" } },
{ name: "moonbridge-config", emptyDir: {} },
@@ -4736,11 +4765,11 @@ This directory is generated by \`scripts/g14-gitops-render.mjs --lane v02\` from
}
return `# HWLAB G14 GitOps CI/CD
This directory is generated from built-in Tekton-native CI primitive declarations plus human-authored \`deploy/deploy.json\`, CI-authored \`deploy/artifact-catalog.dev.json\`, and \`deploy/k8s/*\` by \`scripts/g14-gitops-render.mjs\`.
This directory is generated from built-in Tekton-native CI primitive declarations plus human-authored \`deploy/deploy.yaml\`, CI-authored \`deploy/artifact-catalog.dev.json\`, and \`deploy/k8s/*\` by \`scripts/g14-gitops-render.mjs\`.
- Target: G14 k3s only.
- CI: Tekton Pipeline \`hwlab-ci/hwlab-g14-ci-image-publish\`.
- Artifact contract: runtime images come from \`deploy/artifact-catalog.dev.json\`; \`deploy/deploy.json\` only carries human-authored config such as env and topology.
- Artifact contract: runtime images come from \`deploy/artifact-catalog.dev.json\`; \`deploy/deploy.yaml\` only carries human-authored config such as env and topology.
- Branch split: source branch \`${args.sourceBranch}\` remains the watched code branch; generated desired state is promoted to \`${args.gitopsBranch}\`.
- CD: Argo CD Applications \`argocd/hwlab-g14-dev\` and \`argocd/hwlab-g14-prod\` consume \`${args.gitopsBranch}:deploy/gitops/g14/runtime-dev\` and \`runtime-prod\`.
- Public preview: FRP exposes G14 DEV web \`${args.webEndpoint}\` / edge \`${args.runtimeEndpoint}\` through \`hwlab-dev/hwlab-g14-frpc\`, and G14 PROD web \`${args.prodWebEndpoint}\` / edge \`${args.prodRuntimeEndpoint}\` through \`hwlab-prod/hwlab-g14-prod-frpc\`; D601 keeps \`:16666/:16667\`.
@@ -4753,7 +4782,7 @@ This directory is generated from built-in Tekton-native CI primitive declaration
async function plannedFiles(args) {
const [deploy, namespaceTemplate, config, services, health, workloads] = await Promise.all([
readJson("deploy/deploy.json"),
readJson("deploy/deploy.yaml"),
readJson("deploy/k8s/base/namespace.yaml"),
readJson("deploy/k8s/base/code-agent-codex-config.yaml"),
readJson("deploy/k8s/base/services.yaml"),
@@ -4805,7 +4834,7 @@ async function plannedFiles(args) {
if (args.useDeployImages) {
sourceDescriptor.renderMode = {
imageSource: args.catalogPath,
configSource: "deploy/deploy.json",
configSource: "deploy/deploy.yaml",
mixedDesiredState: true
};
}
@@ -4814,7 +4843,7 @@ async function plannedFiles(args) {
putJson("registry/registry.yaml", registryManifest());
if (args.lane === "v02") putJson("devops-infra/git-mirror.yaml", devopsInfraGitMirrorManifest());
putJson(`${settings.tektonDir}/rbac.yaml`, tektonRbac(args));
putJson(`${settings.tektonDir}/pipeline.yaml`, tektonPipeline(args));
putJson(`${settings.tektonDir}/pipeline.yaml`, tektonPipeline(args, deploy));
if (args.lane !== "v02") {
putJson(`${settings.tektonDir}/poller.yaml`, tektonPollerCronJob(args));
putJson(`${settings.tektonDir}/control-plane-reconciler.yaml`, tektonControlPlaneReconcilerCronJob(args));
@@ -4836,13 +4865,13 @@ async function plannedFiles(args) {
putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent }));
putJson(`${runtimePath}/namespace.yaml`, runtimeNamespace);
if (profile !== "v02") putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations));
putJson(`${runtimePath}/services.yaml`, transformServices({ services, namespace, labels: profileLabels, annotations, profile }));
putJson(`${runtimePath}/services.yaml`, transformServices({ services, namespace, labels: profileLabels, annotations, profile, deploy }));
putJson(`${runtimePath}/health-contract.yaml`, transformHealthContract(health, namespace, profileLabels, annotations, endpoints.runtimeEndpoint, endpoints.webEndpoint, profile));
putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, catalog: artifactCatalog, source, sourceBranch: args.sourceBranch, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
putJson(`${runtimePath}/deepseek-proxy.yaml`, deepSeekProxyManifest({ profile, source, sourceBranch: args.sourceBranch, sourceRepo: args.sourceRepo, deploy, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages, metricsSidecarSha256 }));
if (profile === "v02") putJson(`${runtimePath}/postgres.yaml`, v02PostgresManifest({ migrationSql, source }));
if (profile === "v02") putJson(`${runtimePath}/openfga.yaml`, v02OpenFgaManifest({ source }));
if (profile === "v02") putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ namespace, labels: profileLabels, annotations, metricsSidecarScript }));
if (profile === "v02") putJson(`${runtimePath}/observability.yaml`, observabilityManifest({ deploy, namespace, labels: profileLabels, annotations, metricsSidecarScript }));
if (includeDeviceAgent) putJson(`${runtimePath}/device-agent-71-freq.yaml`, deviceAgent71FreqManifest({ profile, source, registryPrefix: args.registryPrefix, catalog: artifactCatalog, useDeployImages: args.useDeployImages }));
putJson(`${runtimePath}/g14-frpc.yaml`, g14FrpcManifest({ profile, source }));
}
+41 -28
View File
@@ -12,12 +12,13 @@ import {
serviceInventoryFromServices
} from "./src/dev-artifact-services.mjs";
import { tempReportPath } from "./src/report-paths.mjs";
import { readStructuredFile } from "./src/structured-config.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultLane = process.env.HWLAB_ARTIFACT_LANE || process.env.HWLAB_GITOPS_LANE || "g14";
const defaultCatalogPath = process.env.HWLAB_ARTIFACT_CATALOG_PATH || "deploy/artifact-catalog.dev.json";
const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.json";
const defaultDeployPath = process.env.HWLAB_DEPLOY_MANIFEST_PATH || "deploy/deploy.yaml";
const defaultPublishReportPath = tempReportPath("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}$/;
@@ -26,16 +27,27 @@ const commitPattern = /^[a-f0-9]{7,40}$/;
const publishReadyStatuses = new Set(["published", "reused"]);
const v02EnvReuseRuntimeMode = "env-reuse-git-mirror-checkout";
const v02RuntimeServiceIds = Object.freeze([
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-gateway",
"hwlab-edge-proxy",
"hwlab-agent-skills"
]);
function serviceIdsForLane(lane, deploy = null) {
return lane === "v02" ? v02ServiceIdsFromDeploy(deploy) : SERVICE_IDS;
}
function serviceIdsForLane(lane) {
return lane === "v02" ? v02RuntimeServiceIds : SERVICE_IDS;
function v02ServiceIdsFromDeploy(deploy) {
const serviceIds = uniquePreserveOrder((Array.isArray(deploy?.lanes?.v02?.envReuseServices) ? deploy.lanes.v02.envReuseServices : [])
.map((item) => String(item ?? "").trim())
.filter(Boolean));
assert.ok(serviceIds.length > 0, "deploy.lanes.v02.envReuseServices must not be empty");
return serviceIds;
}
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 parseArgs(argv) {
@@ -58,7 +70,7 @@ function parseArgs(argv) {
args.lane = readOption(argv, ++index, arg);
} else if (arg === "--catalog-path") {
args.catalogPath = readOption(argv, ++index, arg);
} else if (arg === "--deploy-json") {
} else if (arg === "--deploy-config") {
args.deployPath = readOption(argv, ++index, arg);
} else if (arg === "--image-tag-mode") {
args.imageTagMode = readOption(argv, ++index, arg);
@@ -101,7 +113,7 @@ function usage() {
"usage: node scripts/refresh-artifact-catalog.mjs --target-ref REF (--blocked|--publish-report PATH) [--write|--no-write]",
"",
"Preview or explicitly write DEV artifact catalog identity without faking digest evidence.",
"Default mode is read-only. --write is reserved for G14 Tekton GitOps promotion and never writes deploy/deploy.json.",
"Default mode is read-only. --write is reserved for G14 Tekton GitOps promotion and never writes deploy/deploy.yaml.",
"",
"examples:",
" node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --blocked --no-write",
@@ -110,14 +122,13 @@ function usage() {
].join("\n");
}
async function readJson(relativePath) {
const filePath = path.isAbsolute(relativePath) ? relativePath : path.join(repoRoot, relativePath);
return JSON.parse(await readFile(filePath, "utf8"));
async function readConfig(relativePath) {
return readStructuredFile(repoRoot, relativePath);
}
async function readJsonIfPresent(relativePath, fallback = null) {
async function readConfigIfPresent(relativePath, fallback = null) {
try {
return await readJson(relativePath);
return await readConfig(relativePath);
} catch (error) {
if (error?.code === "ENOENT") return fallback;
throw error;
@@ -212,7 +223,7 @@ function artifactCatalogSkeleton({ args, deploy, target, registryPrefix = defaul
},
allowedProfiles: [environment],
forbiddenProfiles: args.lane === "v02" ? ["dev", "prod"] : ["prod"],
services: serviceIdsForLane(args.lane).map((serviceId) => catalogSkeletonService({ args, serviceId, target, registryPrefix, environment, namespace }))
services: serviceIdsForLane(args.lane, deploy).map((serviceId) => catalogSkeletonService({ args, serviceId, target, registryPrefix, environment, namespace }))
};
}
@@ -291,7 +302,9 @@ function assertV02DeployAndCatalog(deploy, catalog) {
assert.ok(lane && typeof lane === "object", "deploy.lanes.v02 must exist for v02 catalog refresh");
assert.equal(lane.namespace, "hwlab-v02", "deploy.lanes.v02.namespace must be hwlab-v02");
assert.ok(isHttpEndpoint(lane.endpoint), "deploy.lanes.v02.endpoint must be a non-empty http(s) URL");
assert.deepEqual((deploy.services ?? []).map((service) => service.serviceId), SERVICE_IDS, "deploy services must cover frozen service IDs in order");
const serviceIds = serviceIdsForLane("v02", deploy);
assert.ok(serviceIds.every((serviceId) => lane.serviceDeclarations?.[serviceId]), "deploy.lanes.v02.serviceDeclarations must cover envReuseServices");
assert.ok(serviceIds.every((serviceId) => lane.bootScripts?.[serviceId]), "deploy.lanes.v02.bootScripts must cover envReuseServices");
assert.equal(catalog.environment, "v02", "catalog environment must be v02");
assert.equal(catalog.profile, "v02", "catalog profile must be v02");
@@ -299,12 +312,12 @@ function assertV02DeployAndCatalog(deploy, catalog) {
if (catalog.endpoint != null) assert.ok(isHttpEndpoint(catalog.endpoint), "catalog endpoint must be a non-empty http(s) URL");
assert.deepEqual(catalog.allowedProfiles, ["v02"], "catalog allowedProfiles must only allow v02");
assert.deepEqual(catalog.forbiddenProfiles, ["dev", "prod"], "catalog forbiddenProfiles must forbid dev/prod");
assert.deepEqual((catalog.services ?? []).map((service) => service.serviceId), serviceIdsForLane("v02"), "catalog services must cover retained v0.2 service IDs in order");
assert.deepEqual((catalog.services ?? []).map((service) => service.serviceId), serviceIds, "catalog services must cover deploy.lanes.v02.envReuseServices in order");
}
function normalizeCatalogForLane(catalog, lane) {
function normalizeCatalogForLane(catalog, lane, deploy) {
if (!catalog || lane !== "v02") return catalog;
const allowed = new Set(serviceIdsForLane(lane));
const allowed = new Set(serviceIdsForLane(lane, deploy));
return {
...catalog,
services: (catalog.services ?? []).filter((service) => allowed.has(service.serviceId))
@@ -529,16 +542,16 @@ async function main() {
const target = await resolveTarget(args.targetRef, args.imageTagMode);
const [deploy, publishReport] = await Promise.all([
readJson(args.deployPath),
args.publishReportPath ? readJson(args.publishReportPath) : Promise.resolve(null)
readConfig(args.deployPath),
args.publishReportPath ? readConfig(args.publishReportPath) : Promise.resolve(null)
]);
let catalog = await readJsonIfPresent(args.catalogPath, null);
let catalog = await readConfigIfPresent(args.catalogPath, null);
if (!catalog && args.lane === "v02") catalog = artifactCatalogSkeleton({ args, deploy, target, registryPrefix: publishReport?.artifactPublish?.registryPrefix ?? defaultRegistryPrefix });
catalog = normalizeCatalogForLane(catalog, args.lane);
catalog = normalizeCatalogForLane(catalog, args.lane, deploy);
assert.ok(catalog, `${args.catalogPath} is required for ${args.lane} catalog refresh`);
assertLaneDeployAndCatalog(args, deploy, catalog);
const serviceIds = serviceIdsForLane(args.lane);
const serviceIds = serviceIdsForLane(args.lane, deploy);
const publishRecords = publishReport ? publishRecordsFromReport(publishReport, target, serviceIds) : null;
const serviceInventory = publishReport?.artifactPublish?.serviceInventory ?? serviceInventoryFromServices(
await resolveDevArtifactServices(repoRoot, serviceIds, SERVICE_IDS)
@@ -572,7 +585,7 @@ async function main() {
endpoint: catalog.endpoint ?? null,
imageTagMode: args.imageTagMode,
wrote: args.write ? [args.catalogPath] : [],
deployTruthPolicy: "deploy.json is human-authored runtime config; artifact promotion must not rewrite it",
deployTruthPolicy: "deploy.yaml is human-authored runtime config; artifact promotion must not rewrite it",
ciPublished: Boolean(publishRecords),
registryVerified: Boolean(publishRecords),
publishedCount,
+6 -5
View File
@@ -7,6 +7,7 @@ import test from "node:test";
import { promisify } from "node:util";
import { SERVICE_IDS } from "../internal/protocol/index.mjs";
import { writeStructuredFile } from "./src/structured-config.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
@@ -154,7 +155,7 @@ test("refresh is read-only by default", async () => {
const payload = JSON.parse(result.stdout);
assert.equal(payload.status, "published");
assert.deepEqual(payload.wrote, []);
assert.equal(payload.deployTruthPolicy, "deploy.json is human-authored runtime config; artifact promotion must not rewrite it");
assert.equal(payload.deployTruthPolicy, "deploy.yaml is human-authored runtime config; artifact promotion must not rewrite it");
});
test("refresh keeps reused service image tags from the publish report", async () => {
@@ -327,9 +328,9 @@ test("v02 refresh accepts current runtime service full image tags", async () =>
test("v02 refresh follows the current deploy endpoint instead of the retired 19667 freeze", async () => {
const commitId = await git(["rev-parse", "HEAD"]);
const tempDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-refresh-v02-https-endpoint-"));
const deployPath = path.join(tempDir, "deploy.json");
const deployPath = path.join(tempDir, "deploy.yaml");
const catalogPath = path.join(tempDir, "artifact-catalog.v02.json");
await writeFile(deployPath, `${JSON.stringify({
await writeStructuredFile(tempDir, deployPath, {
environment: "dev",
namespace: "hwlab-dev",
endpoint: "http://74.48.78.17:16667",
@@ -341,7 +342,7 @@ test("v02 refresh follows the current deploy endpoint instead of the retired 196
}
},
services: SERVICE_IDS.map((serviceId) => ({ serviceId }))
}, null, 2)}\n`);
});
await writeFile(catalogPath, `${JSON.stringify({
catalogVersion: "v1",
kind: "hwlab-artifact-catalog",
@@ -374,7 +375,7 @@ test("v02 refresh follows the current deploy endpoint instead of the retired 196
"full",
"--target-ref",
"HEAD",
"--deploy-json",
"--deploy-config",
deployPath,
"--catalog-path",
catalogPath,
@@ -8,6 +8,7 @@ import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs";
import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT } from "../../internal/protocol/index.mjs";
import { buildDesiredStatePlan } from "./deploy-desired-state-plan.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
import { readStructuredFile } from "./structured-config.mjs";
const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const cloudWebSourceRoot = "web/hwlab-cloud-web/src";
@@ -154,7 +155,7 @@ export function evaluateArtifactRuntimeReadiness({
pass: desiredState.status !== "blocked" && desiredState.status !== "missing",
type: "contract_blocker",
summary: `Desired-state planner status=${desiredState.status}; convergence=${desiredState.targetConvergence.state}.`,
nextTask: "Repair deploy/deploy.json, deploy/artifact-catalog.dev.json, and deploy/k8s/base/workloads.yaml before any apply."
nextTask: "Repair deploy/deploy.yaml, deploy/artifact-catalog.dev.json, and deploy/k8s/base/workloads.yaml before any apply."
});
addCheck(checks, {
id: "desired-state-target-match",
@@ -1246,8 +1247,7 @@ function nextCommands({ canPublish, canApply, blockedReasons }) {
}
async function readJson(repoRoot, relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
return readStructuredFile(repoRoot, relativePath);
}
async function readOptionalJson(repoRoot, relativePath) {
+29
View File
@@ -0,0 +1,29 @@
import {
parseStructuredConfig,
readStructuredFile,
readStructuredFileIfPresent,
stringifyStructuredConfig,
writeStructuredFile
} from "./structured-config.mjs";
export const DEFAULT_DEPLOY_CONFIG_PATH = "deploy/deploy.yaml";
export {
parseStructuredConfig,
readStructuredFile,
readStructuredFileIfPresent,
stringifyStructuredConfig,
writeStructuredFile
};
export async function readDeployConfig(repoRoot, relativePath = DEFAULT_DEPLOY_CONFIG_PATH) {
return readStructuredFile(repoRoot, relativePath);
}
export async function readDeployConfigIfPresent(repoRoot, relativePath = DEFAULT_DEPLOY_CONFIG_PATH, fallback = null) {
return readStructuredFileIfPresent(repoRoot, relativePath, fallback);
}
export async function writeDeployConfig(repoRoot, relativePath = DEFAULT_DEPLOY_CONFIG_PATH, value) {
return writeStructuredFile(repoRoot, relativePath, value);
}
+4 -3
View File
@@ -7,6 +7,7 @@ import {
codeAgentSecretRefPlaceholder
} from "../../internal/cloud/code-agent-contract.ts";
import { HWLAB_M3_IO_API_BASE_URL_ENV } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
import { readStructuredFile } from "./structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const expectedPorts = Object.freeze({ frontend: 16666, api: 16667 });
@@ -30,7 +31,7 @@ function parseArgs(argv) {
}
async function readJson(relativePath) {
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "utf8"));
return readStructuredFile(repoRoot, relativePath);
}
function stripTomlComment(line) {
@@ -736,7 +737,7 @@ function validateCodeAgentProxyTimeoutArtifacts(ctx, workloads) {
async function buildPlan() {
const ctx = { checks: 0, diagnostics: [] };
const [manifest, services, workloads, healthContract, masterEdge, frpc, frps] = await Promise.all([
readJson("deploy/deploy.json"),
readJson("deploy/deploy.yaml"),
readJson("deploy/k8s/base/services.yaml"),
readJson("deploy/k8s/base/workloads.yaml"),
readJson("deploy/k8s/dev/health-contract.yaml"),
@@ -753,7 +754,7 @@ async function buildPlan() {
kind: "hwlab-deploy-contract-plan",
mode: "dry-run",
status: ctx.diagnostics.length === 0 ? "pass" : "blocked",
source: "deploy/deploy.json",
source: "deploy/deploy.yaml",
dryRunOnly: true,
safety: {
frpApply: false,
+6 -6
View File
@@ -10,11 +10,12 @@ import {
} from "../../internal/cloud/code-agent-contract.ts";
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.ts";
import { tempReportPath } from "./report-paths.mjs";
import { readStructuredFile } from "./structured-config.mjs";
const execFileAsync = promisify(execFile);
const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const deployPath = "deploy/deploy.json";
const deployPath = "deploy/deploy.yaml";
const catalogPath = "deploy/artifact-catalog.dev.json";
const workloadsPath = "deploy/k8s/base/workloads.yaml";
const artifactReportPath = tempReportPath("dev-artifacts.json");
@@ -99,8 +100,7 @@ function usage() {
}
async function readJson(repoRoot, relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
return readStructuredFile(repoRoot, relativePath);
}
async function readOptionalJson(repoRoot, relativePath) {
@@ -753,7 +753,7 @@ export async function buildDesiredStatePlan(options = {}) {
});
if (Object.hasOwn(deploy, "commitId")) {
addMismatch(ctx, "generated_artifact_identity", `${deployPath}.commitId`, "deploy.json is human-authored config; generated commit identity must stay in artifact catalog", "absent", deploy.commitId);
addMismatch(ctx, "generated_artifact_identity", `${deployPath}.commitId`, "deploy.yaml is human-authored config; generated commit identity must stay in artifact catalog", "absent", deploy.commitId);
}
if (!commitPattern.test(String(catalog.commitId ?? ""))) {
addMismatch(ctx, "invalid_commit", `${catalogPath}.commitId`, "artifact catalog commitId must be a short or full lowercase Git SHA", "7-40 lowercase hex", catalog.commitId);
@@ -949,7 +949,7 @@ export async function buildDesiredStatePlan(options = {}) {
prod: false,
devLiveClaim: false
},
checkSemantics: "--check exits non-zero for missing/invalid desired-state sources, generated artifact identity leaking into deploy.json, invalid target tags, partial target drift, or catalog identity mismatch with an explicit --target-ref, --target-tag, or --promotion-commit. When the checked target ref is the current checked-out merge commit, the comparable current-main target is its accepted first parent because a commit cannot embed its own content hash. Without --check, a uniform older catalog under --target-ref/--target-tag is a read-only promotion plan.",
checkSemantics: "--check exits non-zero for missing/invalid desired-state sources, generated artifact identity leaking into deploy.yaml, invalid target tags, partial target drift, or catalog identity mismatch with an explicit --target-ref, --target-tag, or --promotion-commit. When the checked target ref is the current checked-out merge commit, the comparable current-main target is its accepted first parent because a commit cannot embed its own content hash. Without --check, a uniform older catalog under --target-ref/--target-tag is a read-only promotion plan.",
summary: {
desiredCommitId,
desiredImageTag,
@@ -974,7 +974,7 @@ export async function buildDesiredStatePlan(options = {}) {
humanAuthoredTruth: [deployPath, workloadsPath],
artifactIdentityTruth: [catalogPath],
nonAuthoritativeEvidence: [artifactReportPath],
note: "This planner is read-only. deploy.json is human-authored config; artifact-catalog carries generated image identity and is written by G14 Tekton promotion only to G14-gitops. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence."
note: "This planner is read-only. deploy.yaml is human-authored config; artifact-catalog carries generated image identity and is written by G14 Tekton promotion only to G14-gitops. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence."
},
cloudApiDb: cloudApiDb ?? {
status: "blocked",
+3 -2
View File
@@ -16,6 +16,7 @@ import {
import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs";
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
import { readStructuredFile } from "./structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const defaultReportPath = tempReportPath("dev-edge-health.json");
@@ -270,7 +271,7 @@ function requireDevEndpoint() {
async function inspectContracts() {
const frpc = await readText("deploy/frp/frpc.dev.toml");
const frps = await readText("deploy/frp/frps.dev.toml");
const deploy = await readJson("deploy/deploy.json");
const deploy = await readJson("deploy/deploy.yaml");
const master = await readJson("deploy/master-edge/health-contract.json");
const frpsUsesVhostEdgePort = /vhostHTTPPort\s*=\s*(16667|6667)/u.test(frps);
@@ -1189,7 +1190,7 @@ function parseJsonOrNull(text) {
}
async function readJson(relativePath) {
return JSON.parse(await readText(relativePath));
return readStructuredFile(repoRoot, relativePath);
}
async function readText(relativePath) {
+6 -6
View File
@@ -18,6 +18,7 @@ import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs";
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs";
import { probeRegistryCapabilities } from "./registry-capabilities.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
import { readStructuredFile } from "./structured-config.mjs";
const execFileAsync = promisify(execFile);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
@@ -145,8 +146,7 @@ async function commandExists(command) {
}
async function readJson(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
return readStructuredFile(repoRoot, relativePath);
}
async function readOptionalJson(relativePath) {
@@ -430,7 +430,7 @@ async function artifactIdentityFor({ deploy, catalog, artifactReport, targetComm
},
targetCoverage,
deployManifest: {
path: "deploy/deploy.json",
path: "deploy/deploy.yaml",
identitySource: "human-authored-config",
artifactIdentity: false,
matchesSource: true,
@@ -546,7 +546,7 @@ function assertFrpStatic(frpc, frps, masterEdge) {
async function loadContracts() {
return Promise.all([
readJson("deploy/deploy.json"),
readJson("deploy/deploy.yaml"),
readJson("deploy/artifact-catalog.dev.json"),
readJson("deploy/k8s/base/namespace.yaml"),
readJson("deploy/k8s/base/workloads.yaml"),
@@ -871,7 +871,7 @@ function validateCloudApiDbContract(reporter, deploy, workloads, services) {
type: "contract_blocker",
scope: "cloud-api-db-env-contract",
summary: `cloud-api DEV DB contract is incomplete; blocked by ${missing.join(", ")}.`,
nextTask: "Repair deploy/deploy.json and deploy/k8s/base/workloads.yaml so they expose the required DB env names, redacted Secret reference, and HWLAB_CLOUD_DB_SSL_MODE=disable. Do not make cloud-api-db a hard readiness gate unless this repo also owns its Service/Endpoint rollout contract."
nextTask: "Repair deploy/deploy.yaml and deploy/k8s/base/workloads.yaml so they expose the required DB env names, redacted Secret reference, and HWLAB_CLOUD_DB_SSL_MODE=disable. Do not make cloud-api-db a hard readiness gate unless this repo also owns its Service/Endpoint rollout contract."
});
}
@@ -1317,7 +1317,7 @@ function validateCodeAgentProviderContract(reporter, deploy, workloads) {
type: "agent_blocker",
scope: "code-agent-provider-env-contract",
summary: `cloud-api DEV Code Agent provider contract is incomplete; missing ${missing.join(", ")}.`,
nextTask: "Repair deploy/deploy.json and deploy/k8s/base/workloads.yaml so hwlab-cloud-api has OPENAI_API_KEY from hwlab-code-agent-provider/openai-api-key and HWLAB_CODE_AGENT_OPENAI_BASE_URL through DEV egress/proxy; do not print the Secret value."
nextTask: "Repair deploy/deploy.yaml and deploy/k8s/base/workloads.yaml so hwlab-cloud-api has OPENAI_API_KEY from hwlab-code-agent-provider/openai-api-key and HWLAB_CODE_AGENT_OPENAI_BASE_URL through DEV egress/proxy; do not print the Secret value."
});
}
+139 -104
View File
@@ -1,28 +1,15 @@
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { SERVICE_IDS } from "../../internal/protocol/index.mjs";
import { readStructuredFileIfPresent } from "./structured-config.mjs";
const execFileAsync = promisify(execFile);
export const G14_CI_PLAN_VERSION = "v1";
export const V02_ENV_REUSE_RUNTIME_MODE = "env-reuse-git-mirror-checkout";
export const DEFAULT_BUILD_SYSTEM_PATHS = Object.freeze([
"internal/dev-entrypoint/artifact-runtime.mjs",
"scripts/artifact-publish.mjs",
"scripts/g14-artifact-publish.mjs",
"scripts/src/dev-artifact-services.mjs"
]);
export const DEFAULT_ENV_REUSE_LAUNCHER_PATHS = Object.freeze([
"deploy/runtime/launcher/",
"scripts/artifact-publish.mjs"
]);
export const DEFAULT_RUNTIME_DEP_PATHS = Object.freeze([
"package.json",
"package-lock.json"
@@ -56,65 +43,35 @@ export const DEFAULT_GITOPS_ONLY_PATHS = Object.freeze([
"scripts/g14-gitops-render.mjs"
]);
const serviceSpecificPaths = Object.freeze({
"hwlab-cloud-api": [
"cmd/hwlab-cloud-api/",
"cmd/hwlab-codex-api-responses-forwarder/",
"cmd/hwlab-deepseek-responses-bridge/",
"deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh",
"deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh",
"internal/cloud/",
"internal/db/",
"internal/audit/",
"internal/agent/agentrun-dispatch.mjs",
"internal/agent/prompts/",
"skills/hwlab-agent-runtime/"
],
"hwlab-cloud-web": [
"web/hwlab-cloud-web/",
"internal/dev-entrypoint/cloud-web-runtime.mjs",
"internal/dev-entrypoint/cloud-web-proxy.mjs",
"internal/dev-entrypoint/cloud-web-routes.mjs",
"tools/src/hwlab-cli/trace-renderer.ts"
],
"hwlab-gateway": ["cmd/hwlab-gateway/", "internal/sim/"],
"hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"],
"hwlab-agent-skills": ["skills/"]
});
const bunCommandServices = new Set([
"hwlab-cloud-api",
"hwlab-codex-api-responses-forwarder",
"hwlab-deepseek-responses-bridge",
"hwlab-gateway",
"hwlab-edge-proxy"
]);
export async function createG14CiPlan(options = {}) {
const repoRoot = options.repoRoot ?? process.cwd();
const deployJsonPath = options.deployJsonPath ?? "deploy/deploy.json";
const deployConfigPath = options.deployConfigPath ?? "deploy/deploy.yaml";
const targetRef = options.targetRef ?? "HEAD";
const baseRef = options.baseRef ?? await defaultBaseRef(repoRoot, targetRef);
const lane = options.lane ?? (options.artifactCatalogPath?.includes(".v02.") ? "v02" : "g14");
const lane = options.lane ?? "v02";
if (lane !== "v02") throw new Error(`unsupported CI plan lane ${lane}; v02 env reuse is configured from deploy/deploy.yaml`);
const artifactCatalogPath = options.artifactCatalogPath ?? (lane === "v02" ? "deploy/artifact-catalog.v02.json" : "deploy/artifact-catalog.dev.json");
const registryPrefix = options.registryPrefix ?? process.env.HWLAB_DEV_REGISTRY_PREFIX ?? process.env.HWLAB_DEV_REGISTRY ?? "127.0.0.1:5000/hwlab";
const baseImage = options.baseImage ?? process.env.HWLAB_DEV_BASE_IMAGE ?? "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim";
const [deployJson, artifactCatalog] = await Promise.all([
readJsonIfPresent(repoRoot, deployJsonPath, {}),
readJsonIfPresent(repoRoot, artifactCatalogPath, null)
readStructuredFileIfPresent(repoRoot, deployConfigPath, {}),
readStructuredFileIfPresent(repoRoot, artifactCatalogPath, null)
]);
const sourceCommitId = await gitValue(repoRoot, ["rev-parse", targetRef]);
const shortCommitId = await gitValue(repoRoot, ["rev-parse", "--short=7", targetRef]);
const changedPaths = await changedPathsBetween(repoRoot, baseRef, targetRef);
const normalizedChangedPaths = changedPaths.map(normalizeRepoPath).filter(Boolean);
const semanticRuntimeDepPaths = await runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef, normalizedChangedPaths);
const serviceIdResolution = resolveServiceIds({ deployJson, options });
const componentModels = componentModelsForServices(serviceIdResolution.serviceIds);
const laneConfig = laneDeployConfig(deployJson, lane);
assertV02EnvReuseConfig(laneConfig, lane);
const serviceIdResolution = resolveServiceIds({ deployJson, options, laneConfig, lane });
const envReuseRecipe = envReuseRecipeForLane(deployJson, lane);
const componentModels = componentModelsForServices(serviceIdResolution.serviceIds, laneConfig);
const envReuseServices = enabledEnvReuseServices(deployJson, lane, serviceIdResolution.serviceIds);
const globalChange = classifyGlobalChange(normalizedChangedPaths);
const dockerfileHash = await hashGitPaths(repoRoot, targetRef, [
...DEFAULT_BUILD_SYSTEM_PATHS,
...envReuseRecipe.additionalEnvPaths,
...DEFAULT_RUNTIME_DEP_PATHS
], { semanticPackageJson: true });
const catalogByServiceId = new Map((artifactCatalog?.services ?? []).map((service) => [service.serviceId, service]));
@@ -130,7 +87,7 @@ export async function createG14CiPlan(options = {}) {
const bootSh = envReuse ? bootShForService(deployJson, model.serviceId) : null;
const envInputPaths = envReuse ? uniqueSorted([
...model.runtimeDeps,
...DEFAULT_ENV_REUSE_LAUNCHER_PATHS.map(normalizeRepoPath)
...envReuseRecipe.launcherInputPaths
]) : [];
const codeInputPaths = envReuse ? uniqueSorted([
...model.componentPaths,
@@ -215,10 +172,14 @@ export async function createG14CiPlan(options = {}) {
serviceId: model.serviceId,
runtimeKind: model.runtimeKind,
entrypoint: model.entrypoint,
artifactKind: model.artifactKind,
healthPath: model.healthPath,
healthPort: model.healthPort,
componentPaths: model.componentPaths,
sharedPaths: model.sharedPaths,
runtimeDeps: model.runtimeDeps,
buildSystemPaths: model.buildSystemPaths,
envReuseRecipe: envReuse ? envReuseRecipe.publicRecipe : null,
componentCommitId,
componentInputHash,
catalogSourceCommitId: catalogSourceCommitId || null,
@@ -263,7 +224,7 @@ export async function createG14CiPlan(options = {}) {
baseRef,
targetRef,
compatibility: {
deployJson: deployJsonPath,
deployConfig: deployConfigPath,
artifactCatalog: artifactCatalog ? artifactCatalogPath : null,
lane,
ciContractSource: "scripts/g14-gitops-render.mjs",
@@ -271,6 +232,8 @@ export async function createG14CiPlan(options = {}) {
currentRenderCompatible: true,
plannerMutatesDeployJson: false,
serviceIdSource: serviceIdResolution.source,
serviceDeclarationSource: lane === "v02" ? `deploy.lanes.${lane}.serviceDeclarations` : "not-applicable",
envRecipeSource: lane === "v02" ? `deploy.lanes.${lane}.envRecipe` : "not-applicable",
v02EnvReuseServiceIds: [...envReuseServices],
defaultComponentLazyBuild: true,
envReuseCodeOnlyFastLane: true,
@@ -302,20 +265,123 @@ export async function createG14CiPlan(options = {}) {
};
}
export function componentModelsForServices(serviceIds) {
export function componentModelsForServices(serviceIds, laneConfig = null) {
return serviceIds.map((serviceId) => {
const declaration = serviceDeclarationFor(laneConfig, serviceId);
return {
serviceId,
runtimeKind: runtimeKindForService(serviceId),
entrypoint: entrypointForService(serviceId),
componentPaths: uniqueSorted((serviceSpecificPaths[serviceId] ?? [`cmd/${serviceId}/`]).map(normalizeRepoPath)),
runtimeKind: declaration.runtimeKind,
entrypoint: declaration.entrypoint,
artifactKind: declaration.artifactKind,
healthPath: declaration.healthPath,
healthPort: declaration.healthPort,
componentPaths: uniqueSorted(declaration.componentPaths.map(normalizeRepoPath)),
sharedPaths: uniqueSorted(DEFAULT_SHARED_RUNTIME_PATHS.map(normalizeRepoPath)),
runtimeDeps: uniqueSorted(DEFAULT_RUNTIME_DEP_PATHS.map(normalizeRepoPath)),
buildSystemPaths: uniqueSorted(DEFAULT_BUILD_SYSTEM_PATHS.map(normalizeRepoPath))
buildSystemPaths: uniqueSorted(envReuseRecipeForLaneConfig(laneConfig).additionalEnvPaths)
};
});
}
function serviceDeclarationFor(laneConfig, serviceId) {
const configured = laneConfig?.serviceDeclarations?.[serviceId];
const runtimeKind = requireDeclarationString(configured?.runtimeKind, `serviceDeclarations.${serviceId}.runtimeKind`);
const entrypoint = requireDeclarationString(configured?.entrypoint, `serviceDeclarations.${serviceId}.entrypoint`);
const artifactKind = normalizeDeclarationString(configured?.artifactKind) || runtimeKind;
if (!Array.isArray(configured?.componentPaths) || configured.componentPaths.length === 0) {
throw new Error(`deploy.lanes.v02.serviceDeclarations.${serviceId}.componentPaths is required`);
}
return {
runtimeKind,
entrypoint,
artifactKind,
healthPath: normalizeDeclarationString(configured?.healthPath) || "/health/live",
healthPort: Number.isInteger(configured?.healthPort) ? configured.healthPort : null,
componentPaths: configured.componentPaths
};
}
function requireDeclarationString(value, label) {
const text = normalizeDeclarationString(value);
if (!text) throw new Error(`deploy.lanes.v02.${label} is required`);
return text;
}
function normalizeDeclarationString(value) {
const text = String(value ?? "").trim();
return text || null;
}
function laneDeployConfig(deployJson, lane) {
return deployJson?.lanes?.[lane] ?? null;
}
export function envReuseRecipeForLane(deployJson, lane = "v02") {
return envReuseRecipeForLaneConfig(laneDeployConfig(deployJson, lane));
}
function envReuseRecipeForLaneConfig(laneConfig) {
const configured = laneConfig?.envRecipe;
if (!configured) throw new Error("deploy.lanes.v02.envRecipe is required");
const osPackages = normalizeStringList(configured.osPackages, []);
const bunVersion = requireDeclarationString(configured.bunVersion, "envRecipe.bunVersion");
const launcherPath = normalizeRepoPath(requireDeclarationString(configured.launcherPath, "envRecipe.launcherPath"));
const runtimeNodeModulesPath = normalizeAbsolutePath(configured.runtimeNodeModulesPath);
if (!runtimeNodeModulesPath) throw new Error("deploy.lanes.v02.envRecipe.runtimeNodeModulesPath must be absolute");
const additionalEnvPaths = uniqueSorted(normalizeStringList(configured.additionalEnvPaths, []).map(normalizeRepoPath));
const launcherInputPaths = uniqueSorted([
launcherPath,
...additionalEnvPaths
]);
const hwpodAliases = normalizeHwpodAliases(configured.hwpodAliases);
if (osPackages.length === 0) throw new Error("deploy.lanes.v02.envRecipe.osPackages is required");
if (additionalEnvPaths.length === 0) throw new Error("deploy.lanes.v02.envRecipe.additionalEnvPaths is required");
if (hwpodAliases.length === 0) throw new Error("deploy.lanes.v02.envRecipe.hwpodAliases is required");
return {
osPackages,
bunVersion,
launcherPath,
runtimeNodeModulesPath,
additionalEnvPaths,
launcherInputPaths,
hwpodAliases,
publicRecipe: {
osPackages,
bunVersion,
launcherPath,
runtimeNodeModulesPath,
additionalEnvPaths,
hwpodAliases
}
};
}
function normalizeStringList(value, fallback) {
const list = Array.isArray(value) ? value : fallback;
return list.map((item) => String(item ?? "").trim()).filter(Boolean);
}
function normalizeAbsolutePath(value) {
const text = String(value ?? "").trim();
return text.startsWith("/") && !text.includes("..") ? text : null;
}
function normalizeHwpodAliases(value) {
const list = Array.isArray(value) ? value : [];
return list.map((item) => ({
command: normalizeDeclarationString(item?.command),
script: normalizeRepoPath(item?.script)
})).filter((item) => item.command && item.script);
}
function assertV02EnvReuseConfig(laneConfig, lane) {
if (lane !== "v02") return;
if (!laneConfig?.serviceDeclarations || Object.keys(laneConfig.serviceDeclarations).length === 0) {
throw new Error("deploy.lanes.v02.serviceDeclarations is required for v02 env reuse planning");
}
if (!laneConfig?.envRecipe) throw new Error("deploy.lanes.v02.envRecipe is required for v02 env reuse planning");
}
export async function runtimeDependencyChangedPaths(repoRoot, baseRef, targetRef, changedPaths) {
const result = new Set();
for (const filePath of changedPaths) {
@@ -366,7 +432,8 @@ export function enabledEnvReuseServices(deployJson, lane, serviceIds) {
export function bootShForService(deployJson, serviceId) {
const configured = deployJson?.lanes?.v02?.bootScripts?.[serviceId];
const bootSh = normalizeRepoPath(configured || `deploy/runtime/boot/${serviceId}.sh`);
if (!configured) throw new Error(`deploy.lanes.v02.bootScripts.${serviceId} is required`);
const bootSh = normalizeRepoPath(configured);
if (!bootSh || bootSh.startsWith("/") || bootSh.split("/").includes("..")) {
throw new Error(`invalid v02 boot script for ${serviceId}: ${configured}`);
}
@@ -469,46 +536,28 @@ export async function lastCommitForPaths(repoRoot, targetRef, paths) {
return value.trim() || null;
}
function resolveServiceIds({ deployJson, options }) {
function resolveServiceIds({ options, laneConfig, lane }) {
const allowedServiceIds = knownServiceIds(laneConfig, lane);
if (Array.isArray(options.services) && options.services.length > 0) {
const serviceIds = uniqueSorted(options.services.map(String));
const allowedServiceIds = knownServiceIds(deployJson);
const unknownServiceIds = serviceIds.filter((serviceId) => !allowedServiceIds.has(serviceId));
if (unknownServiceIds.length > 0) {
throw new Error(`unknown service IDs for G14 CI plan: ${unknownServiceIds.join(", ")}`);
}
return { source: "cli-services", serviceIds };
}
const deployServices = Array.isArray(deployJson?.services) ? deployJson.services.map((service) => service?.serviceId).filter(Boolean) : [];
if (deployServices.length > 0) return { source: "deploy.services", serviceIds: uniquePreserveOrder(deployServices) };
const mappingServices = Array.isArray(deployJson?.k3s?.serviceMappings)
? deployJson.k3s.serviceMappings.map((service) => service?.serviceId).filter(Boolean)
: [];
if (mappingServices.length > 0) return { source: "deploy.k3s.serviceMappings", serviceIds: uniquePreserveOrder(mappingServices) };
return { source: "internal.protocol.SERVICE_IDS", serviceIds: [...SERVICE_IDS] };
const configured = Array.isArray(laneConfig?.envReuseServices) ? laneConfig.envReuseServices.filter(Boolean) : [];
if (configured.length === 0) throw new Error("deploy.lanes.v02.envReuseServices is required for v02 CI planning");
return { source: "deploy.lanes.v02.envReuseServices", serviceIds: uniquePreserveOrder(configured) };
}
function knownServiceIds(deployJson) {
const values = new Set(SERVICE_IDS);
for (const service of deployJson?.services ?? []) {
if (service?.serviceId) values.add(service.serviceId);
}
for (const service of deployJson?.k3s?.serviceMappings ?? []) {
if (service?.serviceId) values.add(service.serviceId);
}
function knownServiceIds(laneConfig, lane) {
if (lane !== "v02") return new Set();
const values = new Set(Object.keys(laneConfig?.serviceDeclarations ?? {}));
for (const serviceId of laneConfig?.envReuseServices ?? []) values.add(serviceId);
return values;
}
async function readJsonIfPresent(repoRoot, relativePath, fallback) {
try {
const filePath = path.isAbsolute(relativePath) ? relativePath : path.join(repoRoot, relativePath);
return JSON.parse(await readFile(filePath, "utf8"));
} catch (error) {
if (error?.code === "ENOENT") return fallback;
throw error;
}
}
async function defaultBaseRef(repoRoot, targetRef) {
const parent = await gitValue(repoRoot, ["rev-parse", `${targetRef}^`]).catch(() => "");
return parent || targetRef;
@@ -650,20 +699,6 @@ function environmentDigestFromCatalog(catalogRecord) {
return catalogRecord?.environmentDigest ?? (catalogRecord?.environmentImage ? catalogRecord?.digest : null) ?? null;
}
function runtimeKindForService(serviceId) {
if (bunCommandServices.has(serviceId)) return "bun-command";
if (serviceId === "hwlab-cloud-web") return "cloud-web";
if (serviceId === "hwlab-agent-skills") return "skills-bundle";
return "node-command";
}
function entrypointForService(serviceId) {
if (bunCommandServices.has(serviceId)) return `cmd/${serviceId}/main.ts`;
if (serviceId === "hwlab-cloud-web") return "web/hwlab-cloud-web/index.html";
if (serviceId === "hwlab-agent-skills") return "skills/hwlab-agent-runtime/SKILL.md";
return `cmd/${serviceId}/main.mjs`;
}
function digestFromImageReference(image) {
const match = String(image ?? "").match(/@(sha256:[a-f0-9]{64})$/u);
return match ? match[1] : null;
+3 -2
View File
@@ -26,6 +26,7 @@ import {
HWLAB_M3_IO_SKILL_NAME
} from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
import { readStructuredFile } from "./structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const defaultJsonReportPath = tempReportPath("rpt-004-mvp-e2e.json");
@@ -734,7 +735,7 @@ async function buildExpectedArtifactState({ root, expectedCommit, fsJson }) {
const readJson = typeof fsJson === "function"
? (relativePath) => fsJson(relativePath)
: (relativePath) => readJsonOptional(root, relativePath);
const deploy = await readJson("deploy/deploy.json");
const deploy = await readJson("deploy/deploy.yaml");
const catalog = await readJson("deploy/artifact-catalog.dev.json");
const artifactReport = await readJson(defaultArtifactReportPath);
const deployCommit = sanitizeCommit(deploy?.commitId);
@@ -1568,7 +1569,7 @@ function runGit(root, args) {
async function readJsonOptional(root, relativePath) {
try {
return JSON.parse(await readFile(path.join(root, relativePath), "utf8"));
return await readStructuredFile(root, relativePath);
} catch {
return null;
}
+1 -1
View File
@@ -576,7 +576,7 @@ function fixtureArtifactJson() {
}
};
return async (relativePath) => {
if (relativePath === "deploy/deploy.json") return deploy;
if (relativePath === "deploy/deploy.yaml") return deploy;
if (relativePath === "deploy/artifact-catalog.dev.json") return catalog;
if (relativePath === "/tmp/hwlab-dev-gate/dev-artifacts.json") return artifactReport;
return null;
+45
View File
@@ -0,0 +1,45 @@
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import * as YAML from "yaml";
export function structuredConfigFormatForPath(relativePath) {
const extension = path.extname(relativePath).toLowerCase();
if (extension === ".json") return "json";
if (extension === ".yaml" || extension === ".yml") return "yaml";
throw new Error(`unsupported structured config extension for ${relativePath}`);
}
export function parseStructuredConfig(text, relativePath = "<structured-config>") {
const format = structuredConfigFormatForPath(relativePath);
if (format === "json") return JSON.parse(text);
return YAML.parse(text);
}
export function stringifyStructuredConfig(value, relativePath = "<structured-config>") {
const format = structuredConfigFormatForPath(relativePath);
if (format === "json") return `${JSON.stringify(value, null, 2)}\n`;
const text = YAML.stringify(value, { indent: 2, lineWidth: 0 });
return text.endsWith("\n") ? text : `${text}\n`;
}
export async function readStructuredFile(repoRoot, relativePath) {
const filePath = path.isAbsolute(relativePath) ? relativePath : path.join(repoRoot, relativePath);
const raw = await readFile(filePath, "utf8");
return parseStructuredConfig(raw, relativePath);
}
export async function readStructuredFileIfPresent(repoRoot, relativePath, fallback = null) {
try {
return await readStructuredFile(repoRoot, relativePath);
} catch (error) {
if (error?.code === "ENOENT") return fallback;
throw error;
}
}
export async function writeStructuredFile(repoRoot, relativePath, value) {
const filePath = path.isAbsolute(relativePath) ? relativePath : path.join(repoRoot, relativePath);
const raw = stringifyStructuredConfig(value, relativePath);
await writeFile(filePath, raw, "utf8");
}
+3 -3
View File
@@ -5,11 +5,12 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";
import { readStructuredFile } from "./src/structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const catalogPath = "deploy/artifact-catalog.dev.json";
const deployPath = "deploy/deploy.json";
const deployPath = "deploy/deploy.yaml";
const healthContractPath = "deploy/k8s/dev/health-contract.yaml";
const commitPattern = /^[a-f0-9]{7,40}$/;
const digestPattern = /^sha256:[a-f0-9]{64}$/;
@@ -61,8 +62,7 @@ const forbiddenDeployArtifactEnv = new Set([
]);
async function readJSON(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
return readStructuredFile(repoRoot, relativePath);
}
function assertUnique(name, values) {
+5 -12
View File
@@ -14,11 +14,11 @@ import {
import { HWLAB_M3_IO_API_BASE_URL_ENV } from "../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
import {
ENVIRONMENT_DEV,
SERVICE_IDS,
TABLES,
validateRequest,
validateResponse
} from "../internal/protocol/index.mjs";
import { readStructuredFile } from "./src/structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -53,8 +53,7 @@ async function parseJSONSchemaFiles(dir) {
}
async function parseDeployManifest(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
const doc = JSON.parse(raw);
const doc = await readStructuredFile(repoRoot, relativePath);
assert.equal(doc.manifestVersion, "v1", `${relativePath} manifestVersion`);
assert.equal(doc.environment, ENVIRONMENT_DEV, `${relativePath} environment`);
assert.equal(Object.hasOwn(doc, "commitId"), false, `${relativePath} commitId must stay in artifact catalog`);
@@ -63,8 +62,7 @@ async function parseDeployManifest(relativePath) {
}
async function parseK8sList(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
const doc = JSON.parse(raw);
const doc = await readStructuredFile(repoRoot, relativePath);
assert.equal(doc.kind, "List", `${relativePath} kind`);
assert.ok(Array.isArray(doc.items), `${relativePath} items`);
return doc;
@@ -75,14 +73,10 @@ function assertUnique(name, values) {
}
function assertCommonSchema(commonSchema) {
const schemaServiceIds = commonSchema.$defs.serviceId.enum;
assert.deepEqual(schemaServiceIds, SERVICE_IDS, "common service ids must match runtime constants");
assert.deepEqual(commonSchema.$defs.environment.enum, [ENVIRONMENT_DEV]);
}
function assertDeploySchema(deploySchema) {
const serviceIds = deploySchema.$defs.serviceId.enum;
assert.deepEqual(serviceIds, SERVICE_IDS, "deploy service ids must match runtime constants");
assert.equal(deploySchema.properties.endpoint.const, "http://74.48.78.17:16667");
assert.ok(deploySchema.required.includes("health"), "deploy schema requires health source");
assert.ok(deploySchema.required.includes("publicEndpoints"), "deploy schema requires public endpoint source");
@@ -151,14 +145,13 @@ function assertEnvelopeValidation() {
const schemas = await parseJSONSchemaFiles("protocol/schemas");
const deploySchemas = await parseJSONSchemaFiles("deploy");
const deployManifest = await parseDeployManifest("deploy/deploy.json");
const deployManifest = await parseDeployManifest("deploy/deploy.yaml");
const k8sServices = await parseK8sList("deploy/k8s/base/services.yaml");
const k8sWorkloads = await parseK8sList("deploy/k8s/base/workloads.yaml");
const docsByName = new Map([...schemas, ...deploySchemas].map((item) => [path.basename(item.relativePath), item.doc]));
assert.ok(schemas.length >= 13, "expected protocol schemas");
assert.ok(TABLES.includes("patch_panel_status"), "frozen tables must include patch_panel_status");
assertUnique("service ids", SERVICE_IDS);
assertUnique("tables", TABLES);
assertCommonSchema(docsByName.get("common.json"));
assertDeploySchema(docsByName.get("deploy.schema.json"));
@@ -263,7 +256,7 @@ assertCodeAgentProviderWorkloadContract(cloudApiWorkloadEnv);
assertDbForbiddenInvalidHostContract();
assertValidationDoesNotExposeSecretValues(cloudApi.env, cloudApiWorkloadEnv);
console.log(`validated ${schemas.length + deploySchemas.length} JSON files and ${SERVICE_IDS.length} service ids`);
console.log(`validated ${schemas.length + deploySchemas.length} JSON files`);
function listItems(document) {
return document?.kind === "List" ? document.items ?? [] : [document].filter(Boolean);
+9 -8
View File
@@ -4,6 +4,8 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { readStructuredFile } from "./src/structured-config.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const namespace = "hwlab-dev";
const m3EndpointMap = Object.freeze({
@@ -110,8 +112,7 @@ const expectedInstanceServices = Object.freeze([
]);
async function readJson(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
return readStructuredFile(repoRoot, relativePath);
}
function listItems(document) {
@@ -216,14 +217,14 @@ function assertDevK8sInstanceServices(services) {
}
function assertDeployManifestCardinality(deploy) {
assert.equal(deploy.environment, "dev", "deploy/deploy.json environment");
assert.equal(deploy.namespace, namespace, "deploy/deploy.json namespace");
assert.equal(deploy.profiles?.prod?.enabled, false, "deploy/deploy.json prod disabled");
assert.equal(deploy.environment, "dev", "deploy/deploy.yaml environment");
assert.equal(deploy.namespace, namespace, "deploy/deploy.yaml namespace");
assert.equal(deploy.profiles?.prod?.enabled, false, "deploy/deploy.yaml prod disabled");
const byId = new Map((deploy.services ?? []).map((service) => [service.serviceId, service]));
for (const [serviceId, requirement] of Object.entries(expected)) {
const service = byId.get(serviceId);
assert.ok(service, `deploy/deploy.json missing ${serviceId}`);
assert.ok(service, `deploy/deploy.yaml missing ${serviceId}`);
assert.equal(service.namespace, namespace, `${serviceId} deploy namespace`);
assert.equal(service.profile, "dev", `${serviceId} deploy profile`);
assert.equal(service.replicas, requirement.replicas, `${serviceId} deploy replicas`);
@@ -250,7 +251,7 @@ function assertDeployManifestInstanceMappings(deploy) {
const byName = new Map(mappings.map((mapping) => [mapping.name, mapping]));
for (const expectedService of expectedInstanceServices) {
const mapping = byName.get(expectedService.name);
assert.ok(mapping, `deploy/deploy.json missing k3s serviceMapping ${expectedService.name}`);
assert.ok(mapping, `deploy/deploy.yaml missing k3s serviceMapping ${expectedService.name}`);
assert.equal(mapping.serviceId, expectedService.serviceId, `${expectedService.name} serviceId`);
assert.equal(mapping.namespace, namespace, `${expectedService.name} namespace`);
assert.equal(mapping.port, expectedService.port, `${expectedService.name} port`);
@@ -303,7 +304,7 @@ function assertM3RouteConfig(value, label) {
export async function readDevM3CardinalityInputs() {
const [deploy, workloads, services, topology] = await Promise.all([
readJson("deploy/deploy.json"),
readJson("deploy/deploy.yaml"),
readJson("deploy/k8s/base/workloads.yaml"),
readJson("deploy/k8s/base/services.yaml"),
readJson("fixtures/mvp/m3-hardware-loop/topology.json")
+1 -1
View File
@@ -37,7 +37,7 @@ async function main() {
assertIncludes(doc, "2 x hwlab-gateway-simu", "m3 runbook");
assertIncludes(doc, "1 x hwlab-patch-panel", "m3 runbook");
assertIncludes(doc, "DO1 -> patch-panel -> DI1", "m3 runbook");
assertIncludes(doc, "deploy/deploy.json", "m3 runbook");
assertIncludes(doc, "deploy/deploy.yaml", "m3 runbook");
assertIncludes(doc, "deploy/artifact-catalog.dev.json", "m3 runbook");
assertIncludes(doc, "pikasTech/HWLAB#63", "m3 runbook");
assertIncludes(doc, "Do not promote SOURCE / LOCAL / DRY-RUN / fixture output to `DEV-LIVE`.", "m3 runbook");