From a63ef8435f4de9fc503e01c1eb2a5aa43ecb1511 Mon Sep 17 00:00:00 2001 From: GeiserX <9169332+GeiserX@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:17:31 +0100 Subject: [PATCH] fix(migrate): keep confirmed identity backups confirmed through the owner migration The backup fingerprint includes a volume's creation time, so a volume made again reads as new state. The owner migration makes every earner volume again with the same data, and every confirmed Mysterium or ProxyBase wallet backup turned to "changed" on the Payouts page. The migration now keeps the replaced volume's creation time in a label on the new one, and the fingerprint reads it. --- UPGRADING.md | 4 +++- app/constants.py | 7 +++++++ app/orchestrator.py | 8 ++++++-- app/owner_migrate.py | 9 ++++++++- tests/test_backup_confirmations.py | 17 +++++++++++++++++ tests/test_owner_migrate.py | 24 ++++++++++++++++++++++++ 6 files changed, 65 insertions(+), 4 deletions(-) diff --git a/UPGRADING.md b/UPGRADING.md index 8775df88..6c7123ac 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -54,7 +54,9 @@ ones: - every volume of an earner the worker started is copied to a backup named `-pre-isolation`, checked, recreated with the label, filled back from the backup and checked again, and the earner is recreated as it was. A - Mysterium identity keeps its address. Each backup carries the label + Mysterium identity keeps its address, and a backup you confirmed on the + Payouts page stays confirmed (if you migrated with 1.73.0 or 1.73.1, the + page shows those boxes as changed: confirm them again). Each backup carries the label `cashpilot.backup-of=`, and the log line *Backup … holds the data of …* says which earner it belonged to; - the worker's own volumes are copied into new ones with `_owned` names diff --git a/app/constants.py b/app/constants.py index 04239962..091872f1 100644 --- a/app/constants.py +++ b/app/constants.py @@ -40,6 +40,13 @@ UNDEPLOYABLE_STATUSES = frozenset({"broken", "dead", "dropped"}) +#: On a volume the owner migration made again: the creation time of the volume +#: it replaced. The backup fingerprint (orchestrator.state_id) reads it instead of +#: the new volume's own, so moving the same data onto a labelled volume does not +#: void a confirmed backup of it. +LABEL_STATE_CREATED = "cashpilot.state-created" + + #: deployed-by values only CashPilot itself writes: "worker" by deploy_raw, and #: "direct" by the deploy path before workers (removed in #85), whose containers #: still run on old installs. "compose" is an exported file the user runs by hand. diff --git a/app/orchestrator.py b/app/orchestrator.py index 4c096f99..1264c58e 100644 --- a/app/orchestrator.py +++ b/app/orchestrator.py @@ -52,6 +52,7 @@ LABEL_PROXY, LABEL_ROLE, LABEL_SERVICE, + LABEL_STATE_CREATED, LABEL_VERSION, drift_warnings_enabled, ) @@ -1216,7 +1217,7 @@ def state_id(container: Any, targets: dict[str, str] | list[str] | None, client: """A short fingerprint of WHERE a service's irreplaceable state lives. Built from each critical mount's type and source, plus a named volume's - creation time. It changes when the state can have changed under the user: + creation time (the replaced volume's, for one the owner migration moved). It changes when the state can have changed under the user: the volume was removed and made again (same name, new creation time), the service now points at another volume or host path, or the state sits in the container's own layer and the container was replaced. It does NOT change @@ -1248,7 +1249,10 @@ def state_id(container: Any, targets: dict[str, str] | list[str] | None, client: name = str(mount.get("Name") or "") created = "" try: - created = str(((client or _get_client()).volumes.get(name).attrs or {}).get("CreatedAt") or "") + attrs = (client or _get_client()).volumes.get(name).attrs or {} + # The owner migration copies a volume's data onto a new one of the + # same name and keeps the old creation time in this label. + created = str((attrs.get("Labels") or {}).get(LABEL_STATE_CREATED) or attrs.get("CreatedAt") or "") except Exception as exc: # noqa: BLE001 - unknown, not a change logger.debug("Could not read volume %s for the state fingerprint: %s", name, exc) return None diff --git a/app/owner_migrate.py b/app/owner_migrate.py index c8a6c6c9..cbdc1ddb 100644 --- a/app/owner_migrate.py +++ b/app/owner_migrate.py @@ -34,6 +34,7 @@ from pathlib import Path from typing import Any +from app.constants import LABEL_STATE_CREATED from app.socket_policy import OWNER, OWNER_LABEL logger = logging.getLogger("cashpilot.migrate") @@ -690,6 +691,8 @@ def back_up(self, vol: str, used_by: list[str]) -> None: "backup": backup, "used_by": used_by, "labels": attrs.get("Labels") or {}, + # Kept on the new volume, so a confirmed backup of this data stays confirmed. + "created": attrs.get("CreatedAt") or "", "digest": result, "backed_up": True, "restored": False, @@ -722,7 +725,11 @@ def restore(self, vol: str) -> None: current.remove() current = None if current is None: - self.client.volumes.create(vol, driver="local", labels={**record["labels"], OWNER_LABEL: OWNER}) + labels = {**record["labels"], OWNER_LABEL: OWNER} + if record.get("created"): + # A label the old volume already had is the older, truer time. + labels.setdefault(LABEL_STATE_CREATED, record["created"]) + self.client.volumes.create(vol, driver="local", labels=labels) result = self.copy(record["backup"], vol) if result["digest"] != record["digest"]["digest"]: raise MigrationError( diff --git a/tests/test_backup_confirmations.py b/tests/test_backup_confirmations.py index ca6aeb40..861e2e59 100644 --- a/tests/test_backup_confirmations.py +++ b/tests/test_backup_confirmations.py @@ -31,6 +31,7 @@ from app import main, orchestrator, payout_registry from app import state_backup as sb +from app.constants import LABEL_STATE_CREATED ROOT = Path(__file__).resolve().parent.parent NOW = datetime(2026, 10, 31, 12, 0, tzinfo=UTC) @@ -207,6 +208,22 @@ def test_a_volume_removed_and_made_again_changes_it(self): b = orchestrator.state_id(_container(VOLUME), TARGETS, _client({"mysterium-data": "2026-10-30T13:00:36Z"})) assert a != b + def test_a_volume_the_owner_migration_made_again_keeps_it(self): + """The migration copies the data onto a new volume of the same name and + keeps the old creation time in a label; the fingerprint reads that.""" + before = orchestrator.state_id(_container(VOLUME), TARGETS, _client({"mysterium-data": "2026-03-26T11:41:03Z"})) + migrated = SimpleNamespace( + volumes=SimpleNamespace( + get=lambda name: SimpleNamespace( + attrs={ + "CreatedAt": "2026-11-07T13:44:20Z", + "Labels": {LABEL_STATE_CREATED: "2026-03-26T11:41:03Z", "cashpilot.deployed-by": "worker"}, + } + ) + ) + ) + assert orchestrator.state_id(_container(VOLUME), TARGETS, migrated) == before + def test_a_bind_mount_moved_to_another_folder_changes_it(self): mount = {"Type": "bind", "Source": "/mnt/a", "Destination": "/app/identity"} a = orchestrator.state_id(_container([mount]), {"/app/identity": "x"}) diff --git a/tests/test_owner_migrate.py b/tests/test_owner_migrate.py index 8f72894b..3f31d255 100644 --- a/tests/test_owner_migrate.py +++ b/tests/test_owner_migrate.py @@ -429,6 +429,30 @@ def test_the_identity_moves_onto_a_labelled_volume_of_the_same_name(install, tmp assert d.vols["hand-data"].attrs["Labels"] is None +def test_a_migrated_volume_keeps_the_backup_fingerprint_it_had(install, tmp_path): + """A confirmed backup of an identity is tied to its volume's creation time. + The migration makes the volume again with the same data, so it carries the + old time over, and the confirmation stays valid.""" + from types import SimpleNamespace + + from app import orchestrator + from app.constants import LABEL_STATE_CREATED + + d, myst, _hand = install + d.vols["mysterium-data"].attrs["CreatedAt"] = "2026-03-26T11:41:03Z" + targets = {"/var/lib/mysterium-node": "identity"} + box = SimpleNamespace( + id="c", + attrs={"Mounts": [{"Type": "volume", "Name": "mysterium-data", "Destination": "/var/lib/mysterium-node"}]}, + ) + before = orchestrator.state_id(box, targets, d) + assert FakeMigrator(d, om.State(tmp_path)).run([]) == 0 + # Docker gives the volume it made again a creation time of its own. + d.vols["mysterium-data"].attrs["CreatedAt"] = "2026-11-07T13:44:20Z" + assert d.vols["mysterium-data"].attrs["Labels"] == {**OWNER, LABEL_STATE_CREATED: "2026-03-26T11:41:03Z"} + assert orchestrator.state_id(box, targets, d) == before + + def test_a_second_run_finds_nothing_to_do(install, tmp_path): d, _, _ = install assert FakeMigrator(d, om.State(tmp_path)).run([]) == 0