diff --git a/scripts/dev-m3-hardware-loop-smoke.mjs b/scripts/dev-m3-hardware-loop-smoke.mjs index 0d11afaa..615079bd 100644 --- a/scripts/dev-m3-hardware-loop-smoke.mjs +++ b/scripts/dev-m3-hardware-loop-smoke.mjs @@ -76,6 +76,39 @@ const serviceTargets = Object.freeze([ } ]); +const kubernetesM3ServiceIdentities = Object.freeze({ + "hwlab-box-simu": Object.freeze({ + serviceId: "hwlab-box-simu" + }), + "hwlab-box-simu-1": Object.freeze({ + serviceId: "hwlab-box-simu", + targetId: "box-simu-1", + instanceId: "res_boxsimu_1" + }), + "hwlab-box-simu-2": Object.freeze({ + serviceId: "hwlab-box-simu", + targetId: "box-simu-2", + instanceId: "res_boxsimu_2" + }), + "hwlab-gateway-simu": Object.freeze({ + serviceId: "hwlab-gateway-simu" + }), + "hwlab-gateway-simu-1": Object.freeze({ + serviceId: "hwlab-gateway-simu", + targetId: "gateway-simu-1", + instanceId: "gwsimu_1" + }), + "hwlab-gateway-simu-2": Object.freeze({ + serviceId: "hwlab-gateway-simu", + targetId: "gateway-simu-2", + instanceId: "gwsimu_2" + }), + "hwlab-patch-panel": Object.freeze({ + serviceId: "hwlab-patch-panel", + targetId: "patch-panel" + }) +}); + function parseArgs(argv) { const flags = new Set(); const values = new Map(); @@ -744,6 +777,22 @@ function serviceDefinitionByServiceId(serviceId) { return serviceTargets.find((target) => target.serviceId === serviceId); } +function kubernetesServiceIdentity(item) { + const serviceName = item?.metadata?.labels?.["kubernetes.io/service-name"]; + const mapped = kubernetesM3ServiceIdentities[serviceName]; + if (!mapped) { + return null; + } + + const labels = item?.metadata?.labels ?? {}; + return { + serviceName, + serviceId: mapped.serviceId, + targetId: mapped.targetId ?? null, + instanceId: labels["hwlab.pikastech.local/instance-id"] ?? mapped.instanceId ?? null + }; +} + function serviceEndpointUrl(address, port) { return `http://${address}:${port}`; } @@ -795,14 +844,14 @@ function collectKubernetesDirectTargetDiscovery() { }; } - const requiredServices = new Set(["hwlab-box-simu", "hwlab-gateway-simu", "hwlab-patch-panel"]); const services = {}; for (const item of document.items ?? []) { - const serviceId = item.metadata?.labels?.["kubernetes.io/service-name"]; - if (!requiredServices.has(serviceId)) { + const identity = kubernetesServiceIdentity(item); + if (!identity) { continue; } + const { serviceName, serviceId, targetId, instanceId } = identity; const port = item.ports?.find((entry) => entry.name === "http")?.port ?? item.ports?.[0]?.port; if (!Number.isInteger(port)) { continue; @@ -817,12 +866,20 @@ function collectKubernetesDirectTargetDiscovery() { url: serviceEndpointUrl(address, port), address, port, - targetRef: endpoint.targetRef?.name ?? "unknown" + targetRef: endpoint.targetRef?.name ?? "unknown", + serviceName, + serviceId, + targetId, + instanceId }); } } - services[serviceId] = { + services[serviceName] = { endpointSlice: item.metadata?.name ?? "unknown", + serviceName, + serviceId, + targetId, + instanceId, port, count: endpoints.length, endpoints @@ -841,7 +898,8 @@ function collectKubernetesDirectTargetDiscovery() { function candidateTargetsFromKubernetes(discovery) { const candidates = []; - for (const [serviceId, service] of Object.entries(discovery.services ?? {})) { + for (const [serviceName, service] of Object.entries(discovery.services ?? {})) { + const serviceId = service.serviceId ?? serviceName; const definition = serviceDefinitionByServiceId(serviceId); if (!definition) { continue; @@ -850,9 +908,11 @@ function candidateTargetsFromKubernetes(discovery) { for (const endpoint of service.endpoints ?? []) { candidates.push({ ...definition, - id: `${definition.id}-candidate-${index}`, + id: service.targetId ?? endpoint.targetId ?? `${definition.id}-candidate-${index}`, baseUrl: endpoint.url, discoverySource: discovery.source, + serviceName, + instanceId: endpoint.instanceId ?? service.instanceId ?? null, targetRef: endpoint.targetRef, address: endpoint.address, port: endpoint.port @@ -883,6 +943,8 @@ function summarizeCandidate(candidate) { return { id: candidate.id, serviceId: candidate.serviceId, + serviceName: candidate.serviceName, + instanceId: candidate.instanceId, baseUrl: candidate.baseUrl, targetRef: candidate.targetRef, ok: candidate.ok, @@ -1719,8 +1781,11 @@ async function main() { export { classifyDirectTargetBlockerScope, classifyLiveOperationError, + candidateTargetsFromKubernetes, createLiveOperationPlan, + collectKubernetesDirectTargetDiscovery, hasRequiredM3Connection, + kubernetesServiceIdentity, m3FailureClassifications, parseArgs, requireSafetyGates diff --git a/scripts/dev-m3-hardware-loop-smoke.test.mjs b/scripts/dev-m3-hardware-loop-smoke.test.mjs index 4cdc27d0..27e07c22 100644 --- a/scripts/dev-m3-hardware-loop-smoke.test.mjs +++ b/scripts/dev-m3-hardware-loop-smoke.test.mjs @@ -6,9 +6,11 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { + candidateTargetsFromKubernetes, classifyDirectTargetBlockerScope, createLiveOperationPlan, hasRequiredM3Connection, + kubernetesServiceIdentity, parseArgs, requireSafetyGates } from "./dev-m3-hardware-loop-smoke.mjs"; @@ -153,3 +155,89 @@ test("patch-panel route preflight rejects missing res_boxsimu_1 DO1 to res_boxsi assert.equal(Boolean(requiredRoute.active), true); assert.equal(Boolean(requiredRoute.configured), true); }); + +test("Kubernetes discovery preserves current indexed M3 service identities", () => { + const endpointSlice = (serviceName, address, port, targetRef, labels = {}) => ({ + metadata: { + name: `${serviceName}-slice`, + labels: { + "kubernetes.io/service-name": serviceName, + ...labels + } + }, + ports: [{ name: "http", port }], + endpoints: [ + { + addresses: [address], + conditions: { ready: true }, + targetRef: { name: targetRef } + } + ] + }); + + const discovery = { + status: "observed", + source: "kubernetes-endpointslices", + services: Object.fromEntries( + [ + endpointSlice("hwlab-box-simu-1", "10.0.0.11", 7201, "hwlab-box-simu-0", { + "hwlab.pikastech.local/instance-id": "res_boxsimu_1" + }), + endpointSlice("hwlab-box-simu-2", "10.0.0.12", 7201, "hwlab-box-simu-1", { + "hwlab.pikastech.local/instance-id": "res_boxsimu_2" + }), + endpointSlice("hwlab-gateway-simu-1", "10.0.0.21", 7101, "hwlab-gateway-simu-0", { + "hwlab.pikastech.local/instance-id": "gwsimu_1" + }), + endpointSlice("hwlab-gateway-simu-2", "10.0.0.22", 7101, "hwlab-gateway-simu-1", { + "hwlab.pikastech.local/instance-id": "gwsimu_2" + }), + endpointSlice("hwlab-patch-panel", "10.0.0.31", 7301, "hwlab-patch-panel-abc") + ].map((item) => { + const identity = kubernetesServiceIdentity(item); + const port = item.ports[0].port; + const endpoints = item.endpoints.flatMap((endpoint) => + endpoint.addresses.map((address) => ({ + url: `http://${address}:${port}`, + address, + port, + targetRef: endpoint.targetRef.name, + serviceName: identity.serviceName, + serviceId: identity.serviceId, + targetId: identity.targetId, + instanceId: identity.instanceId + })) + ); + return [ + identity.serviceName, + { + endpointSlice: item.metadata.name, + ...identity, + port, + count: endpoints.length, + endpoints + } + ]; + }) + ) + }; + + const candidates = candidateTargetsFromKubernetes(discovery); + + assert.deepEqual( + candidates.map((candidate) => [ + candidate.id, + candidate.serviceName, + candidate.serviceId, + candidate.instanceId, + candidate.baseUrl + ]), + [ + ["box-simu-1", "hwlab-box-simu-1", "hwlab-box-simu", "res_boxsimu_1", "http://10.0.0.11:7201"], + ["box-simu-2", "hwlab-box-simu-2", "hwlab-box-simu", "res_boxsimu_2", "http://10.0.0.12:7201"], + ["gateway-simu-1", "hwlab-gateway-simu-1", "hwlab-gateway-simu", "gwsimu_1", "http://10.0.0.21:7101"], + ["gateway-simu-2", "hwlab-gateway-simu-2", "hwlab-gateway-simu", "gwsimu_2", "http://10.0.0.22:7101"], + ["patch-panel", "hwlab-patch-panel", "hwlab-patch-panel", null, "http://10.0.0.31:7301"] + ] + ); +});