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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,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.
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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,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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:]),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") == []
|
||||
|
||||
|
||||
|
||||
@@ -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,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.
|
||||
"""
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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": []},
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user