Rename Installation to Instance
Follows the portal: the noun is "instance" everywhere the app says it — UI strings, CLI output, error details, docs and comments. The wire keys (`instance_id`, `instance_token`) and the hub route this calls move with it. An existing cloud.json is adopted rather than refused: without the key alias the dataclass fails to parse, which the caller swallows and reads as "never enrolled" instead of "reconnect". `instance_key` on a node type becomes `target_key`. It means the outside thing a node points at, which is a different sense of the word, and keeping both would put two meanings of "instance" in one codebase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,7 +51,7 @@ def _panel_for(payload: dict[str, Any]) -> panels.PanelDef:
|
||||
effect at once. Or the panel's nonce moved on, which revokes exactly one
|
||||
screen's and leaves the panel, its dashboards and their arrangement alone.
|
||||
|
||||
The nonce is only held against a credential this installation signed. One
|
||||
The nonce is only held against a credential this instance signed. One
|
||||
the portal minted for a remote screen carries none — the portal names the
|
||||
panel and nothing else — and is revoked at the hub instead.
|
||||
"""
|
||||
@@ -182,11 +182,11 @@ def decode_token(token: str, request: Request | None = None) -> dict[str, Any]:
|
||||
guards.
|
||||
|
||||
The last branch is the seam a hosted deployment widens: a portal this
|
||||
installation was enrolled with signs tokens with a key pinned at
|
||||
instance was enrolled with signs tokens with a key pinned at
|
||||
enrolment, and they name the portal account holding them, which resolves
|
||||
to whichever local account was mapped to it — the superuser who enrolled,
|
||||
or a remote user one of them admitted since. With no enrolment the branch
|
||||
raises immediately, so an offline installation pays nothing for the
|
||||
raises immediately, so an offline instance pays nothing for the
|
||||
possibility. A screen that paired through that portal
|
||||
arrives there too, naming a panel — which is why the gate below is applied
|
||||
to whichever branch produced the claims rather than to one of them: where a
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Connecting this installation to a Fluksio portal, and cutting it loose.
|
||||
"""Connecting this instance to a Fluksio portal, and cutting it loose.
|
||||
|
||||
Entirely optional, and superuser-only to change: enrolling grants a remote
|
||||
party the rights of the account that performed it, which is not a decision an
|
||||
ordinary user of this installation gets to make on everyone else's behalf.
|
||||
ordinary user of this instance gets to make on everyone else's behalf.
|
||||
|
||||
Admitting further portal accounts is the same decision made again, so it is
|
||||
guarded the same way. A person let in this way gets an ordinary local account
|
||||
@@ -47,7 +47,7 @@ class EnrollBody(BaseModel):
|
||||
|
||||
@router.get("/status", dependencies=[Depends(get_current_user)])
|
||||
def read_status(request: Request) -> dict[str, Any]:
|
||||
"""Whether this installation is enrolled, and whether the link is up.
|
||||
"""Whether this instance is enrolled, and whether the link is up.
|
||||
|
||||
Readable by any signed-in user: everyone here has a right to know whether
|
||||
the machine they are using can be reached from outside.
|
||||
@@ -61,7 +61,7 @@ def read_status(request: Request) -> dict[str, Any]:
|
||||
"portal_url": config.portal_url if config else None,
|
||||
"issuer": config.issuer if config else None,
|
||||
"portal_account": config.portal_account if config else None,
|
||||
"installation_id": config.installation_id if config else None,
|
||||
"instance_id": config.instance_id if config else None,
|
||||
"last_error": None,
|
||||
"connected_since": None,
|
||||
}
|
||||
@@ -80,7 +80,7 @@ async def enroll(
|
||||
"""Redeem a claim code and start dialling the portal.
|
||||
|
||||
The account performing this is mapped to the portal account that owns the
|
||||
installation, so the owner's portal sessions arrive here as them. Widening
|
||||
instance, so the owner's portal sessions arrive here as them. Widening
|
||||
that to anyone else is a local decision made one person at a time, below —
|
||||
never something the portal can do from its side.
|
||||
|
||||
@@ -121,11 +121,11 @@ def add_remote_user(session: SessionDep, body: RemoteUserBody) -> Any:
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="This installation is not connected to a portal",
|
||||
detail="This instance is not connected to a portal",
|
||||
)
|
||||
try:
|
||||
response = httpx.post(
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/installation-members/",
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/instance-members/",
|
||||
headers={"Authorization": f"Bearer {config.token}"},
|
||||
json={"code": body.code.strip()},
|
||||
timeout=15.0,
|
||||
@@ -140,7 +140,7 @@ def add_remote_user(session: SessionDep, body: RemoteUserBody) -> Any:
|
||||
)
|
||||
if response.status_code == 409:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="That code belongs to this installation's owner"
|
||||
status_code=409, detail="That code belongs to this instance's owner"
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(
|
||||
@@ -183,14 +183,14 @@ def forget_remote_user(portal_sub: str) -> None:
|
||||
|
||||
Best effort on purpose: the local account is what grants access, so it is
|
||||
already over by the time this runs. A portal that cannot be reached keeps a
|
||||
row that opens nothing — the installation refuses the session either way.
|
||||
row that opens nothing — the instance refuses the session either way.
|
||||
"""
|
||||
config = cloud_config.load()
|
||||
if config is None:
|
||||
return
|
||||
try:
|
||||
response = httpx.delete(
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/installation-members/{portal_sub}",
|
||||
f"{config.portal_url.rstrip('/')}/api/v1/instance-members/{portal_sub}",
|
||||
headers={"Authorization": f"Bearer {config.token}"},
|
||||
timeout=15.0,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ shows it on the wall, and somebody with an account types that code into the
|
||||
panels dialog to say which panel the device is. The device polls, collects the
|
||||
credential the approval minted, and never asks again.
|
||||
|
||||
A screen hanging somewhere this installation is not reachable from does the
|
||||
A screen hanging somewhere this instance is not reachable from does the
|
||||
same thing through the portal, which forwards those two calls down the tunnel
|
||||
without a session — a device with no credential is the whole point of them —
|
||||
and mints the credential itself when the approval comes. Which side minted it
|
||||
@@ -81,7 +81,7 @@ class _Pending(BaseModel):
|
||||
#: Self-reported and worth what that is worth.
|
||||
device: str = ""
|
||||
#: Whether it came down the tunnel. A device that reached the portal
|
||||
#: cannot reach this installation, so its credential has to be minted
|
||||
#: cannot reach this instance, so its credential has to be minted
|
||||
#: where it can collect it.
|
||||
remote: bool = False
|
||||
|
||||
@@ -99,7 +99,7 @@ class _PendingStore:
|
||||
A device's poll lands on whichever API worker the proxy picked, and a
|
||||
screen pairing through the portal lands on whichever one holds the tunnel
|
||||
— so a code minted by one worker has to be findable from the next. Redis
|
||||
is where this installation already keeps what must outlive a process.
|
||||
is where this instance already keeps what must outlive a process.
|
||||
|
||||
Without one there is one process by definition (the pip install, and the
|
||||
tests), and a dictionary is the same thing for it.
|
||||
@@ -224,8 +224,8 @@ def _describe(request: Request) -> str:
|
||||
def _mint_at_hub(panel_id: str) -> str:
|
||||
"""Ask the portal for this panel's credential.
|
||||
|
||||
A device that arrived through the portal cannot reach this installation, so
|
||||
a token this installation signed would be one it could never present: the
|
||||
A device that arrived through the portal cannot reach this instance, so
|
||||
a token this instance signed would be one it could never present: the
|
||||
portal verifies what crosses its tunnel, and it verifies against its own
|
||||
key. It mints, we say which panel — and the scope check here decides the
|
||||
rest, on this call and on every later one.
|
||||
@@ -234,7 +234,7 @@ def _mint_at_hub(panel_id: str) -> str:
|
||||
if config is None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="That device came through a portal this installation is no "
|
||||
detail="That device came through a portal this instance is no "
|
||||
"longer enrolled with",
|
||||
)
|
||||
try:
|
||||
@@ -264,14 +264,14 @@ class PanelsPublic(BaseModel):
|
||||
reliable answer to it: an admin working through the portal is on the
|
||||
portal's origin, and this one is for a screen on this network.
|
||||
|
||||
An installation enrolled with a portal has a second address, built by the
|
||||
An instance enrolled with a portal has a second address, built by the
|
||||
dialog from what ``/cloud/status`` reports rather than from here — a panel
|
||||
is not the thing that knows whether remote access is on.
|
||||
"""
|
||||
|
||||
panels: list[PanelDef] = Field(default_factory=list)
|
||||
#: Whatever this installation was told it is reachable at. The same setting
|
||||
#: the password-reset links are built from, so an installation that has it
|
||||
#: Whatever this instance was told it is reachable at. The same setting
|
||||
#: the password-reset links are built from, so an instance that has it
|
||||
#: wrong has it wrong in both places.
|
||||
frontend_host: str = ""
|
||||
|
||||
@@ -310,7 +310,7 @@ async def save_panels(body: PanelsConfig) -> Any:
|
||||
|
||||
def _save() -> None:
|
||||
# Read and write under one lock. The nonce belongs to this
|
||||
# installation, not to whoever is writing the panels back: a client
|
||||
# instance, not to whoever is writing the panels back: a client
|
||||
# holding an older copy must not be able to undo a revocation by
|
||||
# saving an arrangement — and reading it in a separate step from
|
||||
# writing it is exactly how an unpair in between was undone.
|
||||
@@ -335,7 +335,7 @@ def start_pairing(request: Request) -> Any:
|
||||
All this hands out is a code that means nothing until somebody with an
|
||||
account approves it, so the worst an unwelcome caller achieves is an entry
|
||||
that expires ten minutes later. Reachable from the internet when this
|
||||
installation is enrolled with a portal, which is what the cap and the
|
||||
instance is enrolled with a portal, which is what the cap and the
|
||||
portal's own per-address limits are between.
|
||||
"""
|
||||
if _pending.count() >= MAX_PENDING:
|
||||
@@ -412,8 +412,8 @@ def approve_pairing(panel_id: str, body: PairRequest, current_user: CurrentUser)
|
||||
|
||||
A credential minted here names the approver, so what the panel does stays
|
||||
attributable to a person rather than to nobody. One minted by the portal —
|
||||
for a device that reached this installation only through it — names the
|
||||
account this installation was enrolled with instead, since that is the one
|
||||
for a device that reached this instance only through it — names the
|
||||
account this instance was enrolled with instead, since that is the one
|
||||
every portal-borne request already acts as.
|
||||
"""
|
||||
panel = find(panel_id)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""One index of everything in this installation worth jumping to by name."""
|
||||
"""One index of everything in this instance worth jumping to by name."""
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
@@ -20,7 +20,7 @@ from fluksio.flow.secrets import get_secrets
|
||||
from fluksio.flow.store import FlowNotFound
|
||||
|
||||
# A wall panel never reaches this route: ``deps._panel_may`` is a whitelist that
|
||||
# ends in a 403, and a whole-installation index is the opposite of what a screen
|
||||
# ends in a 403, and a whole-instance index is the opposite of what a screen
|
||||
# on a wall is allowed to read.
|
||||
router = APIRouter(
|
||||
prefix="/search", tags=["search"], dependencies=[Depends(get_current_user)]
|
||||
@@ -144,7 +144,7 @@ async def read_search_index(
|
||||
"""Everything searchable, for the client to match against as it is typed.
|
||||
|
||||
The whole index rather than a query: it is a few hundred short rows for an
|
||||
installation of any ordinary size, so one fetch when the panel opens beats a
|
||||
instance of any ordinary size, so one fetch when the panel opens beats a
|
||||
round trip per keystroke — and the client already has a matcher.
|
||||
|
||||
Secrets are named only to a superuser, which is who ``/secrets`` answers to.
|
||||
|
||||
@@ -13,7 +13,7 @@ from fluksio.api.deps import get_current_active_superuser
|
||||
from fluksio.flow.secrets import SecretNotFound, get_secrets
|
||||
from fluksio.models import Message
|
||||
|
||||
# Superuser, not merely signed in. The names alone say what this installation
|
||||
# Superuser, not merely signed in. The names alone say what this instance
|
||||
# talks to, and `PUT /{name}` takes any name — so an ordinary account could
|
||||
# overwrite the credential a flow authenticates with. `/search` already gates
|
||||
# secret names this way and said so; this router was the half that did not.
|
||||
|
||||
+14
-17
@@ -28,7 +28,7 @@ from typing import Any
|
||||
|
||||
import fluksio
|
||||
|
||||
#: Where an installation keeps everything, unless it is told otherwise.
|
||||
#: Where an instance keeps everything, unless it is told otherwise.
|
||||
DEFAULT_HOME = Path("~/.fluksio")
|
||||
|
||||
#: The portal a claim code is redeemed at, unless another is named. Having a
|
||||
@@ -52,7 +52,7 @@ def _say(message: str = "") -> None:
|
||||
|
||||
|
||||
def _data_dir(raw: str | None, shared: bool = False) -> Path:
|
||||
"""Which installation this command is for.
|
||||
"""Which instance this command is for.
|
||||
|
||||
A repository with its own venv wants its own engine too — its own flows,
|
||||
its own run history, its own token — so the default is a `.fluksio` beside
|
||||
@@ -73,12 +73,12 @@ def _data_dir(raw: str | None, shared: bool = False) -> Path:
|
||||
return path.resolve()
|
||||
|
||||
|
||||
def _mention_other_installation(data_dir: Path) -> None:
|
||||
def _mention_other_instance(data_dir: Path) -> None:
|
||||
"""Say when there is a second engine, so neither goes looking lost."""
|
||||
shared = DEFAULT_HOME.expanduser().resolve()
|
||||
if data_dir == shared or not (shared / "fluksio.db").exists():
|
||||
return
|
||||
_say(f" Note {shared} holds another installation; this one is separate.")
|
||||
_say(f" Note {shared} holds another instance; this one is separate.")
|
||||
_say(" `fluksio serve --global` runs that one instead.")
|
||||
|
||||
|
||||
@@ -217,8 +217,7 @@ def _enroll(portal: str, code: str, as_email: str | None) -> int:
|
||||
return 1
|
||||
|
||||
_say(
|
||||
f"Connected to {config.portal_url} as {email} "
|
||||
f"(installation {config.installation_id})."
|
||||
f"Connected to {config.portal_url} as {email} (instance {config.instance_id})."
|
||||
)
|
||||
return 0
|
||||
|
||||
@@ -340,7 +339,7 @@ def probe_engine(url: str, token: str, client: Any = None) -> str:
|
||||
makes stopping it something this command may offer. The proof is the
|
||||
token: it is signed with this directory's secret key, so an engine that
|
||||
accepts it is one reading this directory's database. A Fluksio belonging
|
||||
to another installation answers the health check and refuses the token,
|
||||
to another instance answers the health check and refuses the token,
|
||||
and is only ever named — never stopped from here.
|
||||
"""
|
||||
import logging
|
||||
@@ -443,7 +442,7 @@ def cmd_serve(args: argparse.Namespace) -> int:
|
||||
if port != DEFAULT_PORT:
|
||||
# Moving off the port quietly makes starting a second engine for
|
||||
# one directory look like it worked. Two of them on one SQLite
|
||||
# file is not a supported shape — two *installations* on one
|
||||
# file is not a supported shape — two *instances* on one
|
||||
# machine is — so the one already up is named instead.
|
||||
url = f"http://{reachable}:{DEFAULT_PORT}"
|
||||
who = probe_engine(url, _token_for(data_dir))
|
||||
@@ -455,7 +454,7 @@ def cmd_serve(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
if who == "foreign":
|
||||
_say(
|
||||
f"Port {DEFAULT_PORT} holds another installation's Fluksio; "
|
||||
f"Port {DEFAULT_PORT} holds another instance's Fluksio; "
|
||||
f"serving on {port} instead."
|
||||
)
|
||||
else:
|
||||
@@ -480,15 +479,15 @@ def cmd_serve(args: argparse.Namespace) -> int:
|
||||
_say(f" Nodes {modules.venv_dir() / 'bin' / 'python'}")
|
||||
_say(" a venv of its own; the Modules screen installs into it.")
|
||||
if config is not None:
|
||||
_say(f" Portal {config.portal_url}, installation {config.installation_id}")
|
||||
_say(f" Portal {config.portal_url}, instance {config.instance_id}")
|
||||
_say(" The dashboard is served by the portal; nothing is served here.")
|
||||
else:
|
||||
_say(" No portal. Pair this installation with:")
|
||||
_say(" No portal. Pair this instance with:")
|
||||
_say(" fluksio enroll <code>")
|
||||
_say(f" Signed in as {admin_email}")
|
||||
_say(f" token in {token_path}")
|
||||
_mention_undeclared_cards()
|
||||
_mention_other_installation(data_dir)
|
||||
_mention_other_instance(data_dir)
|
||||
|
||||
# One process: it holds the flow engine, and a second worker would be a
|
||||
# second engine — duplicated subscriptions, cron ticks and webhooks.
|
||||
@@ -558,13 +557,13 @@ def _parser() -> argparse.ArgumentParser:
|
||||
sub.add_argument(
|
||||
"--data-dir",
|
||||
default=os.environ.get("FLUKSIO_HOME"),
|
||||
help="where this installation keeps everything (default: ./.fluksio)",
|
||||
help="where this instance keeps everything (default: ./.fluksio)",
|
||||
)
|
||||
sub.add_argument(
|
||||
"--global",
|
||||
dest="shared",
|
||||
action="store_true",
|
||||
help=f"use the shared installation at {DEFAULT_HOME} instead",
|
||||
help=f"use the shared instance at {DEFAULT_HOME} instead",
|
||||
)
|
||||
|
||||
serve = subparsers.add_parser("serve", help="run the engine")
|
||||
@@ -622,9 +621,7 @@ def _parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
serve.set_defaults(func=cmd_serve)
|
||||
|
||||
enroll = subparsers.add_parser(
|
||||
"enroll", help="pair this installation with a portal"
|
||||
)
|
||||
enroll = subparsers.add_parser("enroll", help="pair this instance with a portal")
|
||||
enroll.add_argument("code", help="the claim code minted on the portal")
|
||||
enroll.add_argument(
|
||||
"--portal",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Optional remote access through a Fluksio portal.
|
||||
|
||||
Nothing in here runs unless someone enrolled this installation: with no
|
||||
Nothing in here runs unless someone enrolled this instance: with no
|
||||
``cloud.json`` the connector never starts and the portal's tokens are refused
|
||||
like any other bad credential. Turning it off is deleting that one file.
|
||||
"""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""What this installation knows about the portal it is enrolled with.
|
||||
"""What this instance knows about the portal it is enrolled with.
|
||||
|
||||
One file on the data volume, beside the OAuth keypair and for the same reason:
|
||||
it is a credential, it must survive a rebuild, and it must never be in the
|
||||
@@ -9,7 +9,7 @@ portal still believes.
|
||||
The portal's public keys are stored here rather than fetched: they are pinned
|
||||
at enrolment, when a person was holding a claim code they had just read off the
|
||||
portal's own screen. A hub whose DNS or TLS is later hijacked cannot re-key an
|
||||
installation that already enrolled; it can only fail to verify.
|
||||
instance that already enrolled; it can only fail to verify.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -42,11 +42,11 @@ VIA_PORTAL = secrets.token_urlsafe(16)
|
||||
class CloudConfig:
|
||||
portal_url: str
|
||||
ws_url: str
|
||||
installation_id: str
|
||||
instance_id: str
|
||||
token: str
|
||||
issuer: str
|
||||
jwks: dict[str, Any]
|
||||
#: The local account this installation acts as on its own behalf: what the
|
||||
#: The local account this instance acts as on its own behalf: what the
|
||||
#: health summary is collected as, and what a screen paired through the
|
||||
#: portal borrows for want of a person. Recorded at enrolment from whoever
|
||||
#: performed it. Portal *sessions* no longer come through here — they name
|
||||
@@ -72,6 +72,10 @@ def _read() -> CloudConfig | None:
|
||||
return _cache[1]
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
# A file written before the installation/instance rename. Adopt it
|
||||
# rather than failing to parse, which would read as "never enrolled".
|
||||
if "installation_id" in data:
|
||||
data["instance_id"] = data.pop("installation_id")
|
||||
config = CloudConfig(**data)
|
||||
except (OSError, ValueError, TypeError):
|
||||
logger.exception("Could not read %s; remote access stays off", path)
|
||||
@@ -109,15 +113,15 @@ def delete() -> None:
|
||||
|
||||
|
||||
def decode_portal_token(token: str) -> dict[str, Any]:
|
||||
"""Validate a token the portal minted for this installation.
|
||||
"""Validate a token the portal minted for this instance.
|
||||
|
||||
Raises ``InvalidTokenError`` for everything else, including the ordinary
|
||||
case of not being enrolled at all — which is what makes this branch free
|
||||
for an installation nobody connected.
|
||||
for an instance nobody connected.
|
||||
"""
|
||||
config = _read()
|
||||
if config is None:
|
||||
raise InvalidTokenError("this installation is not enrolled with a portal")
|
||||
raise InvalidTokenError("this instance is not enrolled with a portal")
|
||||
|
||||
try:
|
||||
key = jwt.PyJWKSet.from_dict(config.jwks).keys[0]
|
||||
@@ -128,9 +132,9 @@ def decode_portal_token(token: str) -> dict[str, Any]:
|
||||
token,
|
||||
key,
|
||||
algorithms=["RS256"],
|
||||
# The audience is this installation's own id, so a token the portal
|
||||
# The audience is this instance's own id, so a token the portal
|
||||
# minted for somebody else's machine fails here.
|
||||
audience=config.installation_id,
|
||||
audience=config.instance_id,
|
||||
issuer=config.issuer,
|
||||
)
|
||||
scope = claims.get("scope")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The link this installation opens to its portal.
|
||||
"""The link this instance opens to its portal.
|
||||
|
||||
Same shape as the remote-worker agent, with the direction of the favour
|
||||
reversed: there, a GPU box dials the engine so the engine can give it work;
|
||||
@@ -50,7 +50,7 @@ MAX_WS_MESSAGE = 1024 * 1024
|
||||
#: Only the versioned API is served over the tunnel. The MCP mount and the
|
||||
#: OAuth endpoints live outside it and stay local-only.
|
||||
ALLOWED_PREFIX = "/api/v1/"
|
||||
#: Proxied calls, and bridged streams, this installation will run at once.
|
||||
#: Proxied calls, and bridged streams, this instance will run at once.
|
||||
#: Neither dict was bounded, and an id the hub reused silently dropped the
|
||||
#: reference to a task that was still running.
|
||||
MAX_IN_FLIGHT = 256
|
||||
@@ -76,7 +76,7 @@ async def watch_enrolment(app: FastAPI) -> None:
|
||||
"""Notice an enrolment that happened outside this process.
|
||||
|
||||
``fluksio enroll`` writes the config against the database directly, with no
|
||||
idea whether an engine is running — so without this, pairing an installation
|
||||
idea whether an engine is running — so without this, pairing an instance
|
||||
that is already serving would take a restart to come into effect. Enrolling
|
||||
through the API starts the link itself and this sees a task already there.
|
||||
"""
|
||||
@@ -124,7 +124,7 @@ class CloudConnector:
|
||||
# this process does: enrolment may have named a container.
|
||||
"issuer": config.issuer if config else None,
|
||||
"portal_account": config.portal_account if config else None,
|
||||
"installation_id": config.installation_id if config else None,
|
||||
"instance_id": config.instance_id if config else None,
|
||||
"last_error": self._last_error,
|
||||
"connected_since": self._connected_since,
|
||||
}
|
||||
@@ -165,7 +165,7 @@ class CloudConnector:
|
||||
|
||||
delay = BASE_BACKOFF_S if attached else min(MAX_BACKOFF_S, delay * 2)
|
||||
# Jittered, so a portal coming back up is not met by every
|
||||
# installation it serves in the same instant.
|
||||
# instance it serves in the same instant.
|
||||
wait = delay * (0.75 + random.random() * 0.5)
|
||||
logger.info("Reconnecting to the portal in %.0fs", wait)
|
||||
await asyncio.sleep(wait)
|
||||
@@ -197,9 +197,7 @@ class CloudConnector:
|
||||
self._connected = True
|
||||
self._connected_since = time.time()
|
||||
self._last_error = None
|
||||
logger.info(
|
||||
"Attached to the portal as installation %s", config.installation_id
|
||||
)
|
||||
logger.info("Attached to the portal as instance %s", config.instance_id)
|
||||
|
||||
keepalive = asyncio.create_task(self._keepalive(socket, config))
|
||||
try:
|
||||
@@ -270,7 +268,7 @@ class CloudConnector:
|
||||
|
||||
@staticmethod
|
||||
def _local_account(session: Any, config: cloud_config.CloudConfig) -> Any:
|
||||
"""The account this installation acts as, or the one that now stands for it.
|
||||
"""The account this instance acts as, or the one that now stands for it.
|
||||
|
||||
``local_user_id`` names whoever redeemed the claim code. A database
|
||||
restored from a backup, or rebuilt under an enrolment that outlived it,
|
||||
@@ -301,7 +299,7 @@ class CloudConnector:
|
||||
)
|
||||
if len(superusers) != 1:
|
||||
logger.warning(
|
||||
"The account this installation enrolled as is gone, and there "
|
||||
"The account this instance enrolled as is gone, and there "
|
||||
"%s to adopt unambiguously — enrol again from Settings.",
|
||||
"is no superuser" if not superusers else "are several superusers",
|
||||
)
|
||||
@@ -309,7 +307,7 @@ class CloudConnector:
|
||||
adopted = superusers[0]
|
||||
cloud_config.save(replace(config, local_user_id=str(adopted.id)))
|
||||
logger.warning(
|
||||
"The account this installation enrolled as is gone; acting as %s instead",
|
||||
"The account this instance enrolled as is gone; acting as %s instead",
|
||||
adopted.email,
|
||||
)
|
||||
return adopted
|
||||
@@ -334,14 +332,14 @@ class CloudConnector:
|
||||
async def _health_summary(
|
||||
self, config: cloud_config.CloudConfig
|
||||
) -> dict[str, Any] | None:
|
||||
"""This installation's own health, as the dashboard reads it.
|
||||
"""This instance's own health, as the dashboard reads it.
|
||||
|
||||
Fetched through the same in-process transport as everything else, with
|
||||
a short-lived local token: the observability API is authenticated, and
|
||||
this process is entitled to mint one for the enrolling user.
|
||||
|
||||
``failures_24h`` is added here rather than by ``/summary``: it is the
|
||||
portal's tile and nothing on this installation reads it, and summing
|
||||
portal's tile and nothing on this instance reads it, and summing
|
||||
the same rollups over the same window the app's own Home tile sums
|
||||
keeps the two from drifting apart the way they already did once.
|
||||
"""
|
||||
@@ -376,7 +374,7 @@ class CloudConnector:
|
||||
def _client(self) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=self._app),
|
||||
base_url="http://installation.local",
|
||||
base_url="http://instance.local",
|
||||
timeout=httpx.Timeout(300.0, connect=5.0),
|
||||
)
|
||||
|
||||
@@ -404,7 +402,7 @@ class CloudConnector:
|
||||
try:
|
||||
if not path.startswith(ALLOWED_PREFIX):
|
||||
# Defence in depth: the hub only ever forwards API paths, and
|
||||
# an installation that trusted anything else would be an SSRF
|
||||
# an instance that trusted anything else would be an SSRF
|
||||
# gadget aimed at its own process.
|
||||
await socket.send(
|
||||
_dump({"op": "err", "id": call_id, "message": "path not allowed"})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Redeeming a claim code, from the dashboard or from the command line.
|
||||
|
||||
The work is the same either way — ask the portal, keep what it answers, and
|
||||
map the account that asked to the portal identity that owns the installation —
|
||||
map the account that asked to the portal identity that owns the instance —
|
||||
so it lives here rather than in the route. The command line matters because a
|
||||
machine on a cluster has no browser pointed at it: `fluksio enroll` does this
|
||||
before the engine has started, holding nothing but the database.
|
||||
@@ -32,7 +32,7 @@ class EnrollError(Exception):
|
||||
|
||||
class AlreadyEnrolled(EnrollError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(409, "This installation is already connected to a portal")
|
||||
super().__init__(409, "This instance is already connected to a portal")
|
||||
|
||||
|
||||
def _is_local(url: str) -> bool:
|
||||
@@ -44,9 +44,9 @@ def _is_local(url: str) -> bool:
|
||||
def redeem_claim(
|
||||
portal_url: str, claim_code: str, *, timeout: float = 15.0
|
||||
) -> dict[str, Any]:
|
||||
"""Trade a claim code for this installation's credential and the portal's keys."""
|
||||
"""Trade a claim code for this instance's credential and the portal's keys."""
|
||||
base = portal_url.rstrip("/")
|
||||
# The claim code and, from here on, this installation's credential go to
|
||||
# The claim code and, from here on, this instance's credential go to
|
||||
# this address. Over plain http both are readable by anything on the path,
|
||||
# so refuse rather than enrol insecurely — bar a loopback portal, which is
|
||||
# how the stack is developed against itself.
|
||||
@@ -70,12 +70,12 @@ def redeem_claim(
|
||||
data: dict[str, Any] = response.json()
|
||||
if not data.get("owner_id"):
|
||||
# A portal older than remote users does not say who owns the
|
||||
# installation, and without that the enrolling account cannot be mapped
|
||||
# instance, and without that the enrolling account cannot be mapped
|
||||
# to anyone — which would leave the portal connected but refused here.
|
||||
raise EnrollError(
|
||||
502,
|
||||
"That portal is too old for this installation: it did not say "
|
||||
"which account owns the installation",
|
||||
"That portal is too old for this instance: it did not say "
|
||||
"which account owns the instance",
|
||||
)
|
||||
return data
|
||||
|
||||
@@ -91,8 +91,8 @@ def enroll(
|
||||
config = cloud_config.CloudConfig(
|
||||
portal_url=portal_url.rstrip("/"),
|
||||
ws_url=data["ws_url"],
|
||||
installation_id=data["installation_id"],
|
||||
token=data["installation_token"],
|
||||
instance_id=data["instance_id"],
|
||||
token=data["instance_token"],
|
||||
issuer=data["issuer"],
|
||||
# Pinned here, at the one moment the claim code proves who we are
|
||||
# talking to. Nothing refreshes this.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""First run: an account to sign in with.
|
||||
|
||||
An installation started from the command line is given no environment, so the
|
||||
An instance started from the command line is given no environment, so the
|
||||
superuser a deployment sets in `.env` has to come from somewhere. The session
|
||||
key is the CLI's own business — it has to be set before this module can be
|
||||
imported at all.
|
||||
@@ -27,7 +27,7 @@ def ensure_superuser(
|
||||
"""The account to sign in with, made on first run.
|
||||
|
||||
Returns the user and, when it was just created, the password in clear —
|
||||
the caller prints it once. An installation that already has a superuser is
|
||||
the caller prints it once. An instance that already has a superuser is
|
||||
left alone: this is a first run, not a password reset.
|
||||
"""
|
||||
existing = session.exec(
|
||||
@@ -57,7 +57,7 @@ def pick_superuser(session: Session, email: str | None = None) -> User:
|
||||
select(User).where(User.is_superuser == True) # noqa: E712
|
||||
).all()
|
||||
if not users:
|
||||
raise LookupError("This installation has no superuser to enrol as")
|
||||
raise LookupError("This instance has no superuser to enrol as")
|
||||
if len(users) > 1:
|
||||
addresses = ", ".join(sorted(u.email for u in users))
|
||||
raise LookupError(f"Several superusers here — name one with --as: {addresses}")
|
||||
|
||||
@@ -54,7 +54,7 @@ class Settings(BaseSettings):
|
||||
FRONTEND_HOST: str = "http://localhost:5173"
|
||||
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
|
||||
|
||||
#: Everything this installation keeps: the database, the flow repository,
|
||||
#: Everything this instance keeps: the database, the flow repository,
|
||||
#: secrets, artifacts and the user venv. The paths below derive from it
|
||||
#: unless they are set explicitly.
|
||||
DATA_DIR: Path = Path("flow-data")
|
||||
@@ -72,13 +72,13 @@ class Settings(BaseSettings):
|
||||
# Which failures reach which channel. Beside the flows, not in them:
|
||||
# alerting is the deployment's concern, not any one flow's.
|
||||
ALERTS_FILE: Path = Path("flow-data/alerts.json")
|
||||
# This installation's web push keypair and the browsers subscribed to it.
|
||||
# This instance's web push keypair and the browsers subscribed to it.
|
||||
# Beside the alerts it serves; deleting it makes every device subscribe
|
||||
# again.
|
||||
WEBPUSH_FILE: Path = Path("flow-data/webpush.json")
|
||||
# Where machines can be started from when a node needs one and nothing that
|
||||
# could take it is attached. Operator-authored, like the alerts beside it,
|
||||
# and absent on an installation that has nowhere to start one.
|
||||
# and absent on an instance that has nowhere to start one.
|
||||
PROVISIONERS_FILE: Path = Path("flow-data/provisioners.json")
|
||||
# Which dashboards each device shows. Beside the flows for the same reason
|
||||
# alerting is: where a screen hangs is the deployment's concern rather than
|
||||
@@ -99,7 +99,7 @@ class Settings(BaseSettings):
|
||||
PRIVATE_API_ENABLED: bool = False
|
||||
DOMAIN: str = "localhost"
|
||||
OAUTH_PRIVATE_KEY_FILE: Path = Path("flow-data/oauth-key.pem")
|
||||
# Written only when someone enrols this installation with a portal.
|
||||
# Written only when someone enrols this instance with a portal.
|
||||
# Its absence is what keeps remote access off.
|
||||
CLOUD_CONFIG_FILE: Path = Path("flow-data/cloud.json")
|
||||
OAUTH_CODE_EXPIRE_SECONDS: int = 60
|
||||
@@ -113,7 +113,7 @@ class Settings(BaseSettings):
|
||||
# because a limit somebody set and did not get is the worse surprise.
|
||||
FLOW_MAX_WORKERS: PositiveInt = 4
|
||||
# How many cascades may be in flight at once. Sustained throughput is this
|
||||
# over the mean cascade time, so an installation whose nodes wait on the
|
||||
# over the mean cascade time, so an instance whose nodes wait on the
|
||||
# network rather than on a CPU wants it higher than the core count.
|
||||
FLOW_MAX_CASCADES: PositiveInt = 4
|
||||
# How many batch runs are driven at once. A different limit from the one
|
||||
|
||||
@@ -142,9 +142,9 @@ def init_db(session: Session) -> None:
|
||||
user = crud.create_user(session=session, user_create=user_in)
|
||||
|
||||
|
||||
#: The sizes an installation starts with. Written once, when there are none,
|
||||
#: The sizes an instance starts with. Written once, when there are none,
|
||||
#: and editable from there — what a name means is a property of the machines
|
||||
#: this installation has, and nothing here knows what those are.
|
||||
#: this instance has, and nothing here knows what those are.
|
||||
SEED_FLAVORS: tuple[dict[str, Any], ...] = (
|
||||
{"name": "small", "cpus": 1, "ram": 2048, "description": "A poll, a threshold"},
|
||||
{"name": "medium", "cpus": 4, "ram": 8192, "description": "A step that computes"},
|
||||
@@ -160,7 +160,7 @@ SEED_FLAVORS: tuple[dict[str, Any], ...] = (
|
||||
|
||||
|
||||
def seed_flavors(session: Session) -> None:
|
||||
"""Give a new installation sizes to pick from, once.
|
||||
"""Give a new instance sizes to pick from, once.
|
||||
|
||||
Only when there are none at all: they are editable, and re-adding one that
|
||||
somebody deliberately removed would be an argument nobody can win.
|
||||
|
||||
@@ -358,7 +358,7 @@ class AlertManager:
|
||||
await asyncio.to_thread(self.publish, message, alert.model_dump())
|
||||
|
||||
async def _send_webpush(self, alert: Alert) -> None:
|
||||
"""Wake every browser that subscribed to this installation.
|
||||
"""Wake every browser that subscribed to this instance.
|
||||
|
||||
No settings of its own: a browser subscribes by pressing a button on
|
||||
the alerts screen, and this channel goes to whichever ones did. Saying
|
||||
|
||||
@@ -98,7 +98,7 @@ HOOK_PREFIX = "/hooks"
|
||||
NODE_STOP_TIMEOUT = 5.0
|
||||
|
||||
# How long a rebuild asked for by a request waits for one already running.
|
||||
# Generous on purpose: a rebuild of a populated installation reconnects every
|
||||
# Generous on purpose: a rebuild of a populated instance reconnects every
|
||||
# node and takes the better part of ten seconds, and a caller queued behind a
|
||||
# healthy one of those should not be turned away. Past that the controller is
|
||||
# wedged rather than busy, and an error the caller can act on beats a request
|
||||
@@ -106,7 +106,7 @@ NODE_STOP_TIMEOUT = 5.0
|
||||
REBUILD_WAIT = 15.0
|
||||
|
||||
# How long a caller waits for a *flow* rebuild that is already running. A
|
||||
# per-flow rebuild reconnects one flow's nodes rather than the installation's,
|
||||
# per-flow rebuild reconnects one flow's nodes rather than the instance's,
|
||||
# so this is a queueing budget — several of them back to back, which is what
|
||||
# seeding does — not the room a single one needs. Fifteen seconds was sized for
|
||||
# the whole-pipeline rebuild and would let a wedge sit unreported.
|
||||
@@ -680,7 +680,7 @@ class FlowController:
|
||||
another flow owns — but the wiring is derived from message names, so
|
||||
swapping one flow's nodes into the graph and deriving the edges again
|
||||
is enough. What that saves is the reconnecting: the cost of a rebuild
|
||||
on a populated installation is every node opening its socket again,
|
||||
on a populated instance is every node opening its socket again,
|
||||
and only one flow's have changed.
|
||||
|
||||
A flow the store no longer has is taken out instead of replaced.
|
||||
@@ -1522,12 +1522,12 @@ class FlowController:
|
||||
key = None
|
||||
if node_type is not None:
|
||||
try:
|
||||
key = node_type.cls.instance_key(node_def.params)
|
||||
key = node_type.cls.target_key(node_def.params)
|
||||
except Exception:
|
||||
# A plugin's own grouping is not worth the whole graph;
|
||||
# this node just stands on its own.
|
||||
logger.exception(
|
||||
"instance_key failed for node type '%s'", node_def.type
|
||||
"target_key failed for node type '%s'", node_def.type
|
||||
)
|
||||
gid = f"{node_def.type}:{key}" if key else member
|
||||
gid_of[member] = gid
|
||||
|
||||
@@ -81,7 +81,7 @@ WidgetType = Literal[
|
||||
INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown", "color"}
|
||||
|
||||
#: What a colour widget puts on the wire, by the format it was configured for.
|
||||
#: The default is what the Node-RED installation this ports already sends its
|
||||
#: The default is what the Node-RED instance this ports already sends its
|
||||
#: DMX encoders — ``[h, s, v]``, hue in degrees and the other two in percent —
|
||||
#: and the two alternatives exist because fixtures differ. Mirrored in the
|
||||
#: client (``frontend/src/components/Dashboard/ColorWidget.tsx``).
|
||||
@@ -197,7 +197,7 @@ class WidgetDef(BaseModel):
|
||||
differ and a change node per tile is not the answer:
|
||||
|
||||
- ``hsv`` (the default) — ``[h, s, v]``, hue 0-360 degrees, saturation and
|
||||
value 0-100 percent. What the Node-RED installation this ports feeds its
|
||||
value 0-100 percent. What the Node-RED instance this ports feeds its
|
||||
3CH/4CH DMX encoders, which divide by 360 and by 100.
|
||||
- ``rgb`` — ``[r, g, b]``, each 0-255. The conventional range; the
|
||||
reference's own encoders produce it after converting.
|
||||
@@ -479,7 +479,7 @@ class DashboardDef(BaseModel):
|
||||
def _flatten(cls, data: Any) -> Any:
|
||||
"""Read a document written as pages and sections as one grid.
|
||||
|
||||
Stored dashboards live in each installation's git repository, so the
|
||||
Stored dashboards live in each instance's git repository, so the
|
||||
old shape is normalised on the way in rather than migrated: an
|
||||
untouched document keeps working, and the next save writes it flat.
|
||||
"""
|
||||
@@ -502,7 +502,7 @@ class DashboardDef(BaseModel):
|
||||
picker records the payload type beside the name, so neither the editor
|
||||
nor a wall panel has to fetch the catalogue to know the wiring is
|
||||
wrong. A name this build does not know is left alone rather than
|
||||
refused — an older installation reading a newer document simply does
|
||||
refused — an older instance reading a newer document simply does
|
||||
not act on it.
|
||||
"""
|
||||
for name, setting in self.settings.items():
|
||||
|
||||
@@ -49,7 +49,7 @@ DUE_RESERVE = 2
|
||||
# check whether a cascade slot has freed.
|
||||
DUE_CLAIM_BLOCK_MS = 200
|
||||
#: How many cascades may be in flight, unless the service is given a number.
|
||||
#: Sustained throughput is this over the mean cascade time, so an installation
|
||||
#: Sustained throughput is this over the mean cascade time, so an instance
|
||||
#: whose nodes wait on a network rather than a CPU may want more of them —
|
||||
#: `FLOW_MAX_CASCADES` is where that is said.
|
||||
MAX_CASCADES = 4
|
||||
|
||||
@@ -97,7 +97,7 @@ def venv_dir() -> Path:
|
||||
def venv_python() -> str:
|
||||
"""The interpreter node code runs on.
|
||||
|
||||
Falls back to the engine's own when there is no venv — an installation that
|
||||
Falls back to the engine's own when there is no venv — an instance that
|
||||
could not build one still runs python nodes, it just cannot add packages
|
||||
to them.
|
||||
"""
|
||||
|
||||
@@ -179,7 +179,7 @@ class Node:
|
||||
idempotent: bool = True
|
||||
|
||||
@classmethod
|
||||
def instance_key(cls, params: dict[str, Any]) -> str | None:
|
||||
def target_key(cls, params: dict[str, Any]) -> str | None:
|
||||
"""Which outside thing these parameters point at, if any.
|
||||
|
||||
Two nodes with the same key talk to the same broker topic, URL or
|
||||
|
||||
@@ -189,7 +189,7 @@ class HttpNode(Node):
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def instance_key(cls, params: dict[str, Any]) -> str | None:
|
||||
def target_key(cls, params: dict[str, Any]) -> str | None:
|
||||
# ponytail: a webhook's stored url is the path before the flow name is
|
||||
# prefixed onto it, so two flows both receiving on "/tick" merge into
|
||||
# one neuron. Key on mode as well if that ever misleads.
|
||||
|
||||
@@ -18,7 +18,7 @@ def _influxdb() -> Any:
|
||||
"""The client library, which is a `fluksio[server]` extra.
|
||||
|
||||
Imported per use rather than at module level, because the node type is
|
||||
registered at boot and an installation with no InfluxDB behind it should
|
||||
registered at boot and an instance with no InfluxDB behind it should
|
||||
not have to carry the library to start.
|
||||
"""
|
||||
try:
|
||||
@@ -198,7 +198,7 @@ class InfluxDbNode(Node):
|
||||
queries: dict[str, dict[str, Any]] = {}
|
||||
|
||||
@classmethod
|
||||
def instance_key(cls, params: dict[str, Any]) -> str | None:
|
||||
def target_key(cls, params: dict[str, Any]) -> str | None:
|
||||
"""The bucket, which is the thing several flows share."""
|
||||
url, bucket = params.get("url"), params.get("bucket")
|
||||
return f"{url}/{bucket}" if url and bucket else None
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Inject: the node that starts something, on a timer or on request.
|
||||
|
||||
Node-RED's *inject* is the most placed trigger in a real installation — mostly
|
||||
Node-RED's *inject* is the most placed trigger in a real instance — mostly
|
||||
as a button someone presses, sometimes on an interval, occasionally once when
|
||||
everything comes up.
|
||||
"""
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Anything here could be written as a `python` node — that is what the function
|
||||
node is for. These exist because the same handful of shapes account for most of
|
||||
a real installation, and a rule you fill in is easier to read on a canvas, and
|
||||
a real instance, and a rule you fill in is easier to read on a canvas, and
|
||||
to change, than five lines of code repeated eighty times.
|
||||
"""
|
||||
|
||||
@@ -115,7 +115,7 @@ class ChangeNode(Node):
|
||||
"""Reshape a value on its way past: scale, offset, map, or replace.
|
||||
|
||||
Node-RED's *change*, which is the second most common node in a real
|
||||
installation after the function.
|
||||
instance after the function.
|
||||
"""
|
||||
|
||||
class Params(BaseModel):
|
||||
|
||||
@@ -24,7 +24,7 @@ def _aiomqtt() -> Any:
|
||||
"""The client library, which is a `fluksio[server]` extra.
|
||||
|
||||
Imported per use rather than at module level, because the node type is
|
||||
registered at boot and an installation that talks to no broker should not
|
||||
registered at boot and an instance that talks to no broker should not
|
||||
have to carry the library to start.
|
||||
"""
|
||||
try:
|
||||
@@ -218,7 +218,7 @@ class MqttNode(Node):
|
||||
json_key: str | dict[str, str] = ""
|
||||
|
||||
@classmethod
|
||||
def instance_key(cls, params: dict[str, Any]) -> str | None:
|
||||
def target_key(cls, params: dict[str, Any]) -> str | None:
|
||||
"""The broker and topic, which is one physical thing.
|
||||
|
||||
A publisher and a subscriber on the same topic get the same key on
|
||||
|
||||
@@ -45,7 +45,7 @@ class PanelDef(BaseModel):
|
||||
|
||||
|
||||
class PanelsConfig(BaseModel):
|
||||
"""Every panel this installation knows about."""
|
||||
"""Every panel this instance knows about."""
|
||||
|
||||
panels: list[PanelDef] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ def _derive(
|
||||
|
||||
The two lookups come out of the same walk: who consumes a message, and
|
||||
which node an id names. Both were linear scans over every node in the
|
||||
installation, on the per-message path.
|
||||
instance, on the per-message path.
|
||||
"""
|
||||
# A message may have several producers; every one of them is upstream
|
||||
# of the nodes consuming it.
|
||||
|
||||
@@ -10,7 +10,7 @@ counted at all.
|
||||
One question, then, asked once here: of every machine attached — this one and
|
||||
each worker — which could grant what this node asked for, and which of those
|
||||
has it free right now. A worker reports its inventory when it attaches, so the
|
||||
answer covers the whole installation rather than the host the engine happens to
|
||||
answer covers the whole instance rather than the host the engine happens to
|
||||
be on.
|
||||
|
||||
Two properties are worth stating because they are what make the waiting safe:
|
||||
@@ -178,7 +178,7 @@ class Placer:
|
||||
"""Every machine's size, as something to measure a request against.
|
||||
|
||||
A provisioner's job shapes count: a machine it can start on demand is
|
||||
one this installation has, even when nothing is attached yet.
|
||||
one this instance has, even when nothing is attached yet.
|
||||
"""
|
||||
shapes = []
|
||||
for target in self.targets():
|
||||
|
||||
@@ -348,7 +348,7 @@ class SlurmProvisioner:
|
||||
|
||||
|
||||
def load_provisioners(path: Path, events: EventBus | None = None) -> list[Any]:
|
||||
"""Read the configured clusters. An installation with none has no file."""
|
||||
"""Read the configured clusters. An instance with none has no file."""
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
|
||||
@@ -296,7 +296,7 @@ def _from_run(
|
||||
if artifacts is not None and artifacts.path(row.digest) is None:
|
||||
raise RunRejected(
|
||||
f"Parameter '{key}': run '{run_id}' made '{output}', but its "
|
||||
"bytes are gone from this installation's store"
|
||||
"bytes are gone from this instance's store"
|
||||
)
|
||||
return {
|
||||
"digest": row.digest,
|
||||
@@ -327,7 +327,7 @@ def _from_digest(
|
||||
if artifacts is not None and artifacts.path(digest) is None:
|
||||
raise RunRejected(
|
||||
f"Parameter '{key}': '{digest}' is known but its bytes are gone "
|
||||
"from this installation's store"
|
||||
"from this instance's store"
|
||||
)
|
||||
return {
|
||||
"digest": row.digest,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Web Push: an alert reaching a phone that has this installation installed.
|
||||
"""Web Push: an alert reaching a phone that has this instance installed.
|
||||
|
||||
The browser hands us a subscription — an endpoint URL at its own push service,
|
||||
plus two keys — and from then on the engine can wake that device without it
|
||||
holding a connection open. Two pieces of crypto are involved and both are
|
||||
specified: the payload is encrypted to the subscription's keys (RFC 8291,
|
||||
aes128gcm) so the push service carries something it cannot read, and the
|
||||
request is signed with this installation's own keypair (VAPID, RFC 8292) so the
|
||||
request is signed with this instance's own keypair (VAPID, RFC 8292) so the
|
||||
service knows who is sending.
|
||||
|
||||
Only ``http-ece`` is new here; the signing is `pyjwt` and the request is
|
||||
@@ -13,7 +13,7 @@ Only ``http-ece`` is new here; the signing is `pyjwt` and the request is
|
||||
this in one call, but brings `requests` *and* `aiohttp` with it — two more HTTP
|
||||
stacks on a machine that may well be a Raspberry Pi.
|
||||
|
||||
The keypair is this installation's identity to the push services and lives with
|
||||
The keypair is this instance's identity to the push services and lives with
|
||||
the subscriptions in one file. Losing it means every browser has to subscribe
|
||||
again; it is regenerated on the spot if the file goes missing.
|
||||
"""
|
||||
@@ -62,7 +62,7 @@ class Subscription(BaseModel):
|
||||
|
||||
|
||||
class _Store(BaseModel):
|
||||
#: This installation's VAPID private key: the raw P-256 scalar, base64url.
|
||||
#: This instance's VAPID private key: the raw P-256 scalar, base64url.
|
||||
private_key: str = ""
|
||||
subscriptions: list[Subscription] = Field(default_factory=list)
|
||||
|
||||
@@ -113,7 +113,7 @@ def _public_bytes(key: ec.EllipticCurvePrivateKey) -> bytes:
|
||||
|
||||
|
||||
def public_key() -> str:
|
||||
"""This installation's VAPID public key, generating the pair on first ask.
|
||||
"""This instance's VAPID public key, generating the pair on first ask.
|
||||
|
||||
Base64url of the uncompressed point, which is the shape
|
||||
`pushManager.subscribe` wants for `applicationServerKey`. Blocking.
|
||||
@@ -126,7 +126,7 @@ def public_key() -> str:
|
||||
key.private_numbers().private_value.to_bytes(32, "big")
|
||||
)
|
||||
_write(store)
|
||||
logger.info("Generated this installation's web push keypair")
|
||||
logger.info("Generated this instance's web push keypair")
|
||||
return _b64url(_public_bytes(_private_key(store)))
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@ def subscriptions() -> list[Subscription]:
|
||||
|
||||
|
||||
def _vapid_headers(key: ec.EllipticCurvePrivateKey, endpoint: str) -> dict[str, str]:
|
||||
"""Prove to the push service which installation is sending (RFC 8292)."""
|
||||
"""Prove to the push service which instance is sending (RFC 8292)."""
|
||||
origin = urlparse(endpoint)
|
||||
token = jwt.encode(
|
||||
{
|
||||
|
||||
@@ -307,7 +307,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
)
|
||||
)
|
||||
started.append(lambda: run_in_threadpool(close_shared_client))
|
||||
# Optional, and off unless someone enrolled this installation: the
|
||||
# Optional, and off unless someone enrolled this instance: the
|
||||
# connector dials the portal, nothing dials in.
|
||||
cloud_task: asyncio.Task[None] | None = None
|
||||
app.state.cloud_connector = None
|
||||
@@ -346,7 +346,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
await aclose()
|
||||
|
||||
|
||||
# The schema enumerates every endpoint this installation serves, including the
|
||||
# The schema enumerates every endpoint this instance serves, including the
|
||||
# paths trigger nodes mount at runtime. That is exactly what a developer wants
|
||||
# and exactly what an internet-facing deployment should not hand out, so it
|
||||
# follows the environment — the same rule the portal's backend uses. The
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
``server`` holds the tools, ``http`` wires them to the streamable-HTTP
|
||||
transport and the OAuth resource-server checks. Both are imported lazily, only
|
||||
when ``MCP_ENABLED`` is set, so an installation that does not want an agent
|
||||
when ``MCP_ENABLED`` is set, so an instance that does not want an agent
|
||||
endpoint does not carry one.
|
||||
"""
|
||||
|
||||
@@ -53,9 +53,9 @@ class User(UserBase, table=True):
|
||||
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
|
||||
hashed_password: str
|
||||
#: The portal account this local user stands for, if any. Set for the
|
||||
#: superuser who enrolled this installation and for every remote user one
|
||||
#: superuser who enrolled this instance and for every remote user one
|
||||
#: of them admitted; None for a purely local account, which is what an
|
||||
#: installation nobody enrolled has only.
|
||||
#: instance nobody enrolled has only.
|
||||
portal_sub: str | None = Field(default=None, max_length=64, unique=True, index=True)
|
||||
created_at: datetime | None = Field(
|
||||
default_factory=get_datetime_utc,
|
||||
@@ -491,7 +491,7 @@ class FlavorBase(SQLModel):
|
||||
"""A named amount of machine, so a node can ask for one by name.
|
||||
|
||||
The point is not to save typing. Cores and megabytes are a property of the
|
||||
machines an installation actually has, and they change when those machines
|
||||
machines an instance actually has, and they change when those machines
|
||||
do — so a node saying "gpu-small" keeps meaning something after the cluster
|
||||
is replaced, where a node saying 8 and 16384 quietly stops.
|
||||
"""
|
||||
|
||||
@@ -682,7 +682,7 @@ def _flow_state(flow: dict[str, Any]) -> str:
|
||||
|
||||
|
||||
def _portal_phrase(portal: dict[str, Any]) -> tuple[str, str]:
|
||||
"""Where this installation stands with its portal, and how to colour it.
|
||||
"""Where this instance stands with its portal, and how to colour it.
|
||||
|
||||
Three states worth telling apart: never paired, paired and linked, and
|
||||
paired but not reaching it — the last being the one somebody needs to know
|
||||
@@ -734,7 +734,7 @@ def _status_screen(client: Client) -> Any:
|
||||
|
||||
summary = client.summary()
|
||||
flows = client.flows()
|
||||
# Both, because an installation is usually one or the other: a live flow
|
||||
# Both, because an instance is usually one or the other: a live flow
|
||||
# fails as an engine event, while a batch run fails on its own row.
|
||||
failures = client.events(kind="failure", limit=5)
|
||||
runs = client.runs(limit=5)
|
||||
|
||||
@@ -57,7 +57,7 @@ RETRY_STATUS = frozenset({502, 503, 504})
|
||||
WAIT_TOLERANCE = 5
|
||||
|
||||
|
||||
#: What a project-local installation is called, beside `.venv` and `.git`.
|
||||
#: What a project-local instance is called, beside `.venv` and `.git`.
|
||||
DATA_DIR_NAME = ".fluksio"
|
||||
|
||||
#: The shared one, for a machine that wants a single engine rather than one
|
||||
@@ -66,10 +66,10 @@ GLOBAL_DATA_DIR = Path("~/.fluksio")
|
||||
|
||||
|
||||
def find_data_dir(start: Path | None = None) -> Path | None:
|
||||
"""The nearest project-local installation, walking up from ``start``.
|
||||
"""The nearest project-local instance, walking up from ``start``.
|
||||
|
||||
The same search `.git` and `.venv` get, and for the same reason: which
|
||||
installation you mean is a fact about where you are standing, not about
|
||||
instance you mean is a fact about where you are standing, not about
|
||||
which machine you are on. Several repositories on one device each keep
|
||||
their own flows, runs and token this way rather than sharing one.
|
||||
"""
|
||||
@@ -82,16 +82,16 @@ def find_data_dir(start: Path | None = None) -> Path | None:
|
||||
|
||||
|
||||
def data_dir(start: Path | None = None) -> Path:
|
||||
"""The installation this working directory belongs to."""
|
||||
"""The instance this working directory belongs to."""
|
||||
found = find_data_dir(start)
|
||||
return found if found is not None else GLOBAL_DATA_DIR.expanduser()
|
||||
|
||||
|
||||
def config_path(directory: Path | None = None) -> Path:
|
||||
"""Where the token for an installation lives — beside the data it opens.
|
||||
"""Where the token for an instance lives — beside the data it opens.
|
||||
|
||||
Not a single file per machine: a token is for one engine, and with an
|
||||
installation per repository there is more than one. Keeping it inside the
|
||||
instance per repository there is more than one. Keeping it inside the
|
||||
data directory means the client finds the credential for the engine whose
|
||||
directory it is standing in, without either having to be told.
|
||||
"""
|
||||
@@ -99,7 +99,7 @@ def config_path(directory: Path | None = None) -> Path:
|
||||
|
||||
|
||||
def _legacy_config_path() -> Path:
|
||||
"""Where `fluksio login` used to write, before installations were local."""
|
||||
"""Where `fluksio login` used to write, before instances were local."""
|
||||
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
||||
return Path(base) / "fluksio" / "client.json"
|
||||
|
||||
@@ -290,7 +290,7 @@ class Client:
|
||||
return result
|
||||
|
||||
def cloud_status(self) -> dict[str, Any]:
|
||||
"""Whether this installation is enrolled with a portal, and linked."""
|
||||
"""Whether this instance is enrolled with a portal, and linked."""
|
||||
result: dict[str, Any] = self._call("GET", "/cloud/status")
|
||||
return result
|
||||
|
||||
@@ -585,7 +585,7 @@ class RunHandle:
|
||||
|
||||
|
||||
def ignore_self(directory: Path) -> None:
|
||||
"""Keep an installation out of the repository it sits in.
|
||||
"""Keep an instance out of the repository it sits in.
|
||||
|
||||
It holds a token and a database, neither of which belongs in anybody's
|
||||
history. A `.gitignore` of `*` inside the directory ignores it from within,
|
||||
@@ -595,7 +595,7 @@ def ignore_self(directory: Path) -> None:
|
||||
marker = directory / ".gitignore"
|
||||
if not marker.exists():
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text("# A Fluksio installation: a database, and a token.\n*\n")
|
||||
marker.write_text("# A Fluksio instance: a database, and a token.\n*\n")
|
||||
|
||||
|
||||
def write_config(url: str, token: str, directory: Path | None = None) -> Path:
|
||||
|
||||
@@ -71,7 +71,7 @@ class Enroll(ModalScreen[tuple[str, str] | None]):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="enroll"):
|
||||
yield Label("Pair this installation with a portal")
|
||||
yield Label("Pair this instance with a portal")
|
||||
yield Input(placeholder="claim code", id="code")
|
||||
yield Input(value=DEFAULT_PORTAL, id="portal")
|
||||
with Horizontal():
|
||||
@@ -253,7 +253,7 @@ class ServeApp(App[int]):
|
||||
self.connect()
|
||||
return
|
||||
if who == "foreign":
|
||||
self.note(f"Port {wanted} holds another installation's Fluksio.")
|
||||
self.note(f"Port {wanted} holds another instance's Fluksio.")
|
||||
|
||||
self.child = subprocess.Popen( # noqa: S603
|
||||
child_argv(sys.argv[1:]),
|
||||
|
||||
@@ -9,7 +9,7 @@ from fluksio.core.db import seed_flavors
|
||||
from fluksio.models import Flavor
|
||||
|
||||
|
||||
def test_an_installation_starts_with_sizes_to_pick_from(
|
||||
def test_an_instance_starts_with_sizes_to_pick_from(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
response = client.get(
|
||||
@@ -20,7 +20,7 @@ def test_an_installation_starts_with_sizes_to_pick_from(
|
||||
assert {"small", "medium", "large", "gpu-small"} <= names
|
||||
|
||||
|
||||
def test_seeding_leaves_an_installation_that_has_its_own_alone(db: Session) -> None:
|
||||
def test_seeding_leaves_an_instance_that_has_its_own_alone(db: Session) -> None:
|
||||
"""Re-adding a size somebody deliberately removed is an argument to avoid."""
|
||||
before = {row.name for row in db.exec(select(Flavor)).all()}
|
||||
seed_flavors(db)
|
||||
|
||||
@@ -544,7 +544,7 @@ def test_removing_the_panel_revokes_its_credential(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# A screen that reached the portal but not this installation
|
||||
# A screen that reached the portal but not this instance
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -592,7 +592,7 @@ def test_a_remote_device_is_paired_at_the_portal(
|
||||
) -> None:
|
||||
"""A device that arrived through the tunnel gets the portal's credential.
|
||||
|
||||
It could never present one this installation signed: the portal verifies
|
||||
It could never present one this instance signed: the portal verifies
|
||||
what crosses it, and it verifies against its own key.
|
||||
"""
|
||||
import httpx
|
||||
@@ -627,7 +627,7 @@ def test_a_remote_device_is_paired_at_the_portal(
|
||||
assert approved.status_code == 200, approved.text
|
||||
|
||||
assert calls[0]["url"].endswith("/api/v1/panel-tokens/") # type: ignore[union-attr]
|
||||
assert calls[0]["headers"]["Authorization"] == "Bearer installation-token" # type: ignore[index]
|
||||
assert calls[0]["headers"]["Authorization"] == "Bearer instance-token" # type: ignore[index]
|
||||
assert calls[0]["json"] == {"panel": "hallway"} # type: ignore[index]
|
||||
|
||||
collected = client.get(
|
||||
|
||||
@@ -25,7 +25,7 @@ from fluksio.models import (
|
||||
RunNode,
|
||||
User,
|
||||
)
|
||||
from tests.utils.portal import INSTALLATION_ID, ISSUER, jwks
|
||||
from tests.utils.portal import INSTANCE_ID, ISSUER, jwks
|
||||
from tests.utils.user import authentication_token_from_email
|
||||
from tests.utils.utils import get_superuser_token_headers
|
||||
|
||||
@@ -129,7 +129,7 @@ def enrolled(
|
||||
portal_key: rsa.RSAPrivateKey,
|
||||
db: Session,
|
||||
) -> Any:
|
||||
"""Enrol this installation with a fake portal, then undo it."""
|
||||
"""Enrol this instance with a fake portal, then undo it."""
|
||||
local_user = db.exec(
|
||||
select(User).where(User.email == settings.FIRST_SUPERUSER)
|
||||
).one()
|
||||
@@ -141,8 +141,8 @@ def enrolled(
|
||||
cloud_config.CloudConfig(
|
||||
portal_url=ISSUER,
|
||||
ws_url=f"{ISSUER}/api/v1/tunnel/attach",
|
||||
installation_id=INSTALLATION_ID,
|
||||
token="installation-token",
|
||||
instance_id=INSTANCE_ID,
|
||||
token="instance-token",
|
||||
issuer=ISSUER,
|
||||
jwks=jwks(portal_key),
|
||||
local_user_id=str(local_user.id),
|
||||
@@ -151,7 +151,7 @@ def enrolled(
|
||||
)
|
||||
)
|
||||
# Enrolment also maps the enrolling account to the portal identity that
|
||||
# owns the installation; without it a portal session resolves to nobody.
|
||||
# owns the instance; without it a portal session resolves to nobody.
|
||||
local_user.portal_sub = "portal-user-1"
|
||||
db.add(local_user)
|
||||
db.commit()
|
||||
|
||||
@@ -78,7 +78,7 @@ def test_an_edge_carries_the_qualified_message(controller: FlowController):
|
||||
def test_a_credential_never_reaches_the_key():
|
||||
# Stored params, so a secret is still a reference. Neither its name nor its
|
||||
# value belongs in something the browser gets to see.
|
||||
key = MqttNode.instance_key({**BROKER, "password": {"$secret": "broker_pw"}})
|
||||
key = MqttNode.target_key({**BROKER, "password": {"$secret": "broker_pw"}})
|
||||
|
||||
assert key == "mosquitto:1883/sensors/temp"
|
||||
|
||||
|
||||
@@ -423,7 +423,7 @@ def test_an_unbound_setting_is_just_its_value():
|
||||
|
||||
No message means nothing to type-check and nothing for a panel to be
|
||||
entitled to — and a name this build does not know is left alone rather
|
||||
than refused, so an older installation reads a newer document.
|
||||
than refused, so an older instance reads a newer document.
|
||||
"""
|
||||
defn = DashboardDef(
|
||||
name="house",
|
||||
@@ -523,7 +523,7 @@ def test_a_chart_of_runs_must_say_which_runs_and_which_metric():
|
||||
|
||||
|
||||
def test_a_document_written_as_pages_is_read_as_one_grid():
|
||||
"""Stored dashboards live in each installation's repository.
|
||||
"""Stored dashboards live in each instance's repository.
|
||||
|
||||
So the old shape is normalised on the way in rather than migrated, and a
|
||||
placed second section keeps its arrangement instead of piling onto the
|
||||
|
||||
@@ -192,7 +192,7 @@ def test_a_node_waiting_for_a_machine_asks_for_one_once(monkeypatch):
|
||||
assert ran.is_set()
|
||||
|
||||
|
||||
def test_an_installation_with_nowhere_to_start_one_has_no_file(tmp_path):
|
||||
def test_an_instance_with_nowhere_to_start_one_has_no_file(tmp_path):
|
||||
assert load_provisioners(tmp_path / "provisioners.json") == []
|
||||
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ def lifecycle(monkeypatch: pytest.MonkeyPatch) -> list[str]:
|
||||
def test_rebuilding_one_flow_leaves_another_flows_node_running(
|
||||
tmp_path: Path, lifecycle: list[str]
|
||||
):
|
||||
"""The whole point: reconnecting one flow's nodes, not the installation's."""
|
||||
"""The whole point: reconnecting one flow's nodes, not the instance's."""
|
||||
store = FlowStore(tmp_path / "flows")
|
||||
for name in ("a", "b"):
|
||||
store.write_flow(FlowDef(name=name, nodes=[NodeDef(id="io", type="lifecycle")]))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""What the flow-logic nodes actually do.
|
||||
|
||||
These are the shapes a Node-RED installation is mostly made of, so their
|
||||
These are the shapes a Node-RED instance is mostly made of, so their
|
||||
behaviour is worth pinning rather than just their construction.
|
||||
"""
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ from fluksio.flow import webpush
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def store(tmp_path, monkeypatch):
|
||||
"""A store per test, so nothing writes into the real installation."""
|
||||
"""A store per test, so nothing writes into the real instance."""
|
||||
monkeypatch.setattr(settings, "WEBPUSH_FILE", tmp_path / "webpush.json")
|
||||
return tmp_path / "webpush.json"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Which installation a command is for, and where its token lives.
|
||||
"""Which instance a command is for, and where its token lives.
|
||||
|
||||
A repository with its own venv gets its own engine, so "which one" is a fact
|
||||
about the working directory rather than about the machine.
|
||||
@@ -14,14 +14,14 @@ from fluksio.sdk import client
|
||||
|
||||
@pytest.fixture
|
||||
def elsewhere(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""A working directory with no installation above it."""
|
||||
"""A working directory with no instance above it."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
|
||||
monkeypatch.setattr(client, "GLOBAL_DATA_DIR", tmp_path / "home" / ".fluksio")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_a_project_local_installation_is_found_from_below(elsewhere: Path):
|
||||
def test_a_project_local_instance_is_found_from_below(elsewhere: Path):
|
||||
(elsewhere / ".fluksio").mkdir()
|
||||
deep = elsewhere / "src" / "pkg" / "sub"
|
||||
deep.mkdir(parents=True)
|
||||
@@ -31,7 +31,7 @@ def test_a_project_local_installation_is_found_from_below(elsewhere: Path):
|
||||
|
||||
|
||||
def test_the_nearest_one_wins(elsewhere: Path):
|
||||
"""An installation inside another belongs to the directory it is in."""
|
||||
"""An instance inside another belongs to the directory it is in."""
|
||||
(elsewhere / ".fluksio").mkdir()
|
||||
inner = elsewhere / "inner"
|
||||
(inner / ".fluksio").mkdir(parents=True)
|
||||
@@ -63,7 +63,7 @@ def test_the_credential_beside_the_data_is_the_one_used(elsewhere: Path):
|
||||
assert client._stored()["token"] == "local"
|
||||
|
||||
|
||||
def test_a_login_from_before_installations_were_local_still_works(elsewhere: Path):
|
||||
def test_a_login_from_before_instances_were_local_still_works(elsewhere: Path):
|
||||
"""`fluksio login` wrote to XDG once; that must not stop answering."""
|
||||
legacy = client._legacy_config_path()
|
||||
legacy.parent.mkdir(parents=True)
|
||||
|
||||
@@ -106,7 +106,7 @@ def test_a_sweeps_param_spelling_is_refused_by_name() -> None:
|
||||
_params(definition, ["--lr", "fast"])
|
||||
|
||||
|
||||
def test_serve_uses_the_installation_the_directory_belongs_to(
|
||||
def test_serve_uses_the_instance_the_directory_belongs_to(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A repository with its own venv gets its own engine, not the machine's."""
|
||||
@@ -713,7 +713,7 @@ def test_who_holds_the_port_is_told_apart_by_the_token() -> None:
|
||||
|
||||
The token is signed with this directory's secret key, so an engine that
|
||||
accepts it is one reading this directory's database. Another
|
||||
installation's Fluksio answers the health check and refuses it.
|
||||
instance's Fluksio answers the health check and refuses it.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
|
||||
+11
-11
@@ -1,7 +1,7 @@
|
||||
"""Remote access: off by default, and only ever as much as somebody granted.
|
||||
|
||||
The property worth pinning down is the one everything else rests on — a
|
||||
portal's token is worth nothing here until somebody at this installation
|
||||
portal's token is worth nothing here until somebody at this instance
|
||||
enrolled it, and even then it grants exactly the rights of the local account
|
||||
the portal identity holding it was mapped to. An identity nobody mapped gets
|
||||
nothing, which is what makes deleting that local account a revocation.
|
||||
@@ -31,7 +31,7 @@ from tests.utils.portal import ISSUER, portal_token
|
||||
def test_portal_token_is_refused_when_not_enrolled(
|
||||
portal_key: rsa.RSAPrivateKey, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> None:
|
||||
"""An installation nobody connected trusts no portal at all."""
|
||||
"""An instance nobody connected trusts no portal at all."""
|
||||
original = settings.CLOUD_CONFIG_FILE
|
||||
settings.CLOUD_CONFIG_FILE = tmp_path_factory.mktemp("empty") / "cloud.json"
|
||||
try:
|
||||
@@ -55,11 +55,11 @@ def test_portal_token_resolves_by_portal_identity(
|
||||
assert user_from_token(db, portal_token(portal_key, subject="nobody")) is None
|
||||
|
||||
|
||||
def test_token_for_another_installation_is_refused(
|
||||
def test_token_for_another_instance_is_refused(
|
||||
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
|
||||
portal_key: rsa.RSAPrivateKey,
|
||||
) -> None:
|
||||
"""The audience is this installation's id, so someone else's is worthless."""
|
||||
"""The audience is this instance's id, so someone else's is worthless."""
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(portal_token(portal_key, audience=str(uuid.uuid4())))
|
||||
|
||||
@@ -67,7 +67,7 @@ def test_token_for_another_installation_is_refused(
|
||||
def test_token_from_an_unpinned_key_is_refused(
|
||||
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
|
||||
) -> None:
|
||||
"""A different portal, or a hijacked one, cannot sign for this installation."""
|
||||
"""A different portal, or a hijacked one, cannot sign for this instance."""
|
||||
impostor = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
with pytest.raises(jwt.exceptions.InvalidTokenError):
|
||||
decode_token(portal_token(impostor))
|
||||
@@ -104,7 +104,7 @@ def test_status_reports_not_enrolled(
|
||||
def test_enrolling_needs_a_superuser(
|
||||
client: TestClient, normal_user_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
"""Remote access is an installation-wide grant, not a personal setting."""
|
||||
"""Remote access is an instance-wide grant, not a personal setting."""
|
||||
response = client.post(
|
||||
f"{settings.API_V1_STR}/cloud/enroll",
|
||||
headers=normal_user_token_headers,
|
||||
@@ -122,7 +122,7 @@ def test_a_panel_scoped_portal_token_reaches_only_its_panel(
|
||||
"""A screen paired through the portal is bounded here, not there.
|
||||
|
||||
The portal names the panel; everything about what that means is this
|
||||
installation's, which is the whole reason it may mint one at all.
|
||||
instance's, which is the whole reason it may mint one at all.
|
||||
"""
|
||||
write_config(
|
||||
PanelsConfig(
|
||||
@@ -241,7 +241,7 @@ def test_adding_a_remote_user_maps_and_revokes(
|
||||
f"{settings.API_V1_STR}/users/{body['id']}", headers=superuser_token_headers
|
||||
)
|
||||
assert removed.status_code == 200, removed.text
|
||||
assert delete.call_args.args[0].endswith("/installation-members/portal-user-9")
|
||||
assert delete.call_args.args[0].endswith("/instance-members/portal-user-9")
|
||||
db.expire_all()
|
||||
assert user_from_token(db, theirs) is None
|
||||
|
||||
@@ -251,7 +251,7 @@ def test_adding_a_remote_user_needs_a_superuser(
|
||||
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
|
||||
normal_user_token_headers: dict[str, str],
|
||||
) -> None:
|
||||
"""Widening who can reach this installation stays a superuser's decision."""
|
||||
"""Widening who can reach this instance stays a superuser's decision."""
|
||||
response = client.post(
|
||||
f"{settings.API_V1_STR}/cloud/users",
|
||||
headers=normal_user_token_headers,
|
||||
@@ -272,8 +272,8 @@ def test_enrolling_against_a_portal_without_an_owner_is_refused(
|
||||
status_code=200,
|
||||
json=Mock(
|
||||
return_value={
|
||||
"installation_id": str(uuid.uuid4()),
|
||||
"installation_token": "t",
|
||||
"instance_id": str(uuid.uuid4()),
|
||||
"instance_token": "t",
|
||||
"ws_url": f"{ISSUER}/api/v1/tunnel/attach",
|
||||
"issuer": ISSUER,
|
||||
"jwks": {"keys": []},
|
||||
|
||||
@@ -14,7 +14,7 @@ import jwt
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
INSTALLATION_ID = "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f"
|
||||
INSTANCE_ID = "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f"
|
||||
ISSUER = "https://hub.example.test"
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ def portal_token(
|
||||
key: rsa.RSAPrivateKey,
|
||||
*,
|
||||
subject: str = "portal-user-1",
|
||||
audience: str = INSTALLATION_ID,
|
||||
audience: str = INSTANCE_ID,
|
||||
issuer: str = ISSUER,
|
||||
scope: str = "proxy",
|
||||
) -> str:
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user