Rename Installation to Instance

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
This commit is contained in:
2026-08-31 10:12:01 +02:00
co-authored by Claude Opus 5
parent 6534855492
commit d01a8dad37
101 changed files with 374 additions and 375 deletions
+1 -1
View File
@@ -358,7 +358,7 @@ class AlertManager:
await asyncio.to_thread(self.publish, message, alert.model_dump())
async def _send_webpush(self, alert: Alert) -> None:
"""Wake every browser that subscribed to this installation.
"""Wake every browser that subscribed to this instance.
No settings of its own: a browser subscribes by pressing a button on
the alerts screen, and this channel goes to whichever ones did. Saying
+5 -5
View File
@@ -98,7 +98,7 @@ HOOK_PREFIX = "/hooks"
NODE_STOP_TIMEOUT = 5.0
# How long a rebuild asked for by a request waits for one already running.
# Generous on purpose: a rebuild of a populated installation reconnects every
# Generous on purpose: a rebuild of a populated instance reconnects every
# node and takes the better part of ten seconds, and a caller queued behind a
# healthy one of those should not be turned away. Past that the controller is
# wedged rather than busy, and an error the caller can act on beats a request
@@ -106,7 +106,7 @@ NODE_STOP_TIMEOUT = 5.0
REBUILD_WAIT = 15.0
# How long a caller waits for a *flow* rebuild that is already running. A
# per-flow rebuild reconnects one flow's nodes rather than the installation's,
# per-flow rebuild reconnects one flow's nodes rather than the instance's,
# so this is a queueing budget — several of them back to back, which is what
# seeding does — not the room a single one needs. Fifteen seconds was sized for
# the whole-pipeline rebuild and would let a wedge sit unreported.
@@ -680,7 +680,7 @@ class FlowController:
another flow owns — but the wiring is derived from message names, so
swapping one flow's nodes into the graph and deriving the edges again
is enough. What that saves is the reconnecting: the cost of a rebuild
on a populated installation is every node opening its socket again,
on a populated instance is every node opening its socket again,
and only one flow's have changed.
A flow the store no longer has is taken out instead of replaced.
@@ -1522,12 +1522,12 @@ class FlowController:
key = None
if node_type is not None:
try:
key = node_type.cls.instance_key(node_def.params)
key = node_type.cls.target_key(node_def.params)
except Exception:
# A plugin's own grouping is not worth the whole graph;
# this node just stands on its own.
logger.exception(
"instance_key failed for node type '%s'", node_def.type
"target_key failed for node type '%s'", node_def.type
)
gid = f"{node_def.type}:{key}" if key else member
gid_of[member] = gid
+4 -4
View File
@@ -81,7 +81,7 @@ WidgetType = Literal[
INPUT_WIDGETS = {"button", "switch", "slider", "input", "dropdown", "color"}
#: What a colour widget puts on the wire, by the format it was configured for.
#: The default is what the Node-RED installation this ports already sends its
#: The default is what the Node-RED instance this ports already sends its
#: DMX encoders — ``[h, s, v]``, hue in degrees and the other two in percent —
#: and the two alternatives exist because fixtures differ. Mirrored in the
#: client (``frontend/src/components/Dashboard/ColorWidget.tsx``).
@@ -197,7 +197,7 @@ class WidgetDef(BaseModel):
differ and a change node per tile is not the answer:
- ``hsv`` (the default) — ``[h, s, v]``, hue 0-360 degrees, saturation and
value 0-100 percent. What the Node-RED installation this ports feeds its
value 0-100 percent. What the Node-RED instance this ports feeds its
3CH/4CH DMX encoders, which divide by 360 and by 100.
- ``rgb`` — ``[r, g, b]``, each 0-255. The conventional range; the
reference's own encoders produce it after converting.
@@ -479,7 +479,7 @@ class DashboardDef(BaseModel):
def _flatten(cls, data: Any) -> Any:
"""Read a document written as pages and sections as one grid.
Stored dashboards live in each installation's git repository, so the
Stored dashboards live in each instance's git repository, so the
old shape is normalised on the way in rather than migrated: an
untouched document keeps working, and the next save writes it flat.
"""
@@ -502,7 +502,7 @@ class DashboardDef(BaseModel):
picker records the payload type beside the name, so neither the editor
nor a wall panel has to fetch the catalogue to know the wiring is
wrong. A name this build does not know is left alone rather than
refused — an older installation reading a newer document simply does
refused — an older instance reading a newer document simply does
not act on it.
"""
for name, setting in self.settings.items():
+1 -1
View File
@@ -49,7 +49,7 @@ DUE_RESERVE = 2
# check whether a cascade slot has freed.
DUE_CLAIM_BLOCK_MS = 200
#: How many cascades may be in flight, unless the service is given a number.
#: Sustained throughput is this over the mean cascade time, so an installation
#: Sustained throughput is this over the mean cascade time, so an instance
#: whose nodes wait on a network rather than a CPU may want more of them —
#: `FLOW_MAX_CASCADES` is where that is said.
MAX_CASCADES = 4
+1 -1
View File
@@ -97,7 +97,7 @@ def venv_dir() -> Path:
def venv_python() -> str:
"""The interpreter node code runs on.
Falls back to the engine's own when there is no venv — an installation that
Falls back to the engine's own when there is no venv — an instance that
could not build one still runs python nodes, it just cannot add packages
to them.
"""
+1 -1
View File
@@ -179,7 +179,7 @@ class Node:
idempotent: bool = True
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
def target_key(cls, params: dict[str, Any]) -> str | None:
"""Which outside thing these parameters point at, if any.
Two nodes with the same key talk to the same broker topic, URL or
+1 -1
View File
@@ -189,7 +189,7 @@ class HttpNode(Node):
)
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
def target_key(cls, params: dict[str, Any]) -> str | None:
# ponytail: a webhook's stored url is the path before the flow name is
# prefixed onto it, so two flows both receiving on "/tick" merge into
# one neuron. Key on mode as well if that ever misleads.
+2 -2
View File
@@ -18,7 +18,7 @@ def _influxdb() -> Any:
"""The client library, which is a `fluksio[server]` extra.
Imported per use rather than at module level, because the node type is
registered at boot and an installation with no InfluxDB behind it should
registered at boot and an instance with no InfluxDB behind it should
not have to carry the library to start.
"""
try:
@@ -198,7 +198,7 @@ class InfluxDbNode(Node):
queries: dict[str, dict[str, Any]] = {}
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
def target_key(cls, params: dict[str, Any]) -> str | None:
"""The bucket, which is the thing several flows share."""
url, bucket = params.get("url"), params.get("bucket")
return f"{url}/{bucket}" if url and bucket else None
+1 -1
View File
@@ -1,6 +1,6 @@
"""Inject: the node that starts something, on a timer or on request.
Node-RED's *inject* is the most placed trigger in a real installation — mostly
Node-RED's *inject* is the most placed trigger in a real instance — mostly
as a button someone presses, sometimes on an interval, occasionally once when
everything comes up.
"""
+2 -2
View File
@@ -2,7 +2,7 @@
Anything here could be written as a `python` node — that is what the function
node is for. These exist because the same handful of shapes account for most of
a real installation, and a rule you fill in is easier to read on a canvas, and
a real instance, and a rule you fill in is easier to read on a canvas, and
to change, than five lines of code repeated eighty times.
"""
@@ -115,7 +115,7 @@ class ChangeNode(Node):
"""Reshape a value on its way past: scale, offset, map, or replace.
Node-RED's *change*, which is the second most common node in a real
installation after the function.
instance after the function.
"""
class Params(BaseModel):
+2 -2
View File
@@ -24,7 +24,7 @@ def _aiomqtt() -> Any:
"""The client library, which is a `fluksio[server]` extra.
Imported per use rather than at module level, because the node type is
registered at boot and an installation that talks to no broker should not
registered at boot and an instance that talks to no broker should not
have to carry the library to start.
"""
try:
@@ -218,7 +218,7 @@ class MqttNode(Node):
json_key: str | dict[str, str] = ""
@classmethod
def instance_key(cls, params: dict[str, Any]) -> str | None:
def target_key(cls, params: dict[str, Any]) -> str | None:
"""The broker and topic, which is one physical thing.
A publisher and a subscriber on the same topic get the same key on
+1 -1
View File
@@ -45,7 +45,7 @@ class PanelDef(BaseModel):
class PanelsConfig(BaseModel):
"""Every panel this installation knows about."""
"""Every panel this instance knows about."""
panels: list[PanelDef] = Field(default_factory=list)
+1 -1
View File
@@ -210,7 +210,7 @@ def _derive(
The two lookups come out of the same walk: who consumes a message, and
which node an id names. Both were linear scans over every node in the
installation, on the per-message path.
instance, on the per-message path.
"""
# A message may have several producers; every one of them is upstream
# of the nodes consuming it.
+2 -2
View File
@@ -10,7 +10,7 @@ counted at all.
One question, then, asked once here: of every machine attached — this one and
each worker — which could grant what this node asked for, and which of those
has it free right now. A worker reports its inventory when it attaches, so the
answer covers the whole installation rather than the host the engine happens to
answer covers the whole instance rather than the host the engine happens to
be on.
Two properties are worth stating because they are what make the waiting safe:
@@ -178,7 +178,7 @@ class Placer:
"""Every machine's size, as something to measure a request against.
A provisioner's job shapes count: a machine it can start on demand is
one this installation has, even when nothing is attached yet.
one this instance has, even when nothing is attached yet.
"""
shapes = []
for target in self.targets():
+1 -1
View File
@@ -348,7 +348,7 @@ class SlurmProvisioner:
def load_provisioners(path: Path, events: EventBus | None = None) -> list[Any]:
"""Read the configured clusters. An installation with none has no file."""
"""Read the configured clusters. An instance with none has no file."""
if not path.exists():
return []
try:
+2 -2
View File
@@ -296,7 +296,7 @@ def _from_run(
if artifacts is not None and artifacts.path(row.digest) is None:
raise RunRejected(
f"Parameter '{key}': run '{run_id}' made '{output}', but its "
"bytes are gone from this installation's store"
"bytes are gone from this instance's store"
)
return {
"digest": row.digest,
@@ -327,7 +327,7 @@ def _from_digest(
if artifacts is not None and artifacts.path(digest) is None:
raise RunRejected(
f"Parameter '{key}': '{digest}' is known but its bytes are gone "
"from this installation's store"
"from this instance's store"
)
return {
"digest": row.digest,
+7 -7
View File
@@ -1,11 +1,11 @@
"""Web Push: an alert reaching a phone that has this installation installed.
"""Web Push: an alert reaching a phone that has this instance installed.
The browser hands us a subscription an endpoint URL at its own push service,
plus two keys and from then on the engine can wake that device without it
holding a connection open. Two pieces of crypto are involved and both are
specified: the payload is encrypted to the subscription's keys (RFC 8291,
aes128gcm) so the push service carries something it cannot read, and the
request is signed with this installation's own keypair (VAPID, RFC 8292) so the
request is signed with this instance's own keypair (VAPID, RFC 8292) so the
service knows who is sending.
Only ``http-ece`` is new here; the signing is `pyjwt` and the request is
@@ -13,7 +13,7 @@ Only ``http-ece`` is new here; the signing is `pyjwt` and the request is
this in one call, but brings `requests` *and* `aiohttp` with it two more HTTP
stacks on a machine that may well be a Raspberry Pi.
The keypair is this installation's identity to the push services and lives with
The keypair is this instance's identity to the push services and lives with
the subscriptions in one file. Losing it means every browser has to subscribe
again; it is regenerated on the spot if the file goes missing.
"""
@@ -62,7 +62,7 @@ class Subscription(BaseModel):
class _Store(BaseModel):
#: This installation's VAPID private key: the raw P-256 scalar, base64url.
#: This instance's VAPID private key: the raw P-256 scalar, base64url.
private_key: str = ""
subscriptions: list[Subscription] = Field(default_factory=list)
@@ -113,7 +113,7 @@ def _public_bytes(key: ec.EllipticCurvePrivateKey) -> bytes:
def public_key() -> str:
"""This installation's VAPID public key, generating the pair on first ask.
"""This instance's VAPID public key, generating the pair on first ask.
Base64url of the uncompressed point, which is the shape
`pushManager.subscribe` wants for `applicationServerKey`. Blocking.
@@ -126,7 +126,7 @@ def public_key() -> str:
key.private_numbers().private_value.to_bytes(32, "big")
)
_write(store)
logger.info("Generated this installation's web push keypair")
logger.info("Generated this instance's web push keypair")
return _b64url(_public_bytes(_private_key(store)))
@@ -156,7 +156,7 @@ def subscriptions() -> list[Subscription]:
def _vapid_headers(key: ec.EllipticCurvePrivateKey, endpoint: str) -> dict[str, str]:
"""Prove to the push service which installation is sending (RFC 8292)."""
"""Prove to the push service which instance is sending (RFC 8292)."""
origin = urlparse(endpoint)
token = jwt.encode(
{