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
@@ -2,7 +2,7 @@
A named amount of machine — cores, cards and memory — so a node can ask for one
by name. Seeded with a few sizes on first start, and editable afterwards: what
"gpu-small" means is a property of the machines an installation has, and those
"gpu-small" means is a property of the machines an instance has, and those
change.
Revision ID: c7e2b9f34a15
+3 -3
View File
@@ -51,7 +51,7 @@ def _panel_for(payload: dict[str, Any]) -> panels.PanelDef:
effect at once. Or the panel's nonce moved on, which revokes exactly one
screen's and leaves the panel, its dashboards and their arrangement alone.
The nonce is only held against a credential this installation signed. One
The nonce is only held against a credential this instance signed. One
the portal minted for a remote screen carries none — the portal names the
panel and nothing else — and is revoked at the hub instead.
"""
@@ -182,11 +182,11 @@ def decode_token(token: str, request: Request | None = None) -> dict[str, Any]:
guards.
The last branch is the seam a hosted deployment widens: a portal this
installation was enrolled with signs tokens with a key pinned at
instance was enrolled with signs tokens with a key pinned at
enrolment, and they name the portal account holding them, which resolves
to whichever local account was mapped to it — the superuser who enrolled,
or a remote user one of them admitted since. With no enrolment the branch
raises immediately, so an offline installation pays nothing for the
raises immediately, so an offline instance pays nothing for the
possibility. A screen that paired through that portal
arrives there too, naming a panel — which is why the gate below is applied
to whichever branch produced the claims rather than to one of them: where a
+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.
+14 -17
View File
@@ -28,7 +28,7 @@ from typing import Any
import fluksio
#: Where an installation keeps everything, unless it is told otherwise.
#: Where an instance keeps everything, unless it is told otherwise.
DEFAULT_HOME = Path("~/.fluksio")
#: The portal a claim code is redeemed at, unless another is named. Having a
@@ -52,7 +52,7 @@ def _say(message: str = "") -> None:
def _data_dir(raw: str | None, shared: bool = False) -> Path:
"""Which installation this command is for.
"""Which instance this command is for.
A repository with its own venv wants its own engine too — its own flows,
its own run history, its own token — so the default is a `.fluksio` beside
@@ -73,12 +73,12 @@ def _data_dir(raw: str | None, shared: bool = False) -> Path:
return path.resolve()
def _mention_other_installation(data_dir: Path) -> None:
def _mention_other_instance(data_dir: Path) -> None:
"""Say when there is a second engine, so neither goes looking lost."""
shared = DEFAULT_HOME.expanduser().resolve()
if data_dir == shared or not (shared / "fluksio.db").exists():
return
_say(f" Note {shared} holds another installation; this one is separate.")
_say(f" Note {shared} holds another instance; this one is separate.")
_say(" `fluksio serve --global` runs that one instead.")
@@ -217,8 +217,7 @@ def _enroll(portal: str, code: str, as_email: str | None) -> int:
return 1
_say(
f"Connected to {config.portal_url} as {email} "
f"(installation {config.installation_id})."
f"Connected to {config.portal_url} as {email} (instance {config.instance_id})."
)
return 0
@@ -340,7 +339,7 @@ def probe_engine(url: str, token: str, client: Any = None) -> str:
makes stopping it something this command may offer. The proof is the
token: it is signed with this directory's secret key, so an engine that
accepts it is one reading this directory's database. A Fluksio belonging
to another installation answers the health check and refuses the token,
to another instance answers the health check and refuses the token,
and is only ever named — never stopped from here.
"""
import logging
@@ -443,7 +442,7 @@ def cmd_serve(args: argparse.Namespace) -> int:
if port != DEFAULT_PORT:
# Moving off the port quietly makes starting a second engine for
# one directory look like it worked. Two of them on one SQLite
# file is not a supported shape — two *installations* on one
# file is not a supported shape — two *instances* on one
# machine is — so the one already up is named instead.
url = f"http://{reachable}:{DEFAULT_PORT}"
who = probe_engine(url, _token_for(data_dir))
@@ -455,7 +454,7 @@ def cmd_serve(args: argparse.Namespace) -> int:
return 0
if who == "foreign":
_say(
f"Port {DEFAULT_PORT} holds another installation's Fluksio; "
f"Port {DEFAULT_PORT} holds another instance's Fluksio; "
f"serving on {port} instead."
)
else:
@@ -480,15 +479,15 @@ def cmd_serve(args: argparse.Namespace) -> int:
_say(f" Nodes {modules.venv_dir() / 'bin' / 'python'}")
_say(" a venv of its own; the Modules screen installs into it.")
if config is not None:
_say(f" Portal {config.portal_url}, installation {config.installation_id}")
_say(f" Portal {config.portal_url}, instance {config.instance_id}")
_say(" The dashboard is served by the portal; nothing is served here.")
else:
_say(" No portal. Pair this installation with:")
_say(" No portal. Pair this instance with:")
_say(" fluksio enroll <code>")
_say(f" Signed in as {admin_email}")
_say(f" token in {token_path}")
_mention_undeclared_cards()
_mention_other_installation(data_dir)
_mention_other_instance(data_dir)
# One process: it holds the flow engine, and a second worker would be a
# second engine — duplicated subscriptions, cron ticks and webhooks.
@@ -558,13 +557,13 @@ def _parser() -> argparse.ArgumentParser:
sub.add_argument(
"--data-dir",
default=os.environ.get("FLUKSIO_HOME"),
help="where this installation keeps everything (default: ./.fluksio)",
help="where this instance keeps everything (default: ./.fluksio)",
)
sub.add_argument(
"--global",
dest="shared",
action="store_true",
help=f"use the shared installation at {DEFAULT_HOME} instead",
help=f"use the shared instance at {DEFAULT_HOME} instead",
)
serve = subparsers.add_parser("serve", help="run the engine")
@@ -622,9 +621,7 @@ def _parser() -> argparse.ArgumentParser:
)
serve.set_defaults(func=cmd_serve)
enroll = subparsers.add_parser(
"enroll", help="pair this installation with a portal"
)
enroll = subparsers.add_parser("enroll", help="pair this instance with a portal")
enroll.add_argument("code", help="the claim code minted on the portal")
enroll.add_argument(
"--portal",
+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.
+3 -3
View File
@@ -1,6 +1,6 @@
"""First run: an account to sign in with.
An installation started from the command line is given no environment, so the
An instance started from the command line is given no environment, so the
superuser a deployment sets in `.env` has to come from somewhere. The session
key is the CLI's own business — it has to be set before this module can be
imported at all.
@@ -27,7 +27,7 @@ def ensure_superuser(
"""The account to sign in with, made on first run.
Returns the user and, when it was just created, the password in clear —
the caller prints it once. An installation that already has a superuser is
the caller prints it once. An instance that already has a superuser is
left alone: this is a first run, not a password reset.
"""
existing = session.exec(
@@ -57,7 +57,7 @@ def pick_superuser(session: Session, email: str | None = None) -> User:
select(User).where(User.is_superuser == True) # noqa: E712
).all()
if not users:
raise LookupError("This installation has no superuser to enrol as")
raise LookupError("This instance has no superuser to enrol as")
if len(users) > 1:
addresses = ", ".join(sorted(u.email for u in users))
raise LookupError(f"Several superusers here — name one with --as: {addresses}")
+5 -5
View File
@@ -54,7 +54,7 @@ class Settings(BaseSettings):
FRONTEND_HOST: str = "http://localhost:5173"
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
#: Everything this installation keeps: the database, the flow repository,
#: Everything this instance keeps: the database, the flow repository,
#: secrets, artifacts and the user venv. The paths below derive from it
#: unless they are set explicitly.
DATA_DIR: Path = Path("flow-data")
@@ -72,13 +72,13 @@ class Settings(BaseSettings):
# Which failures reach which channel. Beside the flows, not in them:
# alerting is the deployment's concern, not any one flow's.
ALERTS_FILE: Path = Path("flow-data/alerts.json")
# This installation's web push keypair and the browsers subscribed to it.
# This instance's web push keypair and the browsers subscribed to it.
# Beside the alerts it serves; deleting it makes every device subscribe
# again.
WEBPUSH_FILE: Path = Path("flow-data/webpush.json")
# Where machines can be started from when a node needs one and nothing that
# could take it is attached. Operator-authored, like the alerts beside it,
# and absent on an installation that has nowhere to start one.
# and absent on an instance that has nowhere to start one.
PROVISIONERS_FILE: Path = Path("flow-data/provisioners.json")
# Which dashboards each device shows. Beside the flows for the same reason
# alerting is: where a screen hangs is the deployment's concern rather than
@@ -99,7 +99,7 @@ class Settings(BaseSettings):
PRIVATE_API_ENABLED: bool = False
DOMAIN: str = "localhost"
OAUTH_PRIVATE_KEY_FILE: Path = Path("flow-data/oauth-key.pem")
# Written only when someone enrols this installation with a portal.
# Written only when someone enrols this instance with a portal.
# Its absence is what keeps remote access off.
CLOUD_CONFIG_FILE: Path = Path("flow-data/cloud.json")
OAUTH_CODE_EXPIRE_SECONDS: int = 60
@@ -113,7 +113,7 @@ class Settings(BaseSettings):
# because a limit somebody set and did not get is the worse surprise.
FLOW_MAX_WORKERS: PositiveInt = 4
# How many cascades may be in flight at once. Sustained throughput is this
# over the mean cascade time, so an installation whose nodes wait on the
# over the mean cascade time, so an instance whose nodes wait on the
# network rather than on a CPU wants it higher than the core count.
FLOW_MAX_CASCADES: PositiveInt = 4
# How many batch runs are driven at once. A different limit from the one
+3 -3
View File
@@ -142,9 +142,9 @@ def init_db(session: Session) -> None:
user = crud.create_user(session=session, user_create=user_in)
#: The sizes an installation starts with. Written once, when there are none,
#: The sizes an instance starts with. Written once, when there are none,
#: and editable from there — what a name means is a property of the machines
#: this installation has, and nothing here knows what those are.
#: this instance has, and nothing here knows what those are.
SEED_FLAVORS: tuple[dict[str, Any], ...] = (
{"name": "small", "cpus": 1, "ram": 2048, "description": "A poll, a threshold"},
{"name": "medium", "cpus": 4, "ram": 8192, "description": "A step that computes"},
@@ -160,7 +160,7 @@ SEED_FLAVORS: tuple[dict[str, Any], ...] = (
def seed_flavors(session: Session) -> None:
"""Give a new installation sizes to pick from, once.
"""Give a new instance sizes to pick from, once.
Only when there are none at all: they are editable, and re-adding one that
somebody deliberately removed would be an argument nobody can win.
+1 -1
View File
@@ -358,7 +358,7 @@ class AlertManager:
await asyncio.to_thread(self.publish, message, alert.model_dump())
async def _send_webpush(self, alert: Alert) -> None:
"""Wake every browser that subscribed to this installation.
"""Wake every browser that subscribed to this instance.
No settings of its own: a browser subscribes by pressing a button on
the alerts screen, and this channel goes to whichever ones did. Saying
+5 -5
View File
@@ -98,7 +98,7 @@ HOOK_PREFIX = "/hooks"
NODE_STOP_TIMEOUT = 5.0
# How long a rebuild asked for by a request waits for one already running.
# Generous on purpose: a rebuild of a populated installation reconnects every
# Generous on purpose: a rebuild of a populated instance reconnects every
# node and takes the better part of ten seconds, and a caller queued behind a
# healthy one of those should not be turned away. Past that the controller is
# wedged rather than busy, and an error the caller can act on beats a request
@@ -106,7 +106,7 @@ NODE_STOP_TIMEOUT = 5.0
REBUILD_WAIT = 15.0
# How long a caller waits for a *flow* rebuild that is already running. A
# per-flow rebuild reconnects one flow's nodes rather than the installation's,
# per-flow rebuild reconnects one flow's nodes rather than the instance's,
# so this is a queueing budget — several of them back to back, which is what
# seeding does — not the room a single one needs. Fifteen seconds was sized for
# the whole-pipeline rebuild and would let a wedge sit unreported.
@@ -680,7 +680,7 @@ class FlowController:
another flow owns — but the wiring is derived from message names, so
swapping one flow's nodes into the graph and deriving the edges again
is enough. What that saves is the reconnecting: the cost of a rebuild
on a populated installation is every node opening its socket again,
on a populated instance is every node opening its socket again,
and only one flow's have changed.
A flow the store no longer has is taken out instead of replaced.
@@ -1522,12 +1522,12 @@ class FlowController:
key = None
if node_type is not None:
try:
key = node_type.cls.instance_key(node_def.params)
key = node_type.cls.target_key(node_def.params)
except Exception:
# A plugin's own grouping is not worth the whole graph;
# this node just stands on its own.
logger.exception(
"instance_key failed for node type '%s'", node_def.type
"target_key failed for node type '%s'", node_def.type
)
gid = f"{node_def.type}:{key}" if key else member
gid_of[member] = gid
+4 -4
View File
@@ -81,7 +81,7 @@ WidgetType = Literal[
INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown", "color"}
#: What a colour widget puts on the wire, by the format it was configured for.
#: The default is what the Node-RED installation this ports already sends its
#: The default is what the Node-RED instance this ports already sends its
#: DMX encoders — ``[h, s, v]``, hue in degrees and the other two in percent —
#: and the two alternatives exist because fixtures differ. Mirrored in the
#: client (``frontend/src/components/Dashboard/ColorWidget.tsx``).
@@ -197,7 +197,7 @@ class WidgetDef(BaseModel):
differ and a change node per tile is not the answer:
- ``hsv`` (the default) — ``[h, s, v]``, hue 0-360 degrees, saturation and
value 0-100 percent. What the Node-RED installation this ports feeds its
value 0-100 percent. What the Node-RED instance this ports feeds its
3CH/4CH DMX encoders, which divide by 360 and by 100.
- ``rgb`` — ``[r, g, b]``, each 0-255. The conventional range; the
reference's own encoders produce it after converting.
@@ -479,7 +479,7 @@ class DashboardDef(BaseModel):
def _flatten(cls, data: Any) -> Any:
"""Read a document written as pages and sections as one grid.
Stored dashboards live in each installation's git repository, so the
Stored dashboards live in each instance's git repository, so the
old shape is normalised on the way in rather than migrated: an
untouched document keeps working, and the next save writes it flat.
"""
@@ -502,7 +502,7 @@ class DashboardDef(BaseModel):
picker records the payload type beside the name, so neither the editor
nor a wall panel has to fetch the catalogue to know the wiring is
wrong. A name this build does not know is left alone rather than
refused — an older installation reading a newer document simply does
refused — an older instance reading a newer document simply does
not act on it.
"""
for name, setting in self.settings.items():
+1 -1
View File
@@ -49,7 +49,7 @@ DUE_RESERVE = 2
# check whether a cascade slot has freed.
DUE_CLAIM_BLOCK_MS = 200
#: How many cascades may be in flight, unless the service is given a number.
#: Sustained throughput is this over the mean cascade time, so an installation
#: Sustained throughput is this over the mean cascade time, so an instance
#: whose nodes wait on a network rather than a CPU may want more of them —
#: `FLOW_MAX_CASCADES` is where that is said.
MAX_CASCADES = 4
+1 -1
View File
@@ -97,7 +97,7 @@ def venv_dir() -> Path:
def venv_python() -> str:
"""The interpreter node code runs on.
Falls back to the engine's own when there is no venv — an installation that
Falls back to the engine's own when there is no venv — an instance that
could not build one still runs python nodes, it just cannot add packages
to them.
"""
+1 -1
View File
@@ -179,7 +179,7 @@ class Node:
idempotent: bool = True
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
def target_key(cls, params: dict[str, Any]) -> str | None:
"""Which outside thing these parameters point at, if any.
Two nodes with the same key talk to the same broker topic, URL or
+1 -1
View File
@@ -189,7 +189,7 @@ class HttpNode(Node):
)
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
def target_key(cls, params: dict[str, Any]) -> str | None:
# ponytail: a webhook's stored url is the path before the flow name is
# prefixed onto it, so two flows both receiving on "/tick" merge into
# one neuron. Key on mode as well if that ever misleads.
+2 -2
View File
@@ -18,7 +18,7 @@ def _influxdb() -> Any:
"""The client library, which is a `fluksio[server]` extra.
Imported per use rather than at module level, because the node type is
registered at boot and an installation with no InfluxDB behind it should
registered at boot and an instance with no InfluxDB behind it should
not have to carry the library to start.
"""
try:
@@ -198,7 +198,7 @@ class InfluxDbNode(Node):
queries: dict[str, dict[str, Any]] = {}
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
def target_key(cls, params: dict[str, Any]) -> str | None:
"""The bucket, which is the thing several flows share."""
url, bucket = params.get("url"), params.get("bucket")
return f"{url}/{bucket}" if url and bucket else None
+1 -1
View File
@@ -1,6 +1,6 @@
"""Inject: the node that starts something, on a timer or on request.
Node-RED's *inject* is the most placed trigger in a real installation — mostly
Node-RED's *inject* is the most placed trigger in a real instance — mostly
as a button someone presses, sometimes on an interval, occasionally once when
everything comes up.
"""
+2 -2
View File
@@ -2,7 +2,7 @@
Anything here could be written as a `python` node — that is what the function
node is for. These exist because the same handful of shapes account for most of
a real installation, and a rule you fill in is easier to read on a canvas, and
a real instance, and a rule you fill in is easier to read on a canvas, and
to change, than five lines of code repeated eighty times.
"""
@@ -115,7 +115,7 @@ class ChangeNode(Node):
"""Reshape a value on its way past: scale, offset, map, or replace.
Node-RED's *change*, which is the second most common node in a real
installation after the function.
instance after the function.
"""
class Params(BaseModel):
+2 -2
View File
@@ -24,7 +24,7 @@ def _aiomqtt() -> Any:
"""The client library, which is a `fluksio[server]` extra.
Imported per use rather than at module level, because the node type is
registered at boot and an installation that talks to no broker should not
registered at boot and an instance that talks to no broker should not
have to carry the library to start.
"""
try:
@@ -218,7 +218,7 @@ class MqttNode(Node):
json_key: str | dict[str, str] = ""
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
def target_key(cls, params: dict[str, Any]) -> str | None:
"""The broker and topic, which is one physical thing.
A publisher and a subscriber on the same topic get the same key on
+1 -1
View File
@@ -45,7 +45,7 @@ class PanelDef(BaseModel):
class PanelsConfig(BaseModel):
"""Every panel this installation knows about."""
"""Every panel this instance knows about."""
panels: list[PanelDef] = Field(default_factory=list)
+1 -1
View File
@@ -210,7 +210,7 @@ def _derive(
The two lookups come out of the same walk: who consumes a message, and
which node an id names. Both were linear scans over every node in the
installation, on the per-message path.
instance, on the per-message path.
"""
# A message may have several producers; every one of them is upstream
# of the nodes consuming it.
+2 -2
View File
@@ -10,7 +10,7 @@ counted at all.
One question, then, asked once here: of every machine attached this one and
each worker which could grant what this node asked for, and which of those
has it free right now. A worker reports its inventory when it attaches, so the
answer covers the whole installation rather than the host the engine happens to
answer covers the whole instance rather than the host the engine happens to
be on.
Two properties are worth stating because they are what make the waiting safe:
@@ -178,7 +178,7 @@ class Placer:
"""Every machine's size, as something to measure a request against.
A provisioner's job shapes count: a machine it can start on demand is
one this installation has, even when nothing is attached yet.
one this instance has, even when nothing is attached yet.
"""
shapes = []
for target in self.targets():
+1 -1
View File
@@ -348,7 +348,7 @@ class SlurmProvisioner:
def load_provisioners(path: Path, events: EventBus | None = None) -> list[Any]:
"""Read the configured clusters. An installation with none has no file."""
"""Read the configured clusters. An instance with none has no file."""
if not path.exists():
return []
try:
+2 -2
View File
@@ -296,7 +296,7 @@ def _from_run(
if artifacts is not None and artifacts.path(row.digest) is None:
raise RunRejected(
f"Parameter '{key}': run '{run_id}' made '{output}', but its "
"bytes are gone from this installation's store"
"bytes are gone from this instance's store"
)
return {
"digest": row.digest,
@@ -327,7 +327,7 @@ def _from_digest(
if artifacts is not None and artifacts.path(digest) is None:
raise RunRejected(
f"Parameter '{key}': '{digest}' is known but its bytes are gone "
"from this installation's store"
"from this instance's store"
)
return {
"digest": row.digest,
+7 -7
View File
@@ -1,11 +1,11 @@
"""Web Push: an alert reaching a phone that has this installation installed.
"""Web Push: an alert reaching a phone that has this instance installed.
The browser hands us a subscription an endpoint URL at its own push service,
plus two keys and from then on the engine can wake that device without it
holding a connection open. Two pieces of crypto are involved and both are
specified: the payload is encrypted to the subscription's keys (RFC 8291,
aes128gcm) so the push service carries something it cannot read, and the
request is signed with this installation's own keypair (VAPID, RFC 8292) so the
request is signed with this instance's own keypair (VAPID, RFC 8292) so the
service knows who is sending.
Only ``http-ece`` is new here; the signing is `pyjwt` and the request is
@@ -13,7 +13,7 @@ Only ``http-ece`` is new here; the signing is `pyjwt` and the request is
this in one call, but brings `requests` *and* `aiohttp` with it two more HTTP
stacks on a machine that may well be a Raspberry Pi.
The keypair is this installation's identity to the push services and lives with
The keypair is this instance's identity to the push services and lives with
the subscriptions in one file. Losing it means every browser has to subscribe
again; it is regenerated on the spot if the file goes missing.
"""
@@ -62,7 +62,7 @@ class Subscription(BaseModel):
class _Store(BaseModel):
#: This installation's VAPID private key: the raw P-256 scalar, base64url.
#: This instance's VAPID private key: the raw P-256 scalar, base64url.
private_key: str = ""
subscriptions: list[Subscription] = Field(default_factory=list)
@@ -113,7 +113,7 @@ def _public_bytes(key: ec.EllipticCurvePrivateKey) -> bytes:
def public_key() -> str:
"""This installation's VAPID public key, generating the pair on first ask.
"""This instance's VAPID public key, generating the pair on first ask.
Base64url of the uncompressed point, which is the shape
`pushManager.subscribe` wants for `applicationServerKey`. Blocking.
@@ -126,7 +126,7 @@ def public_key() -> str:
key.private_numbers().private_value.to_bytes(32, "big")
)
_write(store)
logger.info("Generated this installation's web push keypair")
logger.info("Generated this instance's web push keypair")
return _b64url(_public_bytes(_private_key(store)))
@@ -156,7 +156,7 @@ def subscriptions() -> list[Subscription]:
def _vapid_headers(key: ec.EllipticCurvePrivateKey, endpoint: str) -> dict[str, str]:
"""Prove to the push service which installation is sending (RFC 8292)."""
"""Prove to the push service which instance is sending (RFC 8292)."""
origin = urlparse(endpoint)
token = jwt.encode(
{
+2 -2
View File
@@ -307,7 +307,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
)
)
started.append(lambda: run_in_threadpool(close_shared_client))
# Optional, and off unless someone enrolled this installation: the
# Optional, and off unless someone enrolled this instance: the
# connector dials the portal, nothing dials in.
cloud_task: asyncio.Task[None] | None = None
app.state.cloud_connector = None
@@ -346,7 +346,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await aclose()
# The schema enumerates every endpoint this installation serves, including the
# The schema enumerates every endpoint this instance serves, including the
# paths trigger nodes mount at runtime. That is exactly what a developer wants
# and exactly what an internet-facing deployment should not hand out, so it
# follows the environment — the same rule the portal's backend uses. The
+1 -1
View File
@@ -2,6 +2,6 @@
``server`` holds the tools, ``http`` wires them to the streamable-HTTP
transport and the OAuth resource-server checks. Both are imported lazily, only
when ``MCP_ENABLED`` is set, so an installation that does not want an agent
when ``MCP_ENABLED`` is set, so an instance that does not want an agent
endpoint does not carry one.
"""
+3 -3
View File
@@ -53,9 +53,9 @@ class User(UserBase, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
hashed_password: str
#: The portal account this local user stands for, if any. Set for the
#: superuser who enrolled this installation and for every remote user one
#: superuser who enrolled this instance and for every remote user one
#: of them admitted; None for a purely local account, which is what an
#: installation nobody enrolled has only.
#: instance nobody enrolled has only.
portal_sub: str | None = Field(default=None, max_length=64, unique=True, index=True)
created_at: datetime | None = Field(
default_factory=get_datetime_utc,
@@ -491,7 +491,7 @@ class FlavorBase(SQLModel):
"""A named amount of machine, so a node can ask for one by name.
The point is not to save typing. Cores and megabytes are a property of the
machines an installation actually has, and they change when those machines
machines an instance actually has, and they change when those machines
do so a node saying "gpu-small" keeps meaning something after the cluster
is replaced, where a node saying 8 and 16384 quietly stops.
"""
+2 -2
View File
@@ -682,7 +682,7 @@ def _flow_state(flow: dict[str, Any]) -> str:
def _portal_phrase(portal: dict[str, Any]) -> tuple[str, str]:
"""Where this installation stands with its portal, and how to colour it.
"""Where this instance stands with its portal, and how to colour it.
Three states worth telling apart: never paired, paired and linked, and
paired but not reaching it the last being the one somebody needs to know
@@ -734,7 +734,7 @@ def _status_screen(client: Client) -> Any:
summary = client.summary()
flows = client.flows()
# Both, because an installation is usually one or the other: a live flow
# Both, because an instance is usually one or the other: a live flow
# fails as an engine event, while a batch run fails on its own row.
failures = client.events(kind="failure", limit=5)
runs = client.runs(limit=5)
+10 -10
View File
@@ -57,7 +57,7 @@ RETRY_STATUS = frozenset({502, 503, 504})
WAIT_TOLERANCE = 5
#: What a project-local installation is called, beside `.venv` and `.git`.
#: What a project-local instance is called, beside `.venv` and `.git`.
DATA_DIR_NAME = ".fluksio"
#: The shared one, for a machine that wants a single engine rather than one
@@ -66,10 +66,10 @@ GLOBAL_DATA_DIR = Path("~/.fluksio")
def find_data_dir(start: Path | None = None) -> Path | None:
"""The nearest project-local installation, walking up from ``start``.
"""The nearest project-local instance, walking up from ``start``.
The same search `.git` and `.venv` get, and for the same reason: which
installation you mean is a fact about where you are standing, not about
instance you mean is a fact about where you are standing, not about
which machine you are on. Several repositories on one device each keep
their own flows, runs and token this way rather than sharing one.
"""
@@ -82,16 +82,16 @@ def find_data_dir(start: Path | None = None) -> Path | None:
def data_dir(start: Path | None = None) -> Path:
"""The installation this working directory belongs to."""
"""The instance this working directory belongs to."""
found = find_data_dir(start)
return found if found is not None else GLOBAL_DATA_DIR.expanduser()
def config_path(directory: Path | None = None) -> Path:
"""Where the token for an installation lives — beside the data it opens.
"""Where the token for an instance lives — beside the data it opens.
Not a single file per machine: a token is for one engine, and with an
installation per repository there is more than one. Keeping it inside the
instance per repository there is more than one. Keeping it inside the
data directory means the client finds the credential for the engine whose
directory it is standing in, without either having to be told.
"""
@@ -99,7 +99,7 @@ def config_path(directory: Path | None = None) -> Path:
def _legacy_config_path() -> Path:
"""Where `fluksio login` used to write, before installations were local."""
"""Where `fluksio login` used to write, before instances were local."""
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
return Path(base) / "fluksio" / "client.json"
@@ -290,7 +290,7 @@ class Client:
return result
def cloud_status(self) -> dict[str, Any]:
"""Whether this installation is enrolled with a portal, and linked."""
"""Whether this instance is enrolled with a portal, and linked."""
result: dict[str, Any] = self._call("GET", "/cloud/status")
return result
@@ -585,7 +585,7 @@ class RunHandle:
def ignore_self(directory: Path) -> None:
"""Keep an installation out of the repository it sits in.
"""Keep an instance out of the repository it sits in.
It holds a token and a database, neither of which belongs in anybody's
history. A `.gitignore` of `*` inside the directory ignores it from within,
@@ -595,7 +595,7 @@ def ignore_self(directory: Path) -> None:
marker = directory / ".gitignore"
if not marker.exists():
directory.mkdir(parents=True, exist_ok=True)
marker.write_text("# A Fluksio installation: a database, and a token.\n*\n")
marker.write_text("# A Fluksio instance: a database, and a token.\n*\n")
def write_config(url: str, token: str, directory: Path | None = None) -> Path:
+2 -2
View File
@@ -71,7 +71,7 @@ class Enroll(ModalScreen[tuple[str, str] | None]):
def compose(self) -> ComposeResult:
with Vertical(id="enroll"):
yield Label("Pair this installation with a portal")
yield Label("Pair this instance with a portal")
yield Input(placeholder="claim code", id="code")
yield Input(value=DEFAULT_PORTAL, id="portal")
with Horizontal():
@@ -253,7 +253,7 @@ class ServeApp(App[int]):
self.connect()
return
if who == "foreign":
self.note(f"Port {wanted} holds another installation's Fluksio.")
self.note(f"Port {wanted} holds another instance's Fluksio.")
self.child = subprocess.Popen( # noqa: S603
child_argv(sys.argv[1:]),
+2 -2
View File
@@ -9,7 +9,7 @@ from fluksio.core.db import seed_flavors
from fluksio.models import Flavor
def test_an_installation_starts_with_sizes_to_pick_from(
def test_an_instance_starts_with_sizes_to_pick_from(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
response = client.get(
@@ -20,7 +20,7 @@ def test_an_installation_starts_with_sizes_to_pick_from(
assert {"small", "medium", "large", "gpu-small"} <= names
def test_seeding_leaves_an_installation_that_has_its_own_alone(db: Session) -> None:
def test_seeding_leaves_an_instance_that_has_its_own_alone(db: Session) -> None:
"""Re-adding a size somebody deliberately removed is an argument to avoid."""
before = {row.name for row in db.exec(select(Flavor)).all()}
seed_flavors(db)
+3 -3
View File
@@ -544,7 +544,7 @@ def test_removing_the_panel_revokes_its_credential(
# --------------------------------------------------------------------------
# A screen that reached the portal but not this installation
# A screen that reached the portal but not this instance
# --------------------------------------------------------------------------
@@ -592,7 +592,7 @@ def test_a_remote_device_is_paired_at_the_portal(
) -> None:
"""A device that arrived through the tunnel gets the portal's credential.
It could never present one this installation signed: the portal verifies
It could never present one this instance signed: the portal verifies
what crosses it, and it verifies against its own key.
"""
import httpx
@@ -627,7 +627,7 @@ def test_a_remote_device_is_paired_at_the_portal(
assert approved.status_code == 200, approved.text
assert calls[0]["url"].endswith("/api/v1/panel-tokens/") # type: ignore[union-attr]
assert calls[0]["headers"]["Authorization"] == "Bearer installation-token" # type: ignore[index]
assert calls[0]["headers"]["Authorization"] == "Bearer instance-token" # type: ignore[index]
assert calls[0]["json"] == {"panel": "hallway"} # type: ignore[index]
collected = client.get(
+5 -5
View File
@@ -25,7 +25,7 @@ from fluksio.models import (
RunNode,
User,
)
from tests.utils.portal import INSTALLATION_ID, ISSUER, jwks
from tests.utils.portal import INSTANCE_ID, ISSUER, jwks
from tests.utils.user import authentication_token_from_email
from tests.utils.utils import get_superuser_token_headers
@@ -129,7 +129,7 @@ def enrolled(
portal_key: rsa.RSAPrivateKey,
db: Session,
) -> Any:
"""Enrol this installation with a fake portal, then undo it."""
"""Enrol this instance with a fake portal, then undo it."""
local_user = db.exec(
select(User).where(User.email == settings.FIRST_SUPERUSER)
).one()
@@ -141,8 +141,8 @@ def enrolled(
cloud_config.CloudConfig(
portal_url=ISSUER,
ws_url=f"{ISSUER}/api/v1/tunnel/attach",
installation_id=INSTALLATION_ID,
token="installation-token",
instance_id=INSTANCE_ID,
token="instance-token",
issuer=ISSUER,
jwks=jwks(portal_key),
local_user_id=str(local_user.id),
@@ -151,7 +151,7 @@ def enrolled(
)
)
# Enrolment also maps the enrolling account to the portal identity that
# owns the installation; without it a portal session resolves to nobody.
# owns the instance; without it a portal session resolves to nobody.
local_user.portal_sub = "portal-user-1"
db.add(local_user)
db.commit()
+1 -1
View File
@@ -78,7 +78,7 @@ def test_an_edge_carries_the_qualified_message(controller: FlowController):
def test_a_credential_never_reaches_the_key():
# Stored params, so a secret is still a reference. Neither its name nor its
# value belongs in something the browser gets to see.
key = MqttNode.instance_key({**BROKER, "password": {"$secret": "broker_pw"}})
key = MqttNode.target_key({**BROKER, "password": {"$secret": "broker_pw"}})
assert key == "mosquitto:1883/sensors/temp"
+2 -2
View File
@@ -423,7 +423,7 @@ def test_an_unbound_setting_is_just_its_value():
No message means nothing to type-check and nothing for a panel to be
entitled to and a name this build does not know is left alone rather
than refused, so an older installation reads a newer document.
than refused, so an older instance reads a newer document.
"""
defn = DashboardDef(
name="house",
@@ -523,7 +523,7 @@ def test_a_chart_of_runs_must_say_which_runs_and_which_metric():
def test_a_document_written_as_pages_is_read_as_one_grid():
"""Stored dashboards live in each installation's repository.
"""Stored dashboards live in each instance's repository.
So the old shape is normalised on the way in rather than migrated, and a
placed second section keeps its arrangement instead of piling onto the
+1 -1
View File
@@ -192,7 +192,7 @@ def test_a_node_waiting_for_a_machine_asks_for_one_once(monkeypatch):
assert ran.is_set()
def test_an_installation_with_nowhere_to_start_one_has_no_file(tmp_path):
def test_an_instance_with_nowhere_to_start_one_has_no_file(tmp_path):
assert load_provisioners(tmp_path / "provisioners.json") == []
+1 -1
View File
@@ -237,7 +237,7 @@ def lifecycle(monkeypatch: pytest.MonkeyPatch) -> list[str]:
def test_rebuilding_one_flow_leaves_another_flows_node_running(
tmp_path: Path, lifecycle: list[str]
):
"""The whole point: reconnecting one flow's nodes, not the installation's."""
"""The whole point: reconnecting one flow's nodes, not the instance's."""
store = FlowStore(tmp_path / "flows")
for name in ("a", "b"):
store.write_flow(FlowDef(name=name, nodes=[NodeDef(id="io", type="lifecycle")]))
+1 -1
View File
@@ -1,6 +1,6 @@
"""What the flow-logic nodes actually do.
These are the shapes a Node-RED installation is mostly made of, so their
These are the shapes a Node-RED instance is mostly made of, so their
behaviour is worth pinning rather than just their construction.
"""
+1 -1
View File
@@ -16,7 +16,7 @@ from fluksio.flow import webpush
@pytest.fixture(autouse=True)
def store(tmp_path, monkeypatch):
"""A store per test, so nothing writes into the real installation."""
"""A store per test, so nothing writes into the real instance."""
monkeypatch.setattr(settings, "WEBPUSH_FILE", tmp_path / "webpush.json")
return tmp_path / "webpush.json"
+5 -5
View File
@@ -1,4 +1,4 @@
"""Which installation a command is for, and where its token lives.
"""Which instance a command is for, and where its token lives.
A repository with its own venv gets its own engine, so "which one" is a fact
about the working directory rather than about the machine.
@@ -14,14 +14,14 @@ from fluksio.sdk import client
@pytest.fixture
def elsewhere(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A working directory with no installation above it."""
"""A working directory with no instance above it."""
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
monkeypatch.setattr(client, "GLOBAL_DATA_DIR", tmp_path / "home" / ".fluksio")
return tmp_path
def test_a_project_local_installation_is_found_from_below(elsewhere: Path):
def test_a_project_local_instance_is_found_from_below(elsewhere: Path):
(elsewhere / ".fluksio").mkdir()
deep = elsewhere / "src" / "pkg" / "sub"
deep.mkdir(parents=True)
@@ -31,7 +31,7 @@ def test_a_project_local_installation_is_found_from_below(elsewhere: Path):
def test_the_nearest_one_wins(elsewhere: Path):
"""An installation inside another belongs to the directory it is in."""
"""An instance inside another belongs to the directory it is in."""
(elsewhere / ".fluksio").mkdir()
inner = elsewhere / "inner"
(inner / ".fluksio").mkdir(parents=True)
@@ -63,7 +63,7 @@ def test_the_credential_beside_the_data_is_the_one_used(elsewhere: Path):
assert client._stored()["token"] == "local"
def test_a_login_from_before_installations_were_local_still_works(elsewhere: Path):
def test_a_login_from_before_instances_were_local_still_works(elsewhere: Path):
"""`fluksio login` wrote to XDG once; that must not stop answering."""
legacy = client._legacy_config_path()
legacy.parent.mkdir(parents=True)
+2 -2
View File
@@ -106,7 +106,7 @@ def test_a_sweeps_param_spelling_is_refused_by_name() -> None:
_params(definition, ["--lr", "fast"])
def test_serve_uses_the_installation_the_directory_belongs_to(
def test_serve_uses_the_instance_the_directory_belongs_to(
tmp_path: Path, monkeypatch
) -> None:
"""A repository with its own venv gets its own engine, not the machine's."""
@@ -713,7 +713,7 @@ def test_who_holds_the_port_is_told_apart_by_the_token() -> None:
The token is signed with this directory's secret key, so an engine that
accepts it is one reading this directory's database. Another
installation's Fluksio answers the health check and refuses it.
instance's Fluksio answers the health check and refuses it.
"""
import httpx
+11 -11
View File
@@ -1,7 +1,7 @@
"""Remote access: off by default, and only ever as much as somebody granted.
The property worth pinning down is the one everything else rests on a
portal's token is worth nothing here until somebody at this installation
portal's token is worth nothing here until somebody at this instance
enrolled it, and even then it grants exactly the rights of the local account
the portal identity holding it was mapped to. An identity nobody mapped gets
nothing, which is what makes deleting that local account a revocation.
@@ -31,7 +31,7 @@ from tests.utils.portal import ISSUER, portal_token
def test_portal_token_is_refused_when_not_enrolled(
portal_key: rsa.RSAPrivateKey, tmp_path_factory: pytest.TempPathFactory
) -> None:
"""An installation nobody connected trusts no portal at all."""
"""An instance nobody connected trusts no portal at all."""
original = settings.CLOUD_CONFIG_FILE
settings.CLOUD_CONFIG_FILE = tmp_path_factory.mktemp("empty") / "cloud.json"
try:
@@ -55,11 +55,11 @@ def test_portal_token_resolves_by_portal_identity(
assert user_from_token(db, portal_token(portal_key, subject="nobody")) is None
def test_token_for_another_installation_is_refused(
def test_token_for_another_instance_is_refused(
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
portal_key: rsa.RSAPrivateKey,
) -> None:
"""The audience is this installation's id, so someone else's is worthless."""
"""The audience is this instance's id, so someone else's is worthless."""
with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(portal_key, audience=str(uuid.uuid4())))
@@ -67,7 +67,7 @@ def test_token_for_another_installation_is_refused(
def test_token_from_an_unpinned_key_is_refused(
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
) -> None:
"""A different portal, or a hijacked one, cannot sign for this installation."""
"""A different portal, or a hijacked one, cannot sign for this instance."""
impostor = rsa.generate_private_key(public_exponent=65537, key_size=2048)
with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(impostor))
@@ -104,7 +104,7 @@ def test_status_reports_not_enrolled(
def test_enrolling_needs_a_superuser(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
"""Remote access is an installation-wide grant, not a personal setting."""
"""Remote access is an instance-wide grant, not a personal setting."""
response = client.post(
f"{settings.API_V1_STR}/cloud/enroll",
headers=normal_user_token_headers,
@@ -122,7 +122,7 @@ def test_a_panel_scoped_portal_token_reaches_only_its_panel(
"""A screen paired through the portal is bounded here, not there.
The portal names the panel; everything about what that means is this
installation's, which is the whole reason it may mint one at all.
instance's, which is the whole reason it may mint one at all.
"""
write_config(
PanelsConfig(
@@ -241,7 +241,7 @@ def test_adding_a_remote_user_maps_and_revokes(
f"{settings.API_V1_STR}/users/{body['id']}", headers=superuser_token_headers
)
assert removed.status_code == 200, removed.text
assert delete.call_args.args[0].endswith("/installation-members/portal-user-9")
assert delete.call_args.args[0].endswith("/instance-members/portal-user-9")
db.expire_all()
assert user_from_token(db, theirs) is None
@@ -251,7 +251,7 @@ def test_adding_a_remote_user_needs_a_superuser(
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
normal_user_token_headers: dict[str, str],
) -> None:
"""Widening who can reach this installation stays a superuser's decision."""
"""Widening who can reach this instance stays a superuser's decision."""
response = client.post(
f"{settings.API_V1_STR}/cloud/users",
headers=normal_user_token_headers,
@@ -272,8 +272,8 @@ def test_enrolling_against_a_portal_without_an_owner_is_refused(
status_code=200,
json=Mock(
return_value={
"installation_id": str(uuid.uuid4()),
"installation_token": "t",
"instance_id": str(uuid.uuid4()),
"instance_token": "t",
"ws_url": f"{ISSUER}/api/v1/tunnel/attach",
"issuer": ISSUER,
"jwks": {"keys": []},
+2 -2
View File
@@ -14,7 +14,7 @@ import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
INSTALLATION_ID = "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f"
INSTANCE_ID = "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f"
ISSUER = "https://hub.example.test"
@@ -28,7 +28,7 @@ def portal_token(
key: rsa.RSAPrivateKey,
*,
subject: str = "portal-user-1",
audience: str = INSTALLATION_ID,
audience: str = INSTANCE_ID,
issuer: str = ISSUER,
scope: str = "proxy",
) -> str: