Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ ones:
- every volume of an earner the worker started is copied to a backup named
`<volume>-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=<volume>`, 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
Expand Down
7 changes: 7 additions & 0 deletions app/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions app/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
LABEL_PROXY,
LABEL_ROLE,
LABEL_SERVICE,
LABEL_STATE_CREATED,
LABEL_VERSION,
drift_warnings_enabled,
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion app/owner_migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions tests/test_backup_confirmations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"})
Expand Down
24 changes: 24 additions & 0 deletions tests/test_owner_migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading