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:
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user