387 lines
13 KiB
Python
387 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
import hashlib
|
|
import importlib.machinery
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
RECOVERY_BUILDER = (
|
|
SCRIPT_DIR / "build-device-plane-foundation-recovery-artifact.mjs"
|
|
)
|
|
FOUNDATION_BUILDER = SCRIPT_DIR / "build-device-plane-artifact.mjs"
|
|
PREDECESSOR_COMPOSE = (
|
|
SCRIPT_DIR
|
|
/ "fixtures/device-plane-foundation-internal-only-v1.yml"
|
|
)
|
|
RUNNER_PATH = SCRIPT_DIR / "nodedc-deploy"
|
|
RECOVERY_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",
|
|
"deployment/device-plane-foundation-recovery-v1.json",
|
|
]
|
|
|
|
|
|
def load_runner():
|
|
loader = importlib.machinery.SourceFileLoader(
|
|
"nodedc_device_plane_recovery_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 DevicePlaneFoundationRecoveryArtifactTest(unittest.TestCase):
|
|
def build(self, builder, 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_recovery_artifact_is_source_only_exact_and_deterministic(self):
|
|
with tempfile.TemporaryDirectory(
|
|
prefix="nodedc-device-plane-recovery-artifact-",
|
|
) as directory:
|
|
artifact_dir = Path(directory)
|
|
first = self.build(
|
|
RECOVERY_BUILDER,
|
|
artifact_dir,
|
|
"device-plane-foundation-recovery-unit-002",
|
|
)
|
|
artifact = Path(first["artifact"])
|
|
first_bytes = artifact.read_bytes()
|
|
second = self.build(
|
|
RECOVERY_BUILDER,
|
|
artifact_dir,
|
|
"device-plane-foundation-recovery-unit-002",
|
|
)
|
|
second_bytes = Path(second["artifact"]).read_bytes()
|
|
|
|
self.assertEqual(first["component"], "device-plane")
|
|
self.assertEqual(first["entries"], RECOVERY_ENTRIES)
|
|
self.assertEqual(first["build"], [])
|
|
self.assertEqual(first["services"], [])
|
|
self.assertEqual(
|
|
first["transition"],
|
|
"failed-foundation-live-runtime-adoption",
|
|
)
|
|
self.assertEqual(first_bytes, second_bytes)
|
|
self.assertEqual(
|
|
first["sha256"],
|
|
hashlib.sha256(first_bytes).hexdigest(),
|
|
)
|
|
|
|
with tarfile.open(artifact, "r:gz") as archive:
|
|
names = {member.name for member in archive.getmembers()}
|
|
files = (
|
|
archive.extractfile("files.txt")
|
|
.read()
|
|
.decode("utf-8")
|
|
.splitlines()
|
|
)
|
|
descriptor = json.loads(
|
|
archive.extractfile(
|
|
"payload/deployment/"
|
|
"device-plane-foundation-recovery-v1.json"
|
|
)
|
|
.read()
|
|
.decode("utf-8")
|
|
)
|
|
|
|
self.assertEqual(files, RECOVERY_ENTRIES)
|
|
self.assertIn(
|
|
"payload/services/device-control-core/src/server.mjs",
|
|
names,
|
|
)
|
|
self.assertIn(
|
|
"payload/services/device-gateway/src/server.mjs",
|
|
names,
|
|
)
|
|
self.assertFalse(any(
|
|
"/test/" in name
|
|
or "/node_modules/" in name
|
|
or Path(name).name.startswith(".env")
|
|
or name.startswith("payload/runtime/")
|
|
or name.startswith("payload/secrets/")
|
|
for name in names
|
|
))
|
|
self.assertEqual(
|
|
descriptor,
|
|
RUNNER.expected_device_plane_foundation_recovery_descriptor(),
|
|
)
|
|
self.assertEqual(
|
|
RUNNER.component_services(
|
|
"device-plane",
|
|
tuple(RECOVERY_ENTRIES),
|
|
),
|
|
(),
|
|
)
|
|
self.assertEqual(
|
|
RUNNER.component_builds(
|
|
"device-plane",
|
|
tuple(RECOVERY_ENTRIES),
|
|
),
|
|
(),
|
|
)
|
|
|
|
def test_preflight_requires_exact_failed_evidence_partial_source_and_runtime(
|
|
self,
|
|
):
|
|
with tempfile.TemporaryDirectory(
|
|
prefix="nodedc-device-plane-recovery-preflight-",
|
|
) as directory:
|
|
workspace = Path(directory)
|
|
artifacts = workspace / "artifacts"
|
|
failed_root = workspace / "failed"
|
|
backups_root = workspace / "backups"
|
|
state_root = workspace / "state"
|
|
temp_root = workspace / "tmp"
|
|
live_root = workspace / "live"
|
|
for path in (
|
|
artifacts,
|
|
failed_root,
|
|
backups_root,
|
|
state_root,
|
|
temp_root,
|
|
live_root / "deployment",
|
|
):
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
|
|
failed_build = self.build(
|
|
FOUNDATION_BUILDER,
|
|
artifacts,
|
|
RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID,
|
|
)
|
|
self.assertEqual(
|
|
failed_build["sha256"],
|
|
hashlib.sha256(
|
|
Path(failed_build["artifact"]).read_bytes()
|
|
).hexdigest(),
|
|
)
|
|
failed_artifact = (
|
|
failed_root / RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT
|
|
)
|
|
shutil.copy2(failed_build["artifact"], failed_artifact)
|
|
|
|
recovery_build = self.build(
|
|
RECOVERY_BUILDER,
|
|
artifacts,
|
|
"device-plane-foundation-recovery-unit-002",
|
|
)
|
|
recovery_extract = workspace / "recovery-extract"
|
|
recovery_extract.mkdir()
|
|
_manifest, entries, payload = RUNNER.load_artifact(
|
|
Path(recovery_build["artifact"]),
|
|
recovery_extract,
|
|
)
|
|
self.assertEqual(entries, RECOVERY_ENTRIES)
|
|
|
|
backup = (
|
|
backups_root
|
|
/ RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID
|
|
)
|
|
backup.mkdir()
|
|
for name in (
|
|
"manifest.env",
|
|
"files.txt",
|
|
"existing-files.txt",
|
|
"missing-files.txt",
|
|
"source-before.tgz",
|
|
):
|
|
(backup / name).write_text(
|
|
f"fixture:{name}\n",
|
|
encoding="utf-8",
|
|
)
|
|
backup_hashes = {
|
|
name: hashlib.sha256((backup / name).read_bytes()).hexdigest()
|
|
for name in (
|
|
"manifest.env",
|
|
"files.txt",
|
|
"existing-files.txt",
|
|
"missing-files.txt",
|
|
"source-before.tgz",
|
|
)
|
|
}
|
|
|
|
(state_root / "failed.jsonl").write_text(
|
|
json.dumps({
|
|
"artifact":
|
|
RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_ARTIFACT,
|
|
"backup_id":
|
|
RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_BACKUP_ID,
|
|
"component": "device-plane",
|
|
"id": RUNNER.DEVICE_PLANE_FOUNDATION_FAILED_PATCH_ID,
|
|
"message":
|
|
"healthcheck failed for "
|
|
"http://127.0.0.1:18120/healthz: "
|
|
"<urlopen error [Errno 111] Connection refused>",
|
|
"rollback_status": "failed:DeployError",
|
|
"sha256":
|
|
failed_build["sha256"],
|
|
"started_apply": True,
|
|
"status": "failed",
|
|
})
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
source_root = SCRIPT_DIR.parent.parent / "device-plane"
|
|
shutil.copy2(
|
|
PREDECESSOR_COMPOSE,
|
|
live_root / "docker-compose.device-plane.yml",
|
|
)
|
|
shutil.copy2(
|
|
source_root
|
|
/ "deployment/device-postgres-bootstrap-v1.json",
|
|
live_root
|
|
/ "deployment/device-postgres-bootstrap-v1.json",
|
|
)
|
|
runtime = {
|
|
service: {
|
|
"containerId": service,
|
|
"imageId":
|
|
RUNNER.DEVICE_PLANE_FOUNDATION_RECOVERY_IMAGE_IDS[
|
|
service
|
|
],
|
|
"health": "healthy",
|
|
"restartCount": 0,
|
|
}
|
|
for service in (
|
|
"device-control-core",
|
|
"device-gateway",
|
|
"device-postgres",
|
|
)
|
|
}
|
|
|
|
with (
|
|
mock.patch.object(RUNNER, "BACKUPS_DIR", backups_root),
|
|
mock.patch.object(RUNNER, "FAILED_DIR", failed_root),
|
|
mock.patch.object(
|
|
RUNNER,
|
|
"FAILED_STATE_FILE",
|
|
state_root / "failed.jsonl",
|
|
),
|
|
mock.patch.object(RUNNER, "TMP_DIR", temp_root),
|
|
mock.patch.object(
|
|
RUNNER,
|
|
"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,
|
|
clear=True,
|
|
),
|
|
mock.patch.object(
|
|
RUNNER,
|
|
"validate_device_plane_foundation_runtime",
|
|
return_value=runtime,
|
|
),
|
|
):
|
|
result = (
|
|
RUNNER
|
|
.validate_device_plane_foundation_recovery_evidence(
|
|
payload
|
|
)
|
|
)
|
|
|
|
self.assertEqual(
|
|
result["mode"],
|
|
"failed-foundation-live-runtime-adoption",
|
|
)
|
|
self.assertEqual(result["runtime"], runtime)
|
|
|
|
def test_recovery_health_acceptance_never_mutates_runtime(self):
|
|
entries = tuple(RECOVERY_ENTRIES)
|
|
checks = ("core-health", "gateway-health")
|
|
with (
|
|
mock.patch.object(
|
|
RUNNER,
|
|
"healthcheck_compose_service",
|
|
) as compose_health,
|
|
mock.patch.object(
|
|
RUNNER,
|
|
"component_healthchecks",
|
|
return_value=checks,
|
|
),
|
|
mock.patch.object(RUNNER, "healthcheck_url") as url_health,
|
|
mock.patch.object(
|
|
RUNNER,
|
|
"validate_device_plane_foundation_installed_source",
|
|
) as source_acceptance,
|
|
mock.patch.object(
|
|
RUNNER,
|
|
"validate_device_plane_foundation_runtime",
|
|
) as runtime_acceptance,
|
|
mock.patch.object(RUNNER, "run_compose") as compose_mutation,
|
|
mock.patch.object(RUNNER, "run_build") as build_mutation,
|
|
):
|
|
RUNNER.run_healthchecks("device-plane", entries, ())
|
|
|
|
self.assertEqual(
|
|
[call.args for call in compose_health.call_args_list],
|
|
[
|
|
("device-plane", "device-control-core"),
|
|
("device-plane", "device-gateway"),
|
|
("device-plane", "device-postgres"),
|
|
],
|
|
)
|
|
self.assertEqual(
|
|
[call.args[0] for call in url_health.call_args_list],
|
|
list(checks),
|
|
)
|
|
source_acceptance.assert_called_once_with()
|
|
runtime_acceptance.assert_called_once_with()
|
|
compose_mutation.assert_not_called()
|
|
build_mutation.assert_not_called()
|
|
|
|
def test_recovery_runtime_phase_has_no_runtime_mutation(self):
|
|
entries = tuple(RECOVERY_ENTRIES)
|
|
with (
|
|
mock.patch.object(
|
|
RUNNER,
|
|
"prepare_component_runtime",
|
|
) as prepare,
|
|
mock.patch.object(RUNNER, "run_compose") as compose_mutation,
|
|
mock.patch.object(RUNNER, "run_build") as build_mutation,
|
|
):
|
|
RUNNER.run_component_runtime("device-plane", entries, ())
|
|
|
|
prepare.assert_not_called()
|
|
compose_mutation.assert_not_called()
|
|
build_mutation.assert_not_called()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|