fix(profiles): standalone rollback clears the active pointer (state no longer matches a named profile) (ref #893)

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-20 11:06:24 +02:00
parent 3edc32cd4b
commit b5503a12d6
2 changed files with 61 additions and 1 deletions

View File

@ -503,8 +503,24 @@ def create_app() -> FastAPI:
@app.post("/api/v1/profiles/rollback")
async def rollback_active(_claims=Depends(require_jwt)):
# A rollback restores an ARBITRARY point-in-time snapshot, which has no
# associated profile name — unlike /apply, there is no "prior profile"
# to revert `active` to. On a SUCCESSFUL rollback the honest state
# afterward is "no named profile / custom": clear the pointer rather
# than leave it naming whatever profile the last /apply set, which no
# longer matches the (rolled-back) board state. Success predicate
# mirrors the CLI's own (see api/cli.py _cmd_rollback: rc 0 iff
# status in ("applied", "planned")) — "planned" is not structurally
# reachable here (the ctl is always invoked with --yes) but matching
# the CLI's exact predicate beats an ad-hoc single-value check.
root = _root()
_mod, _prof_dir, _pins, active_file = _cli._paths(root)
active_path = Path(active_file)
async with _apply_lock:
return await _run_ctl_json("rollback")
report = await _run_ctl_json("rollback")
if isinstance(report, dict) and report.get("status") in ("applied", "planned"):
active_path.unlink(missing_ok=True)
return report
return app

View File

@ -502,3 +502,47 @@ def test_apply_applied_keeps_active_at_target(client, root, monkeypatch):
r = client.post("/api/v1/profiles/apply", json={"profile": "lite"})
assert r.status_code == 200
assert (root / "profiles" / "active").read_text().strip() == "lite" # kept
# ---------------------------------------------------------------------------
# rollback clears `active` — a standalone rollback restores an ARBITRARY
# point-in-time snapshot that has no associated profile name; leaving `active`
# unchanged would have it keep naming whatever profile the last /apply set,
# which no longer matches the (rolled-back) board state. Mirrors the
# apply_active revert-on-rolled_back fix above, but for the separate
# /rollback route: on a successful rollback (CLI status "applied"/"planned",
# see api/cli.py _cmd_rollback's own success predicate) the honest state is
# "no named profile" -> clear the pointer.
# ---------------------------------------------------------------------------
def test_rollback_clears_active_pointer(client, root, monkeypatch):
assert (root / "profiles" / "active").read_text().strip() == "media"
def fake_run(argv, **kw):
class P:
returncode = 0
stdout = ('{"status":"applied","changed":["x"],"failed":[],'
'"rolled_back":["x"],"target":"R1"}')
stderr = ""
return P()
monkeypatch.setattr(web, "_ctl_run", fake_run)
r = client.post("/api/v1/profiles/rollback", json={})
assert r.status_code == 200
assert not (root / "profiles" / "active").exists()
def test_rollback_failure_leaves_active_untouched(client, root, monkeypatch):
assert (root / "profiles" / "active").read_text().strip() == "media"
def fake_run(argv, **kw):
class P:
returncode = 2
stdout = ""
stderr = "boum"
return P()
monkeypatch.setattr(web, "_ctl_run", fake_run)
r = client.post("/api/v1/profiles/rollback", json={})
assert r.status_code == 500
assert (root / "profiles" / "active").read_text().strip() == "media"