remoteRF-server 1.1.0.dev30__py3-none-any.whl → 1.1.0.dev32__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -153,6 +153,8 @@ def _table() -> list[Command]:
153
153
  Command(("cluster invite",), "Invite code for a lab: cluster invite <name>", _federation("invite"), "Clusters"),
154
154
  Command(("cluster join",), "Join with a code: cluster join <code>", _federation("join"), "Clusters"),
155
155
  Command(("cluster remove",), "Leave or disband: cluster remove <name>", _federation("remove"), "Clusters"),
156
+ Command(("cluster groups",), "Groups you share: cluster groups <name> [add|remove <group>]", _federation("groups"), "Clusters"),
157
+ Command(("cluster codes",), "Student codes for a cluster: cluster codes <name> [count]", _federation("codes"), "Clusters"),
156
158
  Command(("cluster accept",), "", _federation("accept"), "Clusters", hidden=True),
157
159
  Command(("global cluster",), "", _federation("cluster"), "Global", hidden=True),
158
160
  # ---- gist ----
@@ -6,9 +6,9 @@ permission aggregation, resources, reservations, or broker authorization.
6
6
 
7
7
  import remoterf_federation_core as _federation_core
8
8
 
9
- if _federation_core.__version__ != "0.14.0":
9
+ if _federation_core.__version__ != "0.15.0":
10
10
  raise RuntimeError(
11
- "RemoteRF Server federation requires remoterf-federation-core==0.14.0; "
11
+ "RemoteRF Server federation requires remoterf-federation-core==0.15.0; "
12
12
  f"found {_federation_core.__version__}"
13
13
  )
14
14
 
@@ -75,6 +75,11 @@ def cmd_clusters() -> int:
75
75
  else:
76
76
  owner = _lab({"display_name": item.get("peer_name"), "deployment_id": item["peer"]})
77
77
  rows.append(f"{item['name']} owned by {owner} {item['state']}")
78
+ for peer in item.get("peers", []):
79
+ rows.append(f" {_lab(peer)} {peer['state'] or 'joining'}")
80
+ shared = ", ".join(group["group_name"] for group in item["groups"]) or "none"
81
+ rows.append(f" sharing: {shared}" + ("" if item["groups"] else f" (`cluster groups {item['name']} add <group>`)"))
82
+ rows.append(f" {item['students']} of your users enrolled, {item['visitors']} visiting")
78
83
  print()
79
84
  print_panel("Clusters", rows, literal=True)
80
85
  print()
@@ -88,13 +93,15 @@ def _ask_for(verb: str) -> str:
88
93
  if verb == "join":
89
94
  return _input("Invite code from the other lab: ")
90
95
  names = [name for name, item in sorted(cluster.load().items()) if item.get("role") == "owner"]
91
- if verb == "remove":
96
+ if verb in ("remove", "groups", "codes"):
92
97
  names = sorted(cluster.load())
93
98
  if verb == "accept":
94
99
  names = [name for name, item in sorted(cluster.load().items()) if item.get("state") == "pending"]
95
100
  what = {"create": "Name for the new cluster (short slug, e.g. ucla-lab)",
96
101
  "invite": "Which cluster to invite a lab into",
97
102
  "accept": "Which cluster's pending join to accept",
103
+ "groups": "Which cluster's shared groups to show",
104
+ "codes": "Which cluster to make student codes for",
98
105
  "remove": "Which cluster to leave (revokes the contract)"}[verb]
99
106
  if verb != "create" and names:
100
107
  print(f" Your clusters: {', '.join(names)}")
@@ -114,6 +121,10 @@ def _input(prompt: str) -> str:
114
121
  return ""
115
122
 
116
123
 
124
+ _USAGE = ("Usage: cluster list | cluster create|invite|remove <name> | cluster join <code> | "
125
+ "cluster groups <name> [add|remove <group>] | cluster codes <name> [count]")
126
+
127
+
117
128
  def cmd_cluster(argv: list[str]) -> int:
118
129
  from ...common.utils.ansi_codes import Sty
119
130
  from . import cluster
@@ -123,7 +134,7 @@ def cmd_cluster(argv: list[str]) -> int:
123
134
  words = [a for a in argv[1:] if not a.startswith("--")]
124
135
  if verb == "list" and not words:
125
136
  return cmd_clusters()
126
- if not words and verb in ("create", "invite", "accept", "join", "remove"):
137
+ if not words and verb in ("create", "invite", "accept", "join", "remove", "groups", "codes"):
127
138
  # Leaving the argument out asks for it (docs/deployment-groups.md §8).
128
139
  answer = _ask_for(verb)
129
140
  if not answer:
@@ -137,7 +148,7 @@ def cmd_cluster(argv: list[str]) -> int:
137
148
  _print(f" Created cluster {arg}. Hand out an invite with `cluster invite {arg}`.", Sty.GRAY)
138
149
  elif verb == "invite" and arg:
139
150
  code = cluster.invite(arg, auto="--manual" not in argv)
140
- _print(" On the other lab's server run (single use, 24h):", Sty.GRAY)
151
+ _print(" On the other lab's server run (single use, 7 days):", Sty.GRAY)
141
152
  _print(f" cluster join {code}", Sty.BOLD)
142
153
  elif verb == "join" and len(argv) > 1:
143
154
  # A code pasted from chat or email may arrive wrapped across lines.
@@ -149,8 +160,25 @@ def cmd_cluster(argv: list[str]) -> int:
149
160
  _print(f" Accepted. {arg} activates once both servers confirm (about a minute).", Sty.GRAY)
150
161
  elif verb == "remove" and arg:
151
162
  _print(f" {arg}: {cluster.remove(arg)}.", Sty.GRAY)
163
+ elif verb == "groups" and len(words) == 1:
164
+ shared = cluster.groups(arg)
165
+ _print(f" {arg} shares: " + (", ".join(g["group_name"] for g in shared) or "nothing yet"), Sty.GRAY)
166
+ _print(f" Attach one with `cluster groups {arg} add <group>`; students of every lab in the "
167
+ "cluster get it. Group names stay on this server.", Sty.GRAY)
168
+ elif verb == "groups" and len(words) == 3 and words[1] in ("add", "remove"):
169
+ if words[1] == "add":
170
+ _print(f" {cluster.attach_group(arg, words[2])} is now shared with {arg}.", Sty.GRAY)
171
+ else:
172
+ _print(f" {cluster.detach_group(arg, words[2])} is no longer shared with {arg}.", Sty.GRAY)
173
+ elif verb == "codes" and arg:
174
+ count = int(words[1]) if len(words) > 1 else 1
175
+ codes = cluster.mint_codes(arg, count=count)
176
+ _print(f" Give each of your users one code; they enter it in the Client like any enrollment "
177
+ f"code (single use, {cluster.CODE_DAYS} days):", Sty.GRAY)
178
+ for code in codes:
179
+ _print(f" {code}", Sty.BOLD)
152
180
  else:
153
- print("Usage: cluster list | cluster create|invite|remove <name> | cluster join <code>", file=sys.stderr)
181
+ print(_USAGE, file=sys.stderr)
154
182
  return 2
155
183
  except (cluster.ClusterError, ContractError, RuntimeError, OSError) as exc:
156
184
  _print(f" {exc}", Sty.WARNING)
@@ -162,7 +190,7 @@ def main(argv) -> int:
162
190
  # `cluster <verb> …` from the table; `global cluster …` is the old spelling.
163
191
  if argv and argv[0] in ("cluster", "clusters"):
164
192
  argv = argv[1:] or ["list"]
165
- if argv and argv[0] in ("list", "create", "invite", "join", "accept", "remove"):
193
+ if argv and argv[0] in ("list", "create", "invite", "join", "accept", "remove", "groups", "codes"):
166
194
  return cmd_cluster(argv)
167
- print("Usage: cluster list | cluster create|invite|remove <name> | cluster join <code>", file=sys.stderr)
195
+ print(_USAGE, file=sys.stderr)
168
196
  return 2
@@ -33,19 +33,25 @@ import json
33
33
  import os
34
34
  import re
35
35
  import secrets
36
+ import sqlite3
36
37
  import time
38
+ from datetime import datetime
37
39
  from pathlib import Path
38
40
  from typing import Optional
39
41
  from uuid import uuid4
40
42
 
41
- from remoterf_federation_core import ContractError, canonical_json
43
+ from remoterf_federation_core import ContractError, FederationError, canonical_json
42
44
 
43
45
  from .. import global_onboarding as g
44
46
  from .contracts import ContractStore
47
+ from .migrations import migrate_gate_f_usergroups_schema
45
48
  from .trust import TrustedKeyStore
46
49
 
47
50
  CAPABILITIES = ["DEVICE_CONTROL", "DEVICE_READ", "DEVICE_RESERVE", "FEDERATED_ENROLL", "PERMISSIONS_READ"]
48
51
  CONTRACT_DAYS = 365
52
+ CODE_PREFIX = "cluster." # a student code minted here, redeemed here
53
+ CLUSTER_CODE = "cluster" # the `code_name` a cluster enrolment carries to a partner
54
+ CODE_DAYS = 7
49
55
  _NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
50
56
  _STATE_FILE = "clusters.json"
51
57
 
@@ -115,6 +121,20 @@ def _find(clusters: dict, name: str) -> dict:
115
121
  return clusters[name]
116
122
 
117
123
 
124
+ def _parties(cluster: dict) -> list[tuple[str, dict]]:
125
+ """Every lab we hold a contract with in this cluster: (deployment_id, party)."""
126
+ if cluster["role"] == "owner":
127
+ return list(cluster["members"].items())
128
+ return [(cluster["peer"], cluster)] + list(cluster.get("peers", {}).items())
129
+
130
+
131
+ def contract_cluster(contract_id: str) -> tuple[str, dict] | None:
132
+ for name, cluster in load().items():
133
+ if any(party.get("contract_id") == contract_id for _, party in _parties(cluster)):
134
+ return name, cluster
135
+ return None
136
+
137
+
118
138
  # -----------------------------
119
139
  # Identity and stores
120
140
  # -----------------------------
@@ -182,6 +202,22 @@ def _pin_self() -> dict:
182
202
  return me
183
203
 
184
204
 
205
+ def _identity_of(deployment_id: str) -> Optional[dict]:
206
+ """A pinned lab's identity, as it would advertise it."""
207
+ rows = [row for row in _trust().list(deployment_id) if row["state"] == "active"]
208
+ if not rows:
209
+ return None
210
+ row = rows[-1]
211
+ return {key: row[key] for key in ("deployment_id", "kid", "public_key_b64", "public_key_sha256")} | {
212
+ "valid_from": int(row["valid_from"]), "valid_until": row["valid_until"]}
213
+
214
+
215
+ def _usergroups() -> sqlite3.Connection:
216
+ conn = sqlite3.connect(_db_path(), timeout=10.0)
217
+ migrate_gate_f_usergroups_schema(conn)
218
+ return conn
219
+
220
+
185
221
  # -----------------------------
186
222
  # Invite codes
187
223
  # -----------------------------
@@ -279,8 +315,12 @@ def _propose(cluster: dict, member: dict, peer: str) -> None:
279
315
  """Owner: pin the joiner and propose its bilateral contract."""
280
316
  joiner = member.pop("joiner")
281
317
  _pin(joiner, source="cluster_invite")
318
+ _contract_with(member, joiner)
319
+
320
+
321
+ def _contract_with(party: dict, other: dict) -> None:
282
322
  me = _identity()
283
- a, b = sorted([me, joiner], key=lambda item: item["deployment_id"])
323
+ a, b = sorted([me, other], key=lambda item: item["deployment_id"])
284
324
  now = int(time.time())
285
325
  terms = {
286
326
  "contract_id": str(uuid4()),
@@ -297,8 +337,8 @@ def _propose(cluster: dict, member: dict, peer: str) -> None:
297
337
  "supersedes_version": None,
298
338
  }
299
339
  _contracts().propose(terms, actor_admin_ref="cluster")
300
- member.update(state="proposed", contract_id=terms["contract_id"])
301
- _publish(member)
340
+ party.update(state="proposed", contract_id=terms["contract_id"])
341
+ _publish(party)
302
342
 
303
343
 
304
344
  def accept(name: str, peer: str | None = None) -> dict:
@@ -335,13 +375,13 @@ def remove(name: str) -> str:
335
375
  clusters = load()
336
376
  cluster = _find(clusters, name)
337
377
  outcome = "removed"
338
- parties = list(cluster["members"].values()) if cluster["role"] == "owner" else [cluster]
339
- for party in parties:
378
+ for _, party in _parties(cluster):
340
379
  _revoke(party.get("contract_id"))
341
380
  try:
342
381
  _publish(party)
343
382
  except g.GlobalError:
344
383
  outcome = "removed locally; could not reach Global to tell the other lab"
384
+ _forget_accounts(name, cluster)
345
385
  del clusters[name]
346
386
  save(clusters)
347
387
  return outcome
@@ -374,11 +414,182 @@ def list_clusters() -> list[dict]:
374
414
  state = store.show(cluster["contract_id"], 1)["state"]
375
415
  except ContractError:
376
416
  pass
377
- item.update(peer=cluster["peer"], peer_name=cluster.get("peer_name"), state=state)
417
+ peers = [{"deployment_id": peer, "display_name": party.get("display_name"), "state": party.get("state")}
418
+ for peer, party in sorted(cluster.get("peers", {}).items())]
419
+ item.update(peer=cluster["peer"], peer_name=cluster.get("peer_name"), state=state, peers=peers)
420
+ item.update(groups=groups(name), **_account_counts(name))
378
421
  out.append(item)
379
422
  return out
380
423
 
381
424
 
425
+ # -----------------------------
426
+ # Groups and students: what a cluster actually shares
427
+ # -----------------------------
428
+ # Attaching is local -- a partner never sees group names, only the devices a
429
+ # proxy in those groups may use. A student redeems one cluster code at home
430
+ # and is enrolled at every lab in the cluster; each lab puts the proxy into
431
+ # whatever it has attached.
432
+
433
+ def _group(conn, ref: str) -> tuple[int, str]:
434
+ row = conn.execute(
435
+ "SELECT group_id,group_name FROM UserGroups WHERE group_name=? OR CAST(group_id AS TEXT)=?",
436
+ (ref, ref),
437
+ ).fetchone()
438
+ if row is None:
439
+ raise ClusterError(f"no user group named {ref}")
440
+ return int(row[0]), row[1]
441
+
442
+
443
+ def groups(name: str) -> list[dict]:
444
+ cluster = _find(load(), name)
445
+ out = []
446
+ with _usergroups() as conn:
447
+ for group_id in cluster.get("groups", []):
448
+ row = conn.execute("SELECT group_name FROM UserGroups WHERE group_id=?", (group_id,)).fetchone()
449
+ if row is not None:
450
+ out.append({"group_id": group_id, "group_name": row[0]})
451
+ return out
452
+
453
+
454
+ def _account_counts(name: str) -> dict:
455
+ with _usergroups() as conn:
456
+ rows = dict(conn.execute(
457
+ "SELECT kind,COUNT(*) FROM ClusterAccounts WHERE cluster_name=? GROUP BY kind", (name,)).fetchall())
458
+ return {"students": rows.get("local", 0), "visitors": rows.get("proxy", 0)}
459
+
460
+
461
+ def attach_group(name: str, ref: str) -> str:
462
+ clusters = load()
463
+ cluster = _find(clusters, name)
464
+ with _usergroups() as conn:
465
+ group_id, group_name = _group(conn, ref)
466
+ if group_id in cluster.get("groups", []):
467
+ raise ClusterError(f"{group_name} is already attached to {name}")
468
+ cluster.setdefault("groups", []).append(group_id)
469
+ conn.execute(
470
+ "INSERT OR IGNORE INTO UsersLookup(user_id,group_id) "
471
+ "SELECT local_account_id,? FROM ClusterAccounts WHERE cluster_name=?",
472
+ (group_id, name),
473
+ )
474
+ save(clusters)
475
+ return group_name
476
+
477
+
478
+ def detach_group(name: str, ref: str) -> str:
479
+ clusters = load()
480
+ cluster = _find(clusters, name)
481
+ with _usergroups() as conn:
482
+ group_id, group_name = _group(conn, ref)
483
+ if group_id not in cluster.get("groups", []):
484
+ raise ClusterError(f"{group_name} is not attached to {name}")
485
+ cluster["groups"].remove(group_id)
486
+ conn.execute(
487
+ "DELETE FROM UsersLookup WHERE group_id=? AND user_id IN "
488
+ "(SELECT local_account_id FROM ClusterAccounts WHERE cluster_name=?)",
489
+ (group_id, name),
490
+ )
491
+ save(clusters)
492
+ return group_name
493
+
494
+
495
+ def _forget_accounts(name: str, cluster: dict) -> None:
496
+ with _usergroups() as conn:
497
+ for group_id in cluster.get("groups", []):
498
+ conn.execute(
499
+ "DELETE FROM UsersLookup WHERE group_id=? AND user_id IN "
500
+ "(SELECT local_account_id FROM ClusterAccounts WHERE cluster_name=?)",
501
+ (group_id, name),
502
+ )
503
+ conn.execute("DELETE FROM ClusterAccounts WHERE cluster_name=?", (name,))
504
+
505
+
506
+ def join_account(conn, name: str, cluster: dict, local_account_id: int, *, kind: str) -> None:
507
+ """Put an account (a local student or a partner's proxy) into the cluster
508
+ and every group attached to it. Runs inside the caller's transaction."""
509
+ conn.execute(
510
+ "INSERT OR IGNORE INTO ClusterAccounts(cluster_name,local_account_id,kind,joined_at) VALUES (?,?,?,?)",
511
+ (name, int(local_account_id), kind, int(time.time())),
512
+ )
513
+ for group_id in cluster.get("groups", []):
514
+ conn.execute("INSERT OR IGNORE INTO UsersLookup(user_id,group_id) VALUES (?,?)",
515
+ (int(local_account_id), int(group_id)))
516
+
517
+
518
+ def mint_codes(name: str, *, count: int = 1, uses: int = 1, days: int = CODE_DAYS) -> list[str]:
519
+ _find(load(), name)
520
+ if not 1 <= count <= 200 or uses < 1 or days < 1:
521
+ raise ClusterError("count must be 1-200, uses and days at least 1")
522
+ codes = [CODE_PREFIX + secrets.token_urlsafe(18) for _ in range(count)]
523
+ with _usergroups() as conn:
524
+ conn.executemany(
525
+ "INSERT INTO EnrollmentCodes(code_name,group_id,uses,duration_sec,created_at,cluster_name) "
526
+ "VALUES (?,-1,?,?,?,?)",
527
+ [(code, uses, days * 86400, datetime.now().isoformat(sep=" "), name) for code in codes],
528
+ )
529
+ return codes
530
+
531
+
532
+ def redeem_code(local_account_id: int, code_name: str) -> Optional[str]:
533
+ """A student types a cluster code at home: returns the cluster joined, or None."""
534
+ if not code_name.startswith(CODE_PREFIX):
535
+ return None
536
+ clusters = load()
537
+ with _usergroups() as conn:
538
+ row = conn.execute(
539
+ "SELECT code_id,cluster_name,uses,duration_sec,created_at FROM EnrollmentCodes "
540
+ "WHERE code_name=? AND cluster_name IS NOT NULL",
541
+ (code_name,),
542
+ ).fetchone()
543
+ if row is None or int(row[2]) <= 0 or row[1] not in clusters:
544
+ return None
545
+ if int(row[3]) > 0:
546
+ try:
547
+ created = datetime.fromisoformat(str(row[4]))
548
+ except ValueError:
549
+ return None
550
+ if (datetime.now() - created).total_seconds() >= int(row[3]):
551
+ return None
552
+ conn.execute("UPDATE EnrollmentCodes SET uses=uses-1 WHERE code_id=?", (row[0],))
553
+ conn.execute("DELETE FROM EnrollmentCodes WHERE code_id=? AND uses<=0", (row[0],))
554
+ join_account(conn, row[1], clusters[row[1]], local_account_id, kind="local")
555
+ return row[1]
556
+
557
+
558
+ def enroll_students(home, notify=None, *, only_account: int | None = None) -> None:
559
+ """HOME: enrol every local student at every active lab they are not yet
560
+ at. Idempotent; the tick retries whatever failed."""
561
+ notify = notify or (lambda text: None)
562
+ enroller, plane = getattr(home, "federation_enroller", None), getattr(home, "federation_devices", None)
563
+ if enroller is None or plane is None:
564
+ return
565
+ clusters = load()
566
+ with _usergroups() as conn:
567
+ rows = conn.execute(
568
+ "SELECT cluster_name,local_account_id FROM ClusterAccounts WHERE kind='local'"
569
+ + (" AND local_account_id=?" if only_account is not None else ""),
570
+ (only_account,) if only_account is not None else (),
571
+ ).fetchall()
572
+ for name, account in rows:
573
+ cluster = clusters.get(name)
574
+ if cluster is None:
575
+ continue
576
+ enrolled = plane.enrolled(account)
577
+ for peer, party in _parties(cluster):
578
+ if party.get("state") != "active" or not party.get("contract_id") or peer in enrolled:
579
+ continue
580
+ try:
581
+ enroller.outbound.execute(
582
+ local_account_id=account, destination_deployment_id=peer,
583
+ contract_id=party["contract_id"], contract_version=1,
584
+ action="federated_enroll", code_name=CLUSTER_CODE,
585
+ )
586
+ except (FederationError, OSError) as exc:
587
+ notify(f"[cluster] {name}: could not enrol account {account} at "
588
+ f"{party.get('display_name') or peer}: {getattr(exc, 'code', exc)}")
589
+ continue
590
+ plane.record_enrollment(local_account_id=account, destination_deployment_id=peer)
591
+
592
+
382
593
  # -----------------------------
383
594
  # Agent: one pass per Global liveness tick
384
595
  # -----------------------------
@@ -452,6 +663,76 @@ def _advance(party: dict, peer: str, directory: list[dict], *, label: str, notif
452
663
  notify(f"[cluster] {label} was ended by the other lab")
453
664
 
454
665
 
666
+ def _publish_roster(name: str, cluster: dict) -> None:
667
+ """Owner: whenever the active member set changes, sign and post it so the
668
+ members can pin each other and mesh."""
669
+ active = sorted(peer for peer, member in cluster["members"].items() if member.get("state") == "active")
670
+ if active == cluster.get("roster"):
671
+ return
672
+ key, _, me = g._signing_material()
673
+ members = []
674
+ for peer in active:
675
+ identity = _identity_of(peer)
676
+ if identity is not None:
677
+ members.append({"identity": identity, "display_name": cluster["members"][peer].get("display_name")})
678
+ roster = {"cluster_name": name, "owner": me, "issued_at": int(time.time()), "members": members}
679
+ signature = base64.b64encode(key.sign(canonical_json(roster).encode())).decode("ascii")
680
+ g.call("POST", "/v2/broker/clusters/roster", {"protocol_version": "2", "roster": roster, "signature": signature})
681
+ cluster["roster"] = active
682
+
683
+
684
+ def _roster_signed_by(owner: str, roster: dict, signature: str) -> bool:
685
+ from cryptography.exceptions import InvalidSignature
686
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
687
+
688
+ for row in _trust().list(owner):
689
+ if row["state"] != "active":
690
+ continue
691
+ try:
692
+ Ed25519PublicKey.from_public_bytes(base64.b64decode(row["public_key_b64"])).verify(
693
+ base64.b64decode(signature), canonical_json(roster).encode())
694
+ return True
695
+ except (InvalidSignature, ValueError):
696
+ continue
697
+ return False
698
+
699
+
700
+ def _mesh(name: str, cluster: dict, rosters: list[dict], directory: list[dict], notify) -> None:
701
+ """Member: pin the other members the owner vouches for and hold one
702
+ contract with each. The lower deployment id proposes; the other accepts."""
703
+ me = g._signing_material()[2]
704
+ peers = cluster.setdefault("peers", {})
705
+ for item in rosters:
706
+ roster = item.get("roster") or {}
707
+ if roster.get("owner") != cluster["peer"] or roster.get("cluster_name") != name:
708
+ continue
709
+ if not _roster_signed_by(cluster["peer"], roster, item.get("signature", "")):
710
+ notify(f"[cluster] {name}: ignored a member list not signed by the owner")
711
+ continue
712
+ listed = {m["identity"]["deployment_id"]: m for m in roster.get("members", [])
713
+ if m["identity"]["deployment_id"] != me}
714
+ for peer, member in listed.items():
715
+ if peer in peers:
716
+ continue
717
+ _pin(member["identity"], source="cluster_invite")
718
+ peers[peer] = {"display_name": member.get("display_name"), "contract_id": None,
719
+ "state": "joined", "published": []}
720
+ if me < peer:
721
+ _contract_with(peers[peer], member["identity"])
722
+ for peer in [p for p in peers if p not in listed]:
723
+ _revoke(peers[peer].get("contract_id"))
724
+ try:
725
+ _publish(peers[peer])
726
+ except g.GlobalError:
727
+ pass
728
+ notify(f"[cluster] {name}: {peers[peer].get('display_name') or peer} left")
729
+ del peers[peer]
730
+ break
731
+ for peer, party in peers.items():
732
+ _advance(party, peer, directory, label=f"{name}: {party.get('display_name') or peer}",
733
+ notify=notify, is_member=me > peer)
734
+
735
+
455
736
  def _collect_joins(name: str, cluster: dict, broker_invites: list[dict], notify) -> None:
456
737
  """Owner: turn redeemed invites into members; drop invites Global no longer holds."""
457
738
  seen = {item["invite_id"]: item for item in broker_invites}
@@ -478,14 +759,16 @@ def _collect_joins(name: str, cluster: dict, broker_invites: list[dict], notify)
478
759
  notify(f"[cluster] {peer} wants to join {name}: run `cluster accept {name}`")
479
760
 
480
761
 
481
- def tick(notify=None) -> None:
482
- """One agent pass: move every cluster and member one step closer to active."""
762
+ def tick(notify=None, home=None) -> None:
763
+ """One agent pass: move every cluster and member one step closer to active,
764
+ then (given the HOME runtime) enrol students wherever they are missing."""
483
765
  notify = notify or (lambda text: None)
484
766
  clusters = load()
485
767
  if not clusters:
486
768
  return
487
769
  directory = None
488
770
  broker_invites = None
771
+ rosters = None
489
772
  for name, cluster in clusters.items():
490
773
  if cluster["role"] == "owner":
491
774
  if cluster["invites"]:
@@ -500,8 +783,17 @@ def tick(notify=None) -> None:
500
783
  for peer, member in cluster["members"].items():
501
784
  _advance(member, peer, directory, label=f"{name}: {member.get('display_name') or peer}",
502
785
  notify=notify, is_member=False)
786
+ try:
787
+ _publish_roster(name, cluster)
788
+ except g.GlobalError as exc:
789
+ notify(f"[cluster] {name}: could not publish the member list: {exc}")
503
790
  else:
504
791
  if directory is None:
505
792
  directory = g.call("GET", "/v2/broker/contracts")["contracts"]
506
793
  _advance(cluster, cluster["peer"], directory, label=name, notify=notify, is_member=True)
794
+ if rosters is None:
795
+ rosters = g.call("GET", "/v2/broker/clusters/roster")["rosters"]
796
+ _mesh(name, cluster, rosters, directory, notify)
507
797
  save(clusters)
798
+ if home is not None:
799
+ enroll_students(home, notify)
@@ -8,7 +8,7 @@ import sqlite3
8
8
  import time
9
9
  from datetime import datetime
10
10
  from pathlib import Path
11
- from uuid import uuid4
11
+ from uuid import NAMESPACE_URL, uuid4, uuid5
12
12
 
13
13
  from remoterf_federation_core import (
14
14
  FederationError,
@@ -24,6 +24,8 @@ from remoterf_federation_core import (
24
24
  )
25
25
 
26
26
  from .destination import ActionRegistry, FederationBusinessError, ServerFederationExecutor
27
+
28
+ CLUSTER_CODE = "cluster"
27
29
  from .migrations import configure_connection, migrate_gate_f_usergroups_schema
28
30
  from .principals import PreparedFederatedAccount, prepare_federated_account
29
31
 
@@ -294,6 +296,19 @@ class ServerFederatedEnrollHandler:
294
296
  """Never mutates owner authorization; invalid cases wait for owner tx."""
295
297
  claims = action.claims
296
298
  payload = action.request.value["payload"]
299
+ if payload["code_name"] == CLUSTER_CODE:
300
+ # No code to check: the home lab's signature under an active
301
+ # cluster contract is the vouching. Prepare a proxy only once.
302
+ with sqlite3.connect(self.usergroups_path) as conn:
303
+ if get_principal_tx(conn, claims["iss"], claims["sub"]) is not None:
304
+ return None
305
+ return self.account_preparer(
306
+ self.reservations_path,
307
+ issuer_deployment_id=claims["iss"],
308
+ remote_subject_id=claims["sub"],
309
+ perms_path=self.perms_path,
310
+ now=int(self.clock()),
311
+ )
297
312
  with sqlite3.connect(self.usergroups_path) as conn:
298
313
  conn.row_factory = sqlite3.Row
299
314
  receipt = conn.execute(
@@ -367,10 +382,42 @@ class ServerFederatedEnrollHandler:
367
382
  "membership_present": membership_present,
368
383
  }
369
384
 
385
+ def _cluster_enroll(self, tx, action, now):
386
+ """Cluster enrolment: the contract names the cluster; the proxy joins
387
+ every group attached to it here. Re-running only re-adds memberships."""
388
+ from . import cluster as cluster_module
389
+
390
+ claims = action.claims
391
+ found = cluster_module.contract_cluster(claims["contract_id"])
392
+ if found is None:
393
+ raise FederationBusinessError("code_invalid")
394
+ name, cluster = found
395
+ principal = get_principal_tx(tx, claims["iss"], claims["sub"])
396
+ if principal is None:
397
+ prepared = action.prepared
398
+ if not isinstance(prepared, PreparedFederatedAccount):
399
+ raise RuntimeError("federated proxy preparation unavailable")
400
+ principal = resolve_or_create_principal_tx(
401
+ tx, issuer=claims["iss"], subject=claims["sub"], prepared=prepared, now=now
402
+ )
403
+ elif principal[4] != "active":
404
+ raise FederationBusinessError("principal_disabled")
405
+ else:
406
+ tx.execute("UPDATE FederatedPrincipals SET last_seen_at=? WHERE id=?", (now, principal[0]))
407
+ cluster_module.join_account(tx, name, cluster, int(principal[3]), kind="proxy")
408
+ self._audit(tx, action, code="ok", principal_id=principal[0], metadata={"cluster": name})
409
+ # Group names stay local; the home lab learns only which cluster admitted it.
410
+ return self._result(
411
+ action, redemption_id=str(uuid5(NAMESPACE_URL, f"remoterf-cluster:{name}:{principal[0]}")),
412
+ membership_present=True, groups=[{"group_id": "0", "group_name": f"cluster {name}"}], now=now,
413
+ )
414
+
370
415
  def __call__(self, tx, action):
371
416
  claims = action.claims
372
417
  payload = action.request.value["payload"]
373
418
  now = int(self.clock())
419
+ if payload["code_name"] == CLUSTER_CODE:
420
+ return self._cluster_enroll(tx, action, now)
374
421
  receipt = lookup_redemption_tx(
375
422
  tx, claims["iss"], claims["sub"], claims["request_id"]
376
423
  )
@@ -116,6 +116,10 @@ class ServerHomeDevices:
116
116
  (int(local_account_id), canonical_uuid(destination_deployment_id), int(self.clock())),
117
117
  )
118
118
 
119
+ def enrolled(self, local_account_id: int) -> set[str]:
120
+ with self._connect() as conn:
121
+ return self._enrolled_at(conn, local_account_id)
122
+
119
123
  def _enrolled_at(self, conn, local_account_id: int) -> set[str]:
120
124
  rows = conn.execute(
121
125
  "SELECT destination_deployment_id FROM FederatedEnrollments WHERE local_account_id=?",
@@ -769,6 +769,16 @@ def migrate_gate_f_usergroups_schema(conn: sqlite3.Connection) -> None:
769
769
  group_columns = {str(item[1]) for item in conn.execute("PRAGMA table_info(UserGroups)")}
770
770
  if "lan_only" not in group_columns:
771
771
  conn.execute("ALTER TABLE UserGroups ADD COLUMN lan_only INTEGER NOT NULL DEFAULT 0")
772
+ # Cluster codes enrol into a cluster, not a group (group_id is -1 then);
773
+ # ClusterAccounts remembers who joined so attached groups can follow.
774
+ if "cluster_name" not in columns:
775
+ conn.execute("ALTER TABLE EnrollmentCodes ADD COLUMN cluster_name TEXT")
776
+ conn.executescript(
777
+ "CREATE TABLE IF NOT EXISTS ClusterAccounts ("
778
+ "cluster_name TEXT NOT NULL, local_account_id INTEGER NOT NULL, "
779
+ "kind TEXT NOT NULL CHECK(kind IN ('local','proxy')), joined_at INTEGER NOT NULL, "
780
+ "PRIMARY KEY(cluster_name,local_account_id));"
781
+ )
772
782
  conn.executescript(_GATE_F_DDL)
773
783
  conn.execute(
774
784
  "INSERT OR IGNORE INTO SchemaMigrations "
@@ -987,7 +987,11 @@ def _cluster_tick() -> None:
987
987
  print(text)
988
988
 
989
989
  try:
990
- cluster.tick(notify)
990
+ from .reservation import reservation_handler
991
+ except Exception: # noqa: BLE001
992
+ reservation_handler = None
993
+ try:
994
+ cluster.tick(notify, home=reservation_handler)
991
995
  except Exception as exc: # noqa: BLE001
992
996
  logging.getLogger("remoteRF_server.global").warning("cluster tick failed: %s", exc)
993
997
 
@@ -321,6 +321,17 @@ class ReservationHandler:
321
321
  "UC": map_arg("Federated enrollment confirmed."),
322
322
  "federation": map_arg(canonical_json(result)),
323
323
  }
324
+ if code_name.startswith("cluster."):
325
+ from .federation import cluster as cluster_module
326
+
327
+ joined = cluster_module.redeem_code(acc_id, code_name)
328
+ if joined is None:
329
+ return {"UE": map_arg(f"Failed to enroll {username}. Invalid or expired code.")}
330
+ try:
331
+ cluster_module.enroll_students(self, only_account=acc_id)
332
+ except Exception: # noqa: BLE001 - the tick retries
333
+ pass
334
+ return {"UC": map_arg(f"Enrolled {username} in cluster {joined}.")}
324
335
  if RELAYED.get() and user_group_handler.code_is_lan_only(code_name=code_name):
325
336
  return {"UE": map_arg(lan_only_hint("This enrollment code"))}
326
337
  if user_group_handler.enroll_user_with_code(uid=acc_id, code_name=code_name):
@@ -37,7 +37,7 @@ def _invites(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
37
37
  code = str(row.get("code_name", ""))
38
38
  # A federation-bound code is redeemed through its signed invitation,
39
39
  # never typed as a Global invite.
40
- federated = row.get("federation_contract_id") is not None
40
+ federated = row.get("federation_contract_id") is not None or row.get("cluster_name")
41
41
  row["invite"] = code if federated else invite(
42
42
  code, lan_only=lan_only.get(int(row.get("group_id", -1)), False)
43
43
  )
@@ -562,17 +562,18 @@ def list_enrollment_codes_local(session) -> None:
562
562
  row.get("federation_contract_id"),
563
563
  row.get("federation_contract_version"),
564
564
  row.get("allowed_issuer_deployment_id"),
565
+ row.get("cluster_name"),
565
566
  )
566
567
  buckets.setdefault(key, []).append(row)
567
568
 
568
569
  width = content_width()
569
570
  lines: List[str] = []
570
- for (gid, uses, duration, contract, version, issuer), items in sorted(
571
+ for (gid, uses, duration, contract, version, issuer, cluster_name), items in sorted(
571
572
  buckets.items(), key=lambda kv: (kv[0][0], kv[0][3] or "", -kv[0][1], kv[0][2])
572
573
  ):
573
574
  group = groups.get(gid, {})
574
575
  head = " · ".join([
575
- f"group {gid} {group.get('group_name', '?')}".rstrip(),
576
+ f"cluster {cluster_name}" if cluster_name else f"group {gid} {group.get('group_name', '?')}".rstrip(),
576
577
  f"{_plural(uses, 'use')} left",
577
578
  _fmt_duration(duration),
578
579
  _plural(len(items), "code"),
@@ -610,7 +610,7 @@ class UserGroupHandler:
610
610
  self.cursor.execute(
611
611
  "SELECT code_id, code_name, group_id, uses, duration_sec, "
612
612
  "federation_contract_id, federation_contract_version, "
613
- "allowed_issuer_deployment_id, federation_expires_at "
613
+ "allowed_issuer_deployment_id, federation_expires_at, cluster_name "
614
614
  "FROM EnrollmentCodes"
615
615
  )
616
616
  rows = self.cursor.fetchall() or []
@@ -631,6 +631,7 @@ class UserGroupHandler:
631
631
  "federation_contract_version": r[6],
632
632
  "allowed_issuer_deployment_id": r[7],
633
633
  "federation_expires_at": r[8],
634
+ "cluster_name": r[9],
634
635
  }
635
636
  )
636
637
  except Exception:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoteRF-server
3
- Version: 1.1.0.dev30
3
+ Version: 1.1.0.dev32
4
4
  Summary: RemoteRF server-side control package
5
5
  License: GPL-3.0-or-later
6
6
  Classifier: Programming Language :: Python :: 3
@@ -9,7 +9,7 @@ Classifier: Operating System :: OS Independent
9
9
  Requires-Python: >=3.10
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE
12
- Requires-Dist: remoterf-federation-core==0.14.0
12
+ Requires-Dist: remoterf-federation-core==0.15.0
13
13
  Requires-Dist: grpcio<2.0.0,>=1.81.1
14
14
  Requires-Dist: protobuf<7.0.0,>=6.33.5
15
15
  Requires-Dist: numpy
@@ -52,35 +52,35 @@ remoteRF_server/host/host_tunnel_server.py,sha256=sz06xOtFbaDZSy1UQfw6faKhJmuOy5
52
52
  remoteRF_server/server/__init__.py,sha256=-1h_xQjiE66oiWRO-vnLGJ4smpgUNhFuV-ZreW9_tD4,667
53
53
  remoteRF_server/server/acc_perms.py,sha256=2loCvstVu8uvsb2uCv2Sk7iNQ5GyO_sBIkPQFzqOaRI,13334
54
54
  remoteRF_server/server/cert_provider.py,sha256=CRDrZL4DSf-eL6wzPJzwucrzPLOr2PvNRq5V--oZtr0,6272
55
- remoteRF_server/server/commands.py,sha256=dH19PK86cUiZhTnN9jC1RdM1t_t6dPuL6x-bdACxkSY,17008
55
+ remoteRF_server/server/commands.py,sha256=9ExGTi3nG7wqgSAyOEIFMDz0iBFLwC8QJdXrKNZNB5A,17277
56
56
  remoteRF_server/server/console.py,sha256=J91LfwPVmBgielOxfE740NOYduekggyZkqi-47y_A4w,4472
57
57
  remoteRF_server/server/control.py,sha256=Uq1Fk0zIoHHX_EXfz3OuNle8urvO_UhAiB5Lk5cs2e4,5177
58
58
  remoteRF_server/server/device_manager.py,sha256=nRTvk_jeZFU3QnlM6qf3DIckU2V2e_gyDeD5gW14KQI,38802
59
59
  remoteRF_server/server/direct_path.py,sha256=CQs0uPTANJq6Bn-TzsV0APmD3hYKH8xYvczXZZk1OXk,15229
60
60
  remoteRF_server/server/dynamic_v2.py,sha256=AMhQEFhNAvCNcF-PdfCCQQLmeoaR7s1Os8et1KFGn_8,70814
61
- remoteRF_server/server/global_onboarding.py,sha256=UwTkVZYTGspHaKcHtAV7TA9DnxH5OB82d5LDc4EDIr4,39730
61
+ remoteRF_server/server/global_onboarding.py,sha256=boLAKXCKvcsIY3TDiFwduMaHdDMB9Cr104MyWJYZ-BA,39891
62
62
  remoteRF_server/server/grpc_server.py,sha256=91FpJubzN_Wfuk6fW_owIEzNCukd333sAGW68FUU8pk,46787
63
63
  remoteRF_server/server/host_dynamic_v2.py,sha256=1rJQwP4knSd0EXkByrSn_ryjLx81f6sp6SNYr_aEass,8105
64
- remoteRF_server/server/reservation.py,sha256=Cv8A7U2N6trUJayW8TJw1KDfFZhZED2UMF3iDLcpaSs,66731
64
+ remoteRF_server/server/reservation.py,sha256=83rEWlfKauxqDOgv2IdDa8JBZWsLf_l0v1sEv9xl1RM,67411
65
65
  remoteRF_server/server/rpc_manager.py,sha256=BDvsTSVRxwNF2ujuMbIb5TYNAUI6AH4xXBuXrb8lltM,17223
66
- remoteRF_server/server/user_group_cli.py,sha256=FGdzhzRfIukmwpzEUKRiT15d7l2kcw_xgkJp_ZXx_TY,24947
67
- remoteRF_server/server/user_group_handler.py,sha256=EpL3zbz_e8j_MwF88ewxE4J0V61TrUuc-XkVIKVNSuo,41716
66
+ remoteRF_server/server/user_group_cli.py,sha256=6zNquuedH_q6pwq4trzRkEfE0wzAe7-RkFlGMWR-ZnM,25072
67
+ remoteRF_server/server/user_group_handler.py,sha256=1ax73W_P3H6GDxQS13evwA7G7skIElFVLuS0MFqsFyU,41780
68
68
  remoteRF_server/server/v2_errors.py,sha256=IS3hIhf5tDe7jaOrDAVQPjGBpqAjN7442BlJYgfJo7g,3331
69
69
  remoteRF_server/server/deployment/__init__.py,sha256=YV86uafd8XiGD7sLaqNfO5xxmt5te2R6DUhD-w1lZuY,77
70
70
  remoteRF_server/server/deployment/capabilities.py,sha256=20RxmGG03VoQQZnV3jGhc-3i9B5hOl5fCopnsErFjPk,872
71
- remoteRF_server/server/federation/__init__.py,sha256=MZLqqh0Lzzh1t59VXrkS_bdpMO5znk1UVBjRGPvnUCg,735
71
+ remoteRF_server/server/federation/__init__.py,sha256=auqdGiGnEGIKQiXX2jFVt8iWmfyZ-V3w4dUmtWDKAZE,735
72
72
  remoteRF_server/server/federation/admin.py,sha256=t3F17FIytekzclPICmaFJEA4-F5OuWcDx3rgmFga17A,9187
73
- remoteRF_server/server/federation/cli.py,sha256=Ax0tQw7_giLUP5lX1Y6x3sS1cbUbU2PxYur5k_5f3Po,7028
74
- remoteRF_server/server/federation/cluster.py,sha256=2C8ys2BwUSxCLlYGvNjWU840q8e6H2ShGfl380yzA0I,19970
73
+ remoteRF_server/server/federation/cli.py,sha256=hQt6tM2y42EzKE2WDzBat3pxEqLjreunogjI_kauxYk,8849
74
+ remoteRF_server/server/federation/cluster.py,sha256=dsraUf7wKG4GdqcyhdO195v1ypwSRpFcJdyY5uatHCM,32932
75
75
  remoteRF_server/server/federation/contracts.py,sha256=NYh7lb_9etJHFfzXwevjC4HfSBUypLxg5xGeBgydLF4,29305
76
76
  remoteRF_server/server/federation/destination.py,sha256=XawmGCiVbL_MfNNn8xwV9_bYxUDW1L7efCpL11K2JAc,35909
77
77
  remoteRF_server/server/federation/devices.py,sha256=n5MvnD0UZyl4sFpiekG9ProanWXTYizYq6YchCflLgY,18061
78
- remoteRF_server/server/federation/enrollment.py,sha256=1RFwc-XJ9oYGg-WIIwPzvMpdELR_410p8hTD1FNWktU,20693
78
+ remoteRF_server/server/federation/enrollment.py,sha256=HsyyPpMzuQ289sxHj4XLTbiCVpp3er1YXBXQ6OZyEfw,23168
79
79
  remoteRF_server/server/federation/home.py,sha256=-_0__wJN50Y1-Dd396_iUZjGESBkoBNGISXb8gbLrjM,23982
80
- remoteRF_server/server/federation/home_devices.py,sha256=HTOC1FCYLCOQ97jtQw9q0h_QaYtLr2JnLc0Zu_t25Cw,18595
80
+ remoteRF_server/server/federation/home_devices.py,sha256=lbsw201HBOYPP5vbN172vdm2O_ouCdZBo1cRYCVScmg,18754
81
81
  remoteRF_server/server/federation/identity.py,sha256=UtQJKm2mChWZto0ZCg385k_ObrwOUtxY7XFyU_s5Q10,7436
82
82
  remoteRF_server/server/federation/legacy_state.py,sha256=SpeTZHFqymNYGWG7A3B-78dIRIVLYltBbE2zGDWzt-Q,19838
83
- remoteRF_server/server/federation/migrations.py,sha256=Sh6GdClQod0p5LMWyQLwAgTHkZouLSxgPKmGSvCi2Do,50018
83
+ remoteRF_server/server/federation/migrations.py,sha256=wClgRrwelcqHebSLJEDQR98WWUgzaEMrio8DLVOfcdA,50594
84
84
  remoteRF_server/server/federation/permissions.py,sha256=ou4uFsMAGHnDeBW6zbpGeUTwp0thlYXT5nIAgd3B1rg,15861
85
85
  remoteRF_server/server/federation/principals.py,sha256=CsRYa8POmtMaxKzAGwY14sxBTNS5uKjM1nUE19mjpoQ,10410
86
86
  remoteRF_server/server/federation/runtime.py,sha256=5CoDlu0jSQrFdwRoGL2Z4_CWxnA46B3j6-eNQo8N2FI,7624
@@ -90,9 +90,9 @@ remoteRF_server/tools/gen_certs.py,sha256=Vbyu8JjRu__UYPVmyCRXt5SOw0Za1HV7g1md3G
90
90
  remoteRF_server/tools/gist_status.py,sha256=SFv2iQgQrIKT9gxKrU-_Dr7S0p0rE4wQM6J3OC6FcoM,4649
91
91
  remoteRF_server/tools/gist_status_testing.py,sha256=IYw7UkPjAUpKDTtIlnYGb5g6SJkwz4TRGUfJJ6f4LhQ,2690
92
92
  remoteRF_server/tools/introspect_uhd.py,sha256=vUpPb0rM9tsHPxWadS3EfMDE-gMd-TvNV0TjrZOQqBY,4531
93
- remoterf_server-1.1.0.dev30.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
94
- remoterf_server-1.1.0.dev30.dist-info/METADATA,sha256=HAEJaoA51VNwScm3KxXvq650DbitxyOWXy1ChAXpD3s,42069
95
- remoterf_server-1.1.0.dev30.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
96
- remoterf_server-1.1.0.dev30.dist-info/entry_points.txt,sha256=_bbKk65PfYi-n6zUjqOiOwcalf_Ws1TCGATIe1TukxE,136
97
- remoterf_server-1.1.0.dev30.dist-info/top_level.txt,sha256=NuYWe8dSx3qdbxshWBwOgx68EHmJ9I8Zvxam4ARsQ_I,30
98
- remoterf_server-1.1.0.dev30.dist-info/RECORD,,
93
+ remoterf_server-1.1.0.dev32.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
94
+ remoterf_server-1.1.0.dev32.dist-info/METADATA,sha256=E5JcHfXHeFhGNYFGR6kxbeehuNFsCStN4wJr9E6RrCk,42069
95
+ remoterf_server-1.1.0.dev32.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
96
+ remoterf_server-1.1.0.dev32.dist-info/entry_points.txt,sha256=_bbKk65PfYi-n6zUjqOiOwcalf_Ws1TCGATIe1TukxE,136
97
+ remoterf_server-1.1.0.dev32.dist-info/top_level.txt,sha256=NuYWe8dSx3qdbxshWBwOgx68EHmJ9I8Zvxam4ARsQ_I,30
98
+ remoterf_server-1.1.0.dev32.dist-info/RECORD,,