Files
pikasTech-HWLAB/internal/cloud/server-rest-payloads.ts
T

761 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { buildMetadataFromEnv, imageTagFromReference, normalizeIsoTimestamp } from "../build-metadata.mjs";
import { ENVIRONMENT_DEV, SERVICE_IDS } from "../protocol/index.mjs";
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
import { buildDevicePodRestPayload, buildDevicePodStatus } from "../device-pod/fake-data.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_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-gateway-simu": Object.freeze({ urlEnv: "HWLAB_GATEWAY_SIMU_URL", defaultUrl: "http://hwlab-gateway-simu.hwlab-dev.svc.cluster.local:7101" }),
"hwlab-box-simu": Object.freeze({ urlEnv: "HWLAB_BOX_SIMU_URL", defaultUrl: "http://hwlab-box-simu.hwlab-dev.svc.cluster.local:7201" }),
"hwlab-patch-panel": Object.freeze({ urlEnv: "HWLAB_PATCH_PANEL_URL", defaultUrl: "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301" }),
"hwlab-router": Object.freeze({ urlEnv: "HWLAB_ROUTER_URL", defaultUrl: "http://hwlab-router.hwlab-dev.svc.cluster.local:7401" }),
"hwlab-tunnel-client": Object.freeze({ urlEnv: "HWLAB_TUNNEL_CLIENT_URL", defaultUrl: "http://hwlab-tunnel-client.hwlab-dev.svc.cluster.local:7402" }),
"hwlab-edge-proxy": Object.freeze({ urlEnv: "HWLAB_EDGE_PROXY_URL", defaultUrl: "http://hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667", healthPath: "/health" }),
"hwlab-cli": Object.freeze({ urlEnv: "HWLAB_CLI_URL", defaultUrl: "http://hwlab-cli.hwlab-dev.svc.cluster.local:7501" }),
"hwlab-agent-skills": Object.freeze({ urlEnv: "HWLAB_AGENT_SKILLS_URL", defaultUrl: "http://hwlab-agent-skills.hwlab-dev.svc.cluster.local:7430" })
});
const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([
Object.freeze({
serviceId: "hwlab-frpc",
serviceName: "hwlab-frpc",
kind: "external",
externalImage: true,
defaultImage: "127.0.0.1:5000/hwlab/frpc:v0.68.1",
externalReason: "外部镜像或非 HWLAB 构建产物"
})
]);
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;
}
export async function buildLiveBuildsPayload(options = {}, dependencies = {}) {
const env = options.env ?? process.env;
const metadata = await loadLiveBuildMetadata(options);
const inventory = liveBuildServiceInventory(metadata);
const services = await Promise.all(
inventory.map((service) => observeLiveBuildService(service, { ...options, env, buildHealthPayload: dependencies.buildHealthPayload }))
);
const hwlabServices = services.filter((service) => service.kind === "hwlab");
const latest = [...hwlabServices]
.filter((service) => service.build.createdAt)
.sort((left, right) => Date.parse(right.build.createdAt) - Date.parse(left.build.createdAt))[0] ?? null;
return {
serviceId: CLOUD_API_SERVICE_ID,
environment: (dependencies.runtimeEnvironment ?? defaultRuntimeEnvironment)(env),
status: "ok",
contractVersion: "live-builds-v1",
observedAt: new Date().toISOString(),
source: {
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",
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,
counts: {
total: services.length,
hwlab: hwlabServices.length,
withBuildTime: hwlabServices.filter((service) => Boolean(service.build.createdAt)).length,
unavailable: hwlabServices.filter((service) => service.build.createdAt === null).length,
external: services.filter((service) => service.kind === "external").length
},
services
};
}
export async function buildDevicePodCloudApiPayload(url, options = {}) {
const upstreamPayload = await fetchDevicePodUpstreamPayload(url, options);
if (upstreamPayload) {
return {
...upstreamPayload,
cloudApiProxy: {
route: url.pathname,
upstream: configuredDevicePodUrl(options.env ?? process.env),
status: "proxied"
}
};
}
const fallback = buildDevicePodRestPayload(url.pathname, url.searchParams, {
sourceKind: "FAKE-CLOUD-API"
}) ?? buildDevicePodStatus(undefined, { sourceKind: "FAKE-CLOUD-API" });
return {
...fallback,
cloudApiProxy: {
route: url.pathname,
upstream: configuredDevicePodUrl(options.env ?? process.env),
status: "fake-fallback",
reason: "hwlab-device-pod upstream is unavailable or not configured"
}
};
}
async function fetchDevicePodUpstreamPayload(url, options = {}) {
const baseUrl = configuredDevicePodUrl(options.env ?? process.env);
if (!baseUrl) return null;
const target = `${baseUrl}${url.pathname}${url.search}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 900);
try {
const response = await (options.fetchImpl ?? fetch)(target, {
headers: { Accept: "application/json" },
signal: controller.signal
});
if (!response.ok) return null;
return await response.json();
} catch {
return null;
} finally {
clearTimeout(timeout);
}
}
function configuredDevicePodUrl(env = process.env) {
const value = String(env.HWLAB_DEVICE_POD_URL ?? "").trim();
return value ? value.replace(/\/+$/u, "") : "";
}
async function observeLiveBuildService(service, options) {
if (service.externalImage) {
return externalLiveBuildRecord(service, {
healthUrl: null,
httpStatus: null,
imageReference: service.defaultImage ?? "unknown",
reason: "外部镜像或非 HWLAB 构建产物"
});
}
const metadataRecords = liveBuildRecordsFromMetadata(service);
const metadataRecord = metadataRecords[0] ?? null;
if (service.serviceId === CLOUD_API_SERVICE_ID) {
const health = await options.buildHealthPayload(options);
return liveBuildRecordFromHealth(service, {
ok: true,
status: 200,
url: "/health/live",
payload: health
}, metadataRecords);
}
const baseUrl = String(options.env?.[service.urlEnv] || service.defaultUrl || "").replace(/\/+$/u, "");
if (!baseUrl) {
return metadataRecord
? metadataRecordWithHealthGap(metadataRecord, {
service,
healthUrl: null,
httpStatus: null,
reason: `${service.serviceId} 没有配置 live health URL`
})
: unavailableLiveBuildRecord(service, {
reasonCode: "service_url_unavailable",
reason: `${service.serviceId} 没有配置 live health URL`
});
}
const healthPath = service.healthPath ?? "/health/live";
return liveBuildRecordFromHealth(
service,
await fetchServiceHealth(`${baseUrl}${healthPath}`, options.fetchImpl ?? fetch),
metadataRecords
);
}
async function fetchServiceHealth(url, fetchImpl) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), LIVE_BUILD_TIMEOUT_MS);
try {
const response = await fetchImpl(url, {
headers: { Accept: "application/json" },
signal: controller.signal
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
return {
ok: false,
status: response.status,
url,
reasonCode: "invalid_json",
reason: `${url} returned non-JSON health payload`
};
}
return {
ok: response.ok,
status: response.status,
url,
payload,
reasonCode: response.ok ? null : "http_error",
reason: response.ok ? null : `${url} returned HTTP ${response.status}`
};
} catch (error) {
const timedOut = error?.name === "AbortError";
return {
ok: false,
status: null,
url,
reasonCode: timedOut ? "timeout" : "request_failed",
reason: timedOut ? `${url} timed out after ${LIVE_BUILD_TIMEOUT_MS}ms` : error.message
};
} finally {
clearTimeout(timeout);
}
}
function liveBuildRecordFromHealth(service, result, metadataRecords = []) {
const candidates = Array.isArray(metadataRecords) ? metadataRecords : [metadataRecords].filter(Boolean);
const metadataRecord = candidates[0] ?? null;
if (!result.ok) {
const reason = healthUnavailableReason(service, result.reason ?? `${service.serviceId} health unavailable`);
return metadataRecord
? metadataRecordWithHealthGap(metadataRecord, {
service,
healthUrl: result.url,
httpStatus: result.status,
reason
})
: unavailableLiveBuildRecord(service, {
healthUrl: result.url,
httpStatus: result.status,
reasonCode: result.reasonCode ?? "health_unavailable",
reason
});
}
const payload = result.payload ?? {};
const payloadServiceId = payload.serviceId ?? service.serviceId;
if (!String(payloadServiceId).startsWith("hwlab-")) {
return externalLiveBuildRecord(service, {
healthUrl: result.url,
httpStatus: result.status,
imageReference: payload.image?.reference ?? payload.image ?? "unknown",
imageTag: payload.image?.tag ?? null,
imageDigest: payload.image?.digest ?? "unknown",
commitId: payload.commit?.id ?? payload.revision ?? payload.version ?? "unknown",
commitSource: payload.commit?.source ?? "external-health",
revision: payload.revision ?? payload.commit?.id ?? payload.version ?? "unknown",
reason: "外部镜像或非 HWLAB 构建产物"
});
}
const imageReference = payload.image?.reference ?? payload.image ?? "unknown";
const commitId = payload.commit?.id ?? payload.revision ?? "unknown";
const liveIdentity = liveBuildIdentityFromHealth({ payload, imageReference, commitId });
const createdAt = normalizeHealthBuildTime(payload);
if (createdAt) {
return liveBuildHealthPayloadRecord(service, result, {
payload,
imageReference,
commitId,
createdAt,
metadataSource: payload.build?.metadataSource ?? "health:build.createdAt",
unavailableReason: null
});
}
const matched = selectMatchingMetadataRecord(liveIdentity, candidates);
if (matched.record?.build?.createdAt) {
return mergeLiveHealthOntoMetadataRecord(matched.record, {
match: matched.match,
liveIdentity,
service,
result,
payload,
imageReference,
commitId
});
}
const mismatch = matched.match ?? liveBuildMetadataMatch(liveIdentity, metadataRecord);
const unavailableReason = matched.record
? "构建时间不可用:当前 live 镜像未提供 buildCreatedAt"
: metadataRecord
? `构建时间不可用:live tag 与 ${controlledMetadataLabel(metadataRecord)} 不匹配`
: "构建时间不可用:当前 live 镜像未提供 buildCreatedAt";
return liveBuildHealthPayloadRecord(service, result, {
payload,
imageReference,
commitId,
createdAt: null,
metadataSource: matched.record ? "live-health:matched-controlled-metadata-without-build-time" : metadataRecord ? "live-health:controlled-metadata-mismatch" : "live-health:build-created-at-missing",
unavailableReason,
metadataMismatch: mismatch,
unavailableDetail: matched.record
? `live tag ${liveIdentity.imageTag} / revision ${liveIdentity.revision} 已匹配 ${controlledMetadataLabel(matched.record)},但该 metadata 没有 buildCreatedAtmetadata tag ${matched.record.image.tag}metadata revision ${matched.record.revision},来源 ${matched.record.build.metadataSource}`
: metadataRecord
? `未使用可能过期的构建时间:live tag ${liveIdentity.imageTag}live revision ${liveIdentity.revision}metadata tag ${metadataRecord.image.tag}metadata revision ${metadataRecord.revision},来源 ${metadataRecord.build.metadataSource}`
: "health payload 缺少 build.createdAt / image.createdAt / buildCreatedAtdeploy/artifact catalog 也没有可匹配的 buildCreatedAt"
});
}
function liveBuildHealthPayloadRecord(service, result, {
payload,
imageReference,
commitId,
createdAt,
metadataSource,
unavailableReason,
metadataMismatch = null,
unavailableDetail = null
}) {
return {
serviceId: service.serviceId,
name: service.serviceName,
kind: "hwlab",
status: createdAt ? "ok" : "build_time_unavailable",
healthUrl: result.url,
httpStatus: result.status,
desiredState: desiredStateSummary(service),
build: {
createdAt,
source: payload.build?.source ?? payload.commit?.source ?? "health-payload",
metadataSource,
unavailableReason,
unavailableDetail,
metadataMismatch
},
image: {
reference: imageReference,
tag: payload.image?.tag ?? imageTagFromReference(imageReference) ?? "unknown",
digest: payload.image?.digest ?? "unknown"
},
commit: {
id: commitId,
source: payload.commit?.source ?? "health-payload"
},
revision: payload.revision ?? commitId
};
}
function metadataRecordWithHealthGap(metadataRecord, { service, healthUrl, httpStatus, reason }) {
const metadataTag = metadataRecord.image?.tag ?? "unknown";
const metadataRevision = metadataRecord.revision ?? metadataRecord.commit?.id ?? "unknown";
return {
...metadataRecord,
healthUrl,
httpStatus,
status: "build_time_unavailable",
build: {
...metadataRecord.build,
createdAt: null,
metadataSource: "live-health-unavailable",
unavailableReason: `构建时间不可用:当前 live health 不可用,无法确认 ${service.serviceId} live tag/revision 是否匹配 deploy/artifact catalog metadata,未使用可能过期的构建时间(metadata tag ${metadataTag}metadata revision ${metadataRevision});${reason}`
}
};
}
function mergeLiveHealthOntoMetadataRecord(metadataRecord, { match, liveIdentity, service, result, payload, imageReference, commitId }) {
return {
...metadataRecord,
serviceId: service.serviceId,
name: service.serviceName,
kind: "hwlab",
status: "ok",
healthUrl: result.url,
httpStatus: result.status,
build: {
...metadataRecord.build,
metadataSource: metadataRecord.build.metadataSource,
unavailableReason: null,
liveMetadataMatch: match,
liveHealthMissingReason: `health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,已使用与 live tag/revision 匹配的 ${controlledMetadataLabel(metadataRecord)}live tag ${liveIdentity.imageTag}`
},
image: {
reference: imageReference !== "unknown" ? imageReference : metadataRecord.image.reference,
tag: payload.image?.tag ?? imageTagFromReference(imageReference) ?? metadataRecord.image.tag,
digest: payload.image?.digest ?? metadataRecord.image.digest
},
commit: {
id: commitId !== "unknown" ? commitId : metadataRecord.commit.id,
source: payload.commit?.source ?? metadataRecord.commit.source
},
revision: payload.revision ?? (commitId !== "unknown" ? commitId : metadataRecord.revision)
};
}
function liveBuildIdentityFromHealth({ payload, imageReference, commitId }) {
return {
imageReference,
imageTag: payload.image?.tag ?? imageTagFromReference(imageReference) ?? "unknown",
commitId,
revision: payload.revision ?? commitId,
digest: payload.image?.digest ?? "unknown"
};
}
function selectMatchingMetadataRecord(liveIdentity, metadataRecords) {
let firstMatched = null;
for (const record of metadataRecords) {
const match = liveBuildMetadataMatch(liveIdentity, record);
if (!match.matched) continue;
if (record.build?.createdAt) return { record, match };
firstMatched ??= { record, match };
}
if (firstMatched) return firstMatched;
return {
record: null,
match: metadataRecords.length ? liveBuildMetadataMatch(liveIdentity, metadataRecords[0]) : null
};
}
function liveBuildMetadataMatch(liveIdentity, metadataRecord) {
if (!metadataRecord) {
return {
matched: false,
matchedValues: [],
live: liveBuildIdentitySummary(liveIdentity),
metadata: null
};
}
const liveValues = uniqueStrings([
liveIdentity.imageTag,
imageTagFromReference(liveIdentity.imageReference),
liveIdentity.revision,
liveIdentity.commitId,
liveIdentity.digest
]).filter((value) => value !== "unknown");
const metadataValues = uniqueStrings([
metadataRecord.image?.tag,
imageTagFromReference(metadataRecord.image?.reference),
metadataRecord.revision,
metadataRecord.commit?.id,
metadataRecord.image?.digest
]).filter((value) => value !== "unknown" && value !== "not_published");
const matchedValues = liveValues.filter((liveValue) =>
metadataValues.some((metadataValue) => liveBuildIdentityEquivalent(liveValue, metadataValue))
);
return {
matched: matchedValues.length > 0,
matchedValues,
live: liveBuildIdentitySummary(liveIdentity),
metadata: {
imageTag: metadataRecord.image?.tag ?? "unknown",
revision: metadataRecord.revision ?? "unknown",
commitId: metadataRecord.commit?.id ?? "unknown",
digest: metadataRecord.image?.digest ?? "unknown"
}
};
}
function liveBuildIdentitySummary(identity) {
return {
imageTag: identity?.imageTag ?? "unknown",
revision: identity?.revision ?? "unknown",
commitId: identity?.commitId ?? "unknown",
digest: identity?.digest ?? "unknown"
};
}
function liveBuildIdentityEquivalent(left, right) {
const a = String(left ?? "").trim();
const b = String(right ?? "").trim();
if (!a || !b || a === "unknown" || b === "unknown") return false;
if (a === b) return true;
if (a.startsWith("sha256:") || b.startsWith("sha256:")) return false;
return a.length >= 7 && b.length >= 7 && (a.startsWith(b) || b.startsWith(a));
}
function liveBuildRecordsFromMetadata(service) {
return [
liveBuildRecordFromMetadataSource(service, "artifact"),
liveBuildRecordFromMetadataSource(service, "deploy")
].filter(Boolean);
}
function liveBuildRecordFromMetadataSource(service, sourceKind) {
const source = sourceKind === "artifact"
? service.artifact
: service.deploy;
if (!source) return null;
const createdAt = normalizeIsoTimestamp(
sourceKind === "deploy" ? source.env?.HWLAB_BUILD_CREATED_AT : source.buildCreatedAt
);
const imageReference = sourceKind === "deploy"
? source.env?.HWLAB_IMAGE ?? source.image ?? "unknown"
: source.image ?? service.deploy?.image ?? "unknown";
const commitId = sourceKind === "artifact"
? source.commitId ?? imageTagFromReference(imageReference) ?? "unknown"
: source.env?.HWLAB_COMMIT_ID ?? imageTagFromReference(imageReference) ?? "unknown";
const digest = sourceKind === "deploy"
? source.env?.HWLAB_IMAGE_DIGEST ?? "unknown"
: source.digest ?? "unknown";
const buildSource = sourceKind === "deploy"
? source.env?.HWLAB_BUILD_SOURCE ?? "deploy-desired-state-env"
: source.buildSource ?? "artifact-catalog-metadata";
const imageTag = sourceKind === "deploy"
? source.env?.HWLAB_IMAGE_TAG ?? imageTagFromReference(imageReference) ?? "unknown"
: source.imageTag ?? imageTagFromReference(imageReference) ?? "unknown";
const revision = sourceKind === "deploy"
? source.env?.HWLAB_REVISION ?? commitId
: commitId;
return {
serviceId: service.serviceId,
name: service.serviceName,
kind: "hwlab",
status: createdAt ? "ok" : "build_time_unavailable",
healthUrl: null,
httpStatus: null,
desiredState: desiredStateSummary(service),
build: {
createdAt,
source: buildSource,
metadataSource: artifactMetadataSource(sourceKind),
unavailableReason: createdAt
? null
: `构建时间不可用:${controlledMetadataLabel({ build: { metadataSource: artifactMetadataSource(sourceKind) } })} 缺少 buildCreatedAt`
},
image: {
reference: imageReference,
tag: imageTag,
digest
},
commit: {
id: commitId,
source: sourceKind === "artifact" ? "artifact-catalog" : "deploy-desired-state"
},
revision
};
}
function desiredStateSummary(service) {
return {
namespace: service.deploy?.namespace ?? service.artifact?.namespace ?? "hwlab-dev",
profile: 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"
};
}
function artifactMetadataSource(sourceKind) {
if (sourceKind === "artifact") return "artifact-catalog:buildCreatedAt";
return "deploy-env:HWLAB_BUILD_CREATED_AT";
}
function controlledMetadataLabel(record) {
const source = String(record?.build?.metadataSource ?? "");
if (source.includes("artifact-catalog")) return "catalog metadata";
if (source.includes("deploy-env")) return "deploy metadata";
return "controlled metadata";
}
function unavailableLiveBuildRecord(service, { healthUrl = null, httpStatus = null, reasonCode, reason }) {
return {
serviceId: service.serviceId,
name: service.serviceName,
kind: "hwlab",
status: "unavailable",
healthUrl,
httpStatus,
desiredState: desiredStateSummary(service),
build: {
createdAt: null,
metadataSource: "unavailable",
unavailableReason: `构建时间不可用:${reason}`
},
image: {
reference: "unknown",
tag: "unknown",
digest: "unknown"
},
commit: {
id: "unknown",
source: "unavailable"
},
revision: "unknown",
error: {
code: reasonCode,
message: reason
}
};
}
function externalLiveBuildRecord(service, {
healthUrl = null,
httpStatus = null,
imageReference = "unknown",
imageTag = null,
imageDigest = "unknown",
commitId = "unknown",
commitSource = "external-image",
revision = commitId,
reason
}) {
return {
serviceId: service.serviceId,
name: service.serviceName,
kind: "external",
status: "external",
healthUrl,
httpStatus,
build: {
createdAt: null,
metadataSource: "external-image",
unavailableReason: reason
},
image: {
reference: imageReference,
tag: imageTag ?? imageTagFromReference(imageReference) ?? "unknown",
digest: imageDigest
},
commit: {
id: commitId,
source: commitSource
},
revision
};
}
function healthUnavailableReason(service, reason) {
if (Number(service.deploy?.replicas) === 0) {
return `${service.serviceId} 当前 hwlab-dev desired replicas=0,无可读取的常驻 /health/live${reason}`;
}
if (service.activation === "manual") {
return `${service.serviceId} 是手动激活服务,当前 live health 不可用:${reason}`;
}
return reason;
}
function normalizeHealthBuildTime(payload) {
return normalizeIsoTimestamp(
payload?.build?.createdAt ??
payload?.image?.createdAt ??
payload?.buildCreatedAt ??
payload?.metadata?.buildCreatedAt ??
null
);
}
async function loadLiveBuildMetadata(options = {}) {
if (options.liveBuildMetadata) return normalizeLiveBuildMetadata(options.liveBuildMetadata);
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 });
}
function normalizeLiveBuildMetadata(metadata) {
return {
deployManifest: normalizeMetadataFile(metadata.deployManifest),
artifactCatalog: normalizeMetadataFile(metadata.artifactCatalog)
};
}
async function readJsonMetadata(root, relativePath) {
try {
const payload = JSON.parse(await readFile(path.join(root, relativePath), "utf8"));
return { status: "ok", path: relativePath, payload };
} catch (error) {
return {
status: "unavailable",
path: relativePath,
error: error.message,
payload: null
};
}
}
function normalizeMetadataFile(file) {
if (file?.status === "ok" || file?.status === "unavailable") {
return {
status: file.status,
path: file.path ?? null,
error: file.error ?? null,
payload: file.payload ?? null
};
}
return {
status: file ? "ok" : "unavailable",
path: null,
error: null,
payload: file ?? null
};
}
function liveBuildServiceInventory(metadata) {
const deployServices = Array.isArray(metadata.deployManifest.payload?.services)
? metadata.deployManifest.payload.services
: [];
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) => [service.serviceId, service])
);
const serviceIds = uniqueStrings([
...SERVICE_IDS,
...deployServices.map((service) => service.serviceId),
...artifactByServiceId.keys()
]).filter((serviceId) => String(serviceId).startsWith("hwlab-"));
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";
return Object.freeze({
serviceId,
serviceName: deploy?.name ?? serviceId,
urlEnv: defaults.urlEnv ?? serviceUrlEnvName(serviceId),
defaultUrl: defaults.defaultUrl ?? serviceDefaultUrl(serviceId),
healthPath,
activation: deploy?.replicas === 0 ? "zero-replica" : defaults.activation,
deploy,
artifact: artifactByServiceId.get(serviceId) ?? null
});
}),
...LIVE_BUILD_EXTERNAL_COMPONENTS
];
}
function uniqueStrings(values) {
return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))];
}
function serviceUrlEnvName(serviceId) {
return `${serviceId.replace(/^hwlab-/u, "HWLAB_").replace(/-/gu, "_").toUpperCase()}_URL`;
}
function serviceDefaultUrl(serviceId) {
const port = {
"hwlab-cloud-api": 6667,
"hwlab-cloud-web": 8080,
"hwlab-agent-mgr": 7410,
"hwlab-agent-worker": 7411,
"hwlab-gateway": 7001,
"hwlab-gateway-simu": 7101,
"hwlab-box-simu": 7201,
"hwlab-patch-panel": 7301,
"hwlab-router": 7401,
"hwlab-tunnel-client": 7402,
"hwlab-edge-proxy": 6667,
"hwlab-cli": 7501,
"hwlab-agent-skills": 7430
}[serviceId] ?? 8080;
return `http://${serviceId}.hwlab-dev.svc.cluster.local:${port}`;
}