761 lines
28 KiB
TypeScript
761 lines
28 KiB
TypeScript
|
||
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 没有 buildCreatedAt(metadata 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 / buildCreatedAt,deploy/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}`;
|
||
}
|