diff --git a/UPGRADING.md b/UPGRADING.md index 0769805b..d4ca2059 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -29,11 +29,12 @@ releases run it in its own network namespace on `cashpilot-isolated`, behind the fence, and the worker starts it only once the fence is in place ([Mysterium on the fenced bridge](docs/isolation.md#mysterium-on-the-fenced-bridge)). -A running node keeps working exactly as it is until you redeploy it, in any -isolation mode: the upgraded worker does not stop, start or change it. The -dashboard lists it under drift ("shares the host's network, where its -NET_ADMIN reaches the host's firewall. Switch isolation on in Fleet, then -redeploy Mysterium once"), and **Check isolation** fails on it. +A running node keeps running until you redeploy it, in any isolation mode: +the upgraded worker does not stop, start or change it. Redeploy it once to +move it behind the fence. Until then the Fleet page shows it under its worker +with a **Redeploy MystNodes to move behind the fence** button (or, with +isolation off, says to switch it on first), the dashboard lists it under +drift, and **Check isolation** fails on it. **What to do, in this order.** diff --git a/app/lan_fence.py b/app/lan_fence.py index b43ecfca..0f26f846 100644 --- a/app/lan_fence.py +++ b/app/lan_fence.py @@ -529,6 +529,7 @@ class IsolationConflict(RuntimeError): "earners_outside_stopped": [], # Fence-gated containers the guard holds stopped, by name, with the reason. "guard": {}, + "unfenced": [], } #: The worker's public egress IP, set from the heartbeat's detection #: (app/worker_api.py). The next apply adds its drop. @@ -1125,6 +1126,22 @@ def _gated(client: Any) -> list[Any]: ] +def _unfenced(client: Any) -> list[dict[str, str]]: + """Fence-gated services' containers still on the host's network (2.0.6 or earlier). + + Left running as they are; the Fleet page offers the redeploy that moves them. + """ + orch = _orchestrator() + from app.constants import LABEL_SERVICE + + found = [] + for c in _managed(client): + slug = str((c.labels or {}).get(LABEL_SERVICE) or "") + if orch.fence_gated(slug) and orch.predates_fence(c): + found.append({"name": c.name, "slug": slug, "service": orch.service_name(slug)}) + return found + + def guard() -> dict[str, str]: """Every GUARD_SECONDS: hold each fence-gated container to the fence. Never raises. @@ -1143,8 +1160,10 @@ def guard() -> dict[str, str]: held: dict[str, str] = {} try: if not orch.docker_available(): + _state["unfenced"] = [] return {} client = orch._get_client() + _state["unfenced"] = _unfenced(client) gated = _gated(client) if not gated: _state["guard"] = {} @@ -1186,6 +1205,8 @@ def guard() -> dict[str, str]: orch.GATED_DEPLOY_LOCK.release() except Exception as exc: # noqa: BLE001 - the next pass tries again logger.warning("Fence guard pass failed: %s", exc) + # Not a fresh scan: no Fleet button on a report that may be stale. + _state["unfenced"] = [] _state["guard"] = held return held @@ -1348,6 +1369,7 @@ def status() -> dict[str, Any]: "earners_outside": list(_state["earners_outside"]), "earners_outside_stopped": list(_state.get("earners_outside_stopped") or []), "guard": dict(_state.get("guard") or {}), + "unfenced": [dict(u) for u in _state.get("unfenced") or []], } diff --git a/app/orchestrator.py b/app/orchestrator.py index e5e786ba..5087fbeb 100644 --- a/app/orchestrator.py +++ b/app/orchestrator.py @@ -893,13 +893,25 @@ def deploy_raw( # chance to flush (storj declares 300 s for that reason) and pays for it # with an unclean-shutdown recovery. A stop that fails is not a reason to # abandon the deploy - the forced removal below is what ran before. + if gated: + # A deploy is the owner's way out of a hold, written down or not. + _MISMATCH_HELD.pop(name, None) if gated and not _pending_identity(name): # Read while it still runs; the guard compares the new one with it. # A redeploy over a stopped node clears the record instead. A record # still pending is kept: the container being replaced is itself a # replacement whose identity was never confirmed, so this retry is # compared with the node as it was before the first redeploy. - identity = read_node_identity(old, slug) if getattr(old, "status", "") == "running" else None + running = getattr(old, "status", "") == "running" + identity = read_node_identity(old, slug) if running else None + if running and identity is None and _reads_identity(slug): + # Without it the new node could run on another keystore unchecked. + raise ContainerRuntimeError( + f"{name} was not replaced: its identity could not be read from the running node, so the new " + "one could not be checked against it. The running container was not touched. Try again in a " + "minute. If the node is broken, stop it from the dashboard first: a stopped node is replaced " + "without the check." + ) _remember_identity(name, identity) try: old.stop(timeout=_get_stop_timeout(slug)) @@ -1254,10 +1266,23 @@ def read_node_identity(container: Any, slug: str) -> str | None: return match.group(1) if match else None +def _reads_identity(slug: str) -> bool: + """Whether the catalog says how to read this service's node identity (collector.node_read).""" + svc = (get_service(slug) if get_service else None) or {} + spec = (svc.get("collector") or {}).get("node_read") or {} + return isinstance(spec.get("command"), list) and bool(spec.get("command")) and isinstance(spec.get("node"), str) + + def _identity_file(name: str) -> str: return os.path.join(IDENTITY_DIR, re.sub(r"[^A-Za-z0-9_.-]", "_", name)) +#: Mismatch holds by container name, kept in memory as well as on disk: a hold +#: whose record could not be written must still hold, or the stopped node +#: would read as unchecked and the guard would start it again every pass. +_MISMATCH_HELD: dict[str, str] = {} + + def _pending_identity(name: str) -> str | None: """The identity a replacement still has to match, or None (no record, or a held mismatch).""" try: @@ -1269,6 +1294,9 @@ def _pending_identity(name: str) -> str | None: def _remember_identity(name: str, identity: str | None) -> None: + if not str(identity or "").startswith("MISMATCH "): + # A deploy rewrites the record: the owner's way out of a hold. + _MISMATCH_HELD.pop(name, None) path = _identity_file(name) try: if identity: @@ -1287,6 +1315,8 @@ def identity_problem(container: Any) -> str | None: None while there is nothing to compare (no record, or the node not answering yet) and once it matches, when the record is deleted. """ + if held_reason := _MISMATCH_HELD.get(container.name): + return held_reason path = _identity_file(container.name) try: with open(path, encoding="utf-8") as fh: @@ -1312,6 +1342,7 @@ def identity_problem(container: Any) -> str | None: f"its identity is {current}, not {expected} as before the redeploy, so it is not running on the data " "volume that holds its keystore. It stays stopped: restore that volume, then redeploy" ) + _MISMATCH_HELD[container.name] = reason _remember_identity(container.name, f"MISMATCH {reason}") return reason diff --git a/app/templates/fleet.html b/app/templates/fleet.html index 5fe8d7cc..d73cef54 100644 --- a/app/templates/fleet.html +++ b/app/templates/fleet.html @@ -460,6 +460,7 @@

Proxies

// Turning isolation on, per worker id: {busy}, {done}, or {error}. const _fenceSwitch = {}; + const _movedBehindFence = new Set(); // The LAN fence of one worker (app/lan_fence.py), from its heartbeat. Says // nothing when the fence holds and every earner sits behind it. @@ -498,6 +499,29 @@

Proxies

if (held.length) { line += `${line ? '
' : ''}Held stopped: ${held.map(([n, why]) => `${esc(n)} (${esc(why)})`).join('; ')}. A missing LAN fence clears by itself; any other reason needs a redeploy from the dashboard.${line ? '
' : ''}`; } + // A Mysterium from 2.0.6 or earlier, still on the host's network: left + // running until its owner redeploys it, which moves it behind the fence. + // A node moved from this page stays hidden until the worker stops reporting + // it: a heartbeat sent before the move must not offer the move again. + for (const key of [..._movedBehindFence]) { + if (key.startsWith(`${w.id}:`) && !(iso.unfenced || []).some((u) => key === `${w.id}:${u.slug}`)) _movedBehindFence.delete(key); + } + const unfenced = (iso.unfenced || []).filter((u) => !_movedBehindFence.has(`${w.id}:${u.slug}`)); + if (unfenced.length) { + const fenced = iso.mode === 'on' || iso.mode === 'operator'; + const what = unfenced.map((u) => esc(u.service || u.slug)).join(', '); + const order = fenced + ? 'It keeps running as it is until you redeploy it once, which moves it behind the fence and keeps its identity.' + : iso.mode === 'unsupported' + ? 'It keeps running as it is. Podman cannot be fenced: run it on Docker with isolation on, or on your own network (CASHPILOT_CONTAINER_NETWORK).' + : iso.mode === 'off' + ? 'It keeps running as it is. Set CASHPILOT_ISOLATION=auto on this worker and restart it, then redeploy it once to move it behind the fence.' + : 'It keeps running as it is. Switch isolation on first, then redeploy it once to move it behind the fence.'; + const redeploy = _isOwner && fenced && !(sw && sw.busy) + ? unfenced.map((u) => ` `).join('') + : ''; + line += `${line ? '
' : ''}${what} still shares this host's network, where its NET_ADMIN can rewrite the host's firewall. ${order}${redeploy}${line ? '
' : ''}`; + } if (sw) { const tone = sw.error ? 'text-fail' : sw.busy ? 'muted' : 'text-ok'; line += `
${esc(sw.error || sw.busy || sw.done)}
`; @@ -505,6 +529,24 @@

Proxies

return line ? `
${line} How it works
` : ''; } + async function moveBehindFence(workerId, slug, service) { + if (!confirm(`Redeploy ${service} to move it behind the LAN fence?\n\n` + + '- The worker reads the node\'s identity, replaces the container on the fenced network and checks the new one has the same identity.\n' + + '- The data volume is kept. If the identity differs, the node is held stopped and the volume is left as it is.\n' + + '- The node is down for under a minute.')) return; + _fenceSwitch[workerId] = { busy: `Moving ${service} behind the fence…` }; + loadFleet(); + try { + // An empty body redeploys from the recorded spec; a fence-gated service lands on the fenced bridge. + await CP.api(`/api/deploy/${encodeURIComponent(slug)}?worker_id=${workerId}`, { method: 'POST', body: {} }); + _movedBehindFence.add(`${workerId}:${slug}`); + _fenceSwitch[workerId] = { done: `${service} was redeployed behind the fence.` }; + } catch (err) { + _fenceSwitch[workerId] = { error: `${service} was not moved: ${err.message}` }; + } + loadFleet(); + } + async function enableFence(workerId, name) { if (!confirm(`Turn on LAN isolation on "${name}"?\n\n` + '- The worker creates a Docker network (cashpilot-isolated) and one firewall table (ip cashpilot) on its host.\n' @@ -843,6 +885,13 @@

Proxies

return; } + const unfencedBtn = e.target.closest('.btn-unfenced-redeploy'); + if (unfencedBtn) { + const id = parseInt(unfencedBtn.dataset.workerId, 10); + if (!isNaN(id) && !(_fenceSwitch[id] && _fenceSwitch[id].busy)) moveBehindFence(id, unfencedBtn.dataset.slug, unfencedBtn.dataset.service); + return; + } + const isoBtn = e.target.closest('.btn-isolation-check'); if (isoBtn) { const id = parseInt(isoBtn.dataset.workerId, 10); diff --git a/tests/test_lan_fence.py b/tests/test_lan_fence.py index 11c706d3..f1a20e25 100644 --- a/tests/test_lan_fence.py +++ b/tests/test_lan_fence.py @@ -1291,6 +1291,7 @@ def test_the_worker_image_ships_the_fence(): const esc = s => String(s).replace(/[&<>"']/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c])); const _isOwner = true; const _fenceSwitch = {}; +const _movedBehindFence = new Set(); const calls = []; const loadFleet = () => {}; const confirm = () => true; diff --git a/tests/test_mysterium_fenced.py b/tests/test_mysterium_fenced.py index faf06ec7..2fb3ab1a 100644 --- a/tests/test_mysterium_fenced.py +++ b/tests/test_mysterium_fenced.py @@ -271,6 +271,7 @@ def test_control_the_catalog_spec_passes(self, real_catalog): def markers(tmp_path, monkeypatch): monkeypatch.setattr(orchestrator, "STOPPED_MARKER", str(tmp_path / "stopped")) monkeypatch.setattr(orchestrator, "IDENTITY_DIR", str(tmp_path / "identity")) + monkeypatch.setattr(orchestrator, "_MISMATCH_HELD", {}, raising=False) return tmp_path @@ -981,3 +982,171 @@ def svc(default): assert lan_isolation.published_ports(svc("127.0.0.2")) == [] assert lan_isolation.published_ports(svc("::1,localhost")) == [] assert lan_isolation.published_ports(svc("192.168.1.5")) == ["192.168.1.5:4449"] + + +# --------------------------------------------------------------------------- +# (m) The upgrade: the old node keeps earning, the Fleet page offers the move, +# and the move compares the identity with the node it replaces +# --------------------------------------------------------------------------- + +IDENTITY_A = "0x" + "a" * 40 +IDENTITY_B = "0x" + "b" * 40 + + +def _says(identity): + return (0, b'{"identities":[{"id":"' + identity.encode() + b'"}]}') + + +class TestTheUpgrade: + @pytest.mark.parametrize("mode", [lan_fence.MODE_ON, lan_fence.MODE_LEGACY]) + def test_the_old_node_keeps_running_and_the_fleet_page_is_told(self, markers, mode): + node = _old_node() + problem = None if mode == lan_fence.MODE_ON else lan_fence.mode_problem(mode) + _guard([node], problem=problem, mode=mode) + node.stop.assert_not_called() + node.restart.assert_not_called() + node.remove.assert_not_called() + assert lan_fence.status()["unfenced"] == [ + {"name": "cashpilot-mysterium", "slug": "mysterium", "service": MYST["name"]} + ] + + def test_control_a_fenced_node_is_not_reported_as_unfenced(self, markers): + _guard([_node()]) + assert lan_fence.status()["unfenced"] == [] + + def _old_with_data(self, identity): + old = _old_node() + old.attrs["Mounts"] = [ + {"Type": "bind", "Source": "/mnt/appdata/myst/data", "Destination": "/var/lib/mysterium-node", "RW": True} + ] + old.exec_run.return_value = _says(identity) + return old + + def test_the_redeploy_reads_the_running_old_nodes_identity_before_replacing_it(self, markers): + old = self._old_with_data(IDENTITY_A) + order = [] + old.exec_run.side_effect = lambda *a, **k: order.append("read") or _says(IDENTITY_A) + old.stop.side_effect = lambda **_: order.append("stop") + old.remove.side_effect = lambda **_: order.append("remove") + client, _ = _raw_deploy(old=old, volumes={"mysterium-data": {"bind": "/var/lib/mysterium-node", "mode": "rw"}}) + assert order[0] == "read" and "stop" in order + assert orchestrator._pending_identity("cashpilot-mysterium") == IDENTITY_A + volumes = client.containers.create.call_args.kwargs["volumes"] + assert "/mnt/appdata/myst/data" in str(volumes), "the node's own data must come with it" + + def test_a_different_identity_after_the_move_holds_it_and_keeps_the_volume(self, markers): + _raw_deploy(old=self._old_with_data(IDENTITY_A)) + new = _node() + new.exec_run.return_value = _says(IDENTITY_B) + held, _ = _guard([new]) + new.stop.assert_called_once() + new.remove.assert_not_called() + assert IDENTITY_A in held["cashpilot-mysterium"] and IDENTITY_B in held["cashpilot-mysterium"] + # Sticky: stopped, it reads no identity, and it still is not started. + stopped = _node(status="exited") + _guard([stopped]) + stopped.start.assert_not_called() + + def test_control_the_same_identity_after_the_move_runs(self, markers): + _raw_deploy(old=self._old_with_data(IDENTITY_A)) + new = _node() + new.exec_run.return_value = _says(IDENTITY_A) + held, _ = _guard([new]) + new.stop.assert_not_called() + assert held == {} + assert orchestrator._pending_identity("cashpilot-mysterium") is None + + +def test_the_fleet_page_offers_the_move_and_says_isolation_first(): + fleet = (ROOT / "app" / "templates" / "fleet.html").read_text(encoding="utf-8") + assert "iso.unfenced" in fleet + assert "to move behind the fence" in fleet + assert "Switch isolation on first, then redeploy it once" in fleet + assert "moveBehindFence(" in fleet + + +class TestAHoldThatCannotBeWritten: + """Found in the lab: a pending record the worker could not overwrite let the guard restart the node each pass.""" + + def _unwritable(self, markers): + orchestrator._remember_identity("cashpilot-mysterium", IDENTITY_A) + (markers / "identity").chmod(0o500) + (markers / "identity" / "cashpilot-mysterium").chmod(0o400) + + def test_the_hold_survives_and_the_node_is_not_started_again(self, markers): + self._unwritable(markers) + try: + new = _node() + new.exec_run.return_value = _says(IDENTITY_B) + _guard([new]) + new.stop.assert_called_once() + stopped = _node(status="exited") + held, _ = _guard([stopped]) + stopped.start.assert_not_called() + assert IDENTITY_B in held["cashpilot-mysterium"] + with pytest.raises(orchestrator.ContainerNetworkError, match="identity"): + _start(_node(status="exited")) + finally: + (markers / "identity").chmod(0o700) + + def test_control_a_redeploy_lifts_the_hold(self, markers): + self._unwritable(markers) + try: + new = _node() + new.exec_run.return_value = _says(IDENTITY_B) + _guard([new]) + finally: + (markers / "identity").chmod(0o700) + (markers / "identity" / "cashpilot-mysterium").chmod(0o600) + orchestrator._remember_identity("cashpilot-mysterium", None) + stopped = _node(status="exited") + _guard([stopped]) + stopped.start.assert_called_once() + + +def test_a_failed_scan_drops_the_unfenced_report(markers): + """No Fleet button on a report the worker could not refresh.""" + _guard([_old_node()]) + assert lan_fence.status()["unfenced"] + client = MagicMock() + client.containers.list.side_effect = RuntimeError("docker is gone") + with ( + patch.object(orchestrator, "docker_available", return_value=True), + patch.object(orchestrator, "_get_client", return_value=client), + ): + lan_fence.guard() + assert lan_fence.status()["unfenced"] == [] + + +def test_the_fleet_page_does_not_offer_a_move_it_just_made(): + fleet = (ROOT / "app" / "templates" / "fleet.html").read_text(encoding="utf-8") + assert "_movedBehindFence.add(" in fleet + assert "!_movedBehindFence.has(" in fleet + + +class TestTheMoveNeedsTheOldIdentity: + def test_a_running_node_whose_identity_cannot_be_read_is_not_replaced(self, markers): + old = _node() + old.exec_run.return_value = (1, b"") + old.attrs["Mounts"] = [] + with pytest.raises(orchestrator.ContainerRuntimeError, match="not touched"): + _raw_deploy(old=old) + old.stop.assert_not_called() + old.remove.assert_not_called() + + def test_control_a_stopped_node_is_replaced_without_the_check(self, markers): + old = _node(status="exited") + old.exec_run.return_value = (1, b"") + old.attrs["Mounts"] = [] + _raw_deploy(old=old) + old.remove.assert_called_once() + + def test_a_redeploy_lifts_a_hold_kept_only_in_memory(self, markers): + orchestrator._remember_identity("cashpilot-mysterium", IDENTITY_A) + orchestrator._MISMATCH_HELD["cashpilot-mysterium"] = "its identity is 0xb, not 0xa" + old = _node(status="exited") + old.attrs["Mounts"] = [] + _raw_deploy(old=old) + assert "cashpilot-mysterium" not in orchestrator._MISMATCH_HELD + # The identity to match is kept, so the restored volume is still checked. + assert orchestrator._pending_identity("cashpilot-mysterium") == IDENTITY_A