399 lines
16 KiB
JavaScript
399 lines
16 KiB
JavaScript
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";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
export const G14_CI_PLAN_VERSION = "v1";
|
|
|
|
export const DEFAULT_BUILD_SYSTEM_PATHS = Object.freeze([
|
|
"scripts/artifact-publish.mjs",
|
|
"scripts/g14-artifact-publish.mjs",
|
|
"scripts/src/dev-artifact-services.mjs"
|
|
]);
|
|
|
|
export const DEFAULT_RUNTIME_DEP_PATHS = Object.freeze([
|
|
"package.json",
|
|
"package-lock.json"
|
|
]);
|
|
|
|
export const DEFAULT_SHARED_RUNTIME_PATHS = Object.freeze([
|
|
"internal/protocol/",
|
|
"internal/build-metadata.mjs"
|
|
]);
|
|
|
|
export const DEFAULT_DOCS_ONLY_PATHS = Object.freeze([
|
|
"docs/",
|
|
"README.md",
|
|
"AGENTS.md"
|
|
]);
|
|
|
|
export const DEFAULT_GITOPS_ONLY_PATHS = Object.freeze([
|
|
"deploy/gitops/",
|
|
"deploy/frp/",
|
|
"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/",
|
|
"internal/cloud/",
|
|
"internal/db/",
|
|
"internal/audit/",
|
|
"tools/",
|
|
"skills/"
|
|
],
|
|
"hwlab-cloud-web": ["web/hwlab-cloud-web/"],
|
|
"hwlab-agent-mgr": ["cmd/hwlab-agent-mgr/", "internal/agent/", "internal/db/", "internal/audit/"],
|
|
"hwlab-agent-worker": ["cmd/hwlab-agent-worker/", "internal/agent/", "internal/db/", "internal/audit/", "skills/"],
|
|
"hwlab-gateway": ["cmd/hwlab-gateway/"],
|
|
"hwlab-gateway-simu": ["cmd/hwlab-gateway-simu/", "internal/sim/"],
|
|
"hwlab-box-simu": ["cmd/hwlab-box-simu/", "internal/sim/"],
|
|
"hwlab-patch-panel": ["cmd/hwlab-patch-panel/", "internal/patchpanel/"],
|
|
"hwlab-router": ["cmd/hwlab-router/"],
|
|
"hwlab-tunnel-client": ["cmd/hwlab-tunnel-client/"],
|
|
"hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/"],
|
|
"hwlab-cli": ["tools/hwlab-cli/"],
|
|
"hwlab-agent-skills": ["skills/"]
|
|
});
|
|
|
|
export async function createG14CiPlan(options = {}) {
|
|
const repoRoot = options.repoRoot ?? process.cwd();
|
|
const deployJsonPath = options.deployJsonPath ?? "deploy/deploy.json";
|
|
const artifactCatalogPath = options.artifactCatalogPath ?? "deploy/artifact-catalog.dev.json";
|
|
const targetRef = options.targetRef ?? "HEAD";
|
|
const baseRef = options.baseRef ?? await defaultBaseRef(repoRoot, targetRef);
|
|
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)
|
|
]);
|
|
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 serviceIdResolution = resolveServiceIds({ deployJson, options });
|
|
const componentModels = componentModelsForServices(serviceIdResolution.serviceIds);
|
|
const globalChange = classifyGlobalChange(normalizedChangedPaths);
|
|
const dockerfileHash = await hashGitPaths(repoRoot, targetRef, [
|
|
...DEFAULT_BUILD_SYSTEM_PATHS,
|
|
...DEFAULT_RUNTIME_DEP_PATHS
|
|
]);
|
|
const catalogByServiceId = new Map((artifactCatalog?.services ?? []).map((service) => [service.serviceId, service]));
|
|
|
|
const services = [];
|
|
for (const model of componentModels) {
|
|
const directMatches = matchingPaths(normalizedChangedPaths, model.componentPaths);
|
|
const sharedMatches = matchingPaths(normalizedChangedPaths, model.sharedPaths);
|
|
const runtimeDepMatches = matchingPaths(normalizedChangedPaths, model.runtimeDeps);
|
|
const buildSystemMatches = matchingPaths(normalizedChangedPaths, model.buildSystemPaths);
|
|
const imageRelevantChangedPaths = uniqueSorted([
|
|
...directMatches,
|
|
...sharedMatches,
|
|
...runtimeDepMatches,
|
|
...buildSystemMatches
|
|
].filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item)));
|
|
const catalogRecord = catalogByServiceId.get(model.serviceId) ?? null;
|
|
const catalogReuse = reuseCandidate(catalogRecord, imageRelevantChangedPaths.length > 0);
|
|
const affected = imageRelevantChangedPaths.length > 0 || catalogReuse.status === "candidate-no-catalog" || catalogReuse.status === "candidate-unverified-digest";
|
|
const componentInputPaths = uniqueSorted([
|
|
...model.componentPaths,
|
|
...model.sharedPaths,
|
|
...model.runtimeDeps,
|
|
...model.buildSystemPaths
|
|
]);
|
|
const componentCommitId = await lastCommitForPaths(repoRoot, targetRef, componentInputPaths);
|
|
const componentInputHash = await hashGitPaths(repoRoot, targetRef, componentInputPaths, {
|
|
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath)
|
|
});
|
|
const buildArgsHash = stableHash({
|
|
serviceId: model.serviceId,
|
|
baseImage,
|
|
registryPrefix,
|
|
runtimeKind: model.runtimeKind,
|
|
entrypoint: model.entrypoint
|
|
});
|
|
services.push({
|
|
serviceId: model.serviceId,
|
|
runtimeKind: model.runtimeKind,
|
|
entrypoint: model.entrypoint,
|
|
componentPaths: model.componentPaths,
|
|
sharedPaths: model.sharedPaths,
|
|
runtimeDeps: model.runtimeDeps,
|
|
buildSystemPaths: model.buildSystemPaths,
|
|
componentCommitId,
|
|
componentInputHash,
|
|
dockerfileHash,
|
|
baseImageReference: baseImage,
|
|
baseImageDigest: digestFromImageReference(baseImage),
|
|
buildArgsHash,
|
|
changedPaths: imageRelevantChangedPaths,
|
|
affected,
|
|
reason: affected
|
|
? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse })
|
|
: reasonForUnchanged(globalChange),
|
|
reuse: reuseCandidate(catalogRecord, affected)
|
|
});
|
|
}
|
|
|
|
const affectedServices = services.filter((service) => service.affected).map((service) => service.serviceId);
|
|
const reusedServices = services.filter((service) => !service.affected).map((service) => service.serviceId);
|
|
return {
|
|
planVersion: G14_CI_PLAN_VERSION,
|
|
sourceCommitId,
|
|
shortCommitId,
|
|
baseRef,
|
|
targetRef,
|
|
compatibility: {
|
|
deployJson: deployJsonPath,
|
|
artifactCatalog: artifactCatalog ? artifactCatalogPath : null,
|
|
ciContractSource: "scripts/g14-gitops-render.mjs",
|
|
mode: "advisory-read-only",
|
|
currentRenderCompatible: true,
|
|
plannerMutatesDeployJson: false,
|
|
serviceIdSource: serviceIdResolution.source,
|
|
defaultComponentLazyBuild: true,
|
|
fullBuildFallback: false
|
|
},
|
|
inputs: {
|
|
registryPrefix,
|
|
baseImage,
|
|
serviceCount: services.length
|
|
},
|
|
changedPaths: normalizedChangedPaths,
|
|
changedPathSummary: globalChange,
|
|
imageBuildRequired: affectedServices.length > 0,
|
|
affectedServices,
|
|
reusedServices,
|
|
buildSkippedCount: reusedServices.length,
|
|
services,
|
|
ciCdPlan: {
|
|
willBuild: affectedServices,
|
|
willReuse: reusedServices,
|
|
willRunGitopsPromote: globalChange.gitopsOnly || affectedServices.length > 0,
|
|
noImageBuildReason: affectedServices.length === 0 ? globalChange.summary : null
|
|
}
|
|
};
|
|
}
|
|
|
|
export function componentModelsForServices(serviceIds) {
|
|
return serviceIds.map((serviceId) => {
|
|
return {
|
|
serviceId,
|
|
runtimeKind: runtimeKindForService(serviceId),
|
|
entrypoint: entrypointForService(serviceId),
|
|
componentPaths: uniqueSorted((serviceSpecificPaths[serviceId] ?? [`cmd/${serviceId}/`]).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))
|
|
};
|
|
});
|
|
}
|
|
|
|
export function classifyGlobalChange(changedPaths) {
|
|
const relevant = changedPaths.filter(Boolean);
|
|
const testOnly = relevant.length > 0 && relevant.every(isTestOnlyPath);
|
|
const docsOnly = relevant.length > 0 && relevant.every((item) => isDocsOnlyPath(item) || isTestOnlyPath(item));
|
|
const gitopsOnly = relevant.length > 0 && relevant.every((item) => isGitOpsOnlyPath(item) || isDocsOnlyPath(item) || isTestOnlyPath(item));
|
|
return {
|
|
count: relevant.length,
|
|
testOnly,
|
|
docsOnly,
|
|
gitopsOnly,
|
|
summary: relevant.length === 0
|
|
? "no-source-diff"
|
|
: testOnly
|
|
? "test-only-change"
|
|
: docsOnly
|
|
? "docs-only-change"
|
|
: gitopsOnly
|
|
? "gitops-only-change"
|
|
: "runtime-or-build-change"
|
|
};
|
|
}
|
|
|
|
export function matchingPaths(changedPaths, patterns) {
|
|
return uniqueSorted(changedPaths.filter((filePath) => patterns.some((pattern) => pathMatches(pattern, filePath))));
|
|
}
|
|
|
|
export function normalizeRepoPath(value) {
|
|
return String(value ?? "")
|
|
.replaceAll("\\", "/")
|
|
.replace(/^\.\//u, "")
|
|
.replace(/^\/+/, "")
|
|
.trim();
|
|
}
|
|
|
|
export function pathMatches(pattern, filePath) {
|
|
const normalizedPattern = normalizeRepoPath(pattern);
|
|
const normalizedFilePath = normalizeRepoPath(filePath);
|
|
if (!normalizedPattern || !normalizedFilePath) return false;
|
|
if (normalizedPattern.endsWith("/")) return normalizedFilePath.startsWith(normalizedPattern);
|
|
return normalizedFilePath === normalizedPattern;
|
|
}
|
|
|
|
export function isTestOnlyPath(filePath) {
|
|
const normalized = normalizeRepoPath(filePath);
|
|
return normalized.endsWith(".test.mjs") || normalized.includes("/__tests__/") || normalized.includes("/fixtures/");
|
|
}
|
|
|
|
export function isDocsOnlyPath(filePath) {
|
|
const normalized = normalizeRepoPath(filePath);
|
|
return DEFAULT_DOCS_ONLY_PATHS.some((pattern) => pathMatches(pattern, normalized)) || normalized.endsWith(".md");
|
|
}
|
|
|
|
export function isGitOpsOnlyPath(filePath) {
|
|
const normalized = normalizeRepoPath(filePath);
|
|
return DEFAULT_GITOPS_ONLY_PATHS.some((pattern) => pathMatches(pattern, normalized));
|
|
}
|
|
|
|
export async function changedPathsBetween(repoRoot, baseRef, targetRef) {
|
|
const diff = await gitValue(repoRoot, ["diff", "--name-only", baseRef, targetRef]);
|
|
return diff.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
}
|
|
|
|
export async function hashGitPaths(repoRoot, targetRef, paths, options = {}) {
|
|
const normalizedPaths = uniqueSorted(paths.map(normalizeRepoPath).filter(Boolean));
|
|
if (normalizedPaths.length === 0) return stableHash({ empty: true });
|
|
const output = await gitValue(repoRoot, ["ls-tree", "-r", targetRef, "--", ...normalizedPaths]).catch(() => "");
|
|
const lines = output.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
.filter((line) => {
|
|
const filePath = line.slice(line.indexOf("\t") + 1);
|
|
return !options.skipPath?.(filePath);
|
|
})
|
|
.sort();
|
|
return stableHash({ targetRef, entries: lines });
|
|
}
|
|
|
|
export async function lastCommitForPaths(repoRoot, targetRef, paths) {
|
|
const normalizedPaths = uniqueSorted(paths.map(normalizeRepoPath).filter(Boolean));
|
|
if (normalizedPaths.length === 0) return null;
|
|
const value = await gitValue(repoRoot, ["rev-list", "-1", targetRef, "--", ...normalizedPaths]).catch(() => "");
|
|
return value.trim() || null;
|
|
}
|
|
|
|
function resolveServiceIds({ deployJson, options }) {
|
|
if (Array.isArray(options.services) && options.services.length > 0) {
|
|
return { source: "cli-services", serviceIds: uniqueSorted(options.services.map(String)) };
|
|
}
|
|
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] };
|
|
}
|
|
|
|
async function readJsonIfPresent(repoRoot, relativePath, fallback) {
|
|
try {
|
|
return JSON.parse(await readFile(path.join(repoRoot, relativePath), "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;
|
|
}
|
|
|
|
async function gitValue(repoRoot, args) {
|
|
const result = await execFileAsync("git", args, {
|
|
cwd: repoRoot,
|
|
maxBuffer: 8 * 1024 * 1024,
|
|
timeout: 15000
|
|
});
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse = null }) {
|
|
const reasons = [];
|
|
if (directMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("component-path-changed");
|
|
if (sharedMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("shared-runtime-changed");
|
|
if (runtimeDepMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("runtime-deps-changed");
|
|
if (buildSystemMatches.some((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item))) reasons.push("build-system-changed");
|
|
if (catalogReuse?.status === "candidate-no-catalog") reasons.push("catalog-record-missing");
|
|
if (catalogReuse?.status === "candidate-unverified-digest") reasons.push("catalog-digest-missing");
|
|
return reasons.length ? reasons : ["affected"];
|
|
}
|
|
|
|
function reasonForUnchanged(globalChange) {
|
|
if (globalChange.summary === "docs-only-change") return ["docs-only-no-image-build"];
|
|
if (globalChange.summary === "gitops-only-change") return ["gitops-only-no-image-build"];
|
|
if (globalChange.summary === "test-only-change") return ["test-only-no-image-build"];
|
|
if (globalChange.summary === "no-source-diff") return ["no-source-diff"];
|
|
return ["component-inputs-unchanged"];
|
|
}
|
|
|
|
function reuseCandidate(catalogRecord, affected) {
|
|
if (affected) return { status: "not-reused", reason: "component-affected" };
|
|
if (!catalogRecord) return { status: "candidate-no-catalog", reason: "no-previous-artifact-record" };
|
|
if (!/^sha256:[a-f0-9]{64}$/u.test(catalogRecord.digest ?? "")) {
|
|
return { status: "candidate-unverified-digest", image: catalogRecord.image ?? null, digest: catalogRecord.digest ?? null };
|
|
}
|
|
return {
|
|
status: catalogRecord.componentInputHash ? "ready" : "candidate-without-component-metadata",
|
|
image: catalogRecord.image,
|
|
digest: catalogRecord.digest,
|
|
reusedFrom: catalogRecord.componentInputHash ? catalogRecord.componentInputHash : catalogRecord.commitId ?? catalogRecord.imageTag ?? null
|
|
};
|
|
}
|
|
|
|
function runtimeKindForService(serviceId) {
|
|
if (serviceId === "hwlab-cloud-web") return "cloud-web";
|
|
if (serviceId === "hwlab-cli") return "cli";
|
|
if (serviceId === "hwlab-agent-skills") return "skills-bundle";
|
|
return "node-command";
|
|
}
|
|
|
|
function entrypointForService(serviceId) {
|
|
if (serviceId === "hwlab-cloud-web") return "web/hwlab-cloud-web/index.html";
|
|
if (serviceId === "hwlab-cli") return "tools/hwlab-cli/bin/hwlab-cli.mjs";
|
|
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;
|
|
}
|
|
|
|
function stableHash(value) {
|
|
return createHash("sha256").update(stableJson(value)).digest("hex");
|
|
}
|
|
|
|
function stableJson(value) {
|
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
if (value && typeof value === "object") {
|
|
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function uniqueSorted(values) {
|
|
return [...new Set(values.filter(Boolean))].sort();
|
|
}
|
|
|
|
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;
|
|
}
|