remoteRF-server 1.1.0.dev34__py3-none-any.whl → 1.1.0.dev36__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,8 +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
+ Command(("cluster groups",), "Choose which of your groups a cluster shares (interactive)", _federation("groups"), "Clusters"),
157
+ Command(("cluster codes",), "Codes for your users to join a cluster: cluster codes [count]", _federation("codes"), "Clusters"),
158
158
  Command(("cluster accept",), "", _federation("accept"), "Clusters", hidden=True),
159
159
  Command(("global cluster",), "", _federation("cluster"), "Global", hidden=True),
160
160
  # ---- gist ----
@@ -17,6 +17,7 @@
17
17
 
18
18
  from __future__ import annotations
19
19
 
20
+ import re
20
21
  import sys
21
22
  from pathlib import Path
22
23
 
@@ -37,6 +38,10 @@ def _lab(party: dict) -> str:
37
38
  return f"{party['display_name']} ({short}…)" if party.get("display_name") else party["deployment_id"]
38
39
 
39
40
 
41
+ def _n(count: int, noun: str) -> str:
42
+ return f"{count} {noun}{'' if count == 1 else 's'}"
43
+
44
+
40
45
  def _day(epoch) -> str:
41
46
  import datetime
42
47
 
@@ -63,6 +68,7 @@ def cmd_clusters() -> int:
63
68
  for index, item in enumerate(items):
64
69
  if index:
65
70
  rows.append("")
71
+ owner_row = None
66
72
  if item["role"] == "owner":
67
73
  invites = item["open_invites"]
68
74
  rows.append(f"{item['name']} (yours)" + (f" {invites} open invite{'s' if invites != 1 else ''}" if invites else ""))
@@ -71,20 +77,25 @@ def cmd_clusters() -> int:
71
77
  if not item["members"] and not invites:
72
78
  rows.append(f" nobody yet: `cluster invite {item['name']}`")
73
79
  else:
74
- owner = {"display_name": item.get("peer_name"), "deployment_id": item["peer"]}
80
+ owner_row = owner = {"display_name": item.get("peer_name"), "deployment_id": item["peer"]}
75
81
  rows.append(f"{item['name']} {item['state']}")
76
82
  labs = [(owner, "admin", "", None), (me, "member", "", None)] + [
77
83
  (p, "member", p["state"] or "joining", None) for p in item.get("peers", [])]
78
84
  for lab, role, lab_state, since in labs:
79
- you = " (you)" if lab["deployment_id"] == me["deployment_id"] else ""
80
- line = f" {_lab(lab)} {role}{you}"
85
+ you = lab["deployment_id"] == me["deployment_id"]
86
+ line = f" {_lab(lab)} {role}" + (" (you)" if you else "")
81
87
  if lab_state and lab_state != "active":
82
88
  line += f" {lab_state}"
83
89
  if since:
84
90
  line += f" since {_day(since)}"
85
91
  rows.append(line)
86
- if item["groups"]:
87
- rows.append(" sharing: " + ", ".join(group["group_name"] for group in item["groups"]))
92
+ if you:
93
+ rows.append(" sharing: " + (", ".join(
94
+ f"{g['group_name']} ({_n(len(g['devices']), 'device')})" for g in item["groups"]) or "nothing"))
95
+ else:
96
+ sharing = lab.get("sharing") if lab is not owner_row else item.get("peer_sharing")
97
+ if sharing:
98
+ rows.append(f" sharing {_n(sharing['groups'], 'group')}, {_n(sharing['devices'], 'device')}")
88
99
  rows.append(f" {item['students']} of your users enrolled, {item['visitors']} visiting")
89
100
  print()
90
101
  print_panel("Clusters", rows, literal=True)
@@ -106,7 +117,7 @@ def _ask_for(verb: str) -> str:
106
117
  what = {"create": "Name for the new cluster (short slug, e.g. ucla-lab)",
107
118
  "invite": "Which cluster to invite a lab into",
108
119
  "accept": "Which cluster's pending join to accept",
109
- "groups": "Which cluster's shared groups to show",
120
+ "groups": "Which cluster to share groups with",
110
121
  "codes": "Which cluster to make student codes for",
111
122
  "remove": "Which cluster to leave (revokes the contract)"}[verb]
112
123
  if verb != "create" and names:
@@ -115,6 +126,40 @@ def _ask_for(verb: str) -> str:
115
126
  return _input(f"{what}{f' [{default}]' if default else ''}: ") or default
116
127
 
117
128
 
129
+ def _share_groups(name: str) -> int:
130
+ """Pick, by number, which of this server's groups the cluster shares.
131
+ Toggling: a shared group chosen again is unshared."""
132
+ from ...common.utils.ansi_codes import Sty
133
+ from ..user_group_handler import user_group_handler
134
+ from . import cluster
135
+
136
+ all_groups = user_group_handler.get_all_user_groups(include_users=False) or []
137
+ if not all_groups:
138
+ _print(" No user groups yet: make one with `groups create`, then share it here.", Sty.WARNING)
139
+ return 1
140
+ shared = {g["group_id"] for g in cluster.groups(name)}
141
+ print(f" Groups on this server (shared with {name} are marked):")
142
+ for index, group in enumerate(all_groups, 1):
143
+ mark = "*" if int(group["group_id"]) in shared else " "
144
+ devices = group.get("devices_whitelist") or []
145
+ print(f" {mark} {index:>2} {group['group_name']} ({len(devices)} device{'s' if len(devices) != 1 else ''})")
146
+ answer = _input("Toggle by number, e.g. 2 or 1,3 (Enter to leave as is): ")
147
+ picks = [part for part in re.split(r"[\s,]+", answer) if part]
148
+ if not picks:
149
+ return 0
150
+ for pick in picks:
151
+ if not pick.isdigit() or not 1 <= int(pick) <= len(all_groups):
152
+ _print(f" {pick} is not a number in the list.", Sty.WARNING)
153
+ continue
154
+ group = all_groups[int(pick) - 1]
155
+ if int(group["group_id"]) in shared:
156
+ _print(f" {cluster.detach_group(name, str(group['group_id']))} is no longer shared with {name}.", Sty.GRAY)
157
+ else:
158
+ _print(f" {cluster.attach_group(name, str(group['group_id']))} is now shared with {name}; "
159
+ "everyone in the cluster gets it.", Sty.GRAY)
160
+ return 0
161
+
162
+
118
163
  def _input(prompt: str) -> str:
119
164
  # The console's own prompt session: a bare input() inside the running
120
165
  # server fights prompt_toolkit for the terminal and kills the process.
@@ -128,7 +173,7 @@ def _input(prompt: str) -> str:
128
173
 
129
174
 
130
175
  _USAGE = ("Usage: cluster list | cluster create|invite|remove <name> | cluster join <code> | "
131
- "cluster groups <name> [add|remove <group>] | cluster codes <name> [count]")
176
+ "cluster groups [<name>] | cluster codes [<name>] [count]")
132
177
 
133
178
 
134
179
  def cmd_cluster(argv: list[str]) -> int:
@@ -140,6 +185,10 @@ def cmd_cluster(argv: list[str]) -> int:
140
185
  words = [a for a in argv[1:] if not a.startswith("--")]
141
186
  if verb == "list" and not words:
142
187
  return cmd_clusters()
188
+ if verb == "codes" and words and words[0].isdigit() and words[0] not in cluster.load():
189
+ words = [_ask_for(verb), words[0]] # `cluster codes 3`: the count came first
190
+ if not words[0]:
191
+ return 2
143
192
  if not words and verb in ("create", "invite", "accept", "join", "remove", "groups", "codes"):
144
193
  # Leaving the argument out asks for it (docs/deployment-groups.md §8).
145
194
  answer = _ask_for(verb)
@@ -167,10 +216,8 @@ def cmd_cluster(argv: list[str]) -> int:
167
216
  elif verb == "remove" and arg:
168
217
  _print(f" {arg}: {cluster.remove(arg)}.", Sty.GRAY)
169
218
  elif verb == "groups" and len(words) == 1:
170
- shared = cluster.groups(arg)
171
- _print(f" {arg} shares: " + (", ".join(g["group_name"] for g in shared) or "nothing yet"), Sty.GRAY)
172
- _print(f" Attach one with `cluster groups {arg} add <group>`; students of every lab in the "
173
- "cluster get it. Group names stay on this server.", Sty.GRAY)
219
+ cluster._find(cluster.load(), arg)
220
+ return _share_groups(arg)
174
221
  elif verb == "groups" and len(words) == 3 and words[1] in ("add", "remove"):
175
222
  if words[1] == "add":
176
223
  _print(f" {cluster.attach_group(arg, words[2])} is now shared with {arg}.", Sty.GRAY)
@@ -404,7 +404,8 @@ def list_clusters() -> list[dict]:
404
404
  except ContractError:
405
405
  pass
406
406
  members.append({"deployment_id": peer, "display_name": member.get("display_name"),
407
- "state": state, "joined_at": member.get("joined_at")})
407
+ "state": state, "joined_at": member.get("joined_at"),
408
+ "sharing": member.get("sharing")})
408
409
  item.update(members=members, open_invites=len(cluster["invites"]))
409
410
  else:
410
411
  state = cluster.get("state")
@@ -414,9 +415,11 @@ def list_clusters() -> list[dict]:
414
415
  state = store.show(cluster["contract_id"], 1)["state"]
415
416
  except ContractError:
416
417
  pass
417
- peers = [{"deployment_id": peer, "display_name": party.get("display_name"), "state": party.get("state")}
418
+ peers = [{"deployment_id": peer, "display_name": party.get("display_name"), "state": party.get("state"),
419
+ "sharing": party.get("sharing")}
418
420
  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)
421
+ item.update(peer=cluster["peer"], peer_name=cluster.get("peer_name"), state=state, peers=peers,
422
+ peer_sharing=cluster.get("sharing"))
420
423
  item.update(groups=groups(name), **_account_counts(name))
421
424
  out.append(item)
422
425
  return out
@@ -445,12 +448,23 @@ def groups(name: str) -> list[dict]:
445
448
  out = []
446
449
  with _usergroups() as conn:
447
450
  for group_id in cluster.get("groups", []):
448
- row = conn.execute("SELECT group_name FROM UserGroups WHERE group_id=?", (group_id,)).fetchone()
451
+ row = conn.execute("SELECT group_name,devices_whitelist FROM UserGroups WHERE group_id=?",
452
+ (group_id,)).fetchone()
449
453
  if row is not None:
450
- out.append({"group_id": group_id, "group_name": row[0]})
454
+ try:
455
+ devices = {int(d) for d in json.loads(row[1] or "[]")}
456
+ except (ValueError, TypeError):
457
+ devices = set()
458
+ out.append({"group_id": group_id, "group_name": row[0], "devices": sorted(devices)})
451
459
  return out
452
460
 
453
461
 
462
+ def _sharing(name: str) -> dict:
463
+ """Counts only -- all a partner ever learns about what we share."""
464
+ shared = groups(name)
465
+ return {"groups": len(shared), "devices": len({d for g in shared for d in g["devices"]})}
466
+
467
+
454
468
  def _account_counts(name: str) -> dict:
455
469
  with _usergroups() as conn:
456
470
  rows = dict(conn.execute(
@@ -687,6 +701,32 @@ def _publish_roster(name: str, cluster: dict) -> None:
687
701
  cluster["roster"] = active
688
702
 
689
703
 
704
+ def _publish_status(name: str, cluster: dict) -> None:
705
+ """Any lab: post our counts for this cluster when they change."""
706
+ sharing = _sharing(name)
707
+ if sharing == cluster.get("status"):
708
+ return
709
+ key, _, me = g._signing_material()
710
+ owner = me if cluster["role"] == "owner" else cluster["peer"]
711
+ status = {"cluster_name": name, "owner": owner, "deployment_id": me, "issued_at": int(time.time()), **sharing}
712
+ signature = base64.b64encode(key.sign(canonical_json(status).encode())).decode("ascii")
713
+ g.call("POST", "/v2/broker/clusters/roster", {"protocol_version": "2", "status": status, "signature": signature})
714
+ cluster["status"] = sharing
715
+
716
+
717
+ def _take_statuses(name: str, cluster: dict, statuses: list[dict]) -> None:
718
+ """Attach each partner's verified counts to its row."""
719
+ owner = g._signing_material()[2] if cluster["role"] == "owner" else cluster["peer"]
720
+ parties = dict(_parties(cluster))
721
+ for item in statuses:
722
+ status = item.get("status") or {}
723
+ peer = status.get("deployment_id")
724
+ if status.get("owner") != owner or status.get("cluster_name") != name or peer not in parties:
725
+ continue
726
+ if _roster_signed_by(peer, status, item.get("signature", "")):
727
+ parties[peer]["sharing"] = {"groups": status["groups"], "devices": status["devices"]}
728
+
729
+
690
730
  def _roster_signed_by(owner: str, roster: dict, signature: str) -> bool:
691
731
  from cryptography.exceptions import InvalidSignature
692
732
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
@@ -774,7 +814,7 @@ def tick(notify=None, home=None) -> None:
774
814
  return
775
815
  directory = None
776
816
  broker_invites = None
777
- rosters = None
817
+ board = None # rosters and status notes, one GET for all clusters
778
818
  for name, cluster in clusters.items():
779
819
  if cluster["role"] == "owner":
780
820
  if cluster["invites"]:
@@ -797,9 +837,15 @@ def tick(notify=None, home=None) -> None:
797
837
  if directory is None:
798
838
  directory = g.call("GET", "/v2/broker/contracts")["contracts"]
799
839
  _advance(cluster, cluster["peer"], directory, label=name, notify=notify, is_member=True)
800
- if rosters is None:
801
- rosters = g.call("GET", "/v2/broker/clusters/roster")["rosters"]
802
- _mesh(name, cluster, rosters, directory, notify)
840
+ if board is None:
841
+ board = g.call("GET", "/v2/broker/clusters/roster")
842
+ if cluster["role"] != "owner":
843
+ _mesh(name, cluster, board["rosters"], directory, notify)
844
+ try:
845
+ _publish_status(name, cluster)
846
+ except g.GlobalError as exc:
847
+ notify(f"[cluster] {name}: could not publish what we share: {exc}")
848
+ _take_statuses(name, cluster, board.get("statuses", []))
803
849
  save(clusters)
804
850
  if home is not None:
805
851
  enroll_students(home, notify)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoteRF-server
3
- Version: 1.1.0.dev34
3
+ Version: 1.1.0.dev36
4
4
  Summary: RemoteRF server-side control package
5
5
  License: GPL-3.0-or-later
6
6
  Classifier: Programming Language :: Python :: 3
@@ -52,7 +52,7 @@ 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=9ExGTi3nG7wqgSAyOEIFMDz0iBFLwC8QJdXrKNZNB5A,17277
55
+ remoteRF_server/server/commands.py,sha256=xG_RAYm0nHDqN74b-QPHizw2OodfrYAvdYlVNNi7tGE,17279
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
@@ -70,8 +70,8 @@ remoteRF_server/server/deployment/__init__.py,sha256=YV86uafd8XiGD7sLaqNfO5xxmt5
70
70
  remoteRF_server/server/deployment/capabilities.py,sha256=20RxmGG03VoQQZnV3jGhc-3i9B5hOl5fCopnsErFjPk,872
71
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=nFlUGmhpndw0RafjeRQjz7cYieKtKy7U60_n738g-s0,9021
74
- remoteRF_server/server/federation/cluster.py,sha256=yKGFRAI-RHB_xZivJkxaK_Fniz2XGRUfArJXM_DH2WQ,33207
73
+ remoteRF_server/server/federation/cli.py,sha256=iZpS-31516atRauI6zXdCTw81hV7QNEdyDqC8ijXoEA,11175
74
+ remoteRF_server/server/federation/cluster.py,sha256=m_SW942R7d0a8X16kMfKn3fdUbyJsAmonI67Wl8OIOs,35529
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
@@ -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.dev34.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
94
- remoterf_server-1.1.0.dev34.dist-info/METADATA,sha256=HF3Nrx8Y6Id4gZt4THauRMrCve0ZazMtLrMtVsYpyWU,42069
95
- remoterf_server-1.1.0.dev34.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
96
- remoterf_server-1.1.0.dev34.dist-info/entry_points.txt,sha256=_bbKk65PfYi-n6zUjqOiOwcalf_Ws1TCGATIe1TukxE,136
97
- remoterf_server-1.1.0.dev34.dist-info/top_level.txt,sha256=NuYWe8dSx3qdbxshWBwOgx68EHmJ9I8Zvxam4ARsQ_I,30
98
- remoterf_server-1.1.0.dev34.dist-info/RECORD,,
93
+ remoterf_server-1.1.0.dev36.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
94
+ remoterf_server-1.1.0.dev36.dist-info/METADATA,sha256=PA_v4CEjH9HfN0EABXND3J1B53HdPVIhfxZhxwQ_vvc,42069
95
+ remoterf_server-1.1.0.dev36.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
96
+ remoterf_server-1.1.0.dev36.dist-info/entry_points.txt,sha256=_bbKk65PfYi-n6zUjqOiOwcalf_Ws1TCGATIe1TukxE,136
97
+ remoterf_server-1.1.0.dev36.dist-info/top_level.txt,sha256=NuYWe8dSx3qdbxshWBwOgx68EHmJ9I8Zvxam4ARsQ_I,30
98
+ remoterf_server-1.1.0.dev36.dist-info/RECORD,,