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.
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/PKG-INFO +1 -1
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/pyproject.toml +1 -1
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/commands.py +15 -2
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/cli.py +73 -9
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/cluster.py +261 -10
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/devices.py +3 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/global_onboarding.py +4 -2
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/reservation.py +14 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server.egg-info/PKG-INFO +1 -1
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_cluster.py +254 -1
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_h_devices.py +29 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/LICENSE +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/README.md +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/setup.cfg +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/deployment_capabilities.proto +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/deployment_capabilities_pb2.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/federation.proto +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/federation_pb2.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/federation_pb2_grpc.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/grpc.proto +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/grpc_host.proto +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/grpc_host_pb2.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/grpc_host_pb2_grpc.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/grpc_pb2.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/grpc_pb2_grpc.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/grpc/v2_codec.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/idl/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/idl/device_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/idl/pluto_mimo_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/idl/pluto_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/idl/schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/idl/v2.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/ansi_codes.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/api_token.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/db_connection.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/db_location.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/list_string.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/panel.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/common/utils/process_arg.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/adalm_pluto/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/adalm_pluto/pluto_mimo_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/adalm_pluto/pluto_remote_server.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/adalm_pluto/pluto_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/adalm_pluto/poe.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/hackrf/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/hackrf/hackrf_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/rtl_sdr/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/rtl_sdr/rtl_sdr_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/ti_mmwave/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/ti_mmwave/ti_mmwave_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp/policy.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp/profiles.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp/uhd_manifest.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp/usrp_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp_phaser/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/drivers/usrp_phaser/usrp_phaser_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/host/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/host/host_auth_token.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/host/host_directory_store.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/host/host_tunnel_server.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/acc_perms.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/cert_provider.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/console.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/control.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/deployment/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/deployment/capabilities.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/device_manager.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/direct_path.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/dynamic_v2.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/dynamic_v2_bridge.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/admin.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/contracts.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/destination.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/enrollment.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/home.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/home_devices.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/identity.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/legacy_state.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/migrations.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/peers.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/permissions.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/principals.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/runtime.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/trust.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/grpc_server.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/host_dynamic_v2.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/rpc_manager.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/user_group_cli.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/user_group_handler.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/v2_errors.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/serverrf_cli.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/tools/__init__.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/tools/gen_certs.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/tools/gist_status.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/tools/gist_status_testing.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/tools/introspect_uhd.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server.egg-info/SOURCES.txt +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server.egg-info/dependency_links.txt +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server.egg-info/entry_points.txt +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server.egg-info/requires.txt +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server.egg-info/top_level.txt +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_account_hardening.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_bind_host.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_cert_provider.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_client_sessions.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_commands.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_console.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_default_rtl_sdr_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_default_usrp_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_deployment_capabilities.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_direct_fixed_port.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_direct_path_server.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_dynamic_v2.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_federated_direct_path.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_federated_dynamic_v2.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_federation_edges.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_first_run.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_d_contracts.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_e_federation.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_e_proto.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_f_enrollment.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_g1_logging.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_g_permissions.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gate_i_direct_path.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_gen_certs.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_global_accounts_migration.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_global_onboarding.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_global_v1_disposal.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_hackrf_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_home_device_plane.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_host_directory_store.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_host_dynamic_v2.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_idl_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_panel.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_peer_transport.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_pluto_compat.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_pluto_poe.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_process_arg.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_reservation_handoff.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_reservation_policy_b.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_reservation_timestamps.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_rpc_manager_calln.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_rtl_sdr_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_soak_federation.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_status_push.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_ti_mmwave_hardware.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_ti_mmwave_schema.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_user_group_cli.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_b205mini_uhd410_hardware.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_client_server_hardware.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_client_server_localhost.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_n210_client_server_hardware.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_profiles.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_remoterf_rx_hardware.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_usrp_uhd410_hardware.py +0 -0
- {remoterf_server-1.1.2 → remoterf_server-1.1.3}/tests/test_wipe.py +0 -0
|
@@ -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
|
-
|
|
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:
|
{remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/cli.py
RENAMED
|
@@ -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)
|
|
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(
|
|
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
|
|
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
|
{remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/cluster.py
RENAMED
|
@@ -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
|
-
|
|
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=?"
|
|
515
|
-
|
|
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
|
|
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)
|
{remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/federation/devices.py
RENAMED
|
@@ -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"])
|
{remoterf_server-1.1.2 → remoterf_server-1.1.3}/src/remoteRF_server/server/global_onboarding.py
RENAMED
|
@@ -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
|
-
|
|
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),
|