feat: sync g14 harness and device-pod cli

This commit is contained in:
Codex
2026-05-31 05:07:16 +08:00
parent 1ebbfe2bfe
commit 5bf4bf0f47
17 changed files with 2086 additions and 210 deletions
+12 -2
View File
@@ -11,6 +11,10 @@ const DEVICE_JOB_INTENTS = new Set([
"workspace.cat",
"workspace.rg",
"workspace.apply-patch",
"workspace.put",
"workspace.rm",
"workspace.rmdir",
"workspace.keil",
"workspace.build",
"debug.status",
"debug.chip-id",
@@ -19,7 +23,8 @@ const DEVICE_JOB_INTENTS = new Set([
"io.ports",
"io.uart.read",
"io.uart.read-after-launch-flash",
"io.uart.write"
"io.uart.write",
"io.uart.jsonrpc"
]);
const ACCESS_SCHEMA_STATEMENTS = Object.freeze([
`CREATE TABLE IF NOT EXISTS users (
@@ -109,10 +114,15 @@ const ACCESS_SCHEMA_STATEMENTS = Object.freeze([
const MUTATING_INTENTS = new Set([
"workspace.apply-patch",
"workspace.build",
"workspace.put",
"workspace.rm",
"workspace.rmdir",
"workspace.keil",
"debug.download",
"debug.reset",
"io.uart.read-after-launch-flash",
"io.uart.write"
"io.uart.write",
"io.uart.jsonrpc"
]);
const DEFAULT_DEVICE_LEASE_TTL_SECONDS = 15 * 60;
const MAX_DEVICE_LEASE_TTL_SECONDS = 60 * 60;
-57
View File
@@ -247,7 +247,6 @@ test("cloud api aggregates live HWLAB build times from health and controlled dep
});
}
});
test("cloud api ignores old repository reports and keeps missing buildCreatedAt unavailable", async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "hwlab-live-builds-"));
const deployDir = path.join(tempRoot, "deploy");
@@ -369,9 +368,6 @@ test("cloud api ignores old repository reports and keeps missing buildCreatedAt
const manager = withOldReport.services.find((service) => service.serviceId === "hwlab-agent-mgr");
assert.equal(withOldReport.latest, null);
assert.equal(manager.build.createdAt, null);
assert.equal(manager.build.metadataSource, "live-health:matched-controlled-metadata-without-build-time");
assert.equal(manager.build.unavailableReason, "构建时间不可用:当前 live 镜像未提供 buildCreatedAt");
assert.match(manager.build.unavailableDetail, /catalog metadata/u);
assert.equal(Object.hasOwn(withOldReport.source, "artifactReportSource"), false);
assert.equal(JSON.stringify(withOldReport).includes("2099-01-01T00:00:00.000Z"), false);
assert.equal(JSON.stringify(withOldReport).includes("artifact-report"), false);
@@ -379,56 +375,3 @@ test("cloud api ignores old repository reports and keeps missing buildCreatedAt
await rm(tempRoot, { recursive: true, force: true });
}
});
test("cloud api v02 live builds omit removed simulator and tunnel services from default inventory", async () => {
const server = createCloudApiServer({
env: {
PATH: "",
HWLAB_ENVIRONMENT: "v02",
HWLAB_GITOPS_PROFILE: "v02"
},
liveBuildMetadata: {
deployManifest: {
services: [
{ serviceId: "hwlab-cloud-api", profile: "v02", replicas: 1 },
{ serviceId: "hwlab-gateway-simu", profile: "v02", replicas: 1 },
{ serviceId: "hwlab-box-simu", profile: "v02", replicas: 1 },
{ serviceId: "hwlab-patch-panel", profile: "v02", replicas: 1 },
{ serviceId: "hwlab-router", profile: "v02", replicas: 1 },
{ serviceId: "hwlab-tunnel-client", profile: "v02", replicas: 1 }
]
},
artifactCatalog: {
services: [
{ serviceId: "hwlab-gateway-simu" },
{ serviceId: "hwlab-cloud-web" }
]
}
},
fetchImpl: async () => ({
ok: false,
status: 503,
async text() {
return JSON.stringify({ error: "offline" });
}
})
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const response = await fetch(`http://127.0.0.1:${port}/v1/live-builds`);
assert.equal(response.status, 200);
const payload = await response.json();
const serviceIds = payload.services.map((service) => service.serviceId);
assert.equal(serviceIds.includes("hwlab-cloud-api"), true);
assert.equal(serviceIds.includes("hwlab-cloud-web"), true);
assert.equal(serviceIds.includes("hwlab-gateway-simu"), false);
assert.equal(serviceIds.includes("hwlab-box-simu"), false);
assert.equal(serviceIds.includes("hwlab-patch-panel"), false);
assert.equal(serviceIds.includes("hwlab-router"), false);
assert.equal(serviceIds.includes("hwlab-tunnel-client"), false);
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
+108 -47
View File
@@ -9,19 +9,16 @@ import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const LIVE_BUILD_TIMEOUT_MS = 900;
const LIVE_BUILD_METADATA_PATHS = Object.freeze({
deployManifest: "deploy/deploy.json",
artifactCatalog: "deploy/artifact-catalog.dev.json"
});
const LIVE_BUILD_DEPLOY_MANIFEST_PATH = "deploy/deploy.json";
const LIVE_BUILD_SERVICE_DEFAULTS = Object.freeze({
"hwlab-cloud-api": Object.freeze({ urlEnv: null, defaultUrl: "http://127.0.0.1:6667" }),
"hwlab-cloud-web": Object.freeze({ urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL", defaultUrl: "http://hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080" }),
"hwlab-agent-mgr": Object.freeze({ urlEnv: "HWLAB_AGENT_MGR_URL", defaultUrl: "http://hwlab-agent-mgr.hwlab-dev.svc.cluster.local:7410" }),
"hwlab-agent-worker": Object.freeze({ urlEnv: "HWLAB_AGENT_WORKER_URL", defaultUrl: "http://hwlab-agent-worker.hwlab-dev.svc.cluster.local:7411" }),
"hwlab-device-pod": Object.freeze({ urlEnv: "HWLAB_DEVICE_POD_URL", defaultUrl: "http://hwlab-device-pod.hwlab-dev.svc.cluster.local:7601" }),
"hwlab-gateway": Object.freeze({ urlEnv: "HWLAB_GATEWAY_URL", defaultUrl: "http://hwlab-gateway.hwlab-dev.svc.cluster.local:7001" }),
"hwlab-edge-proxy": Object.freeze({ urlEnv: "HWLAB_EDGE_PROXY_URL", defaultUrl: "http://hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667", healthPath: "/health" }),
"hwlab-agent-skills": Object.freeze({ urlEnv: "HWLAB_AGENT_SKILLS_URL", defaultUrl: "http://hwlab-agent-skills.hwlab-dev.svc.cluster.local:7430" })
"hwlab-cloud-web": Object.freeze({ urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL" }),
"hwlab-agent-mgr": Object.freeze({ urlEnv: "HWLAB_AGENT_MGR_URL" }),
"hwlab-agent-worker": Object.freeze({ urlEnv: "HWLAB_AGENT_WORKER_URL" }),
"hwlab-device-pod": Object.freeze({ urlEnv: "HWLAB_DEVICE_POD_URL" }),
"hwlab-gateway": Object.freeze({ urlEnv: "HWLAB_GATEWAY_URL" }),
"hwlab-edge-proxy": Object.freeze({ urlEnv: "HWLAB_EDGE_PROXY_URL", healthPath: "/health" }),
"hwlab-agent-skills": Object.freeze({ urlEnv: "HWLAB_AGENT_SKILLS_URL" })
});
const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([
Object.freeze({
@@ -33,22 +30,18 @@ const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([
externalReason: "外部镜像或非 HWLAB 构建产物"
})
]);
const V02_REMOVED_SERVICE_IDS = Object.freeze([
"hwlab-gateway-simu",
"hwlab-box-simu",
"hwlab-patch-panel",
"hwlab-router",
"hwlab-tunnel-client"
]);
function defaultRuntimeEnvironment(env = process.env) {
const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV;
return typeof value === "string" && value.trim() ? value.trim() : ENVIRONMENT_DEV;
}
function liveBuildProfile(env = process.env) {
return defaultRuntimeEnvironment(env);
}
export async function buildLiveBuildsPayload(options = {}, dependencies = {}) {
const env = options.env ?? process.env;
const metadata = await loadLiveBuildMetadata(options);
const metadata = await loadLiveBuildMetadata(options, env);
const inventory = liveBuildServiceInventory(metadata, env);
const services = await Promise.all(
inventory.map((service) => observeLiveBuildService(service, { ...options, env, buildHealthPayload: dependencies.buildHealthPayload }))
@@ -68,9 +61,9 @@ export async function buildLiveBuildsPayload(options = {}, dependencies = {}) {
kind: "live-health+deploy-artifact-catalog",
route: "/v1/live-builds",
healthPath: "/health/live",
metadataPaths: LIVE_BUILD_METADATA_PATHS,
desiredStateSource: metadata.deployManifest.status === "ok" ? LIVE_BUILD_METADATA_PATHS.deployManifest : "unavailable",
artifactCatalogSource: metadata.artifactCatalog.status === "ok" ? LIVE_BUILD_METADATA_PATHS.artifactCatalog : "unavailable",
metadataPaths: metadata.paths,
desiredStateSource: metadata.deployManifest.status === "ok" ? metadata.paths.deployManifest : "unavailable",
artifactCatalogSource: metadata.artifactCatalog.status === "ok" ? metadata.paths.artifactCatalog : "unavailable",
note: "Each row prefers current live service health build metadata and falls back only to deploy/artifact catalog metadata when the live tag/revision matches; observedAt is not used as build time."
},
latest,
@@ -425,6 +418,7 @@ function liveBuildRuntimeSummary(service, payload = {}) {
payload?.namespace,
runtime?.namespace,
pod?.namespace,
service.runtimeNamespace,
service.deploy?.namespace,
service.artifact?.namespace
);
@@ -438,7 +432,7 @@ function liveBuildRuntimeSummary(service, payload = {}) {
defaultRuntimeContainer(service.serviceId)
);
const unideskRoute = podName
? `G14:k3s:${namespace ?? "hwlab-v02"}:pod:${podName}${container ? `:${container}` : ""}`
? namespace ? `G14:k3s:${namespace}:pod:${podName}${container ? `:${container}` : ""}` : null
: null;
return pruneUndefined({
pod: pruneUndefined({
@@ -616,11 +610,11 @@ function liveBuildRecordFromMetadataSource(service, sourceKind) {
function desiredStateSummary(service) {
return {
namespace: service.deploy?.namespace ?? service.artifact?.namespace ?? "hwlab-dev",
profile: service.deploy?.profile ?? service.artifact?.profile ?? ENVIRONMENT_DEV,
namespace: service.runtimeNamespace ?? service.deploy?.namespace ?? service.artifact?.namespace ?? null,
profile: service.runtimeProfile ?? service.deploy?.profile ?? service.artifact?.profile ?? ENVIRONMENT_DEV,
replicas: Number.isFinite(Number(service.deploy?.replicas)) ? Number(service.deploy.replicas) : null,
healthPath: service.healthPath ?? service.deploy?.healthPath ?? service.artifact?.healthPath ?? "/health/live",
source: service.deploy ? LIVE_BUILD_METADATA_PATHS.deployManifest : "service-defaults"
source: service.deploy ? service.metadataPaths?.deployManifest ?? LIVE_BUILD_DEPLOY_MANIFEST_PATH : "service-defaults"
};
}
@@ -705,7 +699,8 @@ function externalLiveBuildRecord(service, {
function healthUnavailableReason(service, reason) {
if (Number(service.deploy?.replicas) === 0) {
return `${service.serviceId} 当前 hwlab-dev desired replicas=0,无可读取的常驻 /health/live${reason}`;
const namespace = desiredStateSummary(service).namespace;
return `${service.serviceId} 当前 ${namespace} desired replicas=0,无可读取的常驻 /health/live${reason}`;
}
if (service.activation === "manual") {
return `${service.serviceId} 是手动激活服务,当前 live health 不可用:${reason}`;
@@ -723,23 +718,43 @@ function normalizeHealthBuildTime(payload) {
);
}
async function loadLiveBuildMetadata(options = {}) {
if (options.liveBuildMetadata) return normalizeLiveBuildMetadata(options.liveBuildMetadata);
async function loadLiveBuildMetadata(options = {}, env = process.env) {
if (options.liveBuildMetadata) return normalizeLiveBuildMetadata(options.liveBuildMetadata, env);
const root = options.repoRoot ?? process.env.HWLAB_REPO_ROOT ?? repoRoot;
const [deployManifest, artifactCatalog] = await Promise.all([
readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.deployManifest),
readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.artifactCatalog)
]);
return normalizeLiveBuildMetadata({ deployManifest, artifactCatalog });
const deployManifest = await readJsonMetadata(root, LIVE_BUILD_DEPLOY_MANIFEST_PATH);
const paths = metadataPathsFromDeployManifest(deployManifest.payload, liveBuildProfile(env));
const artifactCatalog = await readJsonMetadata(root, paths.artifactCatalog);
return normalizeLiveBuildMetadata({ deployManifest, artifactCatalog }, paths);
}
function normalizeLiveBuildMetadata(metadata) {
function normalizeLiveBuildMetadata(metadata, envOrPaths = process.env) {
const deployManifest = normalizeMetadataFile(metadata.deployManifest);
const paths = isMetadataPaths(envOrPaths)
? envOrPaths
: metadataPathsFromDeployManifest(deployManifest.payload, liveBuildProfile(envOrPaths));
return {
deployManifest: normalizeMetadataFile(metadata.deployManifest),
paths,
deployManifest,
artifactCatalog: normalizeMetadataFile(metadata.artifactCatalog)
};
}
function isMetadataPaths(value) {
return Boolean(value?.deployManifest && value?.artifactCatalog);
}
function metadataPathsFromDeployManifest(deployManifest, profile) {
const lane = deployLaneForProfile(deployManifest, profile);
const artifactCatalog = firstNonEmpty(
lane?.artifactCatalog,
deployManifest?.artifactCatalog
);
return {
deployManifest: LIVE_BUILD_DEPLOY_MANIFEST_PATH,
artifactCatalog
};
}
async function readJsonMetadata(root, relativePath) {
try {
const payload = JSON.parse(await readFile(path.join(root, relativePath), "utf8"));
@@ -772,32 +787,40 @@ function normalizeMetadataFile(file) {
}
function liveBuildServiceInventory(metadata, env = process.env) {
const deployServices = Array.isArray(metadata.deployManifest.payload?.services)
? metadata.deployManifest.payload.services
: [];
const profile = liveBuildProfile(env);
const lane = deployLaneForProfile(metadata.deployManifest.payload, profile);
const runtimeNamespace = firstNonEmpty(lane?.namespace, metadata.deployManifest.payload?.namespace);
const deployServices = deployServicesForProfile(metadata.deployManifest.payload, profile, lane, runtimeNamespace);
const deployByServiceId = new Map(deployServices.map((service) => [service.serviceId, service]));
const artifactByServiceId = new Map(
(Array.isArray(metadata.artifactCatalog.payload?.services) ? metadata.artifactCatalog.payload.services : [])
.map((service) => normalizeServiceRuntimeTarget(service, profile, runtimeNamespace))
.map((service) => [service.serviceId, service])
);
const serviceIds = uniqueStrings([
...SERVICE_IDS,
...deployServices.map((service) => service.serviceId),
...artifactByServiceId.keys()
]).filter((serviceId) => String(serviceId).startsWith("hwlab-") && !serviceRemovedForRuntimeProfile(serviceId, env));
]).filter((serviceId) => String(serviceId).startsWith("hwlab-") && !serviceRemovedForRuntimeProfile(serviceId, lane));
return [
...serviceIds.map((serviceId) => {
const deploy = deployByServiceId.get(serviceId) ?? null;
const defaults = LIVE_BUILD_SERVICE_DEFAULTS[serviceId] ?? {};
const healthPath = deploy?.healthPath ?? defaults.healthPath ?? "/health/live";
const defaultUrl = serviceId === CLOUD_API_SERVICE_ID
? defaults.defaultUrl ?? serviceDefaultUrl(serviceId, runtimeNamespace)
: serviceDefaultUrl(serviceId, runtimeNamespace);
return Object.freeze({
serviceId,
serviceName: deploy?.name ?? serviceId,
urlEnv: defaults.urlEnv ?? serviceUrlEnvName(serviceId),
defaultUrl: defaults.defaultUrl ?? serviceDefaultUrl(serviceId),
defaultUrl,
healthPath,
activation: deploy?.replicas === 0 ? "zero-replica" : defaults.activation,
runtimeProfile: profile,
runtimeNamespace,
metadataPaths: metadata.paths,
deploy,
artifact: artifactByServiceId.get(serviceId) ?? null
});
@@ -806,9 +829,46 @@ function liveBuildServiceInventory(metadata, env = process.env) {
];
}
function serviceRemovedForRuntimeProfile(serviceId, env = process.env) {
const profile = String(env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || "").trim().toLowerCase();
return profile === "v02" && V02_REMOVED_SERVICE_IDS.includes(serviceId);
function deployLaneForProfile(deployManifest, profile) {
const lanes = deployManifest?.lanes && typeof deployManifest.lanes === "object" ? deployManifest.lanes : {};
return lanes[profile] ?? null;
}
function deployServicesForProfile(deployManifest, profile, lane, runtimeNamespace) {
const baseServices = Array.isArray(deployManifest?.services) ? deployManifest.services : [];
if (!lane) return baseServices;
const servicesById = new Map(baseServices.map((service) => [service.serviceId, { ...service, env: { ...(service.env ?? {}) } }]));
const laneServices = Array.isArray(lane.services) ? lane.services : [];
for (const service of laneServices) {
const current = servicesById.get(service.serviceId) ?? { serviceId: service.serviceId };
servicesById.set(service.serviceId, {
...current,
...service,
env: {
...(current.env ?? {}),
...(service.env ?? {})
}
});
}
return [...servicesById.values()].map((service) => normalizeServiceRuntimeTarget(service, profile, runtimeNamespace));
}
function normalizeServiceRuntimeTarget(service, profile, runtimeNamespace) {
if (!service) return service;
return {
...service,
namespace: runtimeNamespace,
profile
};
}
function serviceRemovedForRuntimeProfile(serviceId, lane) {
const removed = uniqueStrings([
...(Array.isArray(lane?.removedServices) ? lane.removedServices : []),
...(Array.isArray(lane?.disabledServices) ? lane.disabledServices : []),
...(Array.isArray(lane?.omitServices) ? lane.omitServices : [])
]);
return removed.includes(serviceId);
}
function uniqueStrings(values) {
@@ -819,7 +879,8 @@ function serviceUrlEnvName(serviceId) {
return `${serviceId.replace(/^hwlab-/u, "HWLAB_").replace(/-/gu, "_").toUpperCase()}_URL`;
}
function serviceDefaultUrl(serviceId) {
function serviceDefaultUrl(serviceId, namespace = null) {
if (!namespace) return null;
const port = {
"hwlab-cloud-api": 6667,
"hwlab-cloud-web": 8080,
@@ -830,5 +891,5 @@ function serviceDefaultUrl(serviceId) {
"hwlab-edge-proxy": 6667,
"hwlab-agent-skills": 7430
}[serviceId] ?? 8080;
return `http://${serviceId}.hwlab-dev.svc.cluster.local:${port}`;
return `http://${serviceId}.${namespace}.svc.cluster.local:${port}`;
}