feat: add v02 device-pod env reuse boot path

This commit is contained in:
Codex
2026-05-29 22:49:08 +08:00
parent 56b720efa8
commit d1ffc007e4
14 changed files with 901 additions and 74 deletions
+110 -6
View File
@@ -9,6 +9,7 @@ import { SERVICE_IDS } from "../../internal/protocol/index.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([
"scripts/artifact-publish.mjs",
@@ -16,6 +17,14 @@ export const DEFAULT_BUILD_SYSTEM_PATHS = Object.freeze([
"scripts/src/dev-artifact-services.mjs"
]);
export const DEFAULT_ENV_REUSE_LAUNCHER_PATHS = Object.freeze([
"deploy/runtime/launcher/",
"scripts/artifact-publish.mjs",
"scripts/g14-artifact-publish.mjs",
"scripts/src/dev-artifact-services.mjs",
"scripts/src/g14-ci-plan-lib.mjs"
]);
export const DEFAULT_RUNTIME_DEP_PATHS = Object.freeze([
"package.json",
"package-lock.json"
@@ -73,9 +82,10 @@ const bunCommandServices = new Set([
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 lane = options.lane ?? (options.artifactCatalogPath?.includes(".v02.") ? "v02" : "g14");
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";
@@ -89,6 +99,7 @@ export async function createG14CiPlan(options = {}) {
const normalizedChangedPaths = changedPaths.map(normalizeRepoPath).filter(Boolean);
const serviceIdResolution = resolveServiceIds({ deployJson, options });
const componentModels = componentModelsForServices(serviceIdResolution.serviceIds);
const envReuseServices = enabledEnvReuseServices(deployJson, lane, serviceIdResolution.serviceIds);
const globalChange = classifyGlobalChange(normalizedChangedPaths);
const dockerfileHash = await hashGitPaths(repoRoot, targetRef, [
...DEFAULT_BUILD_SYSTEM_PATHS,
@@ -102,6 +113,21 @@ export async function createG14CiPlan(options = {}) {
const sharedMatches = matchingPaths(normalizedChangedPaths, model.sharedPaths);
const runtimeDepMatches = matchingPaths(normalizedChangedPaths, model.runtimeDeps);
const buildSystemMatches = matchingPaths(normalizedChangedPaths, model.buildSystemPaths);
const envReuse = lane === "v02" && envReuseServices.has(model.serviceId);
const bootSh = envReuse ? bootShForService(deployJson, model.serviceId) : null;
const envInputPaths = envReuse ? uniqueSorted([
...model.runtimeDeps,
...DEFAULT_ENV_REUSE_LAUNCHER_PATHS.map(normalizeRepoPath)
]) : [];
const codeInputPaths = envReuse ? uniqueSorted([
...model.componentPaths,
...model.sharedPaths,
bootSh
]) : [];
const envMatches = envReuse ? matchingPaths(normalizedChangedPaths, envInputPaths) : [];
const codeMatches = envReuse ? matchingPaths(normalizedChangedPaths, codeInputPaths) : [];
const relevantEnvMatches = envMatches.filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item));
const relevantCodeMatches = codeMatches.filter((item) => !isTestOnlyPath(item) && !isDocsOnlyPath(item));
const imageRelevantChangedPaths = uniqueSorted([
...directMatches,
...sharedMatches,
@@ -109,8 +135,21 @@ export async function createG14CiPlan(options = {}) {
...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 environmentInputHash = envReuse ? await hashGitPaths(repoRoot, targetRef, envInputPaths, {
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath)
}) : null;
const codeInputHash = envReuse ? await hashGitPaths(repoRoot, targetRef, codeInputPaths, {
skipPath: (filePath) => isTestOnlyPath(filePath) || isDocsOnlyPath(filePath)
}) : null;
const environmentImage = envReuse ? environmentImageFromCatalog(model.serviceId, catalogRecord) : null;
const environmentDigest = envReuse ? environmentDigestFromCatalog(catalogRecord) : null;
const environmentReady = envReuse && /^sha256:[a-f0-9]{64}$/u.test(environmentDigest ?? "") && Boolean(environmentImage);
const envChanged = envReuse ? relevantEnvMatches.length > 0 || !environmentReady || catalogRecord?.environmentInputHash !== environmentInputHash : null;
const codeChanged = envReuse ? relevantCodeMatches.length > 0 || catalogRecord?.codeInputHash !== codeInputHash || catalogRecord?.bootCommit !== sourceCommitId : null;
const catalogReuse = envReuse ? envReuseCandidate(catalogRecord, envChanged) : reuseCandidate(catalogRecord, imageRelevantChangedPaths.length > 0);
const affected = envReuse
? Boolean(envChanged || codeChanged)
: imageRelevantChangedPaths.length > 0 || catalogReuse.status === "candidate-no-catalog" || catalogReuse.status === "candidate-unverified-digest";
const componentInputPaths = uniqueSorted([
...model.componentPaths,
...model.sharedPaths,
@@ -144,10 +183,23 @@ export async function createG14CiPlan(options = {}) {
buildArgsHash,
changedPaths: imageRelevantChangedPaths,
affected,
runtimeMode: envReuse ? V02_ENV_REUSE_RUNTIME_MODE : "service-image",
envReuse,
envChanged,
codeChanged,
environmentInputHash,
codeInputHash,
bootRepo: envReuse ? canonicalBootRepo(deployJson) : null,
bootCommit: envReuse ? sourceCommitId : null,
bootSh,
environmentImage,
environmentDigest,
envChangedPaths: relevantEnvMatches,
codeChangedPaths: relevantCodeMatches,
reason: affected
? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse })
? reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse, envReuse, envChanged, codeChanged })
: reasonForUnchanged(globalChange),
reuse: reuseCandidate(catalogRecord, affected)
reuse: catalogReuse
});
}
@@ -162,11 +214,13 @@ export async function createG14CiPlan(options = {}) {
compatibility: {
deployJson: deployJsonPath,
artifactCatalog: artifactCatalog ? artifactCatalogPath : null,
lane,
ciContractSource: "scripts/g14-gitops-render.mjs",
mode: "advisory-read-only",
currentRenderCompatible: true,
plannerMutatesDeployJson: false,
serviceIdSource: serviceIdResolution.source,
v02EnvReuseServiceIds: [...envReuseServices],
defaultComponentLazyBuild: true,
fullBuildFallback: false
},
@@ -205,6 +259,27 @@ export function componentModelsForServices(serviceIds) {
});
}
export function enabledEnvReuseServices(deployJson, lane, serviceIds) {
if (lane !== "v02") return new Set();
const configured = deployJson?.lanes?.v02?.envReuseServices;
const values = Array.isArray(configured) ? configured : ["hwlab-device-pod"];
const allowed = new Set(serviceIds);
return new Set(values.filter((serviceId) => allowed.has(serviceId)));
}
export function bootShForService(deployJson, serviceId) {
const configured = deployJson?.lanes?.v02?.bootScripts?.[serviceId];
const bootSh = normalizeRepoPath(configured || `deploy/runtime/boot/${serviceId}.sh`);
if (!bootSh || bootSh.startsWith("/") || bootSh.split("/").includes("..")) {
throw new Error(`invalid v02 boot script for ${serviceId}: ${configured}`);
}
return bootSh;
}
function canonicalBootRepo(deployJson) {
return deployJson?.lanes?.v02?.sourceRepo || "git@github.com:pikasTech/HWLAB.git";
}
export function classifyGlobalChange(changedPaths) {
const relevant = changedPaths.filter(Boolean);
const testOnly = relevant.length > 0 && relevant.every(isTestOnlyPath);
@@ -325,8 +400,10 @@ async function gitValue(repoRoot, args) {
return result.stdout.trim();
}
function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse = null }) {
function reasonForService({ directMatches, sharedMatches, runtimeDepMatches, buildSystemMatches, catalogReuse = null, envReuse = false, envChanged = null, codeChanged = null }) {
const reasons = [];
if (envReuse && envChanged) reasons.push("environment-input-changed");
if (envReuse && codeChanged) reasons.push("code-input-changed");
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");
@@ -358,6 +435,33 @@ function reuseCandidate(catalogRecord, affected) {
};
}
function envReuseCandidate(catalogRecord, envChanged) {
if (envChanged) return { status: "not-reused", reason: "environment-affected" };
if (!catalogRecord) return { status: "candidate-no-catalog", reason: "no-previous-artifact-record" };
const image = environmentImageFromCatalog(catalogRecord.serviceId, catalogRecord);
const digest = environmentDigestFromCatalog(catalogRecord);
if (!/^sha256:[a-f0-9]{64}$/u.test(digest ?? "")) {
return { status: "candidate-unverified-digest", image, digest };
}
return {
status: catalogRecord.environmentInputHash ? "ready" : "candidate-without-environment-metadata",
image,
digest,
reusedFrom: catalogRecord.environmentInputHash ?? catalogRecord.commitId ?? catalogRecord.imageTag ?? null
};
}
function environmentImageFromCatalog(serviceId, catalogRecord) {
const image = catalogRecord?.environmentImage ?? null;
if (image) return image;
const fallback = catalogRecord?.image ?? null;
return typeof fallback === "string" && fallback.includes(`/${serviceId}-env:`) ? fallback : null;
}
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";