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
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:
+114
-1
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user