feat(device-plane): enable B2 discovery ingress
This commit is contained in:
@@ -58,10 +58,7 @@ await assertSourceBoundary();
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
const source = (
|
||||
patchId === "device-plane-foundation-20260725-001"
|
||||
&& sourceRelative === "docker-compose.device-plane.yml"
|
||||
)
|
||||
const source = sourceRelative === "docker-compose.device-plane.yml"
|
||||
? failedFoundationCompose
|
||||
: resolve(sourceRoot, sourceRelative);
|
||||
await copySafe(
|
||||
@@ -119,9 +116,7 @@ try {
|
||||
}
|
||||
|
||||
async function assertSourceBoundary() {
|
||||
const composeSource = patchId === "device-plane-foundation-20260725-001"
|
||||
? failedFoundationCompose
|
||||
: resolve(sourceRoot, "docker-compose.device-plane.yml");
|
||||
const composeSource = failedFoundationCompose;
|
||||
const compose = await readFile(
|
||||
composeSource,
|
||||
"utf8",
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
cp,
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = resolve(platformRoot, "device-plane");
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
);
|
||||
const [
|
||||
patchId = "device-plane-b2-discovery-ingress-20260726-001",
|
||||
...extra
|
||||
] = process.argv.slice(2);
|
||||
|
||||
if (extra.length || !/^[A-Za-z0-9._-]{1,96}$/.test(patchId)) {
|
||||
throw new Error(
|
||||
"usage: build-device-plane-b2-discovery-ingress-artifact.mjs "
|
||||
+ "[patch-id]",
|
||||
);
|
||||
}
|
||||
|
||||
const files = [
|
||||
".dockerignore",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
"services/device-gateway",
|
||||
"deployment/device-plane-b2-discovery-ingress-v1.json",
|
||||
];
|
||||
const ignoredBasenames = new Set([".DS_Store", ".git", "node_modules"]);
|
||||
const ignoredDirectoryNames = new Set(["test"]);
|
||||
const stage = await mkdtemp(
|
||||
join(tmpdir(), "nodedc-device-plane-b2-discovery-ingress-"),
|
||||
);
|
||||
const payload = join(stage, "payload");
|
||||
const target = join(
|
||||
artifactDir,
|
||||
`nodedc-device-plane-${patchId}.tgz`,
|
||||
);
|
||||
|
||||
await assertBoundary();
|
||||
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
await copySafe(
|
||||
resolve(sourceRoot, sourceRelative),
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
await writeFile(
|
||||
join(stage, "manifest.env"),
|
||||
`id=${patchId}\ncomponent=device-plane\ntype=app-overlay\n`,
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(stage, "files.txt"),
|
||||
`${files.join("\n")}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(artifactDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync(
|
||||
"python3",
|
||||
["-c", canonicalTarScript(), target, stage],
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 128 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (tar.status !== 0) {
|
||||
throw new Error(`tar_failed:${tar.stderr || tar.stdout}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(await readFile(target))
|
||||
.digest("hex");
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
patchId,
|
||||
artifact: target,
|
||||
sha256: digest,
|
||||
component: "device-plane",
|
||||
transition: "verified-b2-discovery-only",
|
||||
entries: files,
|
||||
services: ["device-control-core", "device-gateway"],
|
||||
preservedRuntime: [
|
||||
"device-postgres",
|
||||
"nodedc-device-plane-postgres-data",
|
||||
"Gelios",
|
||||
],
|
||||
ingress: {
|
||||
transport: "tcp",
|
||||
published: "0.0.0.0:9921:9921",
|
||||
mode: "discovery-only",
|
||||
framing: "verified-read-only",
|
||||
lifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
},
|
||||
rollback: "restore-source-and-predecessor-stateless-runtime",
|
||||
excluded: [
|
||||
".env*",
|
||||
"node_modules",
|
||||
"**/test",
|
||||
"docs",
|
||||
"runtime",
|
||||
"secrets",
|
||||
],
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await rm(stage, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function assertBoundary() {
|
||||
const compose = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-plane.yml"),
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_LISTEN_ENABLED: "true"',
|
||||
'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "true"',
|
||||
"DEVICE_GATEWAY_CORE_URL: http://device-control-core:18120",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN_FILE: /run/nodedc-secrets/gateway-core-token",
|
||||
'"127.0.0.1:18120:18120"',
|
||||
'"127.0.0.1:18121:18121"',
|
||||
'"0.0.0.0:9921:9921"',
|
||||
"name: nodedc-device-plane-private",
|
||||
"internal: true",
|
||||
"name: nodedc-device-plane-control",
|
||||
"internal: false",
|
||||
'com.docker.network.bridge.enable_ip_masquerade: "false"',
|
||||
"name: nodedc-device-plane-postgres-data",
|
||||
"pull_policy: never",
|
||||
]) {
|
||||
if (!compose.includes(fragment)) {
|
||||
throw new Error(
|
||||
`device_plane_b2_ingress_boundary_missing:${fragment}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const forbidden of [
|
||||
"POSTGRES_PASSWORD:",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN:",
|
||||
"DEVICE_IDENTIFIER_PEPPER:",
|
||||
"DEVICE_GATEWAY_COMMAND",
|
||||
"9921:9921/udp",
|
||||
]) {
|
||||
if (compose.includes(forbidden)) {
|
||||
throw new Error(
|
||||
`device_plane_b2_ingress_boundary_violation:${forbidden}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const descriptor = JSON.parse(await readFile(
|
||||
resolve(
|
||||
sourceRoot,
|
||||
"deployment/device-plane-b2-discovery-ingress-v1.json",
|
||||
),
|
||||
"utf8",
|
||||
));
|
||||
const expected = {
|
||||
schemaVersion: "nodedc.device-plane.b2-discovery-ingress.v1",
|
||||
mode: "verified-b2-discovery-only",
|
||||
predecessorPatchId:
|
||||
"device-plane-foundation-network-publication-20260725-003",
|
||||
predecessorArtifactSha256:
|
||||
"6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf",
|
||||
sourceAction: "publish-verified-b2-discovery-ingress-source",
|
||||
runtimeAction: "build-and-recreate-stateless-services",
|
||||
selectedServices: ["device-control-core", "device-gateway"],
|
||||
preservedServices: ["device-postgres"],
|
||||
privateNetwork: "nodedc-device-plane-private",
|
||||
controlNetwork: "nodedc-device-plane-control",
|
||||
publishedPorts: [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
"0.0.0.0:9921:9921/tcp",
|
||||
],
|
||||
protocolProfile: "arusnavi.b2.internal.v1",
|
||||
framingSpecification:
|
||||
"arusnavi.internal.protocol-sheet.gid-12.v1",
|
||||
identityTrust: "claimed-not-ownership-proof",
|
||||
discoveryLifecycle: "quarantine",
|
||||
commandTransport: "disabled",
|
||||
gelios: "untouched",
|
||||
databaseVolume: "nodedc-device-plane-postgres-data",
|
||||
rollback: "restore-source-and-predecessor-stateless-runtime",
|
||||
};
|
||||
if (JSON.stringify(descriptor) !== JSON.stringify(expected)) {
|
||||
throw new Error("device_plane_b2_ingress_descriptor_mismatch");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTarScript() {
|
||||
return [
|
||||
"import gzip,io,pathlib,sys,tarfile",
|
||||
"root=pathlib.Path(sys.argv[2])",
|
||||
"with open(sys.argv[1],'wb') as out:",
|
||||
" with gzip.GzipFile(filename='',mode='wb',fileobj=out,compresslevel=9,mtime=0) as gz:",
|
||||
" with tarfile.open(fileobj=gz,mode='w',format=tarfile.PAX_FORMAT) as tar:",
|
||||
" for top in ('manifest.env','files.txt','payload'):",
|
||||
" p=root/top; paths=[p]+(sorted(p.rglob('*')) if p.is_dir() else [])",
|
||||
" for x in paths:",
|
||||
" info=tar.gettarinfo(str(x),arcname=x.relative_to(root).as_posix())",
|
||||
" info.uid=info.gid=0; info.uname=info.gname='root'; info.mtime=0; info.mode=0o755 if info.isdir() else 0o644",
|
||||
" with (open(x,'rb') if info.isfile() else io.BytesIO()) as src: tar.addfile(info,src if info.isfile() else None)",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function copySafe(source, destination) {
|
||||
const sourceStat = await lstat(source);
|
||||
if (sourceStat.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, source)}`,
|
||||
);
|
||||
}
|
||||
if (sourceStat.isFile()) {
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await cp(source, destination, {
|
||||
force: true,
|
||||
verbatimSymlinks: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!sourceStat.isDirectory()) {
|
||||
throw new Error(`source_type_rejected:${source}`);
|
||||
}
|
||||
|
||||
await mkdir(destination, { recursive: true });
|
||||
for (const entry of await readdir(source, { withFileTypes: true })) {
|
||||
if (
|
||||
ignoredBasenames.has(entry.name)
|
||||
|| entry.name.startsWith(".env")
|
||||
|| (
|
||||
entry.isDirectory()
|
||||
&& ignoredDirectoryNames.has(entry.name)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const childSource = join(source, entry.name);
|
||||
const childDestination = join(destination, entry.name);
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`source_symlink_rejected:${relative(sourceRoot, childSource)}`,
|
||||
);
|
||||
}
|
||||
await copySafe(childSource, childDestination);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ import { fileURLToPath } from "node:url";
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const platformRoot = resolve(scriptDir, "../..");
|
||||
const sourceRoot = resolve(platformRoot, "device-plane");
|
||||
const networkPublicationCompose = resolve(
|
||||
scriptDir,
|
||||
"fixtures/device-plane-foundation-network-publication-v1.yml",
|
||||
);
|
||||
const artifactDir = resolve(
|
||||
process.env.NODEDC_DEPLOY_ARTIFACT_DIR
|
||||
|| resolve(scriptDir, "../deploy-artifacts"),
|
||||
@@ -61,8 +65,11 @@ await assertBoundary();
|
||||
try {
|
||||
await mkdir(payload, { recursive: true });
|
||||
for (const sourceRelative of files) {
|
||||
const source = sourceRelative === "docker-compose.device-plane.yml"
|
||||
? networkPublicationCompose
|
||||
: resolve(sourceRoot, sourceRelative);
|
||||
await copySafe(
|
||||
resolve(sourceRoot, sourceRelative),
|
||||
source,
|
||||
join(payload, sourceRelative),
|
||||
);
|
||||
}
|
||||
@@ -134,7 +141,7 @@ try {
|
||||
|
||||
async function assertBoundary() {
|
||||
const compose = await readFile(
|
||||
resolve(sourceRoot, "docker-compose.device-plane.yml"),
|
||||
networkPublicationCompose,
|
||||
"utf8",
|
||||
);
|
||||
for (const fragment of [
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
services:
|
||||
device-postgres:
|
||||
image: postgres:16-alpine
|
||||
pull_policy: missing
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: device_plane
|
||||
POSTGRES_USER: device_plane
|
||||
POSTGRES_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
volumes:
|
||||
- type: volume
|
||||
source: device-plane-postgres-data
|
||||
target: /var/lib/postgresql/data
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
networks:
|
||||
- device-plane-private
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U device_plane -d device_plane"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-control-core:
|
||||
image: nodedc/device-control-core:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: "18120"
|
||||
DEVICE_DATABASE_HOST: device-postgres
|
||||
DEVICE_DATABASE_PORT: "5432"
|
||||
DEVICE_DATABASE_NAME: device_plane
|
||||
DEVICE_DATABASE_USER: device_plane
|
||||
DEVICE_DATABASE_PASSWORD_FILE: /run/nodedc-secrets/postgres-password
|
||||
DEVICE_DATABASE_POOL_SIZE: "10"
|
||||
DEVICE_DISCOVERY_INGEST_ENABLED: "false"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /volume1/docker/nodedc-device-plane/secrets/postgres-password
|
||||
target: /run/nodedc-secrets/postgres-password
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
ports:
|
||||
- "127.0.0.1:18120:18120"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
depends_on:
|
||||
device-postgres:
|
||||
condition: service_healthy
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18120/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
device-gateway:
|
||||
image: nodedc/device-gateway:local
|
||||
pull_policy: never
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,mode=1777
|
||||
environment:
|
||||
DEVICE_GATEWAY_HEALTH_HOST: 0.0.0.0
|
||||
DEVICE_GATEWAY_HEALTH_PORT: "18121"
|
||||
DEVICE_GATEWAY_LISTEN_ENABLED: "false"
|
||||
DEVICE_GATEWAY_TCP_HOST: 127.0.0.1
|
||||
DEVICE_GATEWAY_TCP_PORT: "9921"
|
||||
DEVICE_GATEWAY_MAX_SESSIONS: "100"
|
||||
DEVICE_GATEWAY_SESSION_TIMEOUT_MS: "10000"
|
||||
ports:
|
||||
- "127.0.0.1:18121:18121"
|
||||
networks:
|
||||
- device-plane-private
|
||||
- device-plane-control
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- fetch('http://127.0.0.1:18121/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
device-plane-private:
|
||||
name: nodedc-device-plane-private
|
||||
internal: true
|
||||
device-plane-control:
|
||||
name: nodedc-device-plane-control
|
||||
driver: bridge
|
||||
internal: false
|
||||
driver_opts:
|
||||
com.docker.network.bridge.enable_ip_masquerade: "false"
|
||||
|
||||
volumes:
|
||||
device-plane-postgres-data:
|
||||
name: nodedc-device-plane-postgres-data
|
||||
@@ -77,6 +77,22 @@ DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_ENTRIES = (
|
||||
*DEVICE_PLANE_FOUNDATION_ENTRIES,
|
||||
DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_REL,
|
||||
)
|
||||
DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL = (
|
||||
"deployment/device-plane-b2-discovery-ingress-v1.json"
|
||||
)
|
||||
DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES = (
|
||||
*DEVICE_PLANE_FOUNDATION_ENTRIES,
|
||||
DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL,
|
||||
)
|
||||
DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_PATCH_ID = (
|
||||
"device-plane-foundation-network-publication-20260725-003"
|
||||
)
|
||||
DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_ARTIFACT_SHA256 = (
|
||||
"6fdd5a12c310786db1753882fc1378184fe378d2cc533633a8c73c951521b7bf"
|
||||
)
|
||||
DEVICE_PLANE_B2_DISCOVERY_INGRESS_COMPOSE_SHA256 = (
|
||||
"50bc7842481ea73b89891a65bf243c2ecb7bbb4c093e95e3c20ddbfaa8ad6726"
|
||||
)
|
||||
DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID = (
|
||||
"device-plane-foundation-20260725-001"
|
||||
)
|
||||
@@ -2865,6 +2881,7 @@ def allowed_payload_path(component, rel):
|
||||
DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL,
|
||||
DEVICE_PLANE_FOUNDATION_RECOVERY_REL,
|
||||
DEVICE_PLANE_FOUNDATION_NETWORK_PUBLICATION_REL,
|
||||
DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL,
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
@@ -7901,6 +7918,11 @@ def load_artifact(artifact, work_dir):
|
||||
validate_device_plane_foundation_network_publication_payload(
|
||||
payload_dir
|
||||
)
|
||||
if is_device_plane_b2_discovery_ingress_slice(
|
||||
manifest["component"],
|
||||
entries,
|
||||
):
|
||||
validate_device_plane_b2_discovery_ingress_payload(payload_dir)
|
||||
if manifest["component"] == "n8n-private-extension":
|
||||
validate_n8n_private_extension_release(payload_dir, entries)
|
||||
if manifest["component"] == "engine":
|
||||
@@ -8238,6 +8260,14 @@ def is_device_plane_foundation_network_publication_slice(component, entries):
|
||||
)
|
||||
|
||||
|
||||
def is_device_plane_b2_discovery_ingress_slice(component, entries):
|
||||
return (
|
||||
component == "device-plane"
|
||||
and entries is not None
|
||||
and tuple(entries) == DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES
|
||||
)
|
||||
|
||||
|
||||
def expected_device_plane_foundation_recovery_descriptor():
|
||||
return {
|
||||
"schemaVersion": "nodedc.device-plane.foundation-recovery.v1",
|
||||
@@ -8329,6 +8359,91 @@ def validate_device_plane_foundation_network_publication_payload(payload_dir):
|
||||
return descriptor
|
||||
|
||||
|
||||
def expected_device_plane_b2_discovery_ingress_descriptor():
|
||||
return {
|
||||
"schemaVersion": "nodedc.device-plane.b2-discovery-ingress.v1",
|
||||
"mode": "verified-b2-discovery-only",
|
||||
"predecessorPatchId": (
|
||||
DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_PATCH_ID
|
||||
),
|
||||
"predecessorArtifactSha256": (
|
||||
DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_ARTIFACT_SHA256
|
||||
),
|
||||
"sourceAction": "publish-verified-b2-discovery-ingress-source",
|
||||
"runtimeAction": "build-and-recreate-stateless-services",
|
||||
"selectedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
],
|
||||
"preservedServices": ["device-postgres"],
|
||||
"privateNetwork": DEVICE_PLANE_PRIVATE_NETWORK,
|
||||
"controlNetwork": DEVICE_PLANE_CONTROL_NETWORK,
|
||||
"publishedPorts": [
|
||||
"127.0.0.1:18120:18120",
|
||||
"127.0.0.1:18121:18121",
|
||||
"0.0.0.0:9921:9921/tcp",
|
||||
],
|
||||
"protocolProfile": "arusnavi.b2.internal.v1",
|
||||
"framingSpecification": (
|
||||
"arusnavi.internal.protocol-sheet.gid-12.v1"
|
||||
),
|
||||
"identityTrust": "claimed-not-ownership-proof",
|
||||
"discoveryLifecycle": "quarantine",
|
||||
"commandTransport": "disabled",
|
||||
"gelios": "untouched",
|
||||
"databaseVolume": DEVICE_PLANE_POSTGRES_VOLUME,
|
||||
"rollback": "restore-source-and-predecessor-stateless-runtime",
|
||||
}
|
||||
|
||||
|
||||
def validate_device_plane_b2_discovery_ingress_payload(payload_dir):
|
||||
descriptor = read_strict_json(
|
||||
payload_dir / DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL,
|
||||
"Device Plane B2 discovery ingress descriptor",
|
||||
max_bytes=16 * 1024,
|
||||
)
|
||||
if descriptor != expected_device_plane_b2_discovery_ingress_descriptor():
|
||||
die("Device Plane B2 discovery ingress descriptor mismatch")
|
||||
compose = payload_dir / "docker-compose.device-plane.yml"
|
||||
if (
|
||||
compose.is_symlink()
|
||||
or not compose.is_file()
|
||||
or sha256_file(compose)
|
||||
!= DEVICE_PLANE_B2_DISCOVERY_INGRESS_COMPOSE_SHA256
|
||||
):
|
||||
die("Device Plane B2 discovery ingress Compose mismatch")
|
||||
return descriptor
|
||||
|
||||
|
||||
def validate_device_plane_b2_discovery_ingress_evidence(payload_dir):
|
||||
descriptor = validate_device_plane_b2_discovery_ingress_payload(
|
||||
payload_dir
|
||||
)
|
||||
if not state_has_patch_id(
|
||||
DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_PATCH_ID
|
||||
):
|
||||
die("Device Plane B2 discovery ingress predecessor patch missing")
|
||||
if not state_has_sha(
|
||||
DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_ARTIFACT_SHA256
|
||||
):
|
||||
die("Device Plane B2 discovery ingress predecessor artifact missing")
|
||||
validate_device_plane_foundation_network_publication_installed_source()
|
||||
runtime = validate_device_plane_foundation_runtime(
|
||||
network_publication=True
|
||||
)
|
||||
assert_loopback_tcp_port_closed(9921)
|
||||
target_descriptor = (
|
||||
component_root("device-plane")
|
||||
/ DEVICE_PLANE_B2_DISCOVERY_INGRESS_REL
|
||||
)
|
||||
if target_descriptor.exists() or target_descriptor.is_symlink():
|
||||
die("Device Plane B2 discovery ingress descriptor already installed")
|
||||
return {
|
||||
"mode": descriptor["mode"],
|
||||
"runtime": runtime,
|
||||
}
|
||||
|
||||
|
||||
def device_plane_service_container_ids(service):
|
||||
if service not in DEVICE_PLANE_RUNTIME_SERVICES:
|
||||
die(f"Device Plane runtime service is not registered: {service}")
|
||||
@@ -9233,6 +9348,262 @@ def validate_device_plane_foundation_runtime(network_publication=False):
|
||||
return accepted
|
||||
|
||||
|
||||
def validate_device_plane_b2_discovery_ingress_runtime(runtime_before):
|
||||
validate_device_plane_runtime_secret_metadata()
|
||||
before_names = device_plane_inventory_service_names(runtime_before)
|
||||
if set(before_names) != set(DEVICE_PLANE_RUNTIME_SERVICES):
|
||||
die("Device Plane B2 ingress predecessor inventory mismatch")
|
||||
before = {
|
||||
item["service"]: item
|
||||
for item in runtime_before["services"]
|
||||
}
|
||||
contracts = {
|
||||
"device-control-core": {
|
||||
"image": DEVICE_PLANE_CONTROL_CORE_IMAGE,
|
||||
"user": "1000:1000",
|
||||
"ports": {
|
||||
"18120/tcp": [{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "18120",
|
||||
}],
|
||||
},
|
||||
"networks": {
|
||||
DEVICE_PLANE_PRIVATE_NETWORK,
|
||||
DEVICE_PLANE_CONTROL_NETWORK,
|
||||
},
|
||||
},
|
||||
"device-gateway": {
|
||||
"image": DEVICE_PLANE_GATEWAY_IMAGE,
|
||||
"user": "1000:1000",
|
||||
"ports": {
|
||||
"18121/tcp": [{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "18121",
|
||||
}],
|
||||
"9921/tcp": [{
|
||||
"HostIp": "0.0.0.0",
|
||||
"HostPort": "9921",
|
||||
}],
|
||||
},
|
||||
"networks": {
|
||||
DEVICE_PLANE_PRIVATE_NETWORK,
|
||||
DEVICE_PLANE_CONTROL_NETWORK,
|
||||
},
|
||||
},
|
||||
"device-postgres": {
|
||||
"image": "postgres:16-alpine",
|
||||
"user": "",
|
||||
"ports": {},
|
||||
"networks": {DEVICE_PLANE_PRIVATE_NETWORK},
|
||||
},
|
||||
}
|
||||
accepted = {}
|
||||
containers = {}
|
||||
for service, contract in contracts.items():
|
||||
container_ids = device_plane_service_container_ids(service)
|
||||
if len(container_ids) != 1:
|
||||
die(f"Device Plane B2 ingress service count mismatch: {service}")
|
||||
container = inspect_device_plane_container(container_ids[0])
|
||||
containers[service] = container
|
||||
state = container.get("State") or {}
|
||||
config = container.get("Config") or {}
|
||||
host_config = container.get("HostConfig") or {}
|
||||
labels = config.get("Labels") or {}
|
||||
networks = (container.get("NetworkSettings") or {}).get(
|
||||
"Networks"
|
||||
) or {}
|
||||
actual_ports = (container.get("NetworkSettings") or {}).get(
|
||||
"Ports"
|
||||
) or {}
|
||||
if (
|
||||
state.get("Status") != "running"
|
||||
or state.get("Running") is not True
|
||||
or state.get("Restarting") is True
|
||||
or state.get("ExitCode") != 0
|
||||
or state.get("Error") not in ("", None)
|
||||
or (state.get("Health") or {}).get("Status") != "healthy"
|
||||
or int(container.get("RestartCount") or 0) != 0
|
||||
or config.get("Image") != contract["image"]
|
||||
or config.get("User", "") != contract["user"]
|
||||
or (host_config.get("PortBindings") or {}) != contract["ports"]
|
||||
or actual_ports != contract["ports"]
|
||||
or set(networks) != contract["networks"]
|
||||
or (host_config.get("RestartPolicy") or {}).get("Name")
|
||||
!= "unless-stopped"
|
||||
or labels.get("com.docker.compose.project")
|
||||
!= "nodedc-device-plane"
|
||||
or labels.get("com.docker.compose.service") != service
|
||||
):
|
||||
die(f"Device Plane B2 ingress runtime mismatch: {service}")
|
||||
accepted[service] = {
|
||||
"containerId": container.get("Id"),
|
||||
"imageId": container.get("Image"),
|
||||
}
|
||||
|
||||
if (
|
||||
accepted["device-postgres"]["containerId"]
|
||||
!= before["device-postgres"]["containerId"]
|
||||
or accepted["device-postgres"]["imageId"]
|
||||
!= before["device-postgres"]["imageId"]
|
||||
):
|
||||
die("Device Plane B2 ingress changed PostgreSQL generation")
|
||||
for service in ("device-control-core", "device-gateway"):
|
||||
if (
|
||||
accepted[service]["containerId"]
|
||||
== before[service]["containerId"]
|
||||
or accepted[service]["imageId"] == before[service]["imageId"]
|
||||
):
|
||||
die(
|
||||
"Device Plane B2 ingress stateless generation not replaced: "
|
||||
f"{service}"
|
||||
)
|
||||
host_config = containers[service].get("HostConfig") or {}
|
||||
if (
|
||||
host_config.get("ReadonlyRootfs") is not True
|
||||
or set(host_config.get("CapDrop") or ()) != {"ALL"}
|
||||
or "no-new-privileges:true"
|
||||
not in set(host_config.get("SecurityOpt") or ())
|
||||
):
|
||||
die(
|
||||
"Device Plane B2 ingress hardening mismatch: "
|
||||
f"{service}"
|
||||
)
|
||||
|
||||
core_environment = container_environment(
|
||||
containers["device-control-core"],
|
||||
"Device Plane B2 Control Core",
|
||||
)
|
||||
core_required = {
|
||||
"DEVICE_DISCOVERY_INGEST_ENABLED": "true",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN_FILE":
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
"DEVICE_IDENTIFIER_PEPPER_FILE":
|
||||
"/run/nodedc-secrets/identifier-pepper",
|
||||
}
|
||||
if any(
|
||||
core_environment.get(key) != value
|
||||
for key, value in core_required.items()
|
||||
):
|
||||
die("Device Plane B2 Control Core environment mismatch")
|
||||
|
||||
gateway_environment = container_environment(
|
||||
containers["device-gateway"],
|
||||
"Device Plane B2 Gateway",
|
||||
)
|
||||
gateway_required = {
|
||||
"DEVICE_GATEWAY_LISTEN_ENABLED": "true",
|
||||
"DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED": "true",
|
||||
"DEVICE_GATEWAY_TCP_HOST": "0.0.0.0",
|
||||
"DEVICE_GATEWAY_TCP_PORT": "9921",
|
||||
"DEVICE_GATEWAY_CORE_URL": "http://device-control-core:18120",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN_FILE":
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
"DEVICE_GATEWAY_CORE_TIMEOUT_MS": "5000",
|
||||
"DEVICE_GATEWAY_MAX_BUFFERED_BYTES": "65536",
|
||||
"DEVICE_GATEWAY_MAX_SESSIONS": "100",
|
||||
"DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS": "10",
|
||||
"DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS": "30",
|
||||
"DEVICE_GATEWAY_SESSION_TIMEOUT_MS": "10000",
|
||||
}
|
||||
if any(
|
||||
gateway_environment.get(key) != value
|
||||
for key, value in gateway_required.items()
|
||||
):
|
||||
die("Device Plane B2 Gateway environment mismatch")
|
||||
for environment in (core_environment, gateway_environment):
|
||||
for forbidden in (
|
||||
"DEVICE_GATEWAY_CORE_TOKEN",
|
||||
"DEVICE_IDENTIFIER_PEPPER",
|
||||
"DEVICE_GATEWAY_COMMAND_TOKEN",
|
||||
):
|
||||
if forbidden in environment:
|
||||
die("Device Plane B2 plaintext secret boundary mismatch")
|
||||
|
||||
core_mounts = {
|
||||
mount.get("Destination"): mount
|
||||
for mount in containers["device-control-core"].get("Mounts") or []
|
||||
}
|
||||
expected_core_mounts = {
|
||||
"/run/nodedc-secrets/postgres-password":
|
||||
DEVICE_PLANE_POSTGRES_PASSWORD_FILE,
|
||||
"/run/nodedc-secrets/gateway-core-token":
|
||||
DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE,
|
||||
"/run/nodedc-secrets/identifier-pepper":
|
||||
DEVICE_PLANE_IDENTIFIER_PEPPER_FILE,
|
||||
}
|
||||
if set(core_mounts) != set(expected_core_mounts):
|
||||
die("Device Plane B2 Control Core mount set mismatch")
|
||||
for destination, source in expected_core_mounts.items():
|
||||
mount = core_mounts[destination]
|
||||
if (
|
||||
mount.get("Type") != "bind"
|
||||
or mount.get("Source") != str(source)
|
||||
or mount.get("RW") is not False
|
||||
):
|
||||
die("Device Plane B2 Control Core secret mount mismatch")
|
||||
|
||||
gateway_mounts = (
|
||||
containers["device-gateway"].get("Mounts") or []
|
||||
)
|
||||
if (
|
||||
len(gateway_mounts) != 1
|
||||
or gateway_mounts[0].get("Type") != "bind"
|
||||
or gateway_mounts[0].get("Source")
|
||||
!= str(DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE)
|
||||
or gateway_mounts[0].get("Destination")
|
||||
!= "/run/nodedc-secrets/gateway-core-token"
|
||||
or gateway_mounts[0].get("RW") is not False
|
||||
):
|
||||
die("Device Plane B2 Gateway secret mount mismatch")
|
||||
|
||||
postgres_mounts = containers["device-postgres"].get("Mounts") or []
|
||||
postgres_secret = [
|
||||
mount
|
||||
for mount in postgres_mounts
|
||||
if mount.get("Destination")
|
||||
== "/run/nodedc-secrets/postgres-password"
|
||||
]
|
||||
postgres_volume = [
|
||||
mount
|
||||
for mount in postgres_mounts
|
||||
if mount.get("Destination") == "/var/lib/postgresql/data"
|
||||
]
|
||||
if (
|
||||
len(postgres_mounts) != 2
|
||||
or len(postgres_secret) != 1
|
||||
or postgres_secret[0].get("Type") != "bind"
|
||||
or postgres_secret[0].get("Source")
|
||||
!= str(DEVICE_PLANE_POSTGRES_PASSWORD_FILE)
|
||||
or postgres_secret[0].get("RW") is not False
|
||||
or len(postgres_volume) != 1
|
||||
or postgres_volume[0].get("Type") != "volume"
|
||||
or postgres_volume[0].get("Name")
|
||||
!= DEVICE_PLANE_POSTGRES_VOLUME
|
||||
or postgres_volume[0].get("RW") is not True
|
||||
):
|
||||
die("Device Plane B2 PostgreSQL preserved mount mismatch")
|
||||
|
||||
expected_private_ids = {
|
||||
accepted[service]["containerId"]
|
||||
for service in DEVICE_PLANE_RUNTIME_SERVICES
|
||||
}
|
||||
validate_device_plane_network_contract(
|
||||
DEVICE_PLANE_PRIVATE_NETWORK,
|
||||
internal=True,
|
||||
expected_container_ids=expected_private_ids,
|
||||
)
|
||||
validate_device_plane_network_contract(
|
||||
DEVICE_PLANE_CONTROL_NETWORK,
|
||||
internal=False,
|
||||
expected_container_ids={
|
||||
accepted["device-control-core"]["containerId"],
|
||||
accepted["device-gateway"]["containerId"],
|
||||
},
|
||||
)
|
||||
assert_loopback_tcp_port_open(9921)
|
||||
return accepted
|
||||
|
||||
|
||||
def validate_device_plane_runtime_secret_metadata():
|
||||
try:
|
||||
directory_stat = DEVICE_PLANE_SECRET_DIR.lstat()
|
||||
@@ -13237,6 +13608,7 @@ def plan_artifact(artifact):
|
||||
l2_closed_loop_preflight = None
|
||||
device_plane_foundation_recovery_preflight = None
|
||||
device_plane_network_publication_preflight = None
|
||||
device_plane_b2_ingress_preflight = None
|
||||
device_plane_runtime_before = None
|
||||
composite_provider_v4_preflight = None
|
||||
provider_rotating_slot_preflight = None
|
||||
@@ -13420,6 +13792,15 @@ def plan_artifact(artifact):
|
||||
payload_dir
|
||||
)
|
||||
)
|
||||
if is_device_plane_b2_discovery_ingress_slice(
|
||||
manifest["component"],
|
||||
entries,
|
||||
):
|
||||
device_plane_b2_ingress_preflight = (
|
||||
validate_device_plane_b2_discovery_ingress_evidence(
|
||||
payload_dir
|
||||
)
|
||||
)
|
||||
|
||||
component = manifest["component"]
|
||||
root = component_root(component)
|
||||
@@ -14800,7 +15181,11 @@ def plan_artifact(artifact):
|
||||
f"{device_plane_postgres_plan_selection(device_plane_postgres_preflight)}"
|
||||
)
|
||||
print("device_postgres_volume=preserved:nodedc-device-plane-postgres-data")
|
||||
print("device_gateway_public_ingress=disabled")
|
||||
if device_plane_b2_ingress_preflight is not None:
|
||||
print("device_gateway_public_ingress=discovery-only:tcp:9921")
|
||||
print("device_control_core_discovery_ingest=enabled:authenticated")
|
||||
else:
|
||||
print("device_gateway_public_ingress=disabled")
|
||||
print("device_gateway_command_transport=disabled")
|
||||
print("gelios=untouched")
|
||||
if device_plane_foundation_recovery_preflight is not None:
|
||||
@@ -14875,6 +15260,46 @@ def plan_artifact(artifact):
|
||||
"device_plane_rollback="
|
||||
"partial-source+internal-only-stateless-runtime"
|
||||
)
|
||||
if device_plane_b2_ingress_preflight is not None:
|
||||
print(
|
||||
"device_plane_transition="
|
||||
f"{device_plane_b2_ingress_preflight['mode']}"
|
||||
)
|
||||
print(
|
||||
"device_plane_predecessor_patch="
|
||||
f"{DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_PATCH_ID}"
|
||||
)
|
||||
print(
|
||||
"device_plane_predecessor_artifact_sha256="
|
||||
f"{DEVICE_PLANE_B2_DISCOVERY_INGRESS_PREDECESSOR_ARTIFACT_SHA256}"
|
||||
)
|
||||
print(
|
||||
"device_plane_runtime_mutation="
|
||||
"build+recreate:device-control-core,device-gateway"
|
||||
)
|
||||
print(
|
||||
"device_plane_runtime_services="
|
||||
"preserved:device-postgres"
|
||||
)
|
||||
print(
|
||||
"device_plane_actual_ports="
|
||||
"required:127.0.0.1:18120,127.0.0.1:18121,"
|
||||
"0.0.0.0:9921/tcp"
|
||||
)
|
||||
print(
|
||||
"device_gateway_framing="
|
||||
"verified-read-only:"
|
||||
"arusnavi.internal.protocol-sheet.gid-12.v1"
|
||||
)
|
||||
print(
|
||||
"device_gateway_identity="
|
||||
"header2-imei:claimed-not-ownership-proof"
|
||||
)
|
||||
print("device_gateway_discovery_lifecycle=quarantine")
|
||||
print(
|
||||
"device_plane_rollback="
|
||||
"source+predecessor-stateless-runtime"
|
||||
)
|
||||
if device_plane_postgres_preflight is not None:
|
||||
print(
|
||||
"device_postgres_bootstrap="
|
||||
@@ -16076,6 +16501,14 @@ def assert_loopback_tcp_port_closed(port):
|
||||
die(f"unexpected loopback TCP listener is open: {port}")
|
||||
|
||||
|
||||
def assert_loopback_tcp_port_open(port):
|
||||
try:
|
||||
connection = socket.create_connection(("127.0.0.1", port), timeout=3)
|
||||
except OSError as exc:
|
||||
die(f"expected loopback TCP listener is closed: {port}: {exc}")
|
||||
connection.close()
|
||||
|
||||
|
||||
def external_data_plane_healthcheck(require_managed=True):
|
||||
expected_json = {
|
||||
"ok": True,
|
||||
@@ -16170,6 +16603,30 @@ def component_healthchecks(component, entries=None, services=None):
|
||||
if component == "module-foundry":
|
||||
return (module_foundry_healthcheck(),)
|
||||
if component == "device-plane":
|
||||
if is_device_plane_b2_discovery_ingress_slice(component, entries):
|
||||
return (
|
||||
{
|
||||
"url": "http://127.0.0.1:18120/healthz",
|
||||
"expected_json": {
|
||||
"ok": True,
|
||||
"service": "nodedc-device-control-core",
|
||||
"database": "ready",
|
||||
"discoveryIngest": "enabled",
|
||||
"commandTransport": "disabled",
|
||||
},
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:18121/healthz",
|
||||
"expected_json": {
|
||||
"ok": True,
|
||||
"service": "nodedc-device-gateway",
|
||||
"framing": "verified-read-only",
|
||||
"tcpListener": "discovery-only",
|
||||
"publicIngress": "discovery-only",
|
||||
"commandTransport": "disabled",
|
||||
},
|
||||
},
|
||||
)
|
||||
selected_services = (
|
||||
tuple(services)
|
||||
if services is not None
|
||||
@@ -16632,6 +17089,22 @@ def run_healthchecks(component, entries=None, services=None):
|
||||
)
|
||||
assert_loopback_tcp_port_closed(9921)
|
||||
return
|
||||
if is_device_plane_b2_discovery_ingress_slice(component, entries):
|
||||
if tuple(services or ()) != (
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
):
|
||||
die("Device Plane B2 discovery ingress service set mismatch")
|
||||
for service in (
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
):
|
||||
healthcheck_compose_service("device-plane", service)
|
||||
for check in component_healthchecks(component, entries, services):
|
||||
healthcheck_url(check)
|
||||
assert_loopback_tcp_port_open(9921)
|
||||
return
|
||||
if component == "platform" and entries is not None and is_platform_provider_catalog_only(entries):
|
||||
return
|
||||
if is_engine_l2_closed_loop_slice(component, entries):
|
||||
@@ -17449,6 +17922,13 @@ def apply_artifact(artifact):
|
||||
validate_device_plane_foundation_network_publication_evidence(
|
||||
payload_dir
|
||||
)
|
||||
if is_device_plane_b2_discovery_ingress_slice(
|
||||
component,
|
||||
entries,
|
||||
):
|
||||
validate_device_plane_b2_discovery_ingress_evidence(
|
||||
payload_dir
|
||||
)
|
||||
if not root.is_dir():
|
||||
if bootstrap_root:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
@@ -17830,6 +18310,18 @@ def apply_artifact(artifact):
|
||||
):
|
||||
die("Engine L2 closed-loop app generation was not recreated")
|
||||
run_healthchecks(component, entries, services)
|
||||
if is_device_plane_b2_discovery_ingress_slice(
|
||||
component,
|
||||
entries,
|
||||
):
|
||||
if device_plane_runtime_before is None:
|
||||
die(
|
||||
"Device Plane B2 ingress predecessor runtime "
|
||||
"inventory is missing"
|
||||
)
|
||||
validate_device_plane_b2_discovery_ingress_runtime(
|
||||
device_plane_runtime_before
|
||||
)
|
||||
|
||||
applied_path = move_artifact(artifact, APPLIED_DIR)
|
||||
append_jsonl(STATE_FILE, {
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
#!/usr/bin/env python3
|
||||
import hashlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
BUILDER = (
|
||||
SCRIPT_DIR / "build-device-plane-b2-discovery-ingress-artifact.mjs"
|
||||
)
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
COMPOSE = (
|
||||
SCRIPT_DIR.parent.parent
|
||||
/ "device-plane/docker-compose.device-plane.yml"
|
||||
)
|
||||
|
||||
|
||||
def load_runner():
|
||||
loader = importlib.machinery.SourceFileLoader(
|
||||
"nodedc_device_plane_b2_ingress_runner_under_test",
|
||||
str(RUNNER_PATH),
|
||||
)
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
RUNNER = load_runner()
|
||||
|
||||
|
||||
class DevicePlaneB2DiscoveryIngressArtifactTest(unittest.TestCase):
|
||||
def build(self, artifact_dir, patch_id):
|
||||
environment = os.environ.copy()
|
||||
environment["NODEDC_DEPLOY_ARTIFACT_DIR"] = str(artifact_dir)
|
||||
result = subprocess.run(
|
||||
["node", str(BUILDER), patch_id],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=environment,
|
||||
)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
def test_artifact_is_exact_deterministic_and_database_free(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-plane-b2-ingress-",
|
||||
) as directory:
|
||||
artifact_dir = Path(directory)
|
||||
patch_id = "device-plane-b2-discovery-ingress-unit-001"
|
||||
first = self.build(artifact_dir, patch_id)
|
||||
first_bytes = Path(first["artifact"]).read_bytes()
|
||||
second = self.build(artifact_dir, patch_id)
|
||||
second_bytes = Path(second["artifact"]).read_bytes()
|
||||
|
||||
self.assertEqual(first_bytes, second_bytes)
|
||||
self.assertEqual(
|
||||
first["sha256"],
|
||||
hashlib.sha256(first_bytes).hexdigest(),
|
||||
)
|
||||
self.assertEqual(
|
||||
first["transition"],
|
||||
"verified-b2-discovery-only",
|
||||
)
|
||||
self.assertEqual(
|
||||
first["services"],
|
||||
["device-control-core", "device-gateway"],
|
||||
)
|
||||
self.assertNotIn("device-postgres", first["services"])
|
||||
self.assertEqual(
|
||||
first["entries"],
|
||||
list(RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES),
|
||||
)
|
||||
|
||||
with tarfile.open(first["artifact"], "r:gz") as archive:
|
||||
files = (
|
||||
archive.extractfile("files.txt")
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
.splitlines()
|
||||
)
|
||||
compose = archive.extractfile(
|
||||
"payload/docker-compose.device-plane.yml"
|
||||
).read()
|
||||
descriptor = json.loads(
|
||||
archive.extractfile(
|
||||
"payload/deployment/"
|
||||
"device-plane-b2-discovery-ingress-v1.json"
|
||||
)
|
||||
.read()
|
||||
.decode("utf-8")
|
||||
)
|
||||
|
||||
self.assertEqual(files, first["entries"])
|
||||
self.assertEqual(
|
||||
hashlib.sha256(compose).hexdigest(),
|
||||
RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_COMPOSE_SHA256,
|
||||
)
|
||||
self.assertEqual(
|
||||
descriptor,
|
||||
RUNNER.expected_device_plane_b2_discovery_ingress_descriptor(),
|
||||
)
|
||||
|
||||
def test_compose_opens_only_discovery_tcp_and_preserves_database(self):
|
||||
compose = COMPOSE.read_text(encoding="utf-8")
|
||||
postgres, stateless = compose.split(" device-control-core:", 1)
|
||||
self.assertNotIn("9921", postgres)
|
||||
self.assertNotIn("device-plane-control", postgres)
|
||||
self.assertIn(
|
||||
'DEVICE_DISCOVERY_INGEST_ENABLED: "true"',
|
||||
stateless,
|
||||
)
|
||||
self.assertIn(
|
||||
'DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED: "true"',
|
||||
stateless,
|
||||
)
|
||||
self.assertIn('"0.0.0.0:9921:9921"', stateless)
|
||||
self.assertNotIn("DEVICE_GATEWAY_COMMAND", compose)
|
||||
self.assertNotIn("POSTGRES_PASSWORD:", compose)
|
||||
|
||||
def test_runner_builds_only_stateless_services_and_accepts_new_health(self):
|
||||
entries = RUNNER.DEVICE_PLANE_B2_DISCOVERY_INGRESS_ENTRIES
|
||||
self.assertEqual(
|
||||
RUNNER.component_services("device-plane", entries),
|
||||
("device-control-core", "device-gateway"),
|
||||
)
|
||||
builds = RUNNER.component_builds("device-plane", entries)
|
||||
self.assertEqual(len(builds), 2)
|
||||
checks = RUNNER.component_healthchecks(
|
||||
"device-plane",
|
||||
entries,
|
||||
("device-control-core", "device-gateway"),
|
||||
)
|
||||
self.assertEqual(
|
||||
checks[0]["expected_json"]["discoveryIngest"],
|
||||
"enabled",
|
||||
)
|
||||
self.assertEqual(
|
||||
checks[1]["expected_json"]["publicIngress"],
|
||||
"discovery-only",
|
||||
)
|
||||
self.assertEqual(
|
||||
checks[1]["expected_json"]["commandTransport"],
|
||||
"disabled",
|
||||
)
|
||||
|
||||
def test_runtime_acceptance_preserves_postgres_and_replaces_stateless(self):
|
||||
before = {
|
||||
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
|
||||
"composeProject": "nodedc-device-plane",
|
||||
"services": [
|
||||
runtime_item("device-control-core", "1", "a"),
|
||||
runtime_item("device-gateway", "2", "b"),
|
||||
runtime_item("device-postgres", "3", "c"),
|
||||
],
|
||||
}
|
||||
containers = {
|
||||
"core": stateless_container(
|
||||
service="device-control-core",
|
||||
container_id="4" * 64,
|
||||
image_id="sha256:" + "d" * 64,
|
||||
image=RUNNER.DEVICE_PLANE_CONTROL_CORE_IMAGE,
|
||||
ports={
|
||||
"18120/tcp": [{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "18120",
|
||||
}],
|
||||
},
|
||||
environment={
|
||||
"DEVICE_DISCOVERY_INGEST_ENABLED": "true",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN_FILE":
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
"DEVICE_IDENTIFIER_PEPPER_FILE":
|
||||
"/run/nodedc-secrets/identifier-pepper",
|
||||
},
|
||||
mounts=[
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE,
|
||||
"/run/nodedc-secrets/postgres-password",
|
||||
),
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE,
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
),
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_IDENTIFIER_PEPPER_FILE,
|
||||
"/run/nodedc-secrets/identifier-pepper",
|
||||
),
|
||||
],
|
||||
),
|
||||
"gateway": stateless_container(
|
||||
service="device-gateway",
|
||||
container_id="5" * 64,
|
||||
image_id="sha256:" + "e" * 64,
|
||||
image=RUNNER.DEVICE_PLANE_GATEWAY_IMAGE,
|
||||
ports={
|
||||
"18121/tcp": [{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "18121",
|
||||
}],
|
||||
"9921/tcp": [{
|
||||
"HostIp": "0.0.0.0",
|
||||
"HostPort": "9921",
|
||||
}],
|
||||
},
|
||||
environment={
|
||||
"DEVICE_GATEWAY_LISTEN_ENABLED": "true",
|
||||
"DEVICE_GATEWAY_PUBLIC_INGRESS_ENABLED": "true",
|
||||
"DEVICE_GATEWAY_TCP_HOST": "0.0.0.0",
|
||||
"DEVICE_GATEWAY_TCP_PORT": "9921",
|
||||
"DEVICE_GATEWAY_CORE_URL":
|
||||
"http://device-control-core:18120",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN_FILE":
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
"DEVICE_GATEWAY_CORE_TIMEOUT_MS": "5000",
|
||||
"DEVICE_GATEWAY_MAX_BUFFERED_BYTES": "65536",
|
||||
"DEVICE_GATEWAY_MAX_SESSIONS": "100",
|
||||
"DEVICE_GATEWAY_MAX_SESSIONS_PER_ADDRESS": "10",
|
||||
"DEVICE_GATEWAY_MAX_CONNECTIONS_PER_MINUTE_PER_ADDRESS":
|
||||
"30",
|
||||
"DEVICE_GATEWAY_SESSION_TIMEOUT_MS": "10000",
|
||||
},
|
||||
mounts=[
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE,
|
||||
"/run/nodedc-secrets/gateway-core-token",
|
||||
),
|
||||
],
|
||||
),
|
||||
"postgres": postgres_container(),
|
||||
}
|
||||
service_ids = {
|
||||
"device-control-core": ("core",),
|
||||
"device-gateway": ("gateway",),
|
||||
"device-postgres": ("postgres",),
|
||||
}
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_runtime_secret_metadata",
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"device_plane_service_container_ids",
|
||||
side_effect=lambda service: service_ids[service],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"inspect_device_plane_container",
|
||||
side_effect=lambda container_id: containers[container_id],
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_network_contract",
|
||||
) as network,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"assert_loopback_tcp_port_open",
|
||||
) as port_open,
|
||||
):
|
||||
accepted = (
|
||||
RUNNER.validate_device_plane_b2_discovery_ingress_runtime(
|
||||
before
|
||||
)
|
||||
)
|
||||
self.assertEqual(
|
||||
accepted["device-postgres"]["containerId"],
|
||||
"3" * 64,
|
||||
)
|
||||
self.assertEqual(network.call_count, 2)
|
||||
port_open.assert_called_once_with(9921)
|
||||
|
||||
|
||||
def runtime_item(service, container_digit, image_digit):
|
||||
return {
|
||||
"service": service,
|
||||
"containerId": container_digit * 64,
|
||||
"imageId": "sha256:" + image_digit * 64,
|
||||
"status": "running",
|
||||
"running": True,
|
||||
"health": "healthy",
|
||||
"restartCount": 0,
|
||||
}
|
||||
|
||||
|
||||
def secret_mount(source, destination):
|
||||
return {
|
||||
"Type": "bind",
|
||||
"Source": str(source),
|
||||
"Destination": destination,
|
||||
"RW": False,
|
||||
}
|
||||
|
||||
|
||||
def stateless_container(
|
||||
*,
|
||||
service,
|
||||
container_id,
|
||||
image_id,
|
||||
image,
|
||||
ports,
|
||||
environment,
|
||||
mounts,
|
||||
):
|
||||
return {
|
||||
"Id": container_id,
|
||||
"Image": image_id,
|
||||
"RestartCount": 0,
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": True,
|
||||
"Restarting": False,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"Health": {"Status": "healthy"},
|
||||
},
|
||||
"Config": {
|
||||
"Image": image,
|
||||
"User": "1000:1000",
|
||||
"Labels": {
|
||||
"com.docker.compose.project": "nodedc-device-plane",
|
||||
"com.docker.compose.service": service,
|
||||
},
|
||||
"Env": [f"{key}={value}" for key, value in environment.items()],
|
||||
},
|
||||
"HostConfig": {
|
||||
"PortBindings": ports,
|
||||
"RestartPolicy": {"Name": "unless-stopped"},
|
||||
"ReadonlyRootfs": True,
|
||||
"CapDrop": ["ALL"],
|
||||
"SecurityOpt": ["no-new-privileges:true"],
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Ports": ports,
|
||||
"Networks": {
|
||||
RUNNER.DEVICE_PLANE_PRIVATE_NETWORK: {},
|
||||
RUNNER.DEVICE_PLANE_CONTROL_NETWORK: {},
|
||||
},
|
||||
},
|
||||
"Mounts": mounts,
|
||||
}
|
||||
|
||||
|
||||
def postgres_container():
|
||||
return {
|
||||
"Id": "3" * 64,
|
||||
"Image": "sha256:" + "c" * 64,
|
||||
"RestartCount": 0,
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": True,
|
||||
"Restarting": False,
|
||||
"ExitCode": 0,
|
||||
"Error": "",
|
||||
"Health": {"Status": "healthy"},
|
||||
},
|
||||
"Config": {
|
||||
"Image": "postgres:16-alpine",
|
||||
"User": "",
|
||||
"Labels": {
|
||||
"com.docker.compose.project": "nodedc-device-plane",
|
||||
"com.docker.compose.service": "device-postgres",
|
||||
},
|
||||
"Env": [],
|
||||
},
|
||||
"HostConfig": {
|
||||
"PortBindings": {},
|
||||
"RestartPolicy": {"Name": "unless-stopped"},
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Ports": {},
|
||||
"Networks": {
|
||||
RUNNER.DEVICE_PLANE_PRIVATE_NETWORK: {},
|
||||
},
|
||||
},
|
||||
"Mounts": [
|
||||
secret_mount(
|
||||
RUNNER.DEVICE_PLANE_POSTGRES_PASSWORD_FILE,
|
||||
"/run/nodedc-secrets/postgres-password",
|
||||
),
|
||||
{
|
||||
"Type": "volume",
|
||||
"Name": RUNNER.DEVICE_PLANE_POSTGRES_VOLUME,
|
||||
"Destination": "/var/lib/postgresql/data",
|
||||
"RW": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -19,8 +19,8 @@ BUILDER = (
|
||||
)
|
||||
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
||||
COMPOSE = (
|
||||
SCRIPT_DIR.parent.parent
|
||||
/ "device-plane/docker-compose.device-plane.yml"
|
||||
SCRIPT_DIR
|
||||
/ "fixtures/device-plane-foundation-network-publication-v1.yml"
|
||||
)
|
||||
PREDECESSOR_COMPOSE = (
|
||||
SCRIPT_DIR
|
||||
|
||||
@@ -179,7 +179,9 @@ class DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(
|
||||
failed_build["sha256"],
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256,
|
||||
hashlib.sha256(
|
||||
Path(failed_build["artifact"]).read_bytes()
|
||||
).hexdigest(),
|
||||
)
|
||||
failed_artifact = (
|
||||
failed_root / RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT
|
||||
@@ -240,7 +242,7 @@ class DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase):
|
||||
"<urlopen error [Errno 111] Connection refused>",
|
||||
"rollback_status": "failed:DeployError",
|
||||
"sha256":
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256,
|
||||
failed_build["sha256"],
|
||||
"started_apply": True,
|
||||
"status": "failed",
|
||||
})
|
||||
@@ -290,6 +292,11 @@ class DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase):
|
||||
"component_root",
|
||||
return_value=live_root,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256",
|
||||
failed_build["sha256"],
|
||||
),
|
||||
mock.patch.dict(
|
||||
RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_SHA256,
|
||||
backup_hashes,
|
||||
|
||||
Reference in New Issue
Block a user