remoteRF-server 1.1.2__tar.gz → 1.1.3__tar.gz

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.
Files changed (165) hide show
  1. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/PKG-INFO +1 -1
  2. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/pyproject.toml +1 -1
  3. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/commands.py +15 -2
  4. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/cli.py +73 -9
  5. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/cluster.py +261 -10
  6. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/devices.py +3 -0
  7. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/global_onboarding.py +4 -2
  8. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/reservation.py +14 -0
  9. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server.egg-info/PKG-INFO +1 -1
  10. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_cluster.py +254 -1
  11. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_h_devices.py +29 -0
  12. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/LICENSE +0 -0
  13. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/README.md +0 -0
  14. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/setup.cfg +0 -0
  15. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/__init__.py +0 -0
  16. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/__init__.py +0 -0
  17. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/__init__.py +0 -0
  18. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/deployment_capabilities.proto +0 -0
  19. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/deployment_capabilities_pb2.py +0 -0
  20. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  21. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/federation.proto +0 -0
  22. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/federation_pb2.py +0 -0
  23. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/federation_pb2_grpc.py +0 -0
  24. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/grpc.proto +0 -0
  25. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/grpc_host.proto +0 -0
  26. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/grpc_host_pb2.py +0 -0
  27. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/grpc_host_pb2_grpc.py +0 -0
  28. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/grpc_pb2.py +0 -0
  29. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/grpc_pb2_grpc.py +0 -0
  30. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/v2_codec.py +0 -0
  31. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/idl/__init__.py +0 -0
  32. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/idl/device_schema.py +0 -0
  33. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/idl/pluto_mimo_schema.py +0 -0
  34. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/idl/pluto_schema.py +0 -0
  35. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/idl/schema.py +0 -0
  36. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/idl/v2.py +0 -0
  37. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/__init__.py +0 -0
  38. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/ansi_codes.py +0 -0
  39. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/api_token.py +0 -0
  40. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/db_connection.py +0 -0
  41. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/db_location.py +0 -0
  42. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/list_string.py +0 -0
  43. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/panel.py +0 -0
  44. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/process_arg.py +0 -0
  45. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/__init__.py +0 -0
  46. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/adalm_pluto/__init__.py +0 -0
  47. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/adalm_pluto/pluto_mimo_schema.py +0 -0
  48. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/adalm_pluto/pluto_remote_server.py +0 -0
  49. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/adalm_pluto/pluto_schema.py +0 -0
  50. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/adalm_pluto/poe.py +0 -0
  51. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/hackrf/__init__.py +0 -0
  52. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/hackrf/hackrf_schema.py +0 -0
  53. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/rtl_sdr/__init__.py +0 -0
  54. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/rtl_sdr/rtl_sdr_schema.py +0 -0
  55. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/ti_mmwave/__init__.py +0 -0
  56. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/ti_mmwave/ti_mmwave_schema.py +0 -0
  57. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp/__init__.py +0 -0
  58. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp/policy.py +0 -0
  59. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp/profiles.py +0 -0
  60. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp/uhd_manifest.py +0 -0
  61. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp/usrp_schema.py +0 -0
  62. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp_phaser/__init__.py +0 -0
  63. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp_phaser/usrp_phaser_schema.py +0 -0
  64. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/host/__init__.py +0 -0
  65. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/host/host_auth_token.py +0 -0
  66. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/host/host_directory_store.py +0 -0
  67. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/host/host_tunnel_server.py +0 -0
  68. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/__init__.py +0 -0
  69. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/acc_perms.py +0 -0
  70. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/cert_provider.py +0 -0
  71. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/console.py +0 -0
  72. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/control.py +0 -0
  73. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/deployment/__init__.py +0 -0
  74. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/deployment/capabilities.py +0 -0
  75. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/device_manager.py +0 -0
  76. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/direct_path.py +0 -0
  77. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/dynamic_v2.py +0 -0
  78. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/dynamic_v2_bridge.py +0 -0
  79. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/__init__.py +0 -0
  80. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/admin.py +0 -0
  81. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/contracts.py +0 -0
  82. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/destination.py +0 -0
  83. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/enrollment.py +0 -0
  84. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/home.py +0 -0
  85. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/home_devices.py +0 -0
  86. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/identity.py +0 -0
  87. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/legacy_state.py +0 -0
  88. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/migrations.py +0 -0
  89. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/peers.py +0 -0
  90. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/permissions.py +0 -0
  91. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/principals.py +0 -0
  92. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/runtime.py +0 -0
  93. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/trust.py +0 -0
  94. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/grpc_server.py +0 -0
  95. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/host_dynamic_v2.py +0 -0
  96. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/rpc_manager.py +0 -0
  97. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/user_group_cli.py +0 -0
  98. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/user_group_handler.py +0 -0
  99. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/v2_errors.py +0 -0
  100. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/serverrf_cli.py +0 -0
  101. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/tools/__init__.py +0 -0
  102. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/tools/gen_certs.py +0 -0
  103. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/tools/gist_status.py +0 -0
  104. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/tools/gist_status_testing.py +0 -0
  105. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/tools/introspect_uhd.py +0 -0
  106. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server.egg-info/SOURCES.txt +0 -0
  107. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server.egg-info/dependency_links.txt +0 -0
  108. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server.egg-info/entry_points.txt +0 -0
  109. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server.egg-info/requires.txt +0 -0
  110. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server.egg-info/top_level.txt +0 -0
  111. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_account_hardening.py +0 -0
  112. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_bind_host.py +0 -0
  113. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_cert_provider.py +0 -0
  114. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_client_sessions.py +0 -0
  115. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_commands.py +0 -0
  116. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_console.py +0 -0
  117. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_default_rtl_sdr_schema.py +0 -0
  118. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_default_usrp_schema.py +0 -0
  119. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_deployment_capabilities.py +0 -0
  120. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_direct_fixed_port.py +0 -0
  121. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_direct_path_server.py +0 -0
  122. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_dynamic_v2.py +0 -0
  123. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_federated_direct_path.py +0 -0
  124. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_federated_dynamic_v2.py +0 -0
  125. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_federation_edges.py +0 -0
  126. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_first_run.py +0 -0
  127. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_d_contracts.py +0 -0
  128. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_e_federation.py +0 -0
  129. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_e_proto.py +0 -0
  130. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_f_enrollment.py +0 -0
  131. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_g1_logging.py +0 -0
  132. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_g_permissions.py +0 -0
  133. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_i_direct_path.py +0 -0
  134. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gen_certs.py +0 -0
  135. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_global_accounts_migration.py +0 -0
  136. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_global_onboarding.py +0 -0
  137. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_global_v1_disposal.py +0 -0
  138. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_hackrf_schema.py +0 -0
  139. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_home_device_plane.py +0 -0
  140. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_host_directory_store.py +0 -0
  141. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_host_dynamic_v2.py +0 -0
  142. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_idl_schema.py +0 -0
  143. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_panel.py +0 -0
  144. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_peer_transport.py +0 -0
  145. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_pluto_compat.py +0 -0
  146. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_pluto_poe.py +0 -0
  147. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_process_arg.py +0 -0
  148. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_reservation_handoff.py +0 -0
  149. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_reservation_policy_b.py +0 -0
  150. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_reservation_timestamps.py +0 -0
  151. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_rpc_manager_calln.py +0 -0
  152. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_rtl_sdr_schema.py +0 -0
  153. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_soak_federation.py +0 -0
  154. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_status_push.py +0 -0
  155. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_ti_mmwave_hardware.py +0 -0
  156. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_ti_mmwave_schema.py +0 -0
  157. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_user_group_cli.py +0 -0
  158. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_b205mini_uhd410_hardware.py +0 -0
  159. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_client_server_hardware.py +0 -0
  160. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_client_server_localhost.py +0 -0
  161. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_n210_client_server_hardware.py +0 -0
  162. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_profiles.py +0 -0
  163. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_remoterf_rx_hardware.py +0 -0
  164. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_uhd410_hardware.py +0 -0
  165. {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_wipe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoteRF-server
3
- Version: 1.1.2
3
+ Version: 1.1.3
4
4
  Summary: RemoteRF server-side control package
5
5
  License: GPL-3.0-or-later
6
6
  Classifier: Programming Language :: Python :: 3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "remoteRF-server"
7
- version = "1.1.2"
7
+ version = "1.1.3"
8
8
  description = "RemoteRF server-side control package"
9
9
  license = { text = "GPL-3.0-or-later" }
10
10
  requires-python = ">=3.10"
@@ -26,6 +26,7 @@ reloads or restarts itself.
26
26
  from __future__ import annotations
27
27
 
28
28
  import sys
29
+ import textwrap
29
30
  from dataclasses import dataclass
30
31
  from typing import Callable, Iterable, Optional
31
32
 
@@ -152,8 +153,11 @@ def _table() -> list[Command]:
152
153
  Command(("cluster info",), "Every lab, shared group and device in a cluster", _federation("info"), "Clusters"),
153
154
  Command(("cluster create",), "Start a cluster: cluster create <name>", _federation("create"), "Clusters"),
154
155
  Command(("cluster invite",), "Invite code for a lab: cluster invite <name>", _federation("invite"), "Clusters"),
155
- Command(("cluster join",), "Join with a code: cluster join <code>", _federation("join"), "Clusters"),
156
+ Command(("cluster join",), "Join with a code, or a public cluster by name: cluster join <code|name>", _federation("join"), "Clusters"),
156
157
  Command(("cluster remove",), "Leave or disband: cluster remove <name>", _federation("remove"), "Clusters"),
158
+ Command(("cluster public",), "Open a cluster to every lab on Global: cluster public <name> on|off (alone: list them)", _federation("public"), "Clusters"),
159
+ Command(("cluster kick",), "Put one lab out of your cluster: cluster kick <name> <lab>", _federation("kick"), "Clusters"),
160
+ Command(("cluster cap",), "Reservations one lab may hold here at a time: cluster cap <name> <n> (0 = no cap)", _federation("cap"), "Clusters"),
157
161
  Command(("cluster groups",), "Choose which of your groups a cluster shares (interactive)", _federation("groups"), "Clusters"),
158
162
  Command(("cluster codes create",), "Codes for your users to join a cluster (interactive)", _federation("codes create"), "Clusters"),
159
163
  Command(("cluster codes list",), "List cluster codes", _federation("codes list"), "Clusters"),
@@ -249,8 +253,17 @@ DEVICE_TYPES = "pluto, pluto-mimo, rtl-sdr, hackrf, ti-mmwave, usrp-2901, usrp-b
249
253
 
250
254
 
251
255
  def _columns(rows: list[tuple[str, str]], indent: str = " ") -> list[str]:
256
+ from ..common.utils.panel import MAX_CONTENT_WIDTH, _terminal_columns
257
+
252
258
  width = max(len(left) for left, _ in rows)
253
- return [f"{indent}{left:<{width}} {right}" for left, right in rows]
259
+ # Wrap under the right column: a literal panel clips, and the usage sits at the end.
260
+ room = max(20, min(MAX_CONTENT_WIDTH, _terminal_columns() - 4) - len(indent) - width - 2)
261
+ out = []
262
+ for left, right in rows:
263
+ first, *rest = textwrap.wrap(right, room) or [""]
264
+ out.append(f"{indent}{left:<{width}} {first}")
265
+ out += [f"{indent}{'':<{width}} {piece}" for piece in rest]
266
+ return out
254
267
 
255
268
 
256
269
  def cmd_help(argv: list[str]) -> int:
@@ -69,16 +69,19 @@ def cmd_clusters() -> int:
69
69
  if index:
70
70
  rows.append("")
71
71
  owner_row = None
72
+ public = {"open": " public", "closed": " public, closed to new labs", True: " public"}.get(item["public"], "")
72
73
  if item["role"] == "owner":
73
74
  invites = item["open_invites"]
74
- rows.append(f"{item['name']} (yours)" + (f" {invites} open invite{'s' if invites != 1 else ''}" if invites else ""))
75
+ rows.append(f"{item['name']} (yours){public}"
76
+ + (f" {invites} open invite{'s' if invites != 1 else ''}" if invites else ""))
75
77
  labs = [(me, "admin", "", None)] + [
76
78
  (m, "member", m["state"] or "joining", m.get("joined_at")) for m in item["members"]]
77
79
  if not item["members"] and not invites:
78
- rows.append(f" nobody yet: `cluster invite {item['name']}`")
80
+ rows.append(" nobody yet: any lab on Global may walk in" if item["public"] == "open"
81
+ else f" nobody yet: `cluster invite {item['name']}`")
79
82
  else:
80
83
  owner_row = owner = {"display_name": item.get("peer_name"), "deployment_id": item["peer"]}
81
- rows.append(f"{item['name']} {item['state']}")
84
+ rows.append(f"{item['name']} {item['state']}{public}")
82
85
  labs = [(owner, "admin", "", None), (me, "member", "", None)] + [
83
86
  (p, "member", p["state"] or "joining", None) for p in item.get("peers", [])]
84
87
  for lab, role, lab_state, since in labs:
@@ -97,6 +100,10 @@ def cmd_clusters() -> int:
97
100
  if sharing:
98
101
  rows.append(f" sharing {_n(sharing['groups'], 'group')}, {_n(sharing['devices'], 'device')}")
99
102
  rows.append(f" {item['students']} of your users enrolled, {item['visitors']} visiting")
103
+ if item["lab_cap"]:
104
+ rows.append(f" any one other lab may hold {_n(item['lab_cap'], 'reservation')} here at a time")
105
+ if item["kept_out"]:
106
+ rows.append(f" {_n(item['kept_out'], 'lab')} kicked and kept out")
100
107
  print()
101
108
  print_panel("Clusters", rows, literal=True)
102
109
  print()
@@ -159,9 +166,9 @@ def _ask_for(verb: str) -> str:
159
166
  from . import cluster
160
167
 
161
168
  if verb == "join":
162
- return _input("Invite code from the other lab: ")
169
+ return _input("Invite code from the other lab, or a public cluster's name: ")
163
170
  names = [name for name, item in sorted(cluster.load().items()) if item.get("role") == "owner"]
164
- if verb in ("remove", "groups", "codes", "info"):
171
+ if verb in ("remove", "groups", "codes", "info", "cap"):
165
172
  names = sorted(cluster.load())
166
173
  if verb == "accept":
167
174
  names = [name for name, item in sorted(cluster.load().items()) if item.get("state") == "pending"]
@@ -170,6 +177,8 @@ def _ask_for(verb: str) -> str:
170
177
  "accept": "Which cluster's pending join to accept",
171
178
  "groups": "Which cluster to share groups with",
172
179
  "info": "Which cluster to show",
180
+ "kick": "Which cluster to put a lab out of",
181
+ "cap": "Which cluster to cap",
173
182
  "codes": "Which cluster to make codes for",
174
183
  "remove": "Which cluster to leave (revokes the contract)"}[verb]
175
184
  if verb != "create" and names:
@@ -278,6 +287,29 @@ def _codes_list(name: str | None) -> int:
278
287
  return 0
279
288
 
280
289
 
290
+ def _public_pools() -> int:
291
+ """`cluster public` alone: the doors other labs hold open on Global."""
292
+ from ...common.utils.panel import print_panel
293
+ from . import cluster
294
+
295
+ rows = []
296
+ for pool in cluster.public_pools():
297
+ owner = pool["owner"]
298
+ lab = _lab({"display_name": owner.get("display_name"), "deployment_id": owner["identity"]["deployment_id"]})
299
+ state = {"joined": "joined", "left": f"left (`cluster join {pool['cluster_name']}` to return)"}.get(
300
+ pool.get("state"), "joining within a minute")
301
+ rows.append(f"{pool['cluster_name']} from {lab} — {state}")
302
+ mine = [name for name, item in sorted(cluster.load().items())
303
+ if item.get("role") == "owner" and item.get("public") == "open"]
304
+ if mine:
305
+ rows += ([""] if rows else []) + ["Yours, open to every lab: " + ", ".join(mine)]
306
+ print()
307
+ print_panel("Public clusters", rows or ["None yet. Open one of yours with `cluster public <name> on`."],
308
+ literal=True)
309
+ print()
310
+ return 0
311
+
312
+
281
313
  def _input(prompt: str) -> str:
282
314
  # The console's own prompt session: a bare input() inside the running
283
315
  # server fights prompt_toolkit for the terminal and kills the process.
@@ -290,8 +322,9 @@ def _input(prompt: str) -> str:
290
322
  return ""
291
323
 
292
324
 
293
- _USAGE = ("Usage: cluster list | cluster create|invite|remove <name> | cluster join <code> | "
294
- "cluster info [<name>] | cluster groups [<name>] | cluster codes create|list [<name>]")
325
+ _USAGE = ("Usage: cluster list | cluster create|invite|remove <name> | cluster join <code or public name> | "
326
+ "cluster info [<name>] | cluster groups [<name>] | cluster codes create|list [<name>] | "
327
+ "cluster public [<name> on|off] | cluster kick <name> <lab> | cluster cap <name> <reservations>")
295
328
 
296
329
 
297
330
  def cmd_cluster(argv: list[str]) -> int:
@@ -306,7 +339,13 @@ def cmd_cluster(argv: list[str]) -> int:
306
339
  if verb == "codes" and (not words or words[0] not in ("create", "list")):
307
340
  print("Usage: cluster codes create [<cluster>] | cluster codes list [<cluster>]", file=sys.stderr)
308
341
  return 2
309
- if not words and verb in ("create", "invite", "accept", "join", "remove", "groups", "info"):
342
+ if verb == "public" and not words:
343
+ try:
344
+ return _public_pools()
345
+ except RuntimeError as exc:
346
+ _print(f" {exc}", Sty.WARNING)
347
+ return 1
348
+ if not words and verb in ("create", "invite", "accept", "join", "remove", "groups", "info", "kick", "cap"):
310
349
  # Leaving the argument out asks for it (docs/deployment-groups.md §8).
311
350
  answer = _ask_for(verb)
312
351
  if not answer:
@@ -332,6 +371,30 @@ def cmd_cluster(argv: list[str]) -> int:
332
371
  _print(f" Accepted. {arg} activates once both servers confirm (about a minute).", Sty.GRAY)
333
372
  elif verb == "remove" and arg:
334
373
  _print(f" {arg}: {cluster.remove(arg)}.", Sty.GRAY)
374
+ elif verb == "public" and len(words) == 2 and words[1] in ("on", "off"):
375
+ cluster.set_public(arg, words[1] == "on")
376
+ if words[1] == "on":
377
+ cap = cluster.load()[arg]["lab_cap"]
378
+ _print(f" {arg} is public: every lab on Global joins it on its own, and every account on those "
379
+ "labs can reserve what you share into it (`cluster groups`).", Sty.GRAY)
380
+ _print(" Any one lab may hold " + (_n(cap, "reservation") if cap else "any number of reservations")
381
+ + f" here at a time (`cluster cap {arg} <n>`); `cluster kick {arg} <lab>` puts one out.",
382
+ Sty.GRAY)
383
+ else:
384
+ _print(f" {arg} is closed to new labs. The labs already in stay; `cluster kick` removes one.", Sty.GRAY)
385
+ elif verb == "kick" and arg:
386
+ lab = " ".join(words[1:]) or _input("Lab to put out (name or id from `cluster list`): ")
387
+ name = cluster.kick(arg, lab)
388
+ _print(f" {name} is out of {arg}: its contract is revoked and its reservations here are cancelled. "
389
+ f"Only a new `cluster invite {arg}` code lets it back in.", Sty.GRAY)
390
+ elif verb == "cap" and arg:
391
+ answer = words[1] if len(words) > 1 else _input("Reservations one lab may hold here at a time (0 = no cap): ")
392
+ if not answer.isdigit():
393
+ raise cluster.ClusterError("the cap is a number of reservations: 0 (no cap) or more")
394
+ cluster.set_cap(arg, int(answer))
395
+ _print(f" In {arg}, any one other lab may now hold "
396
+ + (_n(int(answer), "reservation") if int(answer) else "any number of reservations")
397
+ + " on your devices at a time.", Sty.GRAY)
335
398
  elif verb == "info" and arg:
336
399
  return cmd_cluster_info(arg)
337
400
  elif verb == "groups" and len(words) == 1:
@@ -402,7 +465,8 @@ def main(argv) -> int:
402
465
  return cmd_peers(argv[1:])
403
466
  if argv and argv[0] in ("cluster", "clusters"):
404
467
  argv = argv[1:] or ["list"]
405
- if argv and argv[0] in ("list", "create", "invite", "join", "accept", "remove", "groups", "codes", "info"):
468
+ if argv and argv[0] in ("list", "create", "invite", "join", "accept", "remove", "groups", "codes", "info",
469
+ "public", "kick", "cap"):
406
470
  return cmd_cluster(argv)
407
471
  print(_USAGE, file=sys.stderr)
408
472
  return 2
@@ -22,6 +22,13 @@ secret's hash) can carry identities but never substitute one. From there the
22
22
  agent walks the ordinary contract lifecycle (propose, accept, activate) over
23
23
  the broker's evidence directory. Nothing here bypasses the local trust store
24
24
  or the contract state machine; it only drives them.
25
+
26
+ A public cluster swaps the code for an open door (`cluster public <name> on`):
27
+ any lab on Global walks in, and every account on a lab that is in can use what
28
+ the cluster shares. There is no secret to check, so both sides pin the other's
29
+ key on Global's word -- an admin approved each lab by hand, and that approval
30
+ is the trust a public cluster runs on. What bounds a lab nobody chose is the
31
+ per-lab cap, and `cluster kick`.
25
32
  """
26
33
 
27
34
  from __future__ import annotations
@@ -36,6 +43,7 @@ import secrets
36
43
  import sqlite3
37
44
  import string
38
45
  import time
46
+ from contextlib import closing
39
47
  from datetime import datetime
40
48
  from pathlib import Path
41
49
  from typing import Optional
@@ -53,6 +61,11 @@ CONTRACT_DAYS = 365
53
61
  CODE_PREFIX = "cluster." # a student code minted here, redeemed here
54
62
  CLUSTER_CODE = "cluster" # the `code_name` a cluster enrolment carries to a partner
55
63
  CODE_DAYS = 7
64
+ PUBLIC_LAB_CAP = 4 # reservations one lab may hold here at a time, as a public cluster starts out
65
+ # ponytail: the broker's roster holds 64 labs, so a cluster does too. A public
66
+ # cluster that outgrows it needs a paged roster, or members that contract with
67
+ # the owner alone instead of meshing with each other.
68
+ MAX_MEMBERS = 64
56
69
  _NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$")
57
70
  _STATE_FILE = "clusters.json"
58
71
 
@@ -282,7 +295,12 @@ def invite(name: str, *, auto: bool = True) -> str:
282
295
 
283
296
 
284
297
  def join(code: str) -> str:
285
- invite_id, secret = decode_code(code)
298
+ try:
299
+ invite_id, secret = decode_code(code)
300
+ except ClusterError:
301
+ if _NAME.match(code or ""):
302
+ return join_public(code) # not a code: the name of a public cluster
303
+ raise
286
304
  me = _pin_self()
287
305
  result = g.call("POST", "/v2/broker/invites/join", {
288
306
  "protocol_version": "2",
@@ -382,18 +400,160 @@ def remove(name: str) -> str:
382
400
  _publish(party)
383
401
  except g.GlobalError:
384
402
  outcome = "removed locally; could not reach Global to tell the other lab"
403
+ if cluster.get("public"):
404
+ try:
405
+ if cluster["role"] == "owner":
406
+ _public_note(name, "gone")
407
+ elif cluster.get("state") != "revoked":
408
+ # Remembered on Global, so the next tick does not walk back in.
409
+ _public_note(name, "left", owner=cluster["peer"])
410
+ except g.GlobalError:
411
+ outcome = "removed locally; could not reach Global to tell the other lab"
385
412
  _forget_accounts(name, cluster)
386
413
  del clusters[name]
387
414
  save(clusters)
388
415
  return outcome
389
416
 
390
417
 
418
+ # -----------------------------
419
+ # Public clusters, and the two things that bound them
420
+ # -----------------------------
421
+
422
+ def _public_note(name: str, state: str, owner: str | None = None) -> None:
423
+ me = _pin_self()
424
+ g.call("POST", "/v2/broker/clusters/roster", {"protocol_version": "2", "public": {
425
+ "cluster_name": name, "owner": owner or me["deployment_id"], "identity": me, "state": state}})
426
+
427
+
428
+ def set_public(name: str, on: bool) -> None:
429
+ """Owner: open the cluster to every lab on Global, or shut the door again.
430
+ Shutting it admits nobody new; the labs already in stay (`kick` is for
431
+ those)."""
432
+ clusters = load()
433
+ cluster = _find(clusters, name)
434
+ if cluster["role"] != "owner":
435
+ raise ClusterError(f"{name} is owned by another deployment; only the owner opens or closes it")
436
+ if not on and not cluster.get("public"):
437
+ raise ClusterError(f"{name} is not public")
438
+ _public_note(name, "open" if on else "closed")
439
+ if on:
440
+ cluster.setdefault("lab_cap", PUBLIC_LAB_CAP)
441
+ cluster["public"] = "open" if on else "closed"
442
+ save(clusters)
443
+
444
+
445
+ def public_pools() -> list[dict]:
446
+ """The public clusters other labs hold open, each with our own state in it."""
447
+ return g.call("GET", "/v2/broker/clusters/roster").get("pools", [])
448
+
449
+
450
+ def join_public(name: str, pool: dict | None = None) -> str:
451
+ """Walk into a public cluster; the owner's key is pinned on Global's word."""
452
+ if pool is None:
453
+ pool = next((item for item in public_pools() if item["cluster_name"] == name), None)
454
+ if pool is None:
455
+ raise ClusterError(f"no public cluster named {name}")
456
+ clusters = load()
457
+ # ponytail: one local name per cluster, so a second lab's public cluster
458
+ # of the same name cannot be joined until the first is removed. Key
459
+ # clusters by (owner, name) if that ever bites.
460
+ if name in clusters:
461
+ raise ClusterError(f"{name} is already a cluster here")
462
+ owner = pool["owner"]
463
+ peer = owner["identity"]["deployment_id"]
464
+ _pin(owner["identity"], source="cluster_invite")
465
+ _public_note(name, "joined", owner=peer)
466
+ clusters[name] = {"role": "member", "state": "joined", "public": True, "lab_cap": PUBLIC_LAB_CAP,
467
+ "peer": peer, "peer_name": owner.get("display_name"), "created_at": int(time.time())}
468
+ save(clusters)
469
+ return name
470
+
471
+
472
+ def _member(cluster: dict, lab: str) -> str:
473
+ """A member lab by deployment id, the id prefix `cluster list` shows, or display name."""
474
+ lab = (lab or "").strip().rstrip("…")
475
+ hits = [peer for peer, member in cluster["members"].items()
476
+ if peer == lab or (len(lab) >= 8 and peer.startswith(lab))
477
+ or (member.get("display_name") or "").lower() == lab.lower()]
478
+ if len(hits) != 1:
479
+ raise ClusterError(f"{lab or 'that'} is not one lab in this cluster; `cluster list` shows them")
480
+ return hits[0]
481
+
482
+
483
+ def kick(name: str, lab: str) -> str:
484
+ """Owner: put one lab out and keep the rest. Its contract is revoked, its
485
+ visitors lose the cluster's groups and the reservations they hold here,
486
+ and the public door stays shut to it -- a fresh invite code is the way
487
+ back in. Returns the lab's name."""
488
+ clusters = load()
489
+ cluster = _find(clusters, name)
490
+ if cluster["role"] != "owner":
491
+ raise ClusterError(f"{name} is owned by another deployment; only the owner puts a lab out")
492
+ peer = _member(cluster, lab)
493
+ member = cluster["members"].pop(peer)
494
+ _revoke(member.get("contract_id"))
495
+ try:
496
+ _publish(member)
497
+ except g.GlobalError:
498
+ pass # revoked here regardless; that lab finds out when its next grant is refused
499
+ if peer not in cluster.setdefault("banned", []):
500
+ cluster["banned"].append(peer)
501
+ _forget_lab(name, peer, member.get("contract_id"))
502
+ save(clusters)
503
+ return member.get("display_name") or peer
504
+
505
+
506
+ def _forget_lab(name: str, peer: str, contract_id: str | None) -> None:
507
+ """Take one lab's visitors out of the cluster and cancel what they hold
508
+ here under its contract."""
509
+ with _usergroups() as conn:
510
+ accounts = [row[0] for row in conn.execute(
511
+ "SELECT a.local_account_id FROM ClusterAccounts a "
512
+ "JOIN FederatedPrincipals p ON p.local_account_id=a.local_account_id "
513
+ "WHERE a.cluster_name=? AND a.kind='proxy' AND p.issuer_deployment_id=?", (name, peer)).fetchall()]
514
+ for account in accounts:
515
+ _take_back(conn, name, account=account)
516
+ conn.execute("DELETE FROM ClusterAccounts WHERE cluster_name=? AND local_account_id=?", (name, account))
517
+ path = _db_path().with_name("reservations.db")
518
+ if not contract_id or not path.exists():
519
+ return
520
+ with closing(sqlite3.connect(path, timeout=10.0)) as conn, conn:
521
+ try:
522
+ conn.execute("DELETE FROM Reservations WHERE res_id IN "
523
+ "(SELECT res_id FROM FederatedReservations WHERE contract_id=?)", (contract_id,))
524
+ conn.execute("DELETE FROM FederatedReservations WHERE contract_id=?", (contract_id,))
525
+ except sqlite3.OperationalError:
526
+ pass # no federated reservations table: nothing was ever reserved from outside
527
+
528
+
529
+ def set_cap(name: str, cap: int) -> None:
530
+ """How many reservations any one other lab in the cluster may hold on this
531
+ server's devices at a time; 0 lifts the cap."""
532
+ clusters = load()
533
+ cluster = _find(clusters, name)
534
+ if cap < 0:
535
+ raise ClusterError("the cap is a number of reservations: 0 (no cap) or more")
536
+ cluster["lab_cap"] = int(cap)
537
+ save(clusters)
538
+
539
+
540
+ def lab_cap(deployment_id: str) -> int | None:
541
+ """Reservations one partner lab may hold here at a time: the most generous
542
+ cap among the clusters shared with it. None when any of those has no cap,
543
+ or the lab is in no cluster (a contract made by hand is not capped)."""
544
+ caps = [cluster.get("lab_cap") or 0 for cluster in load().values()
545
+ if deployment_id in dict(_parties(cluster))]
546
+ return max(caps) if caps and all(caps) else None
547
+
548
+
391
549
  def list_clusters() -> list[dict]:
392
550
  """Owner rows carry `members` and `open_invites`; member rows carry `peer`."""
393
551
  store = None
394
552
  out = []
395
553
  for name, cluster in sorted(load().items()):
396
- item = {"name": name, "role": cluster["role"], "created_at": cluster.get("created_at")}
554
+ item = {"name": name, "role": cluster["role"], "created_at": cluster.get("created_at"),
555
+ "public": cluster.get("public"), "lab_cap": cluster.get("lab_cap") or 0,
556
+ "kept_out": len(cluster.get("banned", []))}
397
557
  if cluster["role"] == "owner":
398
558
  members = []
399
559
  for peer, member in sorted(cluster["members"].items()):
@@ -509,10 +669,13 @@ def _grant(conn, name: str, account: int, group_id: int) -> None:
509
669
  "VALUES (?,?,?)", (name, int(account), int(group_id)))
510
670
 
511
671
 
512
- def _take_back(conn, name: str, group_id: int | None = None) -> None:
513
- """Remove exactly the memberships this cluster granted (one group, or all)."""
514
- where = "cluster_name=?" + ("" if group_id is None else " AND group_id=?")
515
- args = (name,) if group_id is None else (name, int(group_id))
672
+ def _take_back(conn, name: str, group_id: int | None = None, account: int | None = None) -> None:
673
+ """Remove exactly the memberships this cluster granted (one group, one account, or all)."""
674
+ where, args = "cluster_name=?", (name,)
675
+ if group_id is not None:
676
+ where, args = where + " AND group_id=?", args + (int(group_id),)
677
+ if account is not None:
678
+ where, args = where + " AND local_account_id=?", args + (int(account),)
516
679
  conn.execute(
517
680
  f"DELETE FROM UsersLookup WHERE (user_id,group_id) IN "
518
681
  f"(SELECT local_account_id,group_id FROM ClusterMemberships WHERE {where})", args)
@@ -549,6 +712,27 @@ def join_account(conn, name: str, cluster: dict, local_account_id: int, *, kind:
549
712
  _grant(conn, name, local_account_id, group_id)
550
713
 
551
714
 
715
+ def _admit_everyone(clusters: dict) -> None:
716
+ """A public cluster needs no code: every native account here is in it."""
717
+ public = [(name, cluster) for name, cluster in clusters.items() if cluster.get("public")]
718
+ path = _db_path().with_name("reservations.db")
719
+ if not public or not path.exists():
720
+ return
721
+ with closing(sqlite3.connect(path, timeout=10.0)) as conn:
722
+ try:
723
+ accounts = [int(row[0]) for row in conn.execute(
724
+ "SELECT acc_id FROM Accounts WHERE auth_type='local_password'").fetchall()]
725
+ except sqlite3.OperationalError:
726
+ return # no accounts table yet
727
+ with _usergroups() as conn:
728
+ for name, cluster in public:
729
+ have = {row[0] for row in conn.execute(
730
+ "SELECT local_account_id FROM ClusterAccounts WHERE cluster_name=?", (name,)).fetchall()}
731
+ for account in accounts:
732
+ if account not in have:
733
+ join_account(conn, name, cluster, account, kind="local")
734
+
735
+
552
736
  def mint_codes(name: str, *, count: int = 1, uses: int = 1, duration_sec: int = CODE_DAYS * 86400) -> list[str]:
553
737
  """Same shape as a group code (uses, duration; 0 = never expires), bound
554
738
  to the cluster instead of a group."""
@@ -630,6 +814,8 @@ def enroll_students(home, notify=None, *, only_account: int | None = None) -> No
630
814
  for peer, party in _parties(cluster):
631
815
  if party.get("state") != "active" or not party.get("contract_id") or peer in enrolled:
632
816
  continue
817
+ if cluster.get("public") and not (party.get("sharing") or {}).get("devices"):
818
+ continue # a lab nobody chose gets our accounts only once it shares a device
633
819
  try:
634
820
  enroller.outbound.execute(
635
821
  local_account_id=account, destination_deployment_id=peer,
@@ -869,6 +1055,8 @@ def _collect_joins(name: str, cluster: dict, broker_invites: list[dict], notify)
869
1055
  del cluster["invites"][invite_id]
870
1056
  continue
871
1057
  peer = joiner["identity"]["deployment_id"]
1058
+ if peer in cluster.get("banned", []):
1059
+ cluster["banned"].remove(peer) # a code handed to a lab that was kicked is its way back in
872
1060
  member = cluster["members"].setdefault(peer, {"published": [], "joined_at": int(time.time())})
873
1061
  member.update(display_name=joiner.get("display_name"), joiner=joiner["identity"], state="pending")
874
1062
  del cluster["invites"][invite_id]
@@ -878,16 +1066,75 @@ def _collect_joins(name: str, cluster: dict, broker_invites: list[dict], notify)
878
1066
  notify(f"[cluster] {peer} wants to join {name}: run `cluster accept {name}`")
879
1067
 
880
1068
 
1069
+ def _collect_public_joins(name: str, cluster: dict, joins: list[dict], notify) -> None:
1070
+ """Owner of an open cluster: every lab Global lists as having walked in
1071
+ becomes a member -- Global's approval of that lab stands in for the invite
1072
+ secret. A lab that was kicked stays out; one that left may come back."""
1073
+ for item in joins:
1074
+ if item.get("cluster_name") != name:
1075
+ continue
1076
+ identity = item["identity"]
1077
+ peer = identity["deployment_id"]
1078
+ member = cluster["members"].get(peer)
1079
+ if peer in cluster.get("banned", []) or (member is not None and member.get("state") != "revoked"):
1080
+ continue
1081
+ if sum(m.get("state") != "revoked" for m in cluster["members"].values()) >= MAX_MEMBERS:
1082
+ _once(f"full:{name}", f"[cluster] {name} is full ({MAX_MEMBERS} labs); more are waiting at the door",
1083
+ notify)
1084
+ break
1085
+ member = cluster["members"][peer] = {
1086
+ "display_name": item.get("display_name"), "joiner": identity, "state": "pending",
1087
+ "published": [], "joined_at": int(time.time())}
1088
+ try:
1089
+ _propose(cluster, member, peer)
1090
+ except (ContractError, g.GlobalError) as exc:
1091
+ # Left pending: `cluster accept` retries it, `cluster kick` refuses it.
1092
+ if member.get("state") == "pending":
1093
+ member["joiner"] = identity
1094
+ notify(f"[cluster] {name}: could not admit {item.get('display_name') or peer}: "
1095
+ f"{getattr(exc, 'code', exc)}")
1096
+
1097
+
1098
+ _said: set[str] = set()
1099
+
1100
+
1101
+ def _once(key: str, text: str, notify) -> None:
1102
+ """A standing condition is worth one console line, not one per tick."""
1103
+ if key not in _said:
1104
+ _said.add(key)
1105
+ notify(text)
1106
+
1107
+
1108
+ def _walk_in(pools: list[dict], notify) -> None:
1109
+ """Join every public cluster this lab has never been in. One it left, or
1110
+ was put out of, has a state on Global and is left alone."""
1111
+ for pool in pools:
1112
+ name = pool.get("cluster_name")
1113
+ if pool.get("state") is not None or name in load():
1114
+ continue
1115
+ lab = (pool.get("owner") or {}).get("display_name") or "another lab"
1116
+ try:
1117
+ join_public(name, pool)
1118
+ except (ClusterError, ContractError, g.GlobalError, KeyError, TypeError) as exc:
1119
+ _once(f"join:{name}", f"[cluster] could not join public cluster {name} from {lab}: "
1120
+ f"{getattr(exc, 'code', exc)}", notify)
1121
+ continue
1122
+ notify(f"[cluster] joined public cluster {name} from {lab}: every account here can reserve what it "
1123
+ f"shares. `cluster remove {name}` leaves it.")
1124
+
1125
+
881
1126
  def tick(notify=None, home=None) -> None:
882
1127
  """One agent pass: move every cluster and member one step closer to active,
883
1128
  then (given the HOME runtime) enrol students wherever they are missing."""
884
1129
  notify = notify or (lambda text: None)
1130
+ # Rosters, status notes and the public clusters: one GET for the whole pass.
1131
+ board = g.call("GET", "/v2/broker/clusters/roster")
1132
+ _walk_in(board.get("pools", []), notify)
885
1133
  clusters = load()
886
1134
  if not clusters:
887
1135
  return
888
1136
  directory = None
889
1137
  broker_invites = None
890
- board = None # rosters and status notes, one GET for all clusters
891
1138
  for name, cluster in clusters.items():
892
1139
  if cluster["role"] == "owner":
893
1140
  if cluster["invites"]:
@@ -895,7 +1142,12 @@ def tick(notify=None, home=None) -> None:
895
1142
  broker_invites = g.call("GET", "/v2/broker/invites")["invites"]
896
1143
  _collect_joins(name, cluster, broker_invites, notify)
897
1144
  save(clusters)
898
- if not cluster["members"]:
1145
+ if cluster.get("public") == "open":
1146
+ _collect_public_joins(name, cluster, board.get("joins", []), notify)
1147
+ save(clusters)
1148
+ # An empty cluster has nothing to drive, bar withdrawing a roster
1149
+ # that still names a lab that has since been kicked.
1150
+ if not cluster["members"] and not cluster.get("roster"):
899
1151
  continue
900
1152
  if directory is None:
901
1153
  directory = g.call("GET", "/v2/broker/contracts")["contracts"]
@@ -910,8 +1162,6 @@ def tick(notify=None, home=None) -> None:
910
1162
  if directory is None:
911
1163
  directory = g.call("GET", "/v2/broker/contracts")["contracts"]
912
1164
  _advance(cluster, cluster["peer"], directory, label=name, notify=notify, is_member=True)
913
- if board is None:
914
- board = g.call("GET", "/v2/broker/clusters/roster")
915
1165
  if cluster["role"] != "owner":
916
1166
  _mesh(name, cluster, board["rosters"], directory, notify)
917
1167
  try:
@@ -920,5 +1170,6 @@ def tick(notify=None, home=None) -> None:
920
1170
  notify(f"[cluster] {name}: could not publish what we share: {exc}")
921
1171
  _take_statuses(name, cluster, board.get("statuses", []))
922
1172
  save(clusters)
1173
+ _admit_everyone(clusters)
923
1174
  if home is not None:
924
1175
  enroll_students(home, notify)
@@ -311,6 +311,8 @@ class ServerDeviceReserveHandler(_DeviceHandlerBase):
311
311
  now = int(self.clock())
312
312
  claims = action.claims
313
313
  payload = action.request.value["payload"]
314
+ from . import cluster as cluster_module
315
+
314
316
  principal_id, account_id = self._principal(tx, claims)
315
317
  outcome = self.reservations.federated_reserve(
316
318
  local_account_id=account_id,
@@ -322,6 +324,7 @@ class ServerDeviceReserveHandler(_DeviceHandlerBase):
322
324
  contract_id=claims["contract_id"],
323
325
  contract_version=claims["contract_version"],
324
326
  request_id=claims["request_id"],
327
+ lab_max_reservations=cluster_module.lab_cap(claims["iss"]),
325
328
  )
326
329
  if "error" in outcome:
327
330
  raise FederationBusinessError(outcome["error"])
@@ -53,8 +53,10 @@ client still prefers the LAN, so nothing local gets slower.
53
53
  It is also how labs work together. Once you and another deployment sign
54
54
  an agreement, your users can reserve instruments there and theirs can
55
55
  reserve yours, whether that lab is down the hall or on another
56
- continent. Each partnership is arranged deliberately, one at a time --
57
- joining Global shares none of your hardware on its own.
56
+ continent. Each partnership is arranged deliberately, one at a time,
57
+ with one exception: instruments a lab has opened to everyone on Global
58
+ reach your users as soon as you are in (`cluster remove` leaves such a
59
+ cluster). Joining Global shares none of your hardware on its own.
58
60
 
59
61
  Once an hour it sends Global a short note: this deployment's name and
60
62
  ID, version, uptime, how many accounts, what devices it has, how many
@@ -1353,11 +1353,15 @@ class ReservationHandler:
1353
1353
  contract_id: str,
1354
1354
  contract_version: int,
1355
1355
  request_id: str,
1356
+ lab_max_reservations: int | None = None,
1356
1357
  ) -> dict:
1357
1358
  """Create one reservation for a federated principal, idempotently.
1358
1359
 
1359
1360
  Returns the reservation, or {"error": <code>} for an ordinary local
1360
1361
  refusal (permission, quota, conflict, unknown device, bad interval).
1362
+ `lab_max_reservations` bounds what the principal's whole home lab may
1363
+ hold here at once: per-user quotas alone do not bound a lab that can
1364
+ mint accounts.
1361
1365
  The plaintext device token is never returned: it stays inside this
1362
1366
  deployment and is materialized straight onto the device.
1363
1367
  """
@@ -1405,6 +1409,16 @@ class ReservationHandler:
1405
1409
  if validation[0] is False:
1406
1410
  return {"error": "reservation_refused", "detail": str(validation[1])}
1407
1411
 
1412
+ if lab_max_reservations is not None:
1413
+ held = self.cursor.execute(
1414
+ "SELECT COUNT(*) FROM FederatedReservations f "
1415
+ "JOIN Reservations r ON r.res_id = f.res_id "
1416
+ "WHERE f.issuer_deployment_id = ? AND r.end_time > ?",
1417
+ (issuer, datetime.now()),
1418
+ ).fetchone()[0]
1419
+ if held >= int(lab_max_reservations):
1420
+ return {"error": "lab_reservation_limit"}
1421
+
1408
1422
  self.cursor.execute(
1409
1423
  "SELECT 1 FROM Reservations WHERE device_id = ? AND start_time < ? AND end_time > ?",
1410
1424
  (device_id, end_time, start_time),