Rename Installation to Instance

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
This commit is contained in:
2026-08-31 10:12:01 +02:00
co-authored by Claude Opus 5
parent 6534855492
commit d01a8dad37
101 changed files with 374 additions and 375 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ SMTP_TLS=True
SMTP_SSL=False
SMTP_PORT=587
# Where this installation keeps everything: the database, flows, secrets,
# Where this instance keeps everything: the database, flows, secrets,
# artifacts and the user venv. The compose stack sets it to the /data volume.
DATA_DIR=flow-data
# Any SQLAlchemy URL. Empty means SQLite in the data directory, which is what
+1 -1
View File
@@ -33,7 +33,7 @@ help: ## Show available targets
# Hostname the local stack is served under. Never .env's DOMAIN: a checkout
# configured for a deployment carries that deployment's domain, and a local
# stack started under it answers to the same names the live installation does.
# stack started under it answers to the same names the live instance does.
# Target-specific on purpose — an exported DOMAIN outranks --env-file in
# compose interpolation, which would put the *production* targets on localhost.
# `make dev DOMAIN=fluksio.com` still wins.
+1 -1
View File
@@ -29,7 +29,7 @@ pip install fluksio
fluksio serve
```
Then, head over to [fluksio.com](https://fluksio.com), sign up and add a new installation.
Then, head over to [fluksio.com](https://fluksio.com), sign up and add a new instance.
Using the code provided, run
```sh
@@ -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:
+1 -1
View File
@@ -108,7 +108,7 @@ The backend settings load `../.env` relative to `backend/` through pydantic-sett
exported environment variable always outranks the file, which is how the suite pins its own
configuration.
`DATA_DIR` is where this installation keeps everything: the SQLite database, the flow git
`DATA_DIR` is where this instance keeps everything: the SQLite database, the flow git
repository, `secrets.enc`, `oauth-key.pem`, `cloud.json`, artifacts and the user venv. Each
path derives from it and can still be set on its own — the compose stack spells all of them
out against `/data`.
+2 -2
View File
@@ -108,7 +108,7 @@ services:
- SENTRY_DSN=${SENTRY_DSN}
# Flow state survives a restart in Redis; without a host it stays in memory.
- REDIS_HOST=redis
# Everything this installation keeps — the database, the flows, secrets,
# Everything this instance keeps — the database, the flows, secrets,
# artifacts and the user venv — on one volume. The paths below derive
# from it; they are spelled out because each is load bearing.
- DATA_DIR=/data
@@ -120,7 +120,7 @@ services:
# and every issued token with it.
- MCP_ENABLED=${MCP_ENABLED-false}
- OAUTH_PRIVATE_KEY_FILE=/data/oauth-key.pem
# The portal enrolment, for the same reason: it holds this installation's
# The portal enrolment, for the same reason: it holds this instance's
# credential and the key it pinned, so losing it on a rebuild would mean
# enrolling again by hand. Absent until someone connects a portal.
- CLOUD_CONFIG_FILE=/data/cloud.json
+1 -1
View File
@@ -106,6 +106,6 @@ you what the canvas would have told you.
- MCP is not currently reachable through a [portal](../interface/portal.md)
tunnel — the proxy forwards `/api/v1/` only. Connect an agent on the same
network as the installation.
network as the instance.
- Secrets are never readable, by an agent or by anyone else. `list_secrets`
returns names.
+4 -4
View File
@@ -5,7 +5,7 @@ client of this schema, not a privileged path into the engine — so anything you
can click, you can script.
Base URL: `https://api.${DOMAIN}/api/v1`, or `http://127.0.0.1:8000/api/v1` for
a `fluksio serve` installation.
a `fluksio serve` instance.
```sh
export FLUKSIO=http://127.0.0.1:8000/api/v1
@@ -34,7 +34,7 @@ Agents authenticate differently — see [Agents over MCP](agents.md).
the tour.
It is closed in production on purpose: the schema enumerates every endpoint
the installation serves, including the paths webhook nodes mounted at
the instance serves, including the paths webhook nodes mounted at
runtime.
## Flows
@@ -230,7 +230,7 @@ draw from.
| `404` | no such flow, dashboard, run or message |
| `409` | someone else saved first — the body carries `current_version` |
| `422` | a parameter, port or binding did not typecheck |
| `503` | that subsystem is not available on this installation |
| `503` | that subsystem is not available on this instance |
A 409 on a save or a publish is not an error to retry blindly: it means the
stored version moved past the one you were editing. Re-read, merge, save again.
@@ -239,4 +239,4 @@ stored version moved past the one you were editing. Re-read, merge, save again.
The frontend's TypeScript client is generated from the OpenAPI schema
(`make generate-client`). Any OpenAPI generator will do the same for your
language — point it at `/api/v1/openapi.json` on a non-production installation.
language — point it at `/api/v1/openapi.json` on a non-production instance.
+17 -17
View File
@@ -18,10 +18,10 @@ should only *run nodes* for an engine elsewhere. It has none of the engine in
it. See [Remote workers](workers.md).
The command is two things at once: `serve`, `enroll` and `worker` *are* an
installation, while `login`, `sync`, `run`, `runs`, `artifacts`, `sweep` and
instance, while `login`, `sync`, `run`, `runs`, `artifacts`, `sweep` and
`status` talk to one that may be anywhere.
## Where an installation lives
## Where an instance lives
`.fluksio` beside your code, found the way `.git` is: from the working
directory, or any directory above it. Two repositories on one machine are
@@ -55,14 +55,14 @@ The default port moves out of the way when something already has it — 8001,
moved off: `--port 9000` on a taken 9000 fails, because something else is
there and you named it.
What it will *not* do is start a second engine for the same installation. If
What it will *not* do is start a second engine for the same instance. If
the port is held by an engine already serving this directory, it says so and
stops — one SQLite database wants one engine. Another installation's Fluksio
stops — one SQLite database wants one engine. Another instance's Fluksio
on that port is named, and the move happens as usual.
| Option | Default | What it does |
|---|---|---|
| `--data-dir PATH` | `./.fluksio` (or `$FLUKSIO_HOME`) | where this installation keeps everything |
| `--data-dir PATH` | `./.fluksio` (or `$FLUKSIO_HOME`) | where this instance keeps everything |
| `--host HOST` | `127.0.0.1` | what to bind |
| `--port PORT` | `8000`, or the next free one | what to listen on |
| `--plain` | off at a terminal | the log stream rather than the dashboard |
@@ -83,7 +83,7 @@ every other. `--gpus 1` is what serialises them.
`--enroll` with `--portal` is the one-command setup: it pairs before the engine
starts, so the connection is dialled as part of coming up rather than needing a
restart. It is skipped if the installation is already enrolled.
restart. It is skipped if the instance is already enrolled.
!!! warning "One process"
@@ -106,11 +106,11 @@ Created the admin account admin@example.com
Shown once. Change it from the dashboard.
Fluksio 0.1.0 — data in /home/you/.fluksio
API http://127.0.0.1:8000/api/v1
No portal. Pair this installation with:
No portal. Pair this instance with:
fluksio enroll <code>
```
An enrolled installation says which portal it is on instead, and notes that the
An enrolled instance says which portal it is on instead, and notes that the
dashboard is served from there rather than here.
### The dashboard
@@ -135,9 +135,9 @@ possible — and what makes `q` a way out of the screen rather than a way to
stop the engine. Running `fluksio serve` again reattaches to it.
An engine started elsewhere is adopted rather than duplicated, and can be
stopped from here only when it is this installation's own: both the pidfile
stopped from here only when it is this instance's own: both the pidfile
beside the data and a token this directory's key signed have to agree. Another
installation's engine is named and left alone.
instance's engine is named and left alone.
The screen subscribes to the engine's event bus over the same websocket a
browser uses, so a run appears the moment it starts rather than at the next
@@ -172,7 +172,7 @@ comparison refreshes when the engine says a run finished.
## `fluksio enroll`
Pairs an existing installation with a portal.
Pairs an existing instance with a portal.
```sh
fluksio enroll ABCD-1234
@@ -182,10 +182,10 @@ fluksio enroll ABCD-1234
|---|---|
| `--portal URL` | a portal of your own, instead of `https://hub.fluksio.com` |
| `--as EMAIL` | the local account a portal session arrives as |
| `--data-dir PATH` | which installation, if not the one this directory is in |
| `--data-dir PATH` | which instance, if not the one this directory is in |
Get the code from the portal under **Installations → Add installation**. It is
single-use and expires in fifteen minutes. `--as` matters when the installation
Get the code from the portal under **Instances → Add instance**. It is
single-use and expires in fifteen minutes. `--as` matters when the instance
has several superusers — without it, enrolment refuses rather than guessing.
Afterwards, `fluksio serve` dials the portal as it comes up, and keeps dialling:
@@ -353,7 +353,7 @@ answered. See [Stage caching](../concepts/runs.md#stage-caching).
`--local` boots the engine inside this process instead of talking to a served
one, so there is no `fluksio serve` terminal to keep open. It is the same
installation either way — the same `.fluksio`, the same database, artifacts
instance either way — the same `.fluksio`, the same database, artifacts
and run history — so a run made this way and a run made through a served
engine cache against each other. It always waits, because the engine it starts
lives exactly as long as the command. Starting one costs a few seconds of
@@ -376,7 +376,7 @@ A `resources` line names each machine the engine can run a node on and how much
of it is in use, plus how many nodes are queued for one. It is absent on an
engine that accounts for nothing.
The portal reads one of three ways. `no portal` means this installation was
The portal reads one of three ways. `no portal` means this instance was
never enrolled. `portal hub.fluksio.com` means the link is up. `portal
unreachable` names the error, and is the one worth acting on — the dashboard is
served from the other end, so nobody can reach it while that is showing.
@@ -524,7 +524,7 @@ Two things follow from this layout and are worth internalising:
anyone made to any flow. A run records the commit it ran at, so `git show` on
that hash is literally the code that produced the number.
**Backing up the data directory backs up the installation.** Everything else is
**Backing up the data directory backs up the instance.** Everything else is
rebuildable. Copy it while the engine is stopped, or use SQLite's online backup
for the database if it is not.
+1 -1
View File
@@ -213,7 +213,7 @@ with everything else.
## Packages
Node code runs in a virtual environment of its own, on the installation's data
Node code runs in a virtual environment of its own, on the instance's data
volume — deliberately separate from the one Fluksio itself runs on.
Declare what you import in [Modules](../interface/operations.md), or over the
+1 -1
View File
@@ -144,7 +144,7 @@ Every flow has a published version and, while you are working, a draft.
The store is a git repository — `flow.json` for the structure, `nodes/*.py` for
the code — and each save is a commit. So a flow's history is readable with
ordinary git tooling, and copying a flow between installations is copying a
ordinary git tooling, and copying a flow between instances is copying a
directory.
Saving carries the version you last saw. If someone else saved in between, you
+6 -6
View File
@@ -14,7 +14,7 @@ pip install fluksio
fluksio serve
```
That is the whole installation. No Docker, no database server, no ports to
That is the whole instance. No Docker, no database server, no ports to
open, and no login. The first run prints something like:
```text
@@ -25,7 +25,7 @@ Fluksio 0.1.0 — data in /home/you/my-research/.fluksio
API http://127.0.0.1:8000/api/v1
Nodes /home/you/my-research/.venv/bin/python
your environment, adopted. Add packages with pip.
No portal. Pair this installation with:
No portal. Pair this instance with:
fluksio enroll <code>
Signed in as admin@example.com
token in /home/you/my-research/.fluksio/client.json
@@ -39,7 +39,7 @@ below just works. `fluksio login` is for an engine somewhere *else*.
**Write that password down** anyway. It is shown once, and it is what the
dashboard asks for.
### One installation per project
### One instance per project
`.fluksio` sits beside your code, and is found the way `.git` is — from the
directory you are standing in, or any directory above it. So two repositories
@@ -135,7 +135,7 @@ environment it did not make.
### A venv of Fluksio's own
Sometimes you want the isolation instead: a shared installation, a container,
Sometimes you want the isolation instead: a shared instance, a container,
or an environment too precious to let a node's dependency near. Set
`NODE_VENV=managed` and Fluksio builds and owns one under the data directory:
@@ -761,14 +761,14 @@ A submit is around 15 ms, so calling that in a loop is a reasonable thing to do.
The pip install gives you the engine and the API, not a web interface — a
machine with no inbound route cannot serve one usefully anyway. To see the
canvas, the run history and live loss curves, pair the installation with a
canvas, the run history and live loss curves, pair the instance with a
portal, which serves the dashboard from its side:
```sh
fluksio enroll <claim-code> --portal https://hub.fluksio.com
```
Get the claim code from the portal under **Installations → Add installation**.
Get the claim code from the portal under **Instances → Add instance**.
Nothing needs to be exposed: your machine dials out and holds the connection
open. See [Accounts and the portal](../interface/portal.md).
+3 -3
View File
@@ -53,9 +53,9 @@ $EDITOR .env # FIRST_SUPERUSER, ENVIRONMENT=production
| `FIRST_SUPERUSER_PASSWORD` | leave it as `changethis` and one is generated for you |
| `ENVIRONMENT` | `production` closes the interactive API schema; `local` leaves it open |
Everything the installation owns — the database, your flows, secrets,
Everything the instance owns — the database, your flows, secrets,
artifacts, the packages your node code imports — is on one Docker volume.
Backing that volume up is backing up the installation.
Backing that volume up is backing up the instance.
??? note "Even smaller: no Docker at all"
@@ -313,5 +313,5 @@ bad it gets. See [Secrets, modules and alerts](../interface/operations.md).
- [The flow editor](../interface/flow-editor.md) — the canvas, in detail
- [Keeping state in a flow](../concepts/state.md) — running totals, debounces,
and the one rule that makes them safe
- [Accounts and the portal](../interface/portal.md) — reach the installation
- [Accounts and the portal](../interface/portal.md) — reach the instance
from outside the house without opening a port
+2 -2
View File
@@ -53,7 +53,7 @@ Some rough tells:
| **The thing you look at** | run history and loss curves | a dashboard, maybe on a wall |
If both describe you — a lab with instruments to drive *and* models to
fit — start with the data-science path. It is the smaller installation, and it
fit — start with the data-science path. It is the smaller instance, and it
grows into the other one without being reinstalled: the same engine, the same
flows, just more of them running all the time.
@@ -62,7 +62,7 @@ flows, just more of them running all the time.
Whichever door you came in:
- **Flows are files in a git repository.** Every save is a commit. You can read
the history with ordinary git, and you can copy a flow between installations
the history with ordinary git, and you can copy a flow between instances
by copying a directory.
- **Editing is separate from running.** You edit a draft; the engine keeps
running what was published until you publish.
+1 -1
View File
@@ -52,6 +52,6 @@ and a training pipeline on it.
| Drive it from Python, a shell or CI | [Code and the CLI](code/cli.md) |
| Look up a node type or a payload type | [Reference](reference/node-types.md) |
Fluksio is self-hosted by default. An installation runs offline, keeps its data
Fluksio is self-hosted by default. An instance runs offline, keeps its data
on its own disk, and never contacts anything unless you
[connect it to a portal](interface/portal.md) yourself.
+6 -6
View File
@@ -272,26 +272,26 @@ swap the device out without rebuilding what hangs there.
!!! note "If the link is wrong"
The pairing link is built from the installation's `FRONTEND_HOST`. If that
The pairing link is built from the instance's `FRONTEND_HOST`. If that
is not the address devices on your network actually reach, fix the setting
rather than the link: it is the same one password-reset mails and the OAuth
metadata are built from.
### A screen somewhere you cannot reach
Another building, someone else's network, no route in. An installation
Another building, someone else's network, no route in. An instance
[enrolled with a portal](portal.md) shows a second link,
`https://hub.${DOMAIN}/i/{installation-id}/panel`, and the same three steps
`https://hub.${DOMAIN}/i/{instance-id}/panel`, and the same three steps
work through it: the portal serves that one page without a session, forwards
the pairing calls down the tunnel, and mints the credential when you approve
the code. The pairing line then reads *via portal*.
The portal names the panel and nothing else. What the panel may read is decided
on the installation, on every call, by the same check a locally paired screen
passes. Two differences: it acts as the account the installation was enrolled
on the instance, on every call, by the same check a locally paired screen
passes. Two differences: it acts as the account the instance was enrolled
with rather than as whoever approved it, and deleting the panel stops it here
immediately while the portal's copy of the token expires on its own — which is
also why unpairing, which works on a credential this installation signed, does
also why unpairing, which works on a credential this instance signed, does
not reach a remote screen. Revoke that one at the hub.
## See also
+10 -10
View File
@@ -1,12 +1,12 @@
# The dashboard app
The web interface is a single-page app served at `app.${DOMAIN}` — or, for an
installation reached through a portal, at `${DOMAIN}/i/{installation-id}`.
instance reached through a portal, at `${DOMAIN}/i/{instance-id}`.
Either way it is the same application, and it is a client of the same REST API
you can script against.
Sign in with the account the installation was created with. On a fresh
installation that account was printed once, on the first start.
Sign in with the account the instance was created with. On a fresh
instance that account was printed once, on the first start.
## The shell
@@ -22,7 +22,7 @@ phone the sidebar collapses to a sheet.
| **Modules** | the Python packages your node code may import |
| **Alerts** | where failures get sent |
| **Admin** | users (superusers only) |
| **Search** | anything in this installation, by name |
| **Search** | anything in this instance, by name |
| **Settings** | your account, appearance, and remote access |
### Search
@@ -33,8 +33,8 @@ dashboards and the widgets on them, panels, secrets, modules, workers and alert
channels. Picking a node opens its flow with that node in focus; picking a
widget opens its dashboard.
It searches this installation. Reached through a portal, other installations
are behind **All installations** at the top of the sidebar.
It searches this instance. Reached through a portal, other instances
are behind **All instances** at the top of the sidebar.
## Home
@@ -44,7 +44,7 @@ The one screen you leave open. Four things share it.
Every flow drawn as a neuron, wired to the flows it exchanges messages with.
This is the brand mark made live, and it is also the fastest read on the
installation: a neuron pulses when its flow is running work, and its ring turns
instance: a neuron pulses when its flow is running work, and its ring turns
terracotta when the flow cannot run as written. A neuron with a problem keeps
its label showing so you can see which one it is without hovering.
@@ -70,7 +70,7 @@ Always answers, degraded or not. The tiles cover:
because their graph does not validate
- **Nodes** — how many failed to load
- **Runs running** — batch runs in flight right now, and how many are
waiting. Only on an installation that has run something
waiting. Only on an instance that has run something
- **Queue** — depth, and how old the oldest pending item is
- **Loop lag** — whether the engine's event loop is keeping up
@@ -110,8 +110,8 @@ so does Escape. The Dashboards screen works the same way.
- [Dashboards and panels](dashboards.md) — widgets, bindings, and hanging a
screen on a wall
- [Secrets, modules and alerts](operations.md) — the three screens that keep an
installation running
- [Accounts and the portal](portal.md) — reaching an installation from outside
instance running
- [Accounts and the portal](portal.md) — reaching an instance from outside
its network
## Appearance
+5 -5
View File
@@ -1,7 +1,7 @@
# Secrets, modules and alerts
Three screens that have nothing to do with each other except that an
installation you actually depend on needs all of them.
instance you actually depend on needs all of them.
## Secrets
@@ -18,7 +18,7 @@ have stored.
**Secrets** is where the values live. Add a name and a value; the value is
never shown again, and the list only ever shows names.
They are encrypted at rest with a key derived from the installation's
They are encrypted at rest with a key derived from the instance's
`SECRET_KEY`, and kept **outside** the flow repository. That matters because
flows are a git repository you may well push somewhere: what gets committed and
shared never contains a password.
@@ -35,7 +35,7 @@ authentication failure.
## Modules
Node code runs in a virtual environment of its own, on the installation's data
Node code runs in a virtual environment of its own, on the instance's data
volume — separate from the packages Fluksio itself runs on. A pin of yours can
never shadow one of ours, and vice versa.
@@ -69,7 +69,7 @@ where you say who hears about it.
| Kind | Settings |
|---|---|
| **ntfy** | server, topic, and a token for a protected topic |
| **SMTP** | an address to send to (the installation's mail settings do the rest) |
| **SMTP** | an address to send to (the instance's mail settings do the rest) |
| **Webhook** | a URL to POST to |
| **Dashboard** | a message name a notification widget reads |
@@ -126,6 +126,6 @@ are registered.
Your own account: name, email, password, and appearance (light, dark, or
follow the system).
**Remote access** is where an installation is paired with a portal, remote
**Remote access** is where an instance is paired with a portal, remote
users are admitted, and the link is cut again. That has [its own
page](portal.md).
+27 -27
View File
@@ -1,6 +1,6 @@
# Accounts and the portal
Fluksio is self-hosted by default. An installation runs offline, keeps its data
Fluksio is self-hosted by default. An instance runs offline, keeps its data
on its own disk, and never contacts anything unless you tell it to.
The **portal** is optional, and it exists to solve two specific problems:
@@ -8,10 +8,10 @@ The **portal** is optional, and it exists to solve two specific problems:
1. **Your machine has no inbound route.** A homelab behind CGNAT, a cluster
node with no open ports, a laptop. Opening one is work, and often not
allowed.
2. **You want a browser on it anyway.** A `pip install fluksio` installation
2. **You want a browser on it anyway.** A `pip install fluksio` instance
has no web server for the dashboard at all.
An enrolled installation dials *out* to the portal and holds one websocket
An enrolled instance dials *out* to the portal and holds one websocket
open. The portal serves the dashboard from its own side, and only the API calls
travel down the tunnel — so the interface loads at portal speed and your
machine stays unreachable from the internet.
@@ -19,18 +19,18 @@ machine stays unreachable from the internet.
## Enrolling
Two halves, deliberately: whoever performs the second step decides what the
installation's owner gets.
instance's owner gets.
**On the portal** (`hub.${DOMAIN}`, or [fluksio.com](https://fluksio.com) for
the hosted one): **Installations → Add installation**, give it a name, and copy
the hosted one): **Instances → Add instance**, give it a name, and copy
the code.
**On the installation**, either from the dashboard:
**On the instance**, either from the dashboard:
> **Settings → Remote access**, enter the portal URL and the code, press
> **Connect**.
or from the command line, which is the path for an installation with no web
or from the command line, which is the path for an instance with no web
interface of its own:
```sh
@@ -42,40 +42,40 @@ The code is single-use and expires in fifteen minutes.
A portal session then arrives as *that local account* — the settings screen
states this plainly, because it is the whole security model in one sentence.
Use `--as someone@example.com` to enrol as a specific local account when the
installation has several superusers.
instance has several superusers.
Once enrolled, the installation appears under **Installations** with its
Once enrolled, the instance appears under **Instances** with its
status, when it was last seen and its version. **Open** takes you to its
dashboard at `${DOMAIN}/i/{installation-id}`.
dashboard at `${DOMAIN}/i/{instance-id}`.
## What the portal can and cannot do
The portal holds one credential for your installation and proxies requests down
the tunnel. What those requests may do is decided **on the installation**, by
The portal holds one credential for your instance and proxies requests down
the tunnel. What those requests may do is decided **on the instance**, by
the same checks a local session passes.
The trust anchor is a signing keypair on the portal. Every installation pins
The trust anchor is a signing keypair on the portal. Every instance pins
its public half at enrolment and rejects anything else — which is what stops a
hijacked DNS entry or a mis-issued certificate from impersonating the portal.
## Letting someone else in
Anyone else on the portal reaches your installation only if a superuser there
Anyone else on the portal reaches your instance only if a superuser there
admits them, and they arrive as a local user of their own rather than as you.
1. **They**: **Installations → Join an installation**, and copy the code. It is
1. **They**: **Instances → Join an instance**, and copy the code. It is
bound to their portal account and expires in fifteen minutes.
2. **You**, on the installation: **Settings → Remote access → Add remote
2. **You**, on the instance: **Settings → Remote access → Add remote
user**, and enter the code.
3. They now see the installation under **Installations**, marked *Shared by*,
3. They now see the instance under **Instances**, marked *Shared by*,
with **Open** and nothing else. Renaming, re-keying and removing stay with
you.
The installation redeems that code against the portal using its own credential.
The instance redeems that code against the portal using its own credential.
A portal session cannot do this — which is what stops somebody you let in from
letting others in.
On the installation they appear under **Admin → Users**, badged *Portal*, never
On the instance they appear under **Admin → Users**, badged *Portal*, never
a superuser and with no password.
## Cutting it off
@@ -84,29 +84,29 @@ a superuser and with no password.
|---|---|---|
| The portal | **New code** | rotates the credential and drops the current link |
| The portal | **Remove** | deletes the registration and cuts the connection |
| The installation | **Disconnect** | unilateral and immediate — the portal's tokens stop verifying here whatever the portal still has on file |
| The installation | delete a user under **Admin → Users** | that one person, immediately, independent of the portal |
| The instance | **Disconnect** | unilateral and immediate — the portal's tokens stop verifying here whatever the portal still has on file |
| The instance | delete a user under **Admin → Users** | that one person, immediately, independent of the portal |
**New code** also cuts every credential the portal minted for this installation,
**New code** also cuts every credential the portal minted for this instance,
including [wall panels paired through it](dashboards.md#a-screen-somewhere-you-cannot-reach).
The installation's own **Disconnect** is the one to reach for if you are ever
The instance's own **Disconnect** is the one to reach for if you are ever
unsure: it does not need the portal's cooperation.
## Running your own portal
The portal is the `index` stack's `hub` service — accounts, the registry of
connected installations, and the websocket each one dials in on. Two things
connected instances, and the websocket each one dials in on. Two things
about it are load-bearing:
- **It runs a single process.** It keeps its attached installations in the
- **It runs a single process.** It keeps its attached instances in the
memory of the process holding their sockets, so a second worker would answer
for links it does not hold. Scaling out needs a routing layer first.
- **Back up the signing keypair with the database.** Replacing it forces every
installation to be enrolled again.
instance to be enrolled again.
Websocket upgrades must be enabled on the `hub` hostname in whatever proxy
fronts it. Without them every installation sits in a reconnect loop and the
fronts it. Without them every instance sits in a reconnect loop and the
portal shows them all offline.
## See also
+1 -1
View File
@@ -5,7 +5,7 @@
> same engine serves two shapes of work: live flows that never end (buildings,
> labs, homelabs) and batch runs that finish and leave a record (experiments,
> ML pipelines, CI-style jobs). Self-hosted by default; an optional portal
> exists only to reach an installation that has no inbound route.
> exists only to reach an instance that has no inbound route.
## Getting started
- [Pick your starting point](/getting-started/): the two setup paths and how to choose
+4 -4
View File
@@ -1,7 +1,7 @@
# Configuration
Every setting comes from the environment, or from an env file. Which file
depends on how the installation was started:
depends on how the instance was started:
| Started with | Reads |
|---|---|
@@ -36,7 +36,7 @@ what the container images do to pin everything onto `/data`.
| `managed` | a venv the engine builds under `DATA_DIR` and owns, which the Modules screen installs into with `uv pip sync`. The container images set this: the venv in them holds the app and nothing of anybody else's. |
| a path | that interpreter, or that venv, whatever it is. |
An installation that already has a managed venv keeps it on upgrade under
An instance that already has a managed venv keeps it on upgrade under
`auto`, because it may hold packages somebody installed on purpose.
!!! warning "The four files that must be on persistent storage"
@@ -112,10 +112,10 @@ remember it is fixed at build time: changing it means rebuilding that image.
`delay` with a cron expression fires on local time. Left at `UTC`, "off at
02:00" means two in the morning UTC, which in most of the world is neither two
o'clock nor the same hour in summer as in winter. Set it to where the
installation is.
instance is.
`production` closes `/docs`, `/redoc` and the OpenAPI document, because the
schema enumerates every endpoint the installation serves — including the paths
schema enumerates every endpoint the instance serves — including the paths
webhook nodes mounted at runtime. It also turns a `changethis` secret from a
warning into a refusal to start.
+2 -2
View File
@@ -5,7 +5,7 @@ editor generates from its parameter schema, so they all behave the same way.
Anything here could be written as a **Function** 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
for most of 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.
`GET /flows/node-types` returns this list with each type's full parameter
@@ -138,7 +138,7 @@ thing configured elsewhere.
| `start_delay` | `1.0` | how long to wait before that first emission |
The scheduler: a `cron` expression here is what makes a flow run by the clock.
It is also the most-placed node in a real installation — mostly as a button
It is also the most-placed node in a real instance — mostly as a button
someone presses.
### Delay & schedule
+2 -2
View File
@@ -5,9 +5,9 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#59849b" />
<!-- `%BASE_URL%` is `/` on an installation of its own and `/app-shell/` in
<!-- `%BASE_URL%` is `/` on an instance of its own and `/app-shell/` in
the build the portal serves, where the hub rewrites this line to the
installation's own path so the install is scoped to one house. -->
instance's own path so the install is scoped to one house. -->
<link rel="manifest" href="%BASE_URL%manifest.webmanifest" />
<link rel="apple-touch-icon" href="%BASE_URL%apple-touch-icon.png" />
<title>Fluksio</title>
+3 -3
View File
@@ -5,7 +5,7 @@
* so caching it would hand the next visitor somebody else's session.
*
* `registration.scope` is the app's root in both places it runs `/` on an
* installation of its own, `/i/{id}/` through the portal which is why the
* instance of its own, `/i/{id}/` through the portal which is why the
* push payload does not carry a URL.
*/
@@ -16,7 +16,7 @@ self.addEventListener("push", (event) => {
self.registration.showNotification(alert.title || "Fluksio", {
body: alert.body || "",
// Under the portal the icons are the shared bundle's, not this
// installation's path.
// instance's path.
icon: scope.pathname.startsWith("/i/")
? "/app-shell/icon-192.png"
: `${scope.pathname}icon-192.png`,
@@ -35,7 +35,7 @@ self.addEventListener("notificationclick", (event) => {
self.clients
.matchAll({ type: "window", includeUncontrolled: true })
.then((clients) => {
// A tab on this installation is already open: raise it rather than
// A tab on this instance is already open: raise it rather than
// opening a second one.
const open = clients.find((client) => client.url.startsWith(scope))
if (open) return open.focus()
+3 -3
View File
@@ -2379,7 +2379,7 @@ export const PanelsConfigSchema = {
},
type: 'object',
title: 'PanelsConfig',
description: 'Every panel this installation knows about.'
description: 'Every panel this instance knows about.'
} as const;
export const PanelsPublicSchema = {
@@ -2405,7 +2405,7 @@ The address is the server's own, because the browser's origin is not a
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.`
} as const;
@@ -3803,7 +3803,7 @@ A colour widget picks what it publishes with \`\`format\`\`, because fixtures
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.
+6 -6
View File
@@ -177,7 +177,7 @@ export class ArtifactsService {
export class CloudService {
/**
* Read Status
* 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.
@@ -196,7 +196,7 @@ export class CloudService {
* 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.
*
@@ -1710,7 +1710,7 @@ export class PanelsService {
* 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.
* @returns PairStarted Successful Response
* @throws ApiError
@@ -1781,8 +1781,8 @@ export class PanelsService {
*
* 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.
* @param data The data for the request.
* @param data.panelId
@@ -2228,7 +2228,7 @@ export class SearchService {
* 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.
+3 -3
View File
@@ -852,7 +852,7 @@ export type PanelDef = {
};
/**
* Every panel this installation knows about.
* Every panel this instance knows about.
*/
export type PanelsConfig = {
panels?: Array<PanelDef>;
@@ -865,7 +865,7 @@ export type PanelsConfig = {
* 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.
*/
@@ -1286,7 +1286,7 @@ export type WebPushKey = {
* 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.
@@ -9,7 +9,7 @@ import { isPortal } from "@/lib/portal"
const ID = "connection-offline"
/**
* Says when the installation cannot be reached, and when it was last heard
* Says when the instance cannot be reached, and when it was last heard
* from.
*
* Only ever meaningful under a portal: a local install cannot lose contact with
@@ -39,8 +39,8 @@ export function ConnectionNotice() {
}
notify(
connection.lastSeen
? `Installation offline — last seen ${ago(connection.lastSeen / 1000)}, reconnecting…`
: "Installation offline — reconnecting…",
? `Instance offline — last seen ${ago(connection.lastSeen / 1000)}, reconnecting…`
: "Instance offline — reconnecting…",
"warning",
{ id: ID, persistent: true },
)
@@ -19,8 +19,8 @@ const DEFAULT_COLUMNS = 12
* How many tiles are worth a request of their own.
*
* ponytail: the list endpoint carries no placements, so a footprint means
* reading that dashboard's document cheap for the handful an installation
* has, and shared with the editor's own cache. The ceiling is an installation
* reading that dashboard's document cheap for the handful an instance
* has, and shared with the editor's own cache. The ceiling is an instance
* with dozens: the tiles past this show their name and nothing else, and the
* fix would be a stored footprint on `DashboardSummary`.
*/
@@ -58,7 +58,7 @@ function hint(entry: SearchEntry): string {
}
/**
* Everything in this installation, by name, from anywhere.
* Everything in this instance, by name, from anywhere.
*
* The whole index arrives in one fetch and `cmdk` does the matching, so results
* narrow as they are typed without a round trip per keystroke.
@@ -190,7 +190,7 @@ export function GlobalSearch({
</>
) : (
<p className="py-6 text-center text-sm text-muted-foreground">
Start typing to search this installation.
Start typing to search this instance.
</p>
)}
</CommandList>
@@ -3,7 +3,7 @@
*
* A single stack at the top centre, over whatever is below it. Newest on top,
* each card going on its own after a few seconds unless it was raised as
* persistent an offline notice stays until the installation answers again.
* persistent an offline notice stays until the instance answers again.
*
* Raised from anywhere through `notify()` in `lib/notificationStore`, which is
* where the semantics live; this only draws them.
@@ -303,10 +303,10 @@ export function ConfirmDelete({
<DialogDescription>
{description ?? (
<>
{names.length === 1 ? "It goes" : "They go"} from the
installation at once. The store's git history keeps what was
there, but nothing in the app brings{" "}
{names.length === 1 ? "it" : "them"} back.
{names.length === 1 ? "It goes" : "They go"} from the instance
at once. The store's git history keeps what was there, but
nothing in the app brings {names.length === 1 ? "it" : "them"}{" "}
back.
</>
)}
</DialogDescription>
@@ -51,7 +51,7 @@ export function PanelsDialog() {
const { data: config } = useQuery(panelsQueryOptions())
const { data: dashboards } = useQuery(dashboardsQueryOptions())
const { user } = useAuth()
// Where a screen that cannot reach this installation pairs instead. The
// Where a screen that cannot reach this instance pairs instead. The
// issuer is the portal as a browser reaches it, which is not always the
// address this machine dialled — enrolment may have named a container.
const { data: cloud } = useQuery({
@@ -60,12 +60,12 @@ export function PanelsDialog() {
(await CloudService.readStatus()) as {
enrolled: boolean
issuer: string | null
installation_id: string | null
instance_id: string | null
},
})
const remoteHost =
cloud?.enrolled && cloud.issuer && cloud.installation_id
? `${cloud.issuer.replace(/\/$/, "")}/i/${cloud.installation_id}`
cloud?.enrolled && cloud.issuer && cloud.instance_id
? `${cloud.issuer.replace(/\/$/, "")}/i/${cloud.instance_id}`
: ""
const save = useSavePanels()
const { showErrorToast } = useCustomToast()
@@ -75,7 +75,7 @@ export function PanelsDialog() {
const panels = config?.panels ?? []
const known = dashboards?.data ?? []
// The installation's own address, not this browser's: administering through
// The instance's own address, not this browser's: administering through
// the portal puts the page on the portal's origin, and a screen cannot be
// sent there — it has no portal session and could not hold a panel
// credential if it had one.
@@ -98,7 +98,7 @@ export function PanelsDialog() {
<DialogTitle>Panels</DialogTitle>
<DialogDescription>
A panel is one screen and the dashboards it shows. Point the device at
a link and it asks for a code you enter here this installation's own
a link and it asks for a code you enter here this instance's own
address for a screen on your network, or the portal's for one hanging
where this machine is not reachable.
</DialogDescription>
@@ -187,9 +187,9 @@ function PanelRow({
onRemove,
}: {
panel: PanelDef
/** Where this installation answers, as it knows itself. */
/** Where this instance answers, as it knows itself. */
host: string
/** Where the portal serves this installation, when it is enrolled. */
/** Where the portal serves this instance, when it is enrolled. */
remoteHost: string
/**
* Whether this account may change anything here. Every control below saves
@@ -335,7 +335,7 @@ function PanelRow({
<Input
readOnly
value={link}
placeholder="This installation has no address set"
placeholder="This instance has no address set"
aria-label={`Link for ${panel.id}`}
className="text-muted-foreground"
onFocus={(event) => event.currentTarget.select()}
+1 -1
View File
@@ -854,7 +854,7 @@ function ParamsForm({
* How much of a machine this node takes, and how long it is expected to take.
*
* A named size is the usual answer: what "gpu-small" means is a property of the
* machines this installation has, and those change. The numbers are still there
* machines this instance has, and those change. The numbers are still there
* for a node that genuinely wants its own.
*/
function ResourcesSection({
+1 -1
View File
@@ -100,7 +100,7 @@ export function useParamSuggestions(
staleTime: SUGGEST_STALE,
})
// ponytail: reads every flow to collect them, sharing the editor's own cache
// entries; an aggregate endpoint if a big installation makes that hurt.
// entries; an aggregate endpoint if a big instance makes that hurt.
const details = useQueries({
queries: (type ? (flows?.data ?? []) : []).map((flow) => ({
...flowQueryOptions(flow.name),
@@ -32,7 +32,7 @@ type FlowEvent =
nodes: { id: string; status: string; error?: string | null }[]
paused?: string[]
logs?: LogLine[]
/** Emission counts per qualified node. Absent on older installations. */
/** Emission counts per qualified node. Absent on older instances. */
emits?: Record<string, number>
}
| {
@@ -194,7 +194,7 @@ function connect() {
liveStore.setStatuses(message.nodes ?? [])
liveStore.setPausedFlows(message.paused ?? [])
liveStore.setLogs(message.logs ?? [])
// Missing against an installation older than this bundle; the graph
// Missing against an instance older than this bundle; the graph
// then starts from zero the way it always did.
liveStore.setEmits(message.emits ?? {})
break
@@ -321,7 +321,7 @@ function connect() {
const payload = JSON.parse(event.data)
if (payload?.type === "batch") {
// A cascade publishes a dozen events at once and the engine coalesces
// them into one frame. An installation older than this bundle sends
// them into one frame. An instance older than this bundle sends
// them one at a time, which is the branch below.
for (const message of payload.events ?? []) handle(message)
} else {
@@ -347,7 +347,7 @@ function connect() {
return
}
}
// 1013 is the portal saying the installation is not attached — the one
// 1013 is the portal saying the instance is not attached — the one
// close code that means "offline" rather than "the socket dropped".
if (event.code === 1013) {
connectionStore.setOffline(null)
@@ -75,7 +75,7 @@ export function HealthOverview({
const queue = (summary?.queue ?? {}) as Record<string, number>
const degraded = summary?.status === "degraded"
const errors = (flows ?? []).reduce((total, row) => total + row.errors, 0)
// An installation nobody has run a batch flow on has no runs tile at all —
// An instance nobody has run a batch flow on has no runs tile at all —
// the same "nothing has been run yet" the Runs screen itself goes by.
const running = (runs ?? []).reduce((total, row) => total + row.running, 0)
const queued = (runs ?? []).reduce((total, row) => total + row.queued, 0)
@@ -23,7 +23,7 @@ const GRACE = 3000
* stop pulsing and the values stop changing, with nothing to say why. Nothing
* at all while the socket is up: a page that works needs no chip saying so.
*
* Quiet too while the offline notification is up, since an installation that cannot
* Quiet too while the offline notification is up, since an instance that cannot
* be reached has no socket either and one explanation of that is enough. Named
* in words rather than coloured, and deliberately not terracotta: the brain
* graph above it already owns that accent for a flow that cannot run.
@@ -33,7 +33,7 @@ import { type Item, Main } from "./Main"
/**
* Where the documentation is published.
*
* A constant rather than something derived: an installation reached over a LAN
* A constant rather than something derived: an instance reached over a LAN
* address, or at localhost, has no domain to build this from, and the docs are
* one site for all of them.
*/
@@ -70,13 +70,13 @@ export function AppSidebar() {
? [...baseItems, { icon: Users, title: "Admin", path: "/admin" }]
: baseItems
// Reached through a portal, the way out is back to the installations list —
// Reached through a portal, the way out is back to the instances list —
// and the session belongs to the portal, so logging out is its business.
const items: Item[] = portal
? [
{
icon: ArrowLeft,
title: "All installations",
title: "All instances",
onClick: () => window.location.assign(portal.portalUrl),
},
...withAdmin,
@@ -31,19 +31,19 @@ type CloudStatus = {
connected: boolean
portal_url: string | null
portal_account: string | null
installation_id: string | null
instance_id: string | null
last_error: string | null
connected_since: number | null
}
/**
* Connecting this installation to a Fluksio portal, or cutting it loose, and
* Connecting this instance to a Fluksio portal, or cutting it loose, and
* admitting other portal accounts to it.
*
* Deliberately blunt about what it grants: the account that enrolled is what
* the portal owner's sessions act as, and this screen says which one. Anyone
* else gets in only by being added here, as a local user of their own.
* Everything here is optional an installation nobody enrolls never contacts
* Everything here is optional an instance nobody enrolls never contacts
* anything.
*/
export function RemoteAccess() {
@@ -108,8 +108,8 @@ export function RemoteAccess() {
<CardHeader>
<CardTitle>Remote access</CardTitle>
<CardDescription>
Reach this installation from fluksio.com. Entirely optional without
it, this installation talks to nothing outside your network.
Reach this instance from fluksio.com. Entirely optional without it,
this instance talks to nothing outside your network.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
@@ -133,9 +133,9 @@ export function RemoteAccess() {
own user.
</span>
</Field>
<Field label="Installation">
<Field label="Instance">
<span className="font-mono text-xs">
{status.installation_id ?? "—"}
{status.instance_id ?? "—"}
</span>
</Field>
</dl>
@@ -156,8 +156,8 @@ export function RemoteAccess() {
<div>
<h3 className="font-medium">Remote users</h3>
<p className="text-sm text-muted-foreground">
Let someone else reach this installation through the portal.
They get a user of their own here not yours, and never a
Let someone else reach this instance through the portal. They
get a user of their own here not yours, and never a
superuser, so they cannot pass access on.
</p>
</div>
@@ -173,9 +173,9 @@ export function RemoteAccess() {
}
/>
<p className="text-xs text-muted-foreground">
They get a code at fluksio.com Installations Join an
installation. Added users appear under Admin Users; deleting
them there ends their access.
They get a code at fluksio.com Instances Join an instance.
Added users appear under Admin Users; deleting them there
ends their access.
</p>
</div>
<div>
@@ -210,7 +210,7 @@ export function RemoteAccess() {
onChange={(event) => setCode(event.target.value.toUpperCase())}
/>
<p className="text-xs text-muted-foreground">
Get a code at fluksio.com Installations Add installation.
Get a code at fluksio.com Instances Add instance.
</p>
</div>
<div>
@@ -235,8 +235,8 @@ export function RemoteAccess() {
<DialogTitle>Disconnect from the portal?</DialogTitle>
<DialogDescription>
Remote access ends immediately and the portal's credentials stop
working here. Nothing on this installation is changed or deleted,
and you can connect again with a new code.
working here. Nothing on this instance is changed or deleted, and
you can connect again with a new code.
</DialogDescription>
</DialogHeader>
<DialogFooter>
+5 -5
View File
@@ -1,5 +1,5 @@
/**
* Whether the installation this page talks to is reachable right now.
* Whether the instance this page talks to is reachable right now.
*
* Transport state, kept apart from `liveStore` on purpose: that one holds what
* the engine is doing, this one holds whether we can hear it at all. Only
@@ -11,7 +11,7 @@
type Connection = {
offline: boolean
/** When the portal last heard from the installation, epoch ms. */
/** When the portal last heard from the instance, epoch ms. */
lastSeen: number | null
}
@@ -38,7 +38,7 @@ function emit(next: Connection) {
export const connectionStore = {
setOffline(lastSeen: string | number | null) {
// The socket is up, so the installation is reachable: this was one bad
// The socket is up, so the instance is reachable: this was one bad
// answer rather than a lost tunnel, and the banner would have nothing to
// take it back down again.
if (socketOpen) return
@@ -56,7 +56,7 @@ export const connectionStore = {
/**
* The live socket opened or closed.
*
* An open socket is proof the installation can be heard, so it also clears
* An open socket is proof the instance can be heard, so it also clears
* the banner and since the socket reconnects on its own, that is the one
* release the offline state can always count on.
*/
@@ -76,7 +76,7 @@ export const connectionStore = {
/**
* The proxy's offline answer: a 503 carrying `{offline, last_seen}`.
*
* Told apart from every other 503 by that body, so an installation that is
* Told apart from every other 503 by that body, so an instance that is
* merely busy is not reported as unreachable.
*/
export function offlineDetail(
+6 -6
View File
@@ -1,26 +1,26 @@
/**
* Runtime configuration injected when this app is served through a portal.
*
* A normal installation ships the same bundle and finds nothing here, so every
* A normal instance ships the same bundle and finds nothing here, so every
* portal-aware branch in the app collapses to its ordinary behaviour. When a
* portal serves the page it writes this object into the document first: the
* API lives under the installation's own path, the credential comes with the
* API lives under the instance's own path, the credential comes with the
* page rather than from storage, and there is somewhere to go "back" to.
*
* Deliberately not localStorage: two installations open in one browser share
* Deliberately not localStorage: two instances open in one browser share
* an origin, and a single `access_token` key would have them overwrite each
* other's session.
*/
export type PortalConfig = {
installationId: string
installationName: string
instanceId: string
instanceName: string
/** Router basepath, e.g. `/i/{id}`. */
basePath: string
/** Origin-relative API base; the SDK appends `/api/v1/...`. */
apiBase: string
/** Where "back to portal" goes. */
portalUrl: string
/** Short-lived token scoped to this installation. */
/** Short-lived token scoped to this instance. */
token: string
}
+4 -4
View File
@@ -1,13 +1,13 @@
/**
* Subscribing this browser to the installation's notifications.
* Subscribing this browser to the instance's notifications.
*
* A push arrives through the service worker, so it reaches a phone with no tab
* open which is the whole point, and the reason this is not the in-app
* notification stack. The engine decides *what* is worth sending in its alert
* rules; this only says which browsers hear it.
*
* Everything here is per browser and per installation: the subscription is
* stored by the installation it was made against, so a phone that opens two
* Everything here is per browser and per instance: the subscription is
* stored by the instance it was made against, so a phone that opens two
* houses through the portal is two subscriptions and hears each separately.
*/
@@ -62,7 +62,7 @@ function keyBytes(key: string): Uint8Array<ArrayBuffer> {
}
/**
* Ask permission, subscribe, and tell the installation where to reach us.
* Ask permission, subscribe, and tell the instance where to reach us.
*
* Throws with something worth reading if the person says no the caller puts
* it on screen, because a silently ignored button is worse than a refusal.
+2 -2
View File
@@ -51,7 +51,7 @@ const isPointlessToRetry = (error: unknown) =>
const handleApiError = (error: Error) => {
const offline = offlineDetail(error)
if (offline) {
// The installation is unreachable, not the session invalid: keep the user
// The instance is unreachable, not the session invalid: keep the user
// where they are and let the banner explain.
connectionStore.setOffline(offline.lastSeen)
return
@@ -67,7 +67,7 @@ const handleApiError = (error: Error) => {
if (portal) {
// The portal knows whether they are still signed in; it can mint a fresh
// handoff or send them to the login screen.
window.location.href = `${portal.portalUrl}?reauth=${portal.installationId}`
window.location.href = `${portal.portalUrl}?reauth=${portal.instanceId}`
return
}
safeStorage.remove("access_token")
+1 -1
View File
@@ -97,7 +97,7 @@ function showSetting(value: unknown): string {
/**
* The one channel whose setting is on the device rather than in the config:
* a browser has to ask its own permission, and what it hands back is stored
* against this installation.
* against this instance.
*/
function ThisBrowser() {
const { showSuccessToast, showErrorToast } = useCustomToast()
+1 -1
View File
@@ -33,7 +33,7 @@ const HEADER =
* it has been doing. The brain and the health screens compose in here rather
* than living at routes of their own.
*
* Top to bottom it is a widening lens: the whole installation as a graph, what
* Top to bottom it is a widening lens: the whole instance as a graph, what
* has been built on it, whether it is well, then flow by flow and finally
* moment by moment.
*/
+1 -1
View File
@@ -50,7 +50,7 @@ function UserSettings() {
<motion.div variants={slideUp}>
<Appearance />
</motion.div>
{/* Whether this whole installation can be reached from outside is not
{/* Whether this whole instance can be reached from outside is not
a personal preference, so it only exists for an operator. */}
{currentUser.is_superuser && (
<motion.div variants={slideUp}>
+3 -3
View File
@@ -11,8 +11,8 @@ import { apiToken, appPath, isPortal, openPortalSession } from "@/lib/portal"
/**
* The panel a credential in hand names, or "" when it names none.
*
* Two shapes say it, because two things mint one. This installation writes a
* `panel` claim; the hub, which knows nothing of this installation's users,
* Two shapes say it, because two things mint one. This instance writes a
* `panel` claim; the hub, which knows nothing of this instance's users,
* puts the panel in `sub` and says so with `scope`. A local token carries no
* `scope` at all, so the two cannot be confused and without the second shape
* a screen adopted through the portal named no panel, and asked the household
@@ -53,7 +53,7 @@ function pairedPanel(): string {
* no session and there is nothing to put around it.
*
* Reached through the portal as well, for a screen hanging where this
* installation is not: the hub serves this one page without a session and
* instance is not: the hub serves this one page without a session and
* forwards these two calls down the tunnel, because a device with no
* credential is what they are for.
*/
+3 -3
View File
@@ -4,13 +4,13 @@ import { ago } from "./components/Health/queries"
import { offlineDetail } from "./lib/connectionStore"
function extractErrorMessage(err: ApiError): string {
// An unreachable installation is not a rejected action: say plainly that
// An unreachable instance is not a rejected action: say plainly that
// nothing was delivered, so nobody is left wondering whether it half-landed.
const offline = offlineDetail(err)
if (offline) {
return offline.lastSeen
? `Not delivered — the installation is offline (last seen ${ago(offline.lastSeen)})`
: "Not delivered — the installation is offline"
? `Not delivered — the instance is offline (last seen ${ago(offline.lastSeen)})`
: "Not delivered — the instance is offline"
}
if (err instanceof AxiosError) {
+1 -1
View File
@@ -17,7 +17,7 @@ import { api, apiPage, deleteAll } from "./utils/api"
const flowName = `test_panel_${Date.now().toString(36)}`
const first = `${flowName}_a`
const second = `${flowName}_b`
/** The installation's own panels, put back by the teardown. */
/** The instance's own panels, put back by the teardown. */
let panels: PanelDef[] | null = null
test.use({ storageState: "playwright/.auth/user.json" })
+1 -1
View File
@@ -6,7 +6,7 @@ import { defineConfig } from "vite"
// https://vitejs.dev/config/
export default defineConfig({
// A portal serves this bundle under a path of its own and caches one copy
// for every installation, so asset URLs have to be absolute under that
// for every instance, so asset URLs have to be absolute under that
// prefix. Unset — every ordinary build — this stays "/".
base: process.env.VITE_BASE || "/",
resolve: {

Some files were not shown because too many files have changed in this diff Show More