Close the nine open SDK tasks: one engine per directory, a tabbed dashboard, re-pairing, run recovery
Docs / docs (push) Successful in 27s
Playwright Tests / test-playwright (1, 2) (push) Failing after 17s
Playwright Tests / test-playwright (2, 2) (push) Failing after 12s
pre-commit / pre-commit (push) Failing after 1m59s
Test Backend / test-backend (push) Failing after 2m30s
Compose Smoke Test / test-compose (push) Failing after 13s
Playwright Tests / merge-reports (push) Failing after 2m19s

serve: refuse a second engine for one data directory whatever port it was
asked for, using the pidfile and a token this directory signed. The check
runs before the database is touched and before the credential is written,
which is what left every later CLI call pointing at a dead port.

The terminal dashboard is three tabs (Overview, Runs, Logs) with the toolbar
following the focused pane, the engine's output goes to serve.log rather than
down a pipe, and closing the screen stops both reader threads so the prompt
comes back. It adopts a running engine on every start, so stop/start and
restart work on one it did not start, and a stop waits for the process to be
gone before the next start. Enrolment reports itself in the modal.

enroll: a new claim code replaces the pairing instead of being refused. The
code is redeemed before anything is written, mappings to a portal being left
are cleared, and a running engine redials when the stored enrolment changes.

runs: an engine re-queues the runs left `queued` by the one before it, and
`fluksio retry <id>` / `retry --group <sweep>` submits an interrupted run
again with the same inputs and group, recorded through Run.parent_id.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U9BoNGq6V9MdRWAte7JBuC
This commit is contained in:
2026-08-31 17:37:13 +02:00
co-authored by Claude Opus 5
parent bdad6d7fc2
commit 8a94bf10d7
14 changed files with 873 additions and 140 deletions
+114 -1
View File
@@ -25,7 +25,8 @@ from fluksio.cloud import config as cloud_config
from fluksio.core.config import settings
from fluksio.flow.panels import PanelDef, PanelsConfig, write_config
from fluksio.models import User
from tests.utils.portal import ISSUER, portal_token
from tests.utils.portal import ISSUER, jwks, portal_token
from tests.utils.user import create_random_user
def test_portal_token_is_refused_when_not_enrolled(
@@ -101,6 +102,85 @@ def test_status_reports_not_enrolled(
assert response.json()["enrolled"] is False
def test_enrolling_again_replaces_the_connection(
client: TestClient,
enrolled: User,
portal_key: rsa.RSAPrivateKey,
superuser_token_headers: dict[str, str],
db: Session,
) -> None:
"""A new claim code re-pairs rather than being refused.
Deleting cloud.json by hand used to be the only way through, and it takes
every remote connection with it. The mappings of the portal being left go
too: nothing in the row says which portal issued the subject, so one kept
from the old one would resolve a stranger onto a local account.
"""
stale = create_random_user(db)
stale.portal_sub = "portal-user-7"
db.add(stale)
db.commit()
elsewhere = "https://other.example.test"
reply = Mock(
status_code=200,
json=Mock(
return_value={
"ws_url": f"{elsewhere}/api/v1/tunnel/attach",
"instance_id": "11111111-2222-3333-4444-555555555555",
"instance_token": "the-new-token",
"issuer": elsewhere,
"jwks": jwks(portal_key),
"owner_id": "portal-user-9",
}
),
)
with patch("fluksio.cloud.enroll.httpx.post", return_value=reply):
again = client.post(
f"{settings.API_V1_STR}/cloud/enroll",
headers=superuser_token_headers,
json={"portal_url": elsewhere, "claim_code": "ABCD-EFGH"},
)
assert again.status_code == 200, again.text
config = cloud_config.load()
assert config is not None
assert config.portal_url == elsewhere
assert config.token == "the-new-token"
db.expire_all()
assert db.get(User, enrolled.id).portal_sub == "portal-user-9"
# The other portal's mapping is not carried over to this one.
assert db.get(User, stale.id).portal_sub is None
def test_a_claim_the_portal_refuses_keeps_the_connection(
client: TestClient,
enrolled: User,
superuser_token_headers: dict[str, str],
db: Session,
) -> None:
"""Nothing is written until the portal has accepted the code.
That ordering is what makes replacing safe: a mistyped code leaves the
instance connected to the portal it was connected to.
"""
before = cloud_config.load()
with patch("fluksio.cloud.enroll.httpx.post", return_value=Mock(status_code=404)):
refused = client.post(
f"{settings.API_V1_STR}/cloud/enroll",
headers=superuser_token_headers,
json={"portal_url": ISSUER, "claim_code": "NOPE-NOPE"},
)
assert refused.status_code == 400, refused.text
after = cloud_config.load()
assert before is not None and after is not None
assert (before.instance_id, before.token) == (after.instance_id, after.token)
db.expire_all()
assert db.get(User, enrolled.id).portal_sub == "portal-user-1"
def test_enrolling_needs_a_superuser(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
@@ -388,6 +468,39 @@ async def test_a_config_that_appears_while_running_is_dialled(monkeypatch) -> No
assert started
@pytest.mark.anyio
async def test_an_enrolment_replaced_while_running_is_redialled(monkeypatch) -> None:
"""`fluksio enroll` is its own process and cannot cancel the live link.
Without this the tunnel would stay up on the credential that was replaced,
since the watcher only ever started a link where there was none.
"""
from types import SimpleNamespace
from fluksio.cloud import connector
live = asyncio.create_task(asyncio.sleep(30))
app = Mock()
app.state = Mock(
cloud_task=live, cloud_connector=Mock(), cloud_identity=("old", "old-token")
)
started: list[object] = []
monkeypatch.setattr(connector, "ENROL_POLL_S", 0.01)
monkeypatch.setattr(connector, "start", lambda one: started.append(one))
monkeypatch.setattr(
cloud_config,
"load",
lambda: SimpleNamespace(instance_id="new", token="new-token"),
)
watcher = asyncio.create_task(connector.watch_enrolment(app))
await asyncio.sleep(0.05)
watcher.cancel()
assert live.cancelled() or live.done()
assert started
@pytest.mark.anyio
async def test_a_config_that_cannot_be_read_is_not_dialled(monkeypatch) -> None:
"""Otherwise the connector gives up at once and this restarts it forever."""