fix(deploy): reconcile device plane foundation
This commit is contained in:
@@ -52,6 +52,75 @@ DEVICE_PLANE_POSTGRES_BOOTSTRAP_ENTRIES = (
|
||||
DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL,
|
||||
)
|
||||
DEVICE_PLANE_POSTGRES_VOLUME = "nodedc-device-plane-postgres-data"
|
||||
DEVICE_PLANE_FOUNDATION_ENTRIES = (
|
||||
".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",
|
||||
)
|
||||
DEVICE_PLANE_FOUNDATION_RECOVERY_REL = (
|
||||
"deployment/device-plane-foundation-recovery-v1.json"
|
||||
)
|
||||
DEVICE_PLANE_FOUNDATION_RECOVERY_ENTRIES = (
|
||||
*DEVICE_PLANE_FOUNDATION_ENTRIES,
|
||||
DEVICE_PLANE_FOUNDATION_RECOVERY_REL,
|
||||
)
|
||||
DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID = (
|
||||
"device-plane-foundation-20260725-001"
|
||||
)
|
||||
DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT = (
|
||||
"nodedc-device-plane-device-plane-foundation-20260725-001.tgz."
|
||||
"20260725-223441"
|
||||
)
|
||||
DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256 = (
|
||||
"23d428de547854ad8b1a026671e2f850386ab0be98bde80f016f1e9db631ee24"
|
||||
)
|
||||
DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID = (
|
||||
"device-plane-device-plane-foundation-20260725-001-20260725-223441"
|
||||
)
|
||||
DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_SHA256 = {
|
||||
"manifest.env": (
|
||||
"21ff4250147f99fd1461191e1f05aee99e92da9cdedce4513749046c26327c77"
|
||||
),
|
||||
"files.txt": (
|
||||
"3b7dda5cc42ce460e4a74f6df986d4a4fa8aa28511e9d9b123deb445a0382083"
|
||||
),
|
||||
"existing-files.txt": (
|
||||
"f2e2c6e7501d3a383cd7e909a2e4e4cfbb3b0cda132f508f54e478bfb455585b"
|
||||
),
|
||||
"missing-files.txt": (
|
||||
"308d86656e0a0863d5fa16833ec67d1698c0cef7e009a7bd987e62a9e6469c63"
|
||||
),
|
||||
"source-before.tgz": (
|
||||
"df451cdf7a5798c332d03f15473358b1e7fdb7b96212037aaed2d8d00dfecf68"
|
||||
),
|
||||
}
|
||||
DEVICE_PLANE_FOUNDATION_PREDECESSOR_COMPOSE_SHA256 = (
|
||||
"e3afbb1ab252914e21dfb0acc1a2016fd8af471aa13bb14fb578f106f2cdae36"
|
||||
)
|
||||
DEVICE_PLANE_POSTGRES_BOOTSTRAP_DESCRIPTOR_SHA256 = (
|
||||
"9475f12d151aee386cba74a4c0f9e7485416d5616d3ad71dfd8963fe114abf61"
|
||||
)
|
||||
DEVICE_PLANE_FOUNDATION_RECOVERY_IMAGE_IDS = {
|
||||
"device-control-core": (
|
||||
"sha256:78a9fdfbe46f6e9531536abb997ae105fc7ea5c9a8176cb88ca8c0644900c254"
|
||||
),
|
||||
"device-gateway": (
|
||||
"sha256:9be91a3496d1ac627b6b621ade0c2ae77afa10e9ddb9b2071823769bc5a5a472"
|
||||
),
|
||||
"device-postgres": (
|
||||
"sha256:0001755f47e38bfd163788ccc543387b7f3da35a01b38a7c2ca1cd0277f06b5d"
|
||||
),
|
||||
}
|
||||
DEVICE_PLANE_RUNTIME_SERVICES = (
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
)
|
||||
EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR = MAP_GATEWAY_SECRET_DIR / "external-data-plane-provisioner"
|
||||
EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_FILE = EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR / "token"
|
||||
ENGINE_CREDENTIAL_PROVISIONER_PRIVATE_KEY_FILE = EXTERNAL_DATA_PLANE_PROVISIONER_SECRET_DIR / "engine-credential-provisioner-ed25519.pem"
|
||||
@@ -2736,6 +2805,7 @@ def allowed_payload_path(component, rel):
|
||||
"package-lock.json",
|
||||
"docker-compose.device-plane.yml",
|
||||
DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL,
|
||||
DEVICE_PLANE_FOUNDATION_RECOVERY_REL,
|
||||
"packages/device-protocol-contract",
|
||||
"packages/arusnavi-b2-adapter",
|
||||
"services/device-control-core",
|
||||
@@ -7760,6 +7830,11 @@ def load_artifact(artifact, work_dir):
|
||||
entries,
|
||||
):
|
||||
validate_device_plane_postgres_bootstrap_payload(payload_dir)
|
||||
if is_device_plane_foundation_recovery_slice(
|
||||
manifest["component"],
|
||||
entries,
|
||||
):
|
||||
validate_device_plane_foundation_recovery_payload(payload_dir)
|
||||
if manifest["component"] == "n8n-private-extension":
|
||||
validate_n8n_private_extension_release(payload_dir, entries)
|
||||
if manifest["component"] == "engine":
|
||||
@@ -8034,6 +8109,17 @@ def reject_terminal_engine_l2_failed_artifact(manifest, sha256):
|
||||
)
|
||||
|
||||
|
||||
def reject_terminal_device_plane_foundation_artifact(manifest, sha256):
|
||||
if (
|
||||
manifest.get("id") == DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID
|
||||
or sha256 == DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256
|
||||
):
|
||||
die(
|
||||
"Device Plane foundation 001 is terminal failed; "
|
||||
"use the exact registered foundation recovery successor"
|
||||
)
|
||||
|
||||
|
||||
class DeployLock:
|
||||
def __enter__(self):
|
||||
try:
|
||||
@@ -8059,6 +8145,325 @@ def is_device_plane_postgres_bootstrap_slice(component, entries):
|
||||
)
|
||||
|
||||
|
||||
def is_device_plane_foundation_recovery_slice(component, entries):
|
||||
return (
|
||||
component == "device-plane"
|
||||
and entries is not None
|
||||
and tuple(entries) == DEVICE_PLANE_FOUNDATION_RECOVERY_ENTRIES
|
||||
)
|
||||
|
||||
|
||||
def expected_device_plane_foundation_recovery_descriptor():
|
||||
return {
|
||||
"schemaVersion": "nodedc.device-plane.foundation-recovery.v1",
|
||||
"mode": "failed-foundation-live-runtime-adoption",
|
||||
"failedPatchId": DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID,
|
||||
"failedArtifactSha256": (
|
||||
DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256
|
||||
),
|
||||
"backupId": DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID,
|
||||
"sourceAction": "publish-exact-failed-artifact-source",
|
||||
"runtimeAction": "read-only-acceptance",
|
||||
"preservedServices": [
|
||||
"device-control-core",
|
||||
"device-gateway",
|
||||
"device-postgres",
|
||||
],
|
||||
"databaseVolume": DEVICE_PLANE_POSTGRES_VOLUME,
|
||||
"rollback": "source-only-runtime-unchanged",
|
||||
}
|
||||
|
||||
|
||||
def validate_device_plane_foundation_recovery_payload(payload_dir):
|
||||
descriptor = read_strict_json(
|
||||
payload_dir / DEVICE_PLANE_FOUNDATION_RECOVERY_REL,
|
||||
"Device Plane foundation recovery descriptor",
|
||||
max_bytes=16 * 1024,
|
||||
)
|
||||
if descriptor != expected_device_plane_foundation_recovery_descriptor():
|
||||
die("Device Plane foundation recovery descriptor mismatch")
|
||||
return descriptor
|
||||
|
||||
|
||||
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}")
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(DOCKER),
|
||||
"container",
|
||||
"ls",
|
||||
"-a",
|
||||
"--filter",
|
||||
"label=com.docker.compose.project=nodedc-device-plane",
|
||||
"--filter",
|
||||
f"label=com.docker.compose.service={service}",
|
||||
"--format",
|
||||
"{{.ID}}",
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
container_ids = [
|
||||
line.strip()
|
||||
for line in result.stdout.splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
if (
|
||||
result.returncode != 0
|
||||
or any(
|
||||
not re.fullmatch(r"[a-f0-9]{12,64}", container_id)
|
||||
for container_id in container_ids
|
||||
)
|
||||
or len(container_ids) > 1
|
||||
):
|
||||
die(f"Device Plane runtime inventory failed: {service}")
|
||||
return tuple(container_ids)
|
||||
|
||||
|
||||
def inspect_device_plane_container(container_id):
|
||||
containers = docker_json(
|
||||
["container", "inspect", container_id],
|
||||
"Device Plane container inspect",
|
||||
)
|
||||
if (
|
||||
not isinstance(containers, list)
|
||||
or len(containers) != 1
|
||||
or not isinstance(containers[0], dict)
|
||||
):
|
||||
die("Device Plane container inspect shape mismatch")
|
||||
return containers[0]
|
||||
|
||||
|
||||
def device_plane_runtime_inventory(services):
|
||||
inventory = []
|
||||
for service in services:
|
||||
container_ids = device_plane_service_container_ids(service)
|
||||
if not container_ids:
|
||||
continue
|
||||
container = inspect_device_plane_container(container_ids[0])
|
||||
state = container.get("State") or {}
|
||||
health = (state.get("Health") or {}).get("Status")
|
||||
inventory.append({
|
||||
"service": service,
|
||||
"containerId": container.get("Id"),
|
||||
"imageId": container.get("Image"),
|
||||
"status": state.get("Status"),
|
||||
"running": state.get("Running") is True,
|
||||
"health": health,
|
||||
"restartCount": int(container.get("RestartCount") or 0),
|
||||
})
|
||||
return {
|
||||
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
|
||||
"composeProject": "nodedc-device-plane",
|
||||
"services": inventory,
|
||||
}
|
||||
|
||||
|
||||
def device_plane_inventory_service_names(inventory):
|
||||
if (
|
||||
not isinstance(inventory, dict)
|
||||
or inventory.get("schemaVersion")
|
||||
!= "nodedc.device-plane.runtime-inventory.v1"
|
||||
or inventory.get("composeProject") != "nodedc-device-plane"
|
||||
or not isinstance(inventory.get("services"), list)
|
||||
):
|
||||
die("Device Plane runtime inventory shape mismatch")
|
||||
names = []
|
||||
for item in inventory["services"]:
|
||||
if (
|
||||
not isinstance(item, dict)
|
||||
or set(item) != {
|
||||
"service",
|
||||
"containerId",
|
||||
"imageId",
|
||||
"status",
|
||||
"running",
|
||||
"health",
|
||||
"restartCount",
|
||||
}
|
||||
or item.get("service") not in DEVICE_PLANE_RUNTIME_SERVICES
|
||||
or not isinstance(item.get("containerId"), str)
|
||||
or not re.fullmatch(r"[a-f0-9]{64}", item["containerId"])
|
||||
or not isinstance(item.get("imageId"), str)
|
||||
or not re.fullmatch(r"sha256:[a-f0-9]{64}", item["imageId"])
|
||||
or not isinstance(item.get("status"), str)
|
||||
or not isinstance(item.get("running"), bool)
|
||||
or item.get("health") not in (
|
||||
None,
|
||||
"starting",
|
||||
"healthy",
|
||||
"unhealthy",
|
||||
)
|
||||
or not isinstance(item.get("restartCount"), int)
|
||||
or item["restartCount"] < 0
|
||||
):
|
||||
die("Device Plane runtime inventory service mismatch")
|
||||
service = item["service"]
|
||||
if service in names:
|
||||
die("Device Plane runtime inventory contains duplicates")
|
||||
names.append(service)
|
||||
return tuple(names)
|
||||
|
||||
|
||||
def validate_device_plane_foundation_recovery_evidence(payload_dir):
|
||||
backup_dir = BACKUPS_DIR / DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID
|
||||
try:
|
||||
backup_stat = backup_dir.lstat()
|
||||
except FileNotFoundError:
|
||||
die("Device Plane foundation recovery backup is missing")
|
||||
if stat.S_ISLNK(backup_stat.st_mode) or not stat.S_ISDIR(
|
||||
backup_stat.st_mode
|
||||
):
|
||||
die("Device Plane foundation recovery backup is unsafe")
|
||||
backup_names = {child.name for child in backup_dir.iterdir()}
|
||||
if backup_names != set(DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_SHA256):
|
||||
die("Device Plane foundation recovery backup file set mismatch")
|
||||
for name, expected in (
|
||||
DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_SHA256.items()
|
||||
):
|
||||
path = backup_dir / name
|
||||
path_stat = path.lstat()
|
||||
if (
|
||||
stat.S_ISLNK(path_stat.st_mode)
|
||||
or not stat.S_ISREG(path_stat.st_mode)
|
||||
or sha256_file(path) != expected
|
||||
):
|
||||
die(
|
||||
"Device Plane foundation recovery backup drift detected: "
|
||||
f"{name}"
|
||||
)
|
||||
|
||||
failed_artifact = FAILED_DIR / DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT
|
||||
try:
|
||||
failed_stat = failed_artifact.lstat()
|
||||
except FileNotFoundError:
|
||||
die("Device Plane foundation failed artifact is missing")
|
||||
if (
|
||||
stat.S_ISLNK(failed_stat.st_mode)
|
||||
or not stat.S_ISREG(failed_stat.st_mode)
|
||||
or sha256_file(failed_artifact)
|
||||
!= DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256
|
||||
):
|
||||
die("Device Plane foundation failed artifact evidence mismatch")
|
||||
|
||||
try:
|
||||
state_stat = FAILED_STATE_FILE.lstat()
|
||||
state_lines = FAILED_STATE_FILE.read_text(
|
||||
encoding="utf-8"
|
||||
).splitlines()
|
||||
except (FileNotFoundError, OSError, UnicodeDecodeError):
|
||||
die("Device Plane foundation failed journal is unreadable")
|
||||
if stat.S_ISLNK(state_stat.st_mode) or not stat.S_ISREG(
|
||||
state_stat.st_mode
|
||||
):
|
||||
die("Device Plane foundation failed journal is unsafe")
|
||||
records = []
|
||||
for line in state_lines:
|
||||
try:
|
||||
value = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
die("Device Plane foundation failed journal contains invalid JSON")
|
||||
if (
|
||||
isinstance(value, dict)
|
||||
and value.get("id") == DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID
|
||||
):
|
||||
records.append(value)
|
||||
if len(records) != 1:
|
||||
die("Device Plane foundation failed journal evidence count mismatch")
|
||||
record = records[0]
|
||||
if (
|
||||
record.get("artifact") != DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT
|
||||
or record.get("backup_id")
|
||||
!= DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID
|
||||
or record.get("component") != "device-plane"
|
||||
or record.get("sha256")
|
||||
!= DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256
|
||||
or record.get("started_apply") is not True
|
||||
or record.get("rollback_status") != "failed:DeployError"
|
||||
or record.get("status") != "failed"
|
||||
or record.get("message")
|
||||
!= (
|
||||
"healthcheck failed for http://127.0.0.1:18120/healthz: "
|
||||
"<urlopen error [Errno 111] Connection refused>"
|
||||
)
|
||||
):
|
||||
die("Device Plane foundation failed journal evidence mismatch")
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="device-plane-failed-foundation-",
|
||||
dir=TMP_DIR,
|
||||
) as directory:
|
||||
failed_manifest, failed_entries, failed_payload = load_artifact(
|
||||
failed_artifact,
|
||||
Path(directory),
|
||||
)
|
||||
if (
|
||||
failed_manifest.get("id")
|
||||
!= DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID
|
||||
or failed_manifest.get("component") != "device-plane"
|
||||
or failed_manifest.get("type") != "app-overlay"
|
||||
or tuple(failed_entries) != DEVICE_PLANE_FOUNDATION_ENTRIES
|
||||
):
|
||||
die("Device Plane foundation failed artifact contract mismatch")
|
||||
failed_source = collect_exact_files(
|
||||
failed_payload,
|
||||
DEVICE_PLANE_FOUNDATION_ENTRIES,
|
||||
"Device Plane failed foundation source",
|
||||
)
|
||||
candidate_source = collect_exact_files(
|
||||
payload_dir,
|
||||
DEVICE_PLANE_FOUNDATION_ENTRIES,
|
||||
"Device Plane recovery candidate source",
|
||||
)
|
||||
if candidate_source != failed_source:
|
||||
die(
|
||||
"Device Plane recovery candidate does not match failed "
|
||||
"foundation source"
|
||||
)
|
||||
|
||||
root = component_root("device-plane")
|
||||
compose_path = root / "docker-compose.device-plane.yml"
|
||||
if (
|
||||
compose_path.is_symlink()
|
||||
or not compose_path.is_file()
|
||||
or sha256_file(compose_path)
|
||||
!= DEVICE_PLANE_FOUNDATION_PREDECESSOR_COMPOSE_SHA256
|
||||
):
|
||||
die("Device Plane foundation partial Compose predecessor mismatch")
|
||||
for rel in DEVICE_PLANE_FOUNDATION_ENTRIES:
|
||||
if rel == "docker-compose.device-plane.yml":
|
||||
continue
|
||||
path = root / rel
|
||||
if path.exists() or path.is_symlink():
|
||||
die(
|
||||
"Device Plane foundation partial predecessor retained source: "
|
||||
f"{rel}"
|
||||
)
|
||||
recovery_descriptor = root / DEVICE_PLANE_FOUNDATION_RECOVERY_REL
|
||||
if recovery_descriptor.exists() or recovery_descriptor.is_symlink():
|
||||
die("Device Plane foundation recovery descriptor already exists")
|
||||
bootstrap_descriptor = (
|
||||
root / DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL
|
||||
)
|
||||
if (
|
||||
bootstrap_descriptor.is_symlink()
|
||||
or not bootstrap_descriptor.is_file()
|
||||
or sha256_file(bootstrap_descriptor)
|
||||
!= DEVICE_PLANE_POSTGRES_BOOTSTRAP_DESCRIPTOR_SHA256
|
||||
):
|
||||
die("Device Plane PostgreSQL bootstrap descriptor drift detected")
|
||||
|
||||
runtime = validate_device_plane_foundation_runtime()
|
||||
return {
|
||||
"mode": "failed-foundation-live-runtime-adoption",
|
||||
"backup": backup_dir,
|
||||
"failedArtifact": failed_artifact,
|
||||
"runtime": runtime,
|
||||
}
|
||||
|
||||
|
||||
def validate_device_plane_postgres_bootstrap_payload(payload_dir):
|
||||
descriptor = read_strict_json(
|
||||
payload_dir / DEVICE_PLANE_POSTGRES_BOOTSTRAP_REL,
|
||||
@@ -8132,6 +8537,303 @@ def device_plane_postgres_plan_selection(postgres_preflight):
|
||||
return "bootstrap-selected:create-if-absent"
|
||||
|
||||
|
||||
def validate_device_plane_foundation_runtime():
|
||||
validate_device_plane_runtime_secret_metadata()
|
||||
expected = {
|
||||
"device-control-core": {
|
||||
"image": DEVICE_PLANE_CONTROL_CORE_IMAGE,
|
||||
"imageId": DEVICE_PLANE_FOUNDATION_RECOVERY_IMAGE_IDS[
|
||||
"device-control-core"
|
||||
],
|
||||
"user": "1000:1000",
|
||||
"ports": {
|
||||
"18120/tcp": [{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "18120",
|
||||
}],
|
||||
},
|
||||
},
|
||||
"device-gateway": {
|
||||
"image": DEVICE_PLANE_GATEWAY_IMAGE,
|
||||
"imageId": DEVICE_PLANE_FOUNDATION_RECOVERY_IMAGE_IDS[
|
||||
"device-gateway"
|
||||
],
|
||||
"user": "1000:1000",
|
||||
"ports": {
|
||||
"18121/tcp": [{
|
||||
"HostIp": "127.0.0.1",
|
||||
"HostPort": "18121",
|
||||
}],
|
||||
},
|
||||
},
|
||||
"device-postgres": {
|
||||
"image": "postgres:16-alpine",
|
||||
"imageId": DEVICE_PLANE_FOUNDATION_RECOVERY_IMAGE_IDS[
|
||||
"device-postgres"
|
||||
],
|
||||
"user": "",
|
||||
"ports": {},
|
||||
},
|
||||
}
|
||||
accepted = {}
|
||||
for service, contract in expected.items():
|
||||
container_ids = device_plane_service_container_ids(service)
|
||||
if len(container_ids) != 1:
|
||||
die(
|
||||
"Device Plane foundation runtime service count mismatch: "
|
||||
f"{service}"
|
||||
)
|
||||
container = inspect_device_plane_container(container_ids[0])
|
||||
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 {}
|
||||
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
|
||||
):
|
||||
die(
|
||||
"Device Plane foundation runtime state mismatch: "
|
||||
f"{service}"
|
||||
)
|
||||
if (
|
||||
container.get("Image") != contract["imageId"]
|
||||
or config.get("Image") != contract["image"]
|
||||
or config.get("User", "") != contract["user"]
|
||||
or (host_config.get("PortBindings") or {}) != contract["ports"]
|
||||
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
|
||||
or set(networks) != {"nodedc-device-plane-private"}
|
||||
):
|
||||
die(
|
||||
"Device Plane foundation runtime boundary mismatch: "
|
||||
f"{service}"
|
||||
)
|
||||
accepted[service] = {
|
||||
"containerId": container.get("Id"),
|
||||
"imageId": container.get("Image"),
|
||||
"health": "healthy",
|
||||
"restartCount": 0,
|
||||
}
|
||||
|
||||
core = inspect_device_plane_container(
|
||||
device_plane_service_container_ids("device-control-core")[0]
|
||||
)
|
||||
gateway = inspect_device_plane_container(
|
||||
device_plane_service_container_ids("device-gateway")[0]
|
||||
)
|
||||
postgres = inspect_device_plane_container(
|
||||
device_plane_service_container_ids("device-postgres")[0]
|
||||
)
|
||||
for service, container in (
|
||||
("device-control-core", core),
|
||||
("device-gateway", gateway),
|
||||
):
|
||||
host_config = container.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 stateless hardening mismatch: "
|
||||
f"{service}"
|
||||
)
|
||||
|
||||
core_environment = container_environment(
|
||||
core,
|
||||
"Device Plane Control Core",
|
||||
)
|
||||
core_required_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",
|
||||
}
|
||||
if any(
|
||||
core_environment.get(key) != value
|
||||
for key, value in core_required_environment.items()
|
||||
):
|
||||
die("Device Plane Control Core environment mismatch")
|
||||
for forbidden in (
|
||||
"DEVICE_DATABASE_URL",
|
||||
"DEVICE_DATABASE_PASSWORD",
|
||||
"DEVICE_GATEWAY_CORE_TOKEN",
|
||||
"DEVICE_IDENTIFIER_PEPPER",
|
||||
):
|
||||
if forbidden in core_environment:
|
||||
die("Device Plane Control Core plaintext secret boundary mismatch")
|
||||
|
||||
gateway_environment = container_environment(
|
||||
gateway,
|
||||
"Device Plane Gateway",
|
||||
)
|
||||
gateway_required_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",
|
||||
}
|
||||
if any(
|
||||
gateway_environment.get(key) != value
|
||||
for key, value in gateway_required_environment.items()
|
||||
):
|
||||
die("Device Plane Gateway environment mismatch")
|
||||
if any(
|
||||
key in gateway_environment
|
||||
for key in (
|
||||
"DEVICE_GATEWAY_CORE_TOKEN",
|
||||
"DEVICE_GATEWAY_COMMAND_TOKEN",
|
||||
)
|
||||
):
|
||||
die("Device Plane Gateway plaintext secret boundary mismatch")
|
||||
|
||||
core_mounts = core.get("Mounts") or []
|
||||
if len(core_mounts) != 1:
|
||||
die("Device Plane Control Core mount count mismatch")
|
||||
core_secret = core_mounts[0]
|
||||
if (
|
||||
core_secret.get("Type") != "bind"
|
||||
or core_secret.get("Source")
|
||||
!= str(DEVICE_PLANE_POSTGRES_PASSWORD_FILE)
|
||||
or core_secret.get("Destination")
|
||||
!= "/run/nodedc-secrets/postgres-password"
|
||||
or core_secret.get("RW") is not False
|
||||
):
|
||||
die("Device Plane Control Core secret mount mismatch")
|
||||
if gateway.get("Mounts") not in (None, []):
|
||||
die("Device Plane Gateway unexpected mount")
|
||||
|
||||
postgres_mounts = 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 PostgreSQL preserved mount mismatch")
|
||||
return accepted
|
||||
|
||||
|
||||
def validate_device_plane_runtime_secret_metadata():
|
||||
try:
|
||||
directory_stat = DEVICE_PLANE_SECRET_DIR.lstat()
|
||||
except FileNotFoundError:
|
||||
die("Device Plane runtime secret directory is missing")
|
||||
if (
|
||||
stat.S_ISLNK(directory_stat.st_mode)
|
||||
or not stat.S_ISDIR(directory_stat.st_mode)
|
||||
or directory_stat.st_uid != 0
|
||||
or directory_stat.st_gid != MAP_GATEWAY_RUNTIME_GID
|
||||
or stat.S_IMODE(directory_stat.st_mode) != 0o710
|
||||
):
|
||||
die("Device Plane runtime secret directory boundary mismatch")
|
||||
for path, label in (
|
||||
(DEVICE_PLANE_POSTGRES_PASSWORD_FILE, "PostgreSQL"),
|
||||
(DEVICE_PLANE_GATEWAY_CORE_TOKEN_FILE, "Gateway to Core"),
|
||||
(DEVICE_PLANE_IDENTIFIER_PEPPER_FILE, "identifier pepper"),
|
||||
):
|
||||
try:
|
||||
path_stat = path.lstat()
|
||||
except FileNotFoundError:
|
||||
die(f"Device Plane {label} runtime secret is missing")
|
||||
if (
|
||||
stat.S_ISLNK(path_stat.st_mode)
|
||||
or not stat.S_ISREG(path_stat.st_mode)
|
||||
or path_stat.st_uid != 0
|
||||
or path_stat.st_gid != MAP_GATEWAY_RUNTIME_GID
|
||||
or stat.S_IMODE(path_stat.st_mode) != 0o640
|
||||
or path_stat.st_size > 512
|
||||
):
|
||||
die(f"Device Plane {label} runtime secret boundary mismatch")
|
||||
try:
|
||||
value = path.read_text(encoding="ascii").strip()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
die(f"Device Plane {label} runtime secret is unreadable")
|
||||
if not MAP_GATEWAY_SECRET_RE.fullmatch(value):
|
||||
die(f"Device Plane {label} runtime secret format mismatch")
|
||||
return "exact"
|
||||
|
||||
|
||||
def validate_device_plane_foundation_installed_source():
|
||||
failed_artifact = FAILED_DIR / DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="device-plane-installed-foundation-",
|
||||
dir=TMP_DIR,
|
||||
) as directory:
|
||||
failed_manifest, failed_entries, failed_payload = load_artifact(
|
||||
failed_artifact,
|
||||
Path(directory),
|
||||
)
|
||||
if (
|
||||
failed_manifest.get("id")
|
||||
!= DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID
|
||||
or tuple(failed_entries) != DEVICE_PLANE_FOUNDATION_ENTRIES
|
||||
):
|
||||
die("Device Plane failed foundation source contract mismatch")
|
||||
expected = collect_exact_files(
|
||||
failed_payload,
|
||||
DEVICE_PLANE_FOUNDATION_ENTRIES,
|
||||
"Device Plane failed foundation source",
|
||||
)
|
||||
actual = collect_exact_files(
|
||||
component_root("device-plane"),
|
||||
DEVICE_PLANE_FOUNDATION_ENTRIES,
|
||||
"Device Plane installed foundation source",
|
||||
)
|
||||
if actual != expected:
|
||||
die("Device Plane installed foundation source mismatch")
|
||||
descriptor = read_strict_json(
|
||||
component_root("device-plane")
|
||||
/ DEVICE_PLANE_FOUNDATION_RECOVERY_REL,
|
||||
"installed Device Plane foundation recovery descriptor",
|
||||
max_bytes=16 * 1024,
|
||||
)
|
||||
if descriptor != expected_device_plane_foundation_recovery_descriptor():
|
||||
die("installed Device Plane foundation recovery descriptor mismatch")
|
||||
return actual
|
||||
|
||||
|
||||
def component_compose_root(component):
|
||||
return COMPONENTS[component].get("compose_root", component_root(component))
|
||||
|
||||
@@ -9658,6 +10360,12 @@ def component_services(component, entries=None):
|
||||
# installed database.
|
||||
return ("device-postgres",)
|
||||
|
||||
if is_device_plane_foundation_recovery_slice(component, entries):
|
||||
# The failed apply already built and started the exact reviewed images.
|
||||
# Recovery publishes their matching source and accepts the current
|
||||
# runtime without build/recreate/restart.
|
||||
return ()
|
||||
|
||||
if is_engine_l2_closed_loop_slice(component, entries):
|
||||
# The failed 030 apply published both the backend source and the built
|
||||
# UI before Compose rejected the descriptor/source mismatch. The exact
|
||||
@@ -9992,6 +10700,9 @@ def component_builds(component, entries=None):
|
||||
if is_device_plane_postgres_bootstrap_slice(component, entries):
|
||||
return ()
|
||||
|
||||
if is_device_plane_foundation_recovery_slice(component, entries):
|
||||
return ()
|
||||
|
||||
if component == "device-plane" and entries is not None:
|
||||
selected_services = component_services(component, entries)
|
||||
builds = []
|
||||
@@ -11963,6 +12674,8 @@ def plan_artifact(artifact):
|
||||
mcp_ontology_sdk_preflight = None
|
||||
mcp_autonomy_provider_v5_preflight = None
|
||||
l2_closed_loop_preflight = None
|
||||
device_plane_foundation_recovery_preflight = None
|
||||
device_plane_runtime_before = None
|
||||
composite_provider_v4_preflight = None
|
||||
provider_rotating_slot_preflight = None
|
||||
provider_authority_diagnostics_preflight = None
|
||||
@@ -11982,9 +12695,11 @@ def plan_artifact(artifact):
|
||||
mcp_gelios_units_items_preflight = None
|
||||
provider_catalog_preflight = None
|
||||
device_plane_postgres_preflight = None
|
||||
device_plane_foundation_recovery_preflight = None
|
||||
with tempfile.TemporaryDirectory(prefix="plan-", dir=TMP_DIR) as tmp:
|
||||
manifest, entries, payload_dir = load_artifact(artifact, Path(tmp))
|
||||
reject_terminal_engine_l2_failed_artifact(manifest, sha)
|
||||
reject_terminal_device_plane_foundation_artifact(manifest, sha)
|
||||
transition_descriptor = None
|
||||
transition_preflight = None
|
||||
if is_engine_n8n_transition(manifest["component"], entries):
|
||||
@@ -12125,6 +12840,15 @@ def plan_artifact(artifact):
|
||||
device_plane_postgres_preflight = (
|
||||
preflight_device_plane_postgres_bootstrap()
|
||||
)
|
||||
if is_device_plane_foundation_recovery_slice(
|
||||
manifest["component"],
|
||||
entries,
|
||||
):
|
||||
device_plane_foundation_recovery_preflight = (
|
||||
validate_device_plane_foundation_recovery_evidence(
|
||||
payload_dir
|
||||
)
|
||||
)
|
||||
|
||||
component = manifest["component"]
|
||||
root = component_root(component)
|
||||
@@ -13508,6 +14232,31 @@ def plan_artifact(artifact):
|
||||
print("device_gateway_public_ingress=disabled")
|
||||
print("device_gateway_command_transport=disabled")
|
||||
print("gelios=untouched")
|
||||
if device_plane_foundation_recovery_preflight is not None:
|
||||
print(
|
||||
"device_plane_transition="
|
||||
f"{device_plane_foundation_recovery_preflight['mode']}"
|
||||
)
|
||||
print(
|
||||
"failed_patch="
|
||||
f"{DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID}"
|
||||
)
|
||||
print(
|
||||
"failed_artifact_sha256="
|
||||
f"{DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT_SHA256}"
|
||||
)
|
||||
print(
|
||||
"recovery_backup="
|
||||
f"{device_plane_foundation_recovery_preflight['backup'].name}"
|
||||
)
|
||||
print("device_plane_build=none")
|
||||
print("device_plane_runtime_mutation=none")
|
||||
print(
|
||||
"device_plane_runtime_services="
|
||||
"preserved:device-control-core,device-gateway,device-postgres"
|
||||
)
|
||||
print("device_plane_source_action=publish-exact-failed-source")
|
||||
print("device_plane_rollback=source-only-runtime-unchanged")
|
||||
if device_plane_postgres_preflight is not None:
|
||||
print(
|
||||
"device_postgres_bootstrap="
|
||||
@@ -13813,11 +14562,18 @@ def rollback_device_plane_apply(
|
||||
"Device Plane runtime rollback",
|
||||
)
|
||||
baseline_entries = [rel for rel in entries if rel in existing_set]
|
||||
compose_was_installed = "docker-compose.device-plane.yml" in existing_set
|
||||
baseline_services = (
|
||||
component_services("device-plane", baseline_entries)
|
||||
if compose_was_installed
|
||||
else ()
|
||||
runtime_inventory = read_strict_json(
|
||||
backup_dir / "runtime-before.json",
|
||||
"Device Plane pre-apply runtime inventory",
|
||||
max_bytes=64 * 1024,
|
||||
)
|
||||
preapply_services = set(
|
||||
device_plane_inventory_service_names(runtime_inventory)
|
||||
)
|
||||
baseline_services = tuple(
|
||||
service
|
||||
for service in applied_services
|
||||
if service in preapply_services
|
||||
)
|
||||
candidate_only_services = tuple(
|
||||
service
|
||||
@@ -14555,6 +15311,8 @@ def run_component_runtime(component, entries, services):
|
||||
return
|
||||
if component_artifact_only(component):
|
||||
return
|
||||
if is_device_plane_foundation_recovery_slice(component, entries):
|
||||
return
|
||||
run_build(component, entries)
|
||||
prepare_component_runtime(component, entries)
|
||||
if is_engine_node_intelligence_transition(component, entries):
|
||||
@@ -14663,6 +15421,29 @@ def module_foundry_healthcheck():
|
||||
def component_healthchecks(component, entries=None, services=None):
|
||||
if is_device_plane_postgres_bootstrap_slice(component, entries):
|
||||
return ()
|
||||
if is_device_plane_foundation_recovery_slice(component, entries):
|
||||
return (
|
||||
{
|
||||
"url": "http://127.0.0.1:18120/healthz",
|
||||
"expected_json": {
|
||||
"ok": True,
|
||||
"service": "nodedc-device-control-core",
|
||||
"database": "ready",
|
||||
"discoveryIngest": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
},
|
||||
},
|
||||
{
|
||||
"url": "http://127.0.0.1:18121/healthz",
|
||||
"expected_json": {
|
||||
"ok": True,
|
||||
"service": "nodedc-device-gateway",
|
||||
"tcpListener": "disabled",
|
||||
"publicIngress": "disabled",
|
||||
"commandTransport": "disabled",
|
||||
},
|
||||
},
|
||||
)
|
||||
if is_engine_n8n_transition(component, entries):
|
||||
# The transition does not restart the Engine UI/backend generation.
|
||||
# Its own acceptance below verifies n8n readiness, image, mount, logs,
|
||||
@@ -15101,6 +15882,20 @@ def run_healthchecks(component, entries=None, services=None):
|
||||
die("Device Plane PostgreSQL bootstrap service set mismatch")
|
||||
healthcheck_compose_service("device-plane", "device-postgres")
|
||||
return
|
||||
if is_device_plane_foundation_recovery_slice(component, entries):
|
||||
if tuple(services or ()) != ():
|
||||
die("Device Plane foundation recovery 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)
|
||||
validate_device_plane_foundation_installed_source()
|
||||
validate_device_plane_foundation_runtime()
|
||||
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):
|
||||
@@ -15881,6 +16676,10 @@ def apply_artifact(artifact):
|
||||
work = Path(tmp)
|
||||
manifest, entries, payload_dir = load_artifact(artifact, work)
|
||||
reject_terminal_engine_l2_failed_artifact(manifest, sha)
|
||||
reject_terminal_device_plane_foundation_artifact(
|
||||
manifest,
|
||||
sha,
|
||||
)
|
||||
patch_id = manifest["id"]
|
||||
component = manifest["component"]
|
||||
root = component_root(component)
|
||||
@@ -15898,6 +16697,15 @@ def apply_artifact(artifact):
|
||||
entries,
|
||||
):
|
||||
preflight_device_plane_postgres_bootstrap()
|
||||
if is_device_plane_foundation_recovery_slice(
|
||||
component,
|
||||
entries,
|
||||
):
|
||||
device_plane_foundation_recovery_preflight = (
|
||||
validate_device_plane_foundation_recovery_evidence(
|
||||
payload_dir
|
||||
)
|
||||
)
|
||||
if not root.is_dir():
|
||||
if bootstrap_root:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
@@ -16176,6 +16984,12 @@ def apply_artifact(artifact):
|
||||
die(f"compose env file not found: {compose_env_file}")
|
||||
if not artifact_only and not DOCKER.is_file():
|
||||
die(f"docker not found: {DOCKER}")
|
||||
if component == "device-plane":
|
||||
device_plane_runtime_before = (
|
||||
device_plane_runtime_inventory(
|
||||
DEVICE_PLANE_RUNTIME_SERVICES
|
||||
)
|
||||
)
|
||||
|
||||
if is_engine_n8n_transition(component, entries):
|
||||
transition_descriptor = read_engine_n8n_transition_descriptor(
|
||||
@@ -16202,6 +17016,23 @@ def apply_artifact(artifact):
|
||||
|
||||
include_nginx_html = component == "engine" and component_publish_dist(component, entries)
|
||||
create_backup(root, backup_dir, entries, include_nginx_html)
|
||||
if component == "device-plane":
|
||||
if device_plane_runtime_before is None:
|
||||
die("Device Plane pre-apply runtime inventory is missing")
|
||||
runtime_inventory_path = (
|
||||
backup_dir / "runtime-before.json"
|
||||
)
|
||||
runtime_inventory_path.write_text(
|
||||
json.dumps(
|
||||
device_plane_runtime_before,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
runtime_inventory_path.chmod(0o600)
|
||||
if component == "platform" and touches_external_data_plane_files(entries):
|
||||
validate_backup_partition(
|
||||
entries,
|
||||
|
||||
Reference in New Issue
Block a user