refactor(deploy): make device manager releases declarative
This commit is contained in:
@@ -36,7 +36,14 @@ LAUNCHER_HUB_SERVICE_TRUST_UI_ENTRIES = (
|
||||
)
|
||||
|
||||
|
||||
def healthy_device_plane_inventory():
|
||||
def healthy_device_plane_inventory(*, include_manager=False):
|
||||
services = [
|
||||
("device-control-core", "a"),
|
||||
("device-gateway", "b"),
|
||||
("device-postgres", "c"),
|
||||
]
|
||||
if include_manager:
|
||||
services.append(("device-manager", "d"))
|
||||
return {
|
||||
"schemaVersion": "nodedc.device-plane.runtime-inventory.v1",
|
||||
"composeProject": "nodedc-device-plane",
|
||||
@@ -50,15 +57,32 @@ def healthy_device_plane_inventory():
|
||||
"health": "healthy",
|
||||
"restartCount": 0,
|
||||
}
|
||||
for service, character in (
|
||||
("device-control-core", "a"),
|
||||
("device-gateway", "b"),
|
||||
("device-postgres", "c"),
|
||||
)
|
||||
for service, character in services
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def device_manager_release_descriptor(
|
||||
release_id="device-manager-release-unit-001",
|
||||
*,
|
||||
action="activate",
|
||||
predecessor_kind="reconciliation",
|
||||
predecessor_patch="device-manager-reconciliation-unit-001",
|
||||
predecessor_sha="a" * 64,
|
||||
):
|
||||
return {
|
||||
"schemaVersion": "nodedc.device-plane.device-manager-release.v1",
|
||||
"releaseId": release_id,
|
||||
"action": action,
|
||||
"predecessor": {
|
||||
"kind": predecessor_kind,
|
||||
"patchId": predecessor_patch,
|
||||
"artifactSha256": predecessor_sha,
|
||||
},
|
||||
**RUNNER.expected_device_plane_manager_release_boundaries(),
|
||||
}
|
||||
|
||||
|
||||
class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
def build(self, script, patch_id, artifact_dir):
|
||||
environment = os.environ.copy()
|
||||
@@ -239,55 +263,87 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
)
|
||||
runtime_acceptance.assert_called_once_with()
|
||||
|
||||
def test_v3_activation_requires_exact_applied_v2_reconciliation(self):
|
||||
def test_activation_resolves_predecessor_from_descriptor_and_journal(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-manager-v3-predecessor-",
|
||||
prefix="nodedc-device-manager-release-predecessor-",
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
artifact = (
|
||||
root
|
||||
/ RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_ARTIFACT
|
||||
)
|
||||
applied = root / "applied"
|
||||
backups = root / "backups"
|
||||
temporary = root / "tmp"
|
||||
device_plane = root / "device-plane"
|
||||
for path in (applied, backups, temporary, device_plane / "deployment"):
|
||||
path.mkdir(parents=True)
|
||||
predecessor_patch = "device-manager-reconciliation-unit-004"
|
||||
artifact_name = f"nodedc-device-plane-{predecessor_patch}.tgz"
|
||||
artifact = applied / artifact_name
|
||||
artifact_bytes = b"reviewed-reconciliation-artifact"
|
||||
artifact.write_bytes(artifact_bytes)
|
||||
artifact_sha = hashlib.sha256(artifact_bytes).hexdigest()
|
||||
descriptor = device_manager_release_descriptor(
|
||||
predecessor_patch=predecessor_patch,
|
||||
predecessor_sha=artifact_sha,
|
||||
)
|
||||
state_file = root / "applied.jsonl"
|
||||
state_file.write_text(
|
||||
json.dumps({
|
||||
"id": (
|
||||
RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_PATCH_ID
|
||||
),
|
||||
"artifact": (
|
||||
RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_ARTIFACT
|
||||
),
|
||||
"backup_id": (
|
||||
RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_APPLY_BACKUP_ID
|
||||
),
|
||||
"id": predecessor_patch,
|
||||
"artifact": artifact_name,
|
||||
"backup_id": "predecessor-backup",
|
||||
"component": "device-plane",
|
||||
"sha256": hashlib.sha256(artifact_bytes).hexdigest(),
|
||||
"sha256": artifact_sha,
|
||||
"status": "ok",
|
||||
}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
backup = root / "failed-backup"
|
||||
(backups / "predecessor-backup").mkdir()
|
||||
baseline_backup = root / "failed-backup"
|
||||
predecessor_descriptor = {"marker": "exact"}
|
||||
marker = (
|
||||
device_plane
|
||||
/ RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_REL
|
||||
)
|
||||
marker.write_text(
|
||||
json.dumps(predecessor_descriptor),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
mock.patch.object(RUNNER, "APPLIED_DIR", root),
|
||||
mock.patch.object(RUNNER, "APPLIED_DIR", applied),
|
||||
mock.patch.object(RUNNER, "BACKUPS_DIR", backups),
|
||||
mock.patch.object(RUNNER, "TMP_DIR", temporary),
|
||||
mock.patch.object(RUNNER, "STATE_FILE", state_file),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"DEVICE_PLANE_MANAGER_V2_RECONCILIATION_ARTIFACT_SHA256",
|
||||
hashlib.sha256(artifact_bytes).hexdigest(),
|
||||
"component_root",
|
||||
return_value=device_plane,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_manager_control_plane_payload",
|
||||
return_value=(
|
||||
RUNNER.expected_device_plane_manager_control_plane_descriptor()
|
||||
),
|
||||
return_value=descriptor,
|
||||
) as payload,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"load_artifact",
|
||||
return_value=(
|
||||
{
|
||||
"id": predecessor_patch,
|
||||
"component": "device-plane",
|
||||
"type": "app-overlay",
|
||||
},
|
||||
RUNNER.DEVICE_PLANE_MANAGER_V2_RECONCILIATION_ENTRIES,
|
||||
root / "predecessor-payload",
|
||||
),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_manager_v2_reconciliation_payload",
|
||||
return_value=predecessor_descriptor,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_manager_v2_reconciliation_backup",
|
||||
return_value=backup,
|
||||
return_value=baseline_backup,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
@@ -303,10 +359,147 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
result["mode"],
|
||||
"reconciled-v2-manager-forward-activation",
|
||||
"reconciled-manager-forward-activation",
|
||||
)
|
||||
payload.assert_called_once_with(root / "payload")
|
||||
baseline.assert_called_once_with(backup, marker_installed=True)
|
||||
baseline.assert_called_once_with(
|
||||
baseline_backup,
|
||||
marker_installed=True,
|
||||
)
|
||||
|
||||
def test_failed_patch_id_and_sha_are_globally_non_replayable(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-manager-failed-replay-",
|
||||
) as directory:
|
||||
failed_state = Path(directory) / "failed.jsonl"
|
||||
failed_state.write_text(
|
||||
json.dumps({
|
||||
"id": "device-manager-release-failed-001",
|
||||
"sha256": "b" * 64,
|
||||
"status": "failed",
|
||||
}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with mock.patch.object(
|
||||
RUNNER,
|
||||
"FAILED_STATE_FILE",
|
||||
failed_state,
|
||||
):
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"SHA is terminal failed",
|
||||
):
|
||||
RUNNER.reject_failed_artifact_replay(
|
||||
{"id": "different"},
|
||||
"b" * 64,
|
||||
)
|
||||
with self.assertRaisesRegex(
|
||||
RUNNER.DeployError,
|
||||
"patch id is terminal failed",
|
||||
):
|
||||
RUNNER.reject_failed_artifact_replay(
|
||||
{"id": "device-manager-release-failed-001"},
|
||||
"c" * 64,
|
||||
)
|
||||
|
||||
def test_upgrade_accepts_any_current_release_without_runner_patch_ids(self):
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-manager-release-upgrade-",
|
||||
) as directory:
|
||||
root = Path(directory)
|
||||
applied = root / "applied"
|
||||
backups = root / "backups"
|
||||
temporary = root / "tmp"
|
||||
device_plane = root / "device-plane"
|
||||
for path in (applied, backups, temporary, device_plane / "deployment"):
|
||||
path.mkdir(parents=True)
|
||||
predecessor_patch = "device-manager-release-arbitrary-041"
|
||||
artifact_name = f"nodedc-device-plane-{predecessor_patch}.tgz"
|
||||
artifact = applied / artifact_name
|
||||
artifact_bytes = b"arbitrary-reviewed-device-manager-release"
|
||||
artifact.write_bytes(artifact_bytes)
|
||||
artifact_sha = hashlib.sha256(artifact_bytes).hexdigest()
|
||||
predecessor_descriptor = device_manager_release_descriptor(
|
||||
release_id=predecessor_patch,
|
||||
)
|
||||
candidate_descriptor = device_manager_release_descriptor(
|
||||
release_id="device-manager-release-arbitrary-042",
|
||||
action="upgrade",
|
||||
predecessor_kind="release",
|
||||
predecessor_patch=predecessor_patch,
|
||||
predecessor_sha=artifact_sha,
|
||||
)
|
||||
installed = (
|
||||
device_plane / RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_REL
|
||||
)
|
||||
installed.write_text(
|
||||
json.dumps(predecessor_descriptor),
|
||||
encoding="utf-8",
|
||||
)
|
||||
state_file = root / "applied.jsonl"
|
||||
state_file.write_text(
|
||||
json.dumps({
|
||||
"id": predecessor_patch,
|
||||
"artifact": artifact_name,
|
||||
"backup_id": "arbitrary-release-backup",
|
||||
"component": "device-plane",
|
||||
"sha256": artifact_sha,
|
||||
"status": "ok",
|
||||
}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(backups / "arbitrary-release-backup").mkdir()
|
||||
with (
|
||||
mock.patch.object(RUNNER, "APPLIED_DIR", applied),
|
||||
mock.patch.object(RUNNER, "BACKUPS_DIR", backups),
|
||||
mock.patch.object(RUNNER, "TMP_DIR", temporary),
|
||||
mock.patch.object(RUNNER, "STATE_FILE", state_file),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_root",
|
||||
return_value=device_plane,
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"validate_device_plane_manager_control_plane_payload",
|
||||
side_effect=(
|
||||
candidate_descriptor,
|
||||
predecessor_descriptor,
|
||||
),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"load_artifact",
|
||||
return_value=(
|
||||
{
|
||||
"id": predecessor_patch,
|
||||
"component": "device-plane",
|
||||
"type": "app-overlay",
|
||||
},
|
||||
RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES,
|
||||
root / "predecessor-payload",
|
||||
),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service",
|
||||
) as health,
|
||||
):
|
||||
result = (
|
||||
RUNNER.validate_device_plane_manager_activation_predecessor(
|
||||
root / "candidate-payload"
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(result["mode"], "active-manager-forward-upgrade")
|
||||
self.assertEqual(
|
||||
[call.args for call in health.call_args_list],
|
||||
[
|
||||
("device-plane", "device-control-core"),
|
||||
("device-plane", "device-manager"),
|
||||
("device-plane", "device-postgres"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_initial_install_rollback_removes_manager_and_restores_core_only(self):
|
||||
entries = RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES
|
||||
@@ -393,6 +586,73 @@ class DeviceManagerControlPlaneArtifactsTest(unittest.TestCase):
|
||||
f"source+runtime-restored:{len(entries)}",
|
||||
)
|
||||
|
||||
def test_upgrade_rollback_restores_previous_core_and_manager_release(self):
|
||||
entries = RUNNER.DEVICE_PLANE_MANAGER_CONTROL_PLANE_ENTRIES
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="nodedc-device-manager-upgrade-rollback-",
|
||||
) as directory:
|
||||
backup = Path(directory) / "backup"
|
||||
backup.mkdir()
|
||||
(backup / "existing-files.txt").write_text(
|
||||
"\n".join(entries) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(backup / "missing-files.txt").write_text("", encoding="utf-8")
|
||||
(backup / "runtime-before.json").write_text(
|
||||
json.dumps(healthy_device_plane_inventory(include_manager=True)),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"stop_and_remove_compose_services",
|
||||
) as stop,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"restore_platform_overlay",
|
||||
return_value=len(entries),
|
||||
),
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"run_component_runtime",
|
||||
) as restore_runtime,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"healthcheck_compose_service_with_grace",
|
||||
) as restore_health,
|
||||
mock.patch.object(
|
||||
RUNNER,
|
||||
"component_healthchecks",
|
||||
return_value=(),
|
||||
),
|
||||
):
|
||||
result = RUNNER.rollback_device_plane_apply(
|
||||
Path(directory) / "live",
|
||||
backup,
|
||||
entries,
|
||||
"test-stamp",
|
||||
True,
|
||||
("device-control-core", "device-manager"),
|
||||
)
|
||||
|
||||
stop.assert_not_called()
|
||||
restore_runtime.assert_called_once_with(
|
||||
"device-plane",
|
||||
list(entries),
|
||||
("device-control-core", "device-manager"),
|
||||
)
|
||||
self.assertEqual(
|
||||
[call.args for call in restore_health.call_args_list],
|
||||
[
|
||||
("device-plane", "device-control-core"),
|
||||
("device-plane", "device-manager"),
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
result,
|
||||
f"source+runtime-restored:{len(entries)}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
Reference in New Issue
Block a user