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
+10 -10
View File
@@ -1,8 +1,8 @@
"""Connecting this installation to a Fluksio portal, and cutting it loose.
"""Connecting this instance to a Fluksio portal, and cutting it loose.
Entirely optional, and superuser-only to change: enrolling grants a remote
party the rights of the account that performed it, which is not a decision an
ordinary user of this installation gets to make on everyone else's behalf.
ordinary user of this instance gets to make on everyone else's behalf.
Admitting further portal accounts is the same decision made again, so it is
guarded the same way. A person let in this way gets an ordinary local account
@@ -47,7 +47,7 @@ class EnrollBody(BaseModel):
@router.get("/status", dependencies=[Depends(get_current_user)])
def read_status(request: Request) -> dict[str, Any]:
"""Whether this installation is enrolled, and whether the link is up.
"""Whether this instance is enrolled, and whether the link is up.
Readable by any signed-in user: everyone here has a right to know whether
the machine they are using can be reached from outside.
@@ -61,7 +61,7 @@ def read_status(request: Request) -> dict[str, Any]:
"portal_url": config.portal_url if config else None,
"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": None,
"connected_since": None,
}
@@ -80,7 +80,7 @@ async def enroll(
"""Redeem a claim code and start dialling the portal.
The account performing this is mapped to the portal account that owns the
installation, so the owner's portal sessions arrive here as them. Widening
instance, so the owner's portal sessions arrive here as them. Widening
that to anyone else is a local decision made one person at a time, below —
never something the portal can do from its side.
@@ -121,11 +121,11 @@ def add_remote_user(session: SessionDep, body: RemoteUserBody) -> Any:
if config is None:
raise HTTPException(
status_code=409,
detail="This installation is not connected to a portal",
detail="This instance is not connected to a portal",
)
try:
response = httpx.post(
f"{config.portal_url.rstrip('/')}/api/v1/installation-members/",
f"{config.portal_url.rstrip('/')}/api/v1/instance-members/",
headers={"Authorization": f"Bearer {config.token}"},
json={"code": body.code.strip()},
timeout=15.0,
@@ -140,7 +140,7 @@ def add_remote_user(session: SessionDep, body: RemoteUserBody) -> Any:
)
if response.status_code == 409:
raise HTTPException(
status_code=409, detail="That code belongs to this installation's owner"
status_code=409, detail="That code belongs to this instance's owner"
)
if response.status_code != 200:
raise HTTPException(
@@ -183,14 +183,14 @@ def forget_remote_user(portal_sub: str) -> None:
Best effort on purpose: the local account is what grants access, so it is
already over by the time this runs. A portal that cannot be reached keeps a
row that opens nothing — the installation refuses the session either way.
row that opens nothing — the instance refuses the session either way.
"""
config = cloud_config.load()
if config is None:
return
try:
response = httpx.delete(
f"{config.portal_url.rstrip('/')}/api/v1/installation-members/{portal_sub}",
f"{config.portal_url.rstrip('/')}/api/v1/instance-members/{portal_sub}",
headers={"Authorization": f"Bearer {config.token}"},
timeout=15.0,
)
+13 -13
View File
@@ -5,7 +5,7 @@ shows it on the wall, and somebody with an account types that code into the
panels dialog to say which panel the device is. The device polls, collects the
credential the approval minted, and never asks again.
A screen hanging somewhere this installation is not reachable from does the
A screen hanging somewhere this instance is not reachable from does the
same thing through the portal, which forwards those two calls down the tunnel
without a session — a device with no credential is the whole point of them —
and mints the credential itself when the approval comes. Which side minted it
@@ -81,7 +81,7 @@ class _Pending(BaseModel):
#: Self-reported and worth what that is worth.
device: str = ""
#: Whether it came down the tunnel. A device that reached the portal
#: cannot reach this installation, so its credential has to be minted
#: cannot reach this instance, so its credential has to be minted
#: where it can collect it.
remote: bool = False
@@ -99,7 +99,7 @@ class _PendingStore:
A device's poll lands on whichever API worker the proxy picked, and a
screen pairing through the portal lands on whichever one holds the tunnel
— so a code minted by one worker has to be findable from the next. Redis
is where this installation already keeps what must outlive a process.
is where this instance already keeps what must outlive a process.
Without one there is one process by definition (the pip install, and the
tests), and a dictionary is the same thing for it.
@@ -224,8 +224,8 @@ def _describe(request: Request) -> str:
def _mint_at_hub(panel_id: str) -> str:
"""Ask the portal for this panel's credential.
A device that arrived through the portal cannot reach this installation, so
a token this installation signed would be one it could never present: the
A device that arrived through the portal cannot reach this instance, so
a token this instance signed would be one it could never present: the
portal verifies what crosses its tunnel, and it verifies against its own
key. It mints, we say which panel — and the scope check here decides the
rest, on this call and on every later one.
@@ -234,7 +234,7 @@ def _mint_at_hub(panel_id: str) -> str:
if config is None:
raise HTTPException(
status_code=409,
detail="That device came through a portal this installation is no "
detail="That device came through a portal this instance is no "
"longer enrolled with",
)
try:
@@ -264,14 +264,14 @@ class PanelsPublic(BaseModel):
reliable answer to it: an admin working through the portal is on the
portal's origin, and this one is for a screen on this network.
An installation enrolled with a portal has a second address, built by the
An instance enrolled with a portal has a second address, built by the
dialog from what ``/cloud/status`` reports rather than from here — a panel
is not the thing that knows whether remote access is on.
"""
panels: list[PanelDef] = Field(default_factory=list)
#: Whatever this installation was told it is reachable at. The same setting
#: the password-reset links are built from, so an installation that has it
#: Whatever this instance was told it is reachable at. The same setting
#: the password-reset links are built from, so an instance that has it
#: wrong has it wrong in both places.
frontend_host: str = ""
@@ -310,7 +310,7 @@ async def save_panels(body: PanelsConfig) -> Any:
def _save() -> None:
# Read and write under one lock. The nonce belongs to this
# installation, not to whoever is writing the panels back: a client
# instance, not to whoever is writing the panels back: a client
# holding an older copy must not be able to undo a revocation by
# saving an arrangement — and reading it in a separate step from
# writing it is exactly how an unpair in between was undone.
@@ -335,7 +335,7 @@ def start_pairing(request: Request) -> Any:
All this hands out is a code that means nothing until somebody with an
account approves it, so the worst an unwelcome caller achieves is an entry
that expires ten minutes later. Reachable from the internet when this
installation is enrolled with a portal, which is what the cap and the
instance is enrolled with a portal, which is what the cap and the
portal's own per-address limits are between.
"""
if _pending.count() >= MAX_PENDING:
@@ -412,8 +412,8 @@ def approve_pairing(panel_id: str, body: PairRequest, current_user: CurrentUser)
A credential minted here names the approver, so what the panel does stays
attributable to a person rather than to nobody. One minted by the portal —
for a device that reached this installation only through it — names the
account this installation was enrolled with instead, since that is the one
for a device that reached this instance only through it — names the
account this instance was enrolled with instead, since that is the one
every portal-borne request already acts as.
"""
panel = find(panel_id)
+3 -3
View File
@@ -1,4 +1,4 @@
"""One index of everything in this installation worth jumping to by name."""
"""One index of everything in this instance worth jumping to by name."""
from typing import Any, Literal
@@ -20,7 +20,7 @@ from fluksio.flow.secrets import get_secrets
from fluksio.flow.store import FlowNotFound
# A wall panel never reaches this route: ``deps._panel_may`` is a whitelist that
# ends in a 403, and a whole-installation index is the opposite of what a screen
# ends in a 403, and a whole-instance index is the opposite of what a screen
# on a wall is allowed to read.
router = APIRouter(
prefix="/search", tags=["search"], dependencies=[Depends(get_current_user)]
@@ -144,7 +144,7 @@ async def read_search_index(
"""Everything searchable, for the client to match against as it is typed.
The whole index rather than a query: it is a few hundred short rows for an
installation of any ordinary size, so one fetch when the panel opens beats a
instance of any ordinary size, so one fetch when the panel opens beats a
round trip per keystroke — and the client already has a matcher.
Secrets are named only to a superuser, which is who ``/secrets`` answers to.
+1 -1
View File
@@ -13,7 +13,7 @@ from fluksio.api.deps import get_current_active_superuser
from fluksio.flow.secrets import SecretNotFound, get_secrets
from fluksio.models import Message
# Superuser, not merely signed in. The names alone say what this installation
# Superuser, not merely signed in. The names alone say what this instance
# talks to, and `PUT /{name}` takes any name — so an ordinary account could
# overwrite the credential a flow authenticates with. `/search` already gates
# secret names this way and said so; this router was the half that did not.