feat: update caserun aggregation and v02 deploy config
This commit is contained in:
+112
-41
@@ -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]));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user