Rename Installation to Instance

Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
This commit is contained in:
2026-08-31 10:12:01 +02:00
co-authored by Claude Opus 5
parent 6534855492
commit d01a8dad37
101 changed files with 374 additions and 375 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
"""Optional remote access through a Fluksio portal.
Nothing in here runs unless someone enrolled this installation: with no
Nothing in here runs unless someone enrolled this instance: with no
``cloud.json`` the connector never starts and the portal's tokens are refused
like any other bad credential. Turning it off is deleting that one file.
"""
+13 -9
View File
@@ -1,4 +1,4 @@
"""What this installation knows about the portal it is enrolled with.
"""What this instance knows about the portal it is enrolled with.
One file on the data volume, beside the OAuth keypair and for the same reason:
it is a credential, it must survive a rebuild, and it must never be in the
@@ -9,7 +9,7 @@ portal still believes.
The portal's public keys are stored here rather than fetched: they are pinned
at enrolment, when a person was holding a claim code they had just read off the
portal's own screen. A hub whose DNS or TLS is later hijacked cannot re-key an
installation that already enrolled; it can only fail to verify.
instance that already enrolled; it can only fail to verify.
"""
from __future__ import annotations
@@ -42,11 +42,11 @@ VIA_PORTAL = secrets.token_urlsafe(16)
class CloudConfig:
portal_url: str
ws_url: str
installation_id: str
instance_id: str
token: str
issuer: str
jwks: dict[str, Any]
#: The local account this installation acts as on its own behalf: what the
#: The local account this instance acts as on its own behalf: what the
#: health summary is collected as, and what a screen paired through the
#: portal borrows for want of a person. Recorded at enrolment from whoever
#: performed it. Portal *sessions* no longer come through here — they name
@@ -72,6 +72,10 @@ def _read() -> CloudConfig | None:
return _cache[1]
try:
data = json.loads(path.read_text())
# A file written before the installation/instance rename. Adopt it
# rather than failing to parse, which would read as "never enrolled".
if "installation_id" in data:
data["instance_id"] = data.pop("installation_id")
config = CloudConfig(**data)
except (OSError, ValueError, TypeError):
logger.exception("Could not read %s; remote access stays off", path)
@@ -109,15 +113,15 @@ def delete() -> None:
def decode_portal_token(token: str) -> dict[str, Any]:
"""Validate a token the portal minted for this installation.
"""Validate a token the portal minted for this instance.
Raises ``InvalidTokenError`` for everything else, including the ordinary
case of not being enrolled at all — which is what makes this branch free
for an installation nobody connected.
for an instance nobody connected.
"""
config = _read()
if config is None:
raise InvalidTokenError("this installation is not enrolled with a portal")
raise InvalidTokenError("this instance is not enrolled with a portal")
try:
key = jwt.PyJWKSet.from_dict(config.jwks).keys[0]
@@ -128,9 +132,9 @@ def decode_portal_token(token: str) -> dict[str, Any]:
token,
key,
algorithms=["RS256"],
# The audience is this installation's own id, so a token the portal
# The audience is this instance's own id, so a token the portal
# minted for somebody else's machine fails here.
audience=config.installation_id,
audience=config.instance_id,
issuer=config.issuer,
)
scope = claims.get("scope")
+13 -15
View File
@@ -1,4 +1,4 @@
"""The link this installation opens to its portal.
"""The link this instance opens to its portal.
Same shape as the remote-worker agent, with the direction of the favour
reversed: there, a GPU box dials the engine so the engine can give it work;
@@ -50,7 +50,7 @@ MAX_WS_MESSAGE = 1024 * 1024
#: Only the versioned API is served over the tunnel. The MCP mount and the
#: OAuth endpoints live outside it and stay local-only.
ALLOWED_PREFIX = "/api/v1/"
#: Proxied calls, and bridged streams, this installation will run at once.
#: Proxied calls, and bridged streams, this instance will run at once.
#: Neither dict was bounded, and an id the hub reused silently dropped the
#: reference to a task that was still running.
MAX_IN_FLIGHT = 256
@@ -76,7 +76,7 @@ async def watch_enrolment(app: FastAPI) -> None:
"""Notice an enrolment that happened outside this process.
``fluksio enroll`` writes the config against the database directly, with no
idea whether an engine is running — so without this, pairing an installation
idea whether an engine is running — so without this, pairing an instance
that is already serving would take a restart to come into effect. Enrolling
through the API starts the link itself and this sees a task already there.
"""
@@ -124,7 +124,7 @@ class CloudConnector:
# this process does: enrolment may have named a container.
"issuer": config.issuer if config else None,
"portal_account": config.portal_account if config else None,
"installation_id": config.installation_id if config else None,
"instance_id": config.instance_id if config else None,
"last_error": self._last_error,
"connected_since": self._connected_since,
}
@@ -165,7 +165,7 @@ class CloudConnector:
delay = BASE_BACKOFF_S if attached else min(MAX_BACKOFF_S, delay * 2)
# Jittered, so a portal coming back up is not met by every
# installation it serves in the same instant.
# instance it serves in the same instant.
wait = delay * (0.75 + random.random() * 0.5)
logger.info("Reconnecting to the portal in %.0fs", wait)
await asyncio.sleep(wait)
@@ -197,9 +197,7 @@ class CloudConnector:
self._connected = True
self._connected_since = time.time()
self._last_error = None
logger.info(
"Attached to the portal as installation %s", config.installation_id
)
logger.info("Attached to the portal as instance %s", config.instance_id)
keepalive = asyncio.create_task(self._keepalive(socket, config))
try:
@@ -270,7 +268,7 @@ class CloudConnector:
@staticmethod
def _local_account(session: Any, config: cloud_config.CloudConfig) -> Any:
"""The account this installation acts as, or the one that now stands for it.
"""The account this instance acts as, or the one that now stands for it.
``local_user_id`` names whoever redeemed the claim code. A database
restored from a backup, or rebuilt under an enrolment that outlived it,
@@ -301,7 +299,7 @@ class CloudConnector:
)
if len(superusers) != 1:
logger.warning(
"The account this installation enrolled as is gone, and there "
"The account this instance enrolled as is gone, and there "
"%s to adopt unambiguously — enrol again from Settings.",
"is no superuser" if not superusers else "are several superusers",
)
@@ -309,7 +307,7 @@ class CloudConnector:
adopted = superusers[0]
cloud_config.save(replace(config, local_user_id=str(adopted.id)))
logger.warning(
"The account this installation enrolled as is gone; acting as %s instead",
"The account this instance enrolled as is gone; acting as %s instead",
adopted.email,
)
return adopted
@@ -334,14 +332,14 @@ class CloudConnector:
async def _health_summary(
self, config: cloud_config.CloudConfig
) -> dict[str, Any] | None:
"""This installation's own health, as the dashboard reads it.
"""This instance's own health, as the dashboard reads it.
Fetched through the same in-process transport as everything else, with
a short-lived local token: the observability API is authenticated, and
this process is entitled to mint one for the enrolling user.
``failures_24h`` is added here rather than by ``/summary``: it is the
portal's tile and nothing on this installation reads it, and summing
portal's tile and nothing on this instance reads it, and summing
the same rollups over the same window the app's own Home tile sums
keeps the two from drifting apart the way they already did once.
"""
@@ -376,7 +374,7 @@ class CloudConnector:
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=self._app),
base_url="http://installation.local",
base_url="http://instance.local",
timeout=httpx.Timeout(300.0, connect=5.0),
)
@@ -404,7 +402,7 @@ class CloudConnector:
try:
if not path.startswith(ALLOWED_PREFIX):
# Defence in depth: the hub only ever forwards API paths, and
# an installation that trusted anything else would be an SSRF
# an instance that trusted anything else would be an SSRF
# gadget aimed at its own process.
await socket.send(
_dump({"op": "err", "id": call_id, "message": "path not allowed"})
+9 -9
View File
@@ -1,7 +1,7 @@
"""Redeeming a claim code, from the dashboard or from the command line.
The work is the same either way — ask the portal, keep what it answers, and
map the account that asked to the portal identity that owns the installation
map the account that asked to the portal identity that owns the instance
so it lives here rather than in the route. The command line matters because a
machine on a cluster has no browser pointed at it: `fluksio enroll` does this
before the engine has started, holding nothing but the database.
@@ -32,7 +32,7 @@ class EnrollError(Exception):
class AlreadyEnrolled(EnrollError):
def __init__(self) -> None:
super().__init__(409, "This installation is already connected to a portal")
super().__init__(409, "This instance is already connected to a portal")
def _is_local(url: str) -> bool:
@@ -44,9 +44,9 @@ def _is_local(url: str) -> bool:
def redeem_claim(
portal_url: str, claim_code: str, *, timeout: float = 15.0
) -> dict[str, Any]:
"""Trade a claim code for this installation's credential and the portal's keys."""
"""Trade a claim code for this instance's credential and the portal's keys."""
base = portal_url.rstrip("/")
# The claim code and, from here on, this installation's credential go to
# The claim code and, from here on, this instance's credential go to
# this address. Over plain http both are readable by anything on the path,
# so refuse rather than enrol insecurely — bar a loopback portal, which is
# how the stack is developed against itself.
@@ -70,12 +70,12 @@ def redeem_claim(
data: dict[str, Any] = response.json()
if not data.get("owner_id"):
# A portal older than remote users does not say who owns the
# installation, and without that the enrolling account cannot be mapped
# instance, and without that the enrolling account cannot be mapped
# to anyone — which would leave the portal connected but refused here.
raise EnrollError(
502,
"That portal is too old for this installation: it did not say "
"which account owns the installation",
"That portal is too old for this instance: it did not say "
"which account owns the instance",
)
return data
@@ -91,8 +91,8 @@ def enroll(
config = cloud_config.CloudConfig(
portal_url=portal_url.rstrip("/"),
ws_url=data["ws_url"],
installation_id=data["installation_id"],
token=data["installation_token"],
instance_id=data["instance_id"],
token=data["instance_token"],
issuer=data["issuer"],
# Pinned here, at the one moment the claim code proves who we are
# talking to. Nothing refreshes this.