Rename Installation to Instance

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
This commit is contained in:
2026-08-31 10:12:01 +02:00
co-authored by Claude Opus 5
parent 6534855492
commit d01a8dad37
101 changed files with 374 additions and 375 deletions
+2 -2
View File
@@ -9,7 +9,7 @@ from fluksio.core.db import seed_flavors
from fluksio.models import Flavor
def test_an_installation_starts_with_sizes_to_pick_from(
def test_an_instance_starts_with_sizes_to_pick_from(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
response = client.get(
@@ -20,7 +20,7 @@ def test_an_installation_starts_with_sizes_to_pick_from(
assert {"small", "medium", "large", "gpu-small"} <= names
def test_seeding_leaves_an_installation_that_has_its_own_alone(db: Session) -> None:
def test_seeding_leaves_an_instance_that_has_its_own_alone(db: Session) -> None:
"""Re-adding a size somebody deliberately removed is an argument to avoid."""
before = {row.name for row in db.exec(select(Flavor)).all()}
seed_flavors(db)
+3 -3
View File
@@ -544,7 +544,7 @@ def test_removing_the_panel_revokes_its_credential(
# --------------------------------------------------------------------------
# A screen that reached the portal but not this installation
# A screen that reached the portal but not this instance
# --------------------------------------------------------------------------
@@ -592,7 +592,7 @@ def test_a_remote_device_is_paired_at_the_portal(
) -> None:
"""A device that arrived through the tunnel gets the portal's credential.
It could never present one this installation signed: the portal verifies
It could never present one this instance signed: the portal verifies
what crosses it, and it verifies against its own key.
"""
import httpx
@@ -627,7 +627,7 @@ def test_a_remote_device_is_paired_at_the_portal(
assert approved.status_code == 200, approved.text
assert calls[0]["url"].endswith("/api/v1/panel-tokens/") # type: ignore[union-attr]
assert calls[0]["headers"]["Authorization"] == "Bearer installation-token" # type: ignore[index]
assert calls[0]["headers"]["Authorization"] == "Bearer instance-token" # type: ignore[index]
assert calls[0]["json"] == {"panel": "hallway"} # type: ignore[index]
collected = client.get(
+5 -5
View File
@@ -25,7 +25,7 @@ from fluksio.models import (
RunNode,
User,
)
from tests.utils.portal import INSTALLATION_ID, ISSUER, jwks
from tests.utils.portal import INSTANCE_ID, ISSUER, jwks
from tests.utils.user import authentication_token_from_email
from tests.utils.utils import get_superuser_token_headers
@@ -129,7 +129,7 @@ def enrolled(
portal_key: rsa.RSAPrivateKey,
db: Session,
) -> Any:
"""Enrol this installation with a fake portal, then undo it."""
"""Enrol this instance with a fake portal, then undo it."""
local_user = db.exec(
select(User).where(User.email == settings.FIRST_SUPERUSER)
).one()
@@ -141,8 +141,8 @@ def enrolled(
cloud_config.CloudConfig(
portal_url=ISSUER,
ws_url=f"{ISSUER}/api/v1/tunnel/attach",
installation_id=INSTALLATION_ID,
token="installation-token",
instance_id=INSTANCE_ID,
token="instance-token",
issuer=ISSUER,
jwks=jwks(portal_key),
local_user_id=str(local_user.id),
@@ -151,7 +151,7 @@ def enrolled(
)
)
# Enrolment also maps the enrolling account to the portal identity that
# owns the installation; without it a portal session resolves to nobody.
# owns the instance; without it a portal session resolves to nobody.
local_user.portal_sub = "portal-user-1"
db.add(local_user)
db.commit()
+1 -1
View File
@@ -78,7 +78,7 @@ def test_an_edge_carries_the_qualified_message(controller: FlowController):
def test_a_credential_never_reaches_the_key():
# Stored params, so a secret is still a reference. Neither its name nor its
# value belongs in something the browser gets to see.
key = MqttNode.instance_key({**BROKER, "password": {"$secret": "broker_pw"}})
key = MqttNode.target_key({**BROKER, "password": {"$secret": "broker_pw"}})
assert key == "mosquitto:1883/sensors/temp"
+2 -2
View File
@@ -423,7 +423,7 @@ def test_an_unbound_setting_is_just_its_value():
No message means nothing to type-check and nothing for a panel to be
entitled to — and a name this build does not know is left alone rather
than refused, so an older installation reads a newer document.
than refused, so an older instance reads a newer document.
"""
defn = DashboardDef(
name="house",
@@ -523,7 +523,7 @@ def test_a_chart_of_runs_must_say_which_runs_and_which_metric():
def test_a_document_written_as_pages_is_read_as_one_grid():
"""Stored dashboards live in each installation's repository.
"""Stored dashboards live in each instance's repository.
So the old shape is normalised on the way in rather than migrated, and a
placed second section keeps its arrangement instead of piling onto the
+1 -1
View File
@@ -192,7 +192,7 @@ def test_a_node_waiting_for_a_machine_asks_for_one_once(monkeypatch):
assert ran.is_set()
def test_an_installation_with_nowhere_to_start_one_has_no_file(tmp_path):
def test_an_instance_with_nowhere_to_start_one_has_no_file(tmp_path):
assert load_provisioners(tmp_path / "provisioners.json") == []
+1 -1
View File
@@ -237,7 +237,7 @@ def lifecycle(monkeypatch: pytest.MonkeyPatch) -> list[str]:
def test_rebuilding_one_flow_leaves_another_flows_node_running(
tmp_path: Path, lifecycle: list[str]
):
"""The whole point: reconnecting one flow's nodes, not the installation's."""
"""The whole point: reconnecting one flow's nodes, not the instance's."""
store = FlowStore(tmp_path / "flows")
for name in ("a", "b"):
store.write_flow(FlowDef(name=name, nodes=[NodeDef(id="io", type="lifecycle")]))
+1 -1
View File
@@ -1,6 +1,6 @@
"""What the flow-logic nodes actually do.
These are the shapes a Node-RED installation is mostly made of, so their
These are the shapes a Node-RED instance is mostly made of, so their
behaviour is worth pinning rather than just their construction.
"""
+1 -1
View File
@@ -16,7 +16,7 @@ from fluksio.flow import webpush
@pytest.fixture(autouse=True)
def store(tmp_path, monkeypatch):
"""A store per test, so nothing writes into the real installation."""
"""A store per test, so nothing writes into the real instance."""
monkeypatch.setattr(settings, "WEBPUSH_FILE", tmp_path / "webpush.json")
return tmp_path / "webpush.json"
+5 -5
View File
@@ -1,4 +1,4 @@
"""Which installation a command is for, and where its token lives.
"""Which instance a command is for, and where its token lives.
A repository with its own venv gets its own engine, so "which one" is a fact
about the working directory rather than about the machine.
@@ -14,14 +14,14 @@ from fluksio.sdk import client
@pytest.fixture
def elsewhere(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A working directory with no installation above it."""
"""A working directory with no instance above it."""
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
monkeypatch.setattr(client, "GLOBAL_DATA_DIR", tmp_path / "home" / ".fluksio")
return tmp_path
def test_a_project_local_installation_is_found_from_below(elsewhere: Path):
def test_a_project_local_instance_is_found_from_below(elsewhere: Path):
(elsewhere / ".fluksio").mkdir()
deep = elsewhere / "src" / "pkg" / "sub"
deep.mkdir(parents=True)
@@ -31,7 +31,7 @@ def test_a_project_local_installation_is_found_from_below(elsewhere: Path):
def test_the_nearest_one_wins(elsewhere: Path):
"""An installation inside another belongs to the directory it is in."""
"""An instance inside another belongs to the directory it is in."""
(elsewhere / ".fluksio").mkdir()
inner = elsewhere / "inner"
(inner / ".fluksio").mkdir(parents=True)
@@ -63,7 +63,7 @@ def test_the_credential_beside_the_data_is_the_one_used(elsewhere: Path):
assert client._stored()["token"] == "local"
def test_a_login_from_before_installations_were_local_still_works(elsewhere: Path):
def test_a_login_from_before_instances_were_local_still_works(elsewhere: Path):
"""`fluksio login` wrote to XDG once; that must not stop answering."""
legacy = client._legacy_config_path()
legacy.parent.mkdir(parents=True)
+2 -2
View File
@@ -106,7 +106,7 @@ def test_a_sweeps_param_spelling_is_refused_by_name() -> None:
_params(definition, ["--lr", "fast"])
def test_serve_uses_the_installation_the_directory_belongs_to(
def test_serve_uses_the_instance_the_directory_belongs_to(
tmp_path: Path, monkeypatch
) -> None:
"""A repository with its own venv gets its own engine, not the machine's."""
@@ -713,7 +713,7 @@ def test_who_holds_the_port_is_told_apart_by_the_token() -> None:
The token is signed with this directory's secret key, so an engine that
accepts it is one reading this directory's database. Another
installation's Fluksio answers the health check and refuses it.
instance's Fluksio answers the health check and refuses it.
"""
import httpx
+11 -11
View File
@@ -1,7 +1,7 @@
"""Remote access: off by default, and only ever as much as somebody granted.
The property worth pinning down is the one everything else rests on — a
portal's token is worth nothing here until somebody at this installation
portal's token is worth nothing here until somebody at this instance
enrolled it, and even then it grants exactly the rights of the local account
the portal identity holding it was mapped to. An identity nobody mapped gets
nothing, which is what makes deleting that local account a revocation.
@@ -31,7 +31,7 @@ from tests.utils.portal import ISSUER, portal_token
def test_portal_token_is_refused_when_not_enrolled(
portal_key: rsa.RSAPrivateKey, tmp_path_factory: pytest.TempPathFactory
) -> None:
"""An installation nobody connected trusts no portal at all."""
"""An instance nobody connected trusts no portal at all."""
original = settings.CLOUD_CONFIG_FILE
settings.CLOUD_CONFIG_FILE = tmp_path_factory.mktemp("empty") / "cloud.json"
try:
@@ -55,11 +55,11 @@ def test_portal_token_resolves_by_portal_identity(
assert user_from_token(db, portal_token(portal_key, subject="nobody")) is None
def test_token_for_another_installation_is_refused(
def test_token_for_another_instance_is_refused(
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
portal_key: rsa.RSAPrivateKey,
) -> None:
"""The audience is this installation's id, so someone else's is worthless."""
"""The audience is this instance's id, so someone else's is worthless."""
with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(portal_key, audience=str(uuid.uuid4())))
@@ -67,7 +67,7 @@ def test_token_for_another_installation_is_refused(
def test_token_from_an_unpinned_key_is_refused(
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
) -> None:
"""A different portal, or a hijacked one, cannot sign for this installation."""
"""A different portal, or a hijacked one, cannot sign for this instance."""
impostor = rsa.generate_private_key(public_exponent=65537, key_size=2048)
with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(impostor))
@@ -104,7 +104,7 @@ def test_status_reports_not_enrolled(
def test_enrolling_needs_a_superuser(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
"""Remote access is an installation-wide grant, not a personal setting."""
"""Remote access is an instance-wide grant, not a personal setting."""
response = client.post(
f"{settings.API_V1_STR}/cloud/enroll",
headers=normal_user_token_headers,
@@ -122,7 +122,7 @@ def test_a_panel_scoped_portal_token_reaches_only_its_panel(
"""A screen paired through the portal is bounded here, not there.
The portal names the panel; everything about what that means is this
installation's, which is the whole reason it may mint one at all.
instance's, which is the whole reason it may mint one at all.
"""
write_config(
PanelsConfig(
@@ -241,7 +241,7 @@ def test_adding_a_remote_user_maps_and_revokes(
f"{settings.API_V1_STR}/users/{body['id']}", headers=superuser_token_headers
)
assert removed.status_code == 200, removed.text
assert delete.call_args.args[0].endswith("/installation-members/portal-user-9")
assert delete.call_args.args[0].endswith("/instance-members/portal-user-9")
db.expire_all()
assert user_from_token(db, theirs) is None
@@ -251,7 +251,7 @@ def test_adding_a_remote_user_needs_a_superuser(
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
normal_user_token_headers: dict[str, str],
) -> None:
"""Widening who can reach this installation stays a superuser's decision."""
"""Widening who can reach this instance stays a superuser's decision."""
response = client.post(
f"{settings.API_V1_STR}/cloud/users",
headers=normal_user_token_headers,
@@ -272,8 +272,8 @@ def test_enrolling_against_a_portal_without_an_owner_is_refused(
status_code=200,
json=Mock(
return_value={
"installation_id": str(uuid.uuid4()),
"installation_token": "t",
"instance_id": str(uuid.uuid4()),
"instance_token": "t",
"ws_url": f"{ISSUER}/api/v1/tunnel/attach",
"issuer": ISSUER,
"jwks": {"keys": []},
+2 -2
View File
@@ -14,7 +14,7 @@ import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
INSTALLATION_ID = "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f"
INSTANCE_ID = "6f1c2d3e-4a5b-4c6d-8e9f-0a1b2c3d4e5f"
ISSUER = "https://hub.example.test"
@@ -28,7 +28,7 @@ def portal_token(
key: rsa.RSAPrivateKey,
*,
subject: str = "portal-user-1",
audience: str = INSTALLATION_ID,
audience: str = INSTANCE_ID,
issuer: str = ISSUER,
scope: str = "proxy",
) -> str: