embedded-lab-control 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- labctl/web/websocket.py +208 -0
labctl/web/app.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Flask application factory for lab controller web interface.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
from datetime import timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from flask import Flask, g, jsonify, redirect, request, session, url_for
|
|
10
|
+
|
|
11
|
+
from labctl import __version__
|
|
12
|
+
from labctl.core import audit
|
|
13
|
+
from labctl.core.config import Config, load_config
|
|
14
|
+
from labctl.core.manager import ResourceManager, get_manager
|
|
15
|
+
from labctl.web.auth import (
|
|
16
|
+
generate_csrf_token,
|
|
17
|
+
get_user_by_api_key,
|
|
18
|
+
get_user_by_username,
|
|
19
|
+
validate_csrf_token,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_app(config: Config | None = None) -> Flask:
|
|
24
|
+
"""
|
|
25
|
+
Create and configure Flask application.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
config: Optional Config instance. If None, loads from default location.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Configured Flask application
|
|
32
|
+
"""
|
|
33
|
+
app = Flask(
|
|
34
|
+
__name__,
|
|
35
|
+
template_folder=str(Path(__file__).parent / "templates"),
|
|
36
|
+
static_folder=str(Path(__file__).parent / "static"),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Load configuration
|
|
40
|
+
if config is None:
|
|
41
|
+
config = load_config()
|
|
42
|
+
if config.load_errors:
|
|
43
|
+
# Fail closed: serving on another file's or built-in settings could
|
|
44
|
+
# mean no login (auth.enabled off). `labctl web` checks this too;
|
|
45
|
+
# this covers WSGI servers and other callers of create_app().
|
|
46
|
+
raise RuntimeError(
|
|
47
|
+
"Refusing to create the web app: config not loaded ("
|
|
48
|
+
+ "; ".join(config.load_errors)
|
|
49
|
+
+ ")"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
app.config["LABCTL_CONFIG"] = config
|
|
53
|
+
app.jinja_env.globals["labctl_version"] = __version__
|
|
54
|
+
|
|
55
|
+
# SECRET_KEY signs the session cookie, which carries the CSRF token even
|
|
56
|
+
# when auth is disabled, so it must never be a known constant. Without a
|
|
57
|
+
# configured key, use a random per-process key (the web server is a single
|
|
58
|
+
# process); sessions then reset on restart.
|
|
59
|
+
if config.auth.secret_key:
|
|
60
|
+
app.config["SECRET_KEY"] = config.auth.secret_key
|
|
61
|
+
else:
|
|
62
|
+
app.config["SECRET_KEY"] = secrets.token_hex(32)
|
|
63
|
+
|
|
64
|
+
# Set session lifetime
|
|
65
|
+
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(
|
|
66
|
+
minutes=config.auth.session_lifetime_minutes
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Register blueprints
|
|
70
|
+
from labctl.web.activity_broadcaster import ActivityBroadcaster
|
|
71
|
+
from labctl.web.api import api_bp
|
|
72
|
+
from labctl.web.auth import auth_bp
|
|
73
|
+
from labctl.web.views import views_bp
|
|
74
|
+
|
|
75
|
+
app.register_blueprint(auth_bp)
|
|
76
|
+
app.register_blueprint(api_bp, url_prefix="/api")
|
|
77
|
+
app.register_blueprint(views_bp)
|
|
78
|
+
|
|
79
|
+
# Start the activity-stream broadcaster thread. Polls audit_log and
|
|
80
|
+
# fans new rows out to SSE subscribers on /activity/stream.
|
|
81
|
+
mgr_for_broadcast = get_manager(
|
|
82
|
+
config.database_path,
|
|
83
|
+
timeout_seconds=config.database.timeout_seconds,
|
|
84
|
+
)
|
|
85
|
+
app.config["ACTIVITY_BROADCASTER"] = ActivityBroadcaster(mgr_for_broadcast.db)
|
|
86
|
+
app.config["ACTIVITY_BROADCASTER"].start()
|
|
87
|
+
|
|
88
|
+
# Register csrf_token as Jinja2 template global
|
|
89
|
+
app.jinja_env.globals["csrf_token"] = generate_csrf_token
|
|
90
|
+
|
|
91
|
+
def _request_activity_identity() -> tuple[str, str]:
|
|
92
|
+
"""Derive audit actor/source for the current Flask request."""
|
|
93
|
+
source = (
|
|
94
|
+
"api" if request.endpoint and request.endpoint.startswith("api.") else "web"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
user = getattr(g, "audit_user", None)
|
|
98
|
+
if not user:
|
|
99
|
+
user = session.get("user")
|
|
100
|
+
if not user:
|
|
101
|
+
user = "anonymous"
|
|
102
|
+
|
|
103
|
+
return f"{source}:{user}", source
|
|
104
|
+
|
|
105
|
+
@app.before_request
|
|
106
|
+
def before_request():
|
|
107
|
+
"""Set up manager and enforce auth for each request."""
|
|
108
|
+
config = app.config["LABCTL_CONFIG"]
|
|
109
|
+
g.manager = get_manager(
|
|
110
|
+
config.database_path,
|
|
111
|
+
timeout_seconds=config.database.timeout_seconds,
|
|
112
|
+
base_tcp_port=config.serial.base_tcp_port,
|
|
113
|
+
)
|
|
114
|
+
g.config = config
|
|
115
|
+
|
|
116
|
+
api_request = bool(request.endpoint and request.endpoint.startswith("api."))
|
|
117
|
+
api_user = None
|
|
118
|
+
# The authenticated user (UserConfig), for claim checks: their
|
|
119
|
+
# role decides who may override a claim (D013).
|
|
120
|
+
g.auth_user = None
|
|
121
|
+
|
|
122
|
+
if api_request and config.auth.enabled:
|
|
123
|
+
api_key = request.headers.get("X-API-Key", "")
|
|
124
|
+
if api_key:
|
|
125
|
+
api_user = get_user_by_api_key(config.auth, api_key)
|
|
126
|
+
if api_user:
|
|
127
|
+
g.audit_user = api_user.username
|
|
128
|
+
g.auth_user = api_user
|
|
129
|
+
elif "user" in session:
|
|
130
|
+
g.audit_user = session["user"]
|
|
131
|
+
if config.auth.enabled:
|
|
132
|
+
g.auth_user = get_user_by_username(config.auth, session["user"])
|
|
133
|
+
|
|
134
|
+
actor, source = _request_activity_identity()
|
|
135
|
+
ctx = audit.activity_context(actor, source)
|
|
136
|
+
ctx.__enter__()
|
|
137
|
+
g._activity_context = ctx
|
|
138
|
+
|
|
139
|
+
# Skip auth enforcement if auth is disabled
|
|
140
|
+
if not config.auth.enabled:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
# Whitelist: login page, static files, health check
|
|
144
|
+
if request.endpoint in (
|
|
145
|
+
"auth.login",
|
|
146
|
+
"auth.logout",
|
|
147
|
+
"static",
|
|
148
|
+
"api.health_check",
|
|
149
|
+
):
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
# API requests: check X-API-Key header
|
|
153
|
+
if api_request:
|
|
154
|
+
api_key = request.headers.get("X-API-Key", "")
|
|
155
|
+
if not api_key:
|
|
156
|
+
return jsonify({"error": "API key required"}), 401
|
|
157
|
+
if not api_user:
|
|
158
|
+
return jsonify({"error": "Invalid API key"}), 401
|
|
159
|
+
|
|
160
|
+
# Web requests: check session
|
|
161
|
+
if "user" not in session:
|
|
162
|
+
if not api_request:
|
|
163
|
+
if request.endpoint not in ("auth.login", "auth.logout", "static"):
|
|
164
|
+
return redirect(url_for("auth.login", next=request.path))
|
|
165
|
+
|
|
166
|
+
@app.teardown_request
|
|
167
|
+
def teardown_request(exc):
|
|
168
|
+
"""Reset per-request audit attribution context."""
|
|
169
|
+
ctx = getattr(g, "_activity_context", None)
|
|
170
|
+
if ctx is not None:
|
|
171
|
+
ctx.__exit__(
|
|
172
|
+
type(exc) if exc else None, exc, exc.__traceback__ if exc else None
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@app.before_request
|
|
176
|
+
def enforce_csrf():
|
|
177
|
+
"""Enforce CSRF token on state-changing web requests."""
|
|
178
|
+
config = app.config["LABCTL_CONFIG"]
|
|
179
|
+
if not config.auth.enabled:
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# Only enforce on state-changing methods
|
|
183
|
+
if request.method not in ("POST", "PUT", "DELETE"):
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
# Skip for API endpoints (they use API keys, not CSRF)
|
|
187
|
+
if request.endpoint and request.endpoint.startswith("api."):
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
# Skip for login (uses its own CSRF)
|
|
191
|
+
if request.endpoint == "auth.login":
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
if not validate_csrf_token():
|
|
195
|
+
return redirect(request.referrer or url_for("views.index"))
|
|
196
|
+
|
|
197
|
+
return app
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_manager_from_app() -> ResourceManager:
|
|
201
|
+
"""Get resource manager from Flask g object."""
|
|
202
|
+
return g.manager
|
labctl/web/auth.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication module for lab controller web interface.
|
|
3
|
+
|
|
4
|
+
Provides session-based login for web UI and API key auth for REST API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hmac
|
|
8
|
+
import secrets
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from flask import (
|
|
12
|
+
Blueprint,
|
|
13
|
+
current_app,
|
|
14
|
+
flash,
|
|
15
|
+
redirect,
|
|
16
|
+
render_template,
|
|
17
|
+
request,
|
|
18
|
+
session,
|
|
19
|
+
url_for,
|
|
20
|
+
)
|
|
21
|
+
from werkzeug.security import check_password_hash
|
|
22
|
+
|
|
23
|
+
from labctl.core.config import AuthConfig, UserConfig
|
|
24
|
+
|
|
25
|
+
auth_bp = Blueprint("auth", __name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_user_by_username(
|
|
29
|
+
auth_config: AuthConfig, username: str
|
|
30
|
+
) -> Optional[UserConfig]:
|
|
31
|
+
"""Look up a user by username."""
|
|
32
|
+
for user in auth_config.users:
|
|
33
|
+
if user.username == username:
|
|
34
|
+
return user
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_user_by_api_key(auth_config: AuthConfig, api_key: str) -> Optional[UserConfig]:
|
|
39
|
+
"""Look up a user by API key using constant-time comparison."""
|
|
40
|
+
return auth_config.user_for_api_key(api_key)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def verify_password(user: UserConfig, password: str) -> bool:
|
|
44
|
+
"""Verify a password against a user's stored hash."""
|
|
45
|
+
if not user.password_hash:
|
|
46
|
+
return False
|
|
47
|
+
return check_password_hash(user.password_hash, password)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def generate_csrf_token() -> str:
|
|
51
|
+
"""Generate or retrieve a CSRF token for the current session."""
|
|
52
|
+
if "_csrf_token" not in session:
|
|
53
|
+
session["_csrf_token"] = secrets.token_hex(32)
|
|
54
|
+
return session["_csrf_token"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_csrf_token() -> bool:
|
|
58
|
+
"""Validate CSRF token from form data against session."""
|
|
59
|
+
token = request.form.get("_csrf_token", "")
|
|
60
|
+
session_token = session.get("_csrf_token", "")
|
|
61
|
+
if not token or not session_token:
|
|
62
|
+
return False
|
|
63
|
+
return hmac.compare_digest(token, session_token)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@auth_bp.route("/login", methods=["GET", "POST"])
|
|
67
|
+
def login():
|
|
68
|
+
"""Handle login page and form submission."""
|
|
69
|
+
if request.method == "POST":
|
|
70
|
+
username = request.form.get("username", "")
|
|
71
|
+
password = request.form.get("password", "")
|
|
72
|
+
|
|
73
|
+
config = current_app.config["LABCTL_CONFIG"]
|
|
74
|
+
user = get_user_by_username(config.auth, username)
|
|
75
|
+
|
|
76
|
+
if user and verify_password(user, password):
|
|
77
|
+
session["user"] = username
|
|
78
|
+
session.permanent = True
|
|
79
|
+
flash("Logged in successfully.", "success")
|
|
80
|
+
next_url = request.args.get("next", "")
|
|
81
|
+
# Prevent open redirect: only allow relative paths
|
|
82
|
+
if not next_url or next_url.startswith("//") or "://" in next_url:
|
|
83
|
+
next_url = url_for("views.index")
|
|
84
|
+
return redirect(next_url)
|
|
85
|
+
else:
|
|
86
|
+
flash("Invalid username or password.", "error")
|
|
87
|
+
|
|
88
|
+
return render_template("login.html")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@auth_bp.route("/logout", methods=["POST"])
|
|
92
|
+
def logout():
|
|
93
|
+
"""Log out the current user."""
|
|
94
|
+
session.pop("user", None)
|
|
95
|
+
session.pop("_csrf_token", None)
|
|
96
|
+
flash("Logged out.", "success")
|
|
97
|
+
return redirect(url_for("auth.login"))
|
labctl/web/claims.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claim checks for the web UI and REST API (D013).
|
|
3
|
+
|
|
4
|
+
A change to a board that another user has claimed is refused: REST with
|
|
5
|
+
409 ``sbc_claimed``, the web UI with a page naming the holder. Operators
|
|
6
|
+
may override explicitly (``"override_claim": true`` in a REST body, or the
|
|
7
|
+
button on that page); the override is recorded in the activity log.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from flask import g, render_template, request, session, url_for
|
|
13
|
+
|
|
14
|
+
from labctl.core.models import (
|
|
15
|
+
ClaimActor,
|
|
16
|
+
ClaimConflict,
|
|
17
|
+
ClaimRequiredError,
|
|
18
|
+
NotOperatorError,
|
|
19
|
+
UnknownSBCError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# A refusal: the JSON error body and its HTTP status.
|
|
23
|
+
Refusal = tuple[dict, int]
|
|
24
|
+
|
|
25
|
+
_TRUE_VALUES = ("1", "true", "yes", "on")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def port_number(value) -> Optional[int]:
|
|
29
|
+
"""``value`` (an int, or text of decimal digits) when it is a whole
|
|
30
|
+
number from 1 to 65535, else None. Text is length-checked before
|
|
31
|
+
``int()``, which refuses very long digit strings."""
|
|
32
|
+
if isinstance(value, bool):
|
|
33
|
+
return None
|
|
34
|
+
if isinstance(value, str):
|
|
35
|
+
value = value.strip()
|
|
36
|
+
if not value.isdecimal() or len(value) > 5:
|
|
37
|
+
return None
|
|
38
|
+
value = int(value)
|
|
39
|
+
if isinstance(value, int) and 1 <= value <= 65535:
|
|
40
|
+
return value
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_api_request() -> bool:
|
|
45
|
+
return bool(request.endpoint and request.endpoint.startswith("api."))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def web_actor() -> ClaimActor:
|
|
49
|
+
"""Who is acting, for claim checks.
|
|
50
|
+
|
|
51
|
+
With auth, the logged-in or API-key user: their claims from any
|
|
52
|
+
session (REST, the web UI, MCP over HTTP) count as theirs, and their
|
|
53
|
+
role decides whether they may override. Without auth there is one
|
|
54
|
+
shared identity, which is an operator (as unauthenticated MCP is).
|
|
55
|
+
"""
|
|
56
|
+
if not g.config.auth.enabled:
|
|
57
|
+
return ClaimActor(session_id="web:anonymous", operator=True)
|
|
58
|
+
user = getattr(g, "auth_user", None)
|
|
59
|
+
if user is not None:
|
|
60
|
+
return ClaimActor(
|
|
61
|
+
session_id=f"web:{user.username}",
|
|
62
|
+
owner=user.username,
|
|
63
|
+
operator=user.is_operator,
|
|
64
|
+
)
|
|
65
|
+
# A login whose user has since left the config: theirs, but no
|
|
66
|
+
# operator rights.
|
|
67
|
+
name = session.get("user") or "unknown"
|
|
68
|
+
return ClaimActor(session_id=f"web:{name}", owner=name, operator=False)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def web_user_name() -> str:
|
|
72
|
+
"""The name recorded for this caller's claims, releases and requests."""
|
|
73
|
+
actor = web_actor()
|
|
74
|
+
return actor.owner or "web-operator"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def override_requested() -> bool:
|
|
78
|
+
"""``override_claim`` in the JSON body (must be ``true``), the form or
|
|
79
|
+
the query string (``1``, ``true``, ``yes`` or ``on``)."""
|
|
80
|
+
data = request.get_json(silent=True) if request.is_json else None
|
|
81
|
+
if isinstance(data, dict) and "override_claim" in data:
|
|
82
|
+
return data["override_claim"] is True
|
|
83
|
+
value = request.values.get("override_claim", "")
|
|
84
|
+
return str(value).strip().lower() in _TRUE_VALUES
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def claimed_error(sbc_name: str, claim, *, override_hint: bool = True) -> dict:
|
|
88
|
+
"""``sbc_claimed`` for a change to ``sbc_name``; ``claim`` may be on
|
|
89
|
+
another board that shares its hardware."""
|
|
90
|
+
holder_board = claim.sbc_name or sbc_name
|
|
91
|
+
holder = claim.agent_name or "unknown"
|
|
92
|
+
shared = (
|
|
93
|
+
f" (it shares hardware with '{sbc_name}')" if holder_board != sbc_name else ""
|
|
94
|
+
)
|
|
95
|
+
if claim.is_active:
|
|
96
|
+
until = f"until {claim.expires_at.isoformat()}" if claim.expires_at else ""
|
|
97
|
+
hints = [
|
|
98
|
+
"Wait for the claim to expire or be released",
|
|
99
|
+
f"Ask for it: POST /api/claims/{holder_board}/request-release",
|
|
100
|
+
]
|
|
101
|
+
if web_actor().operator:
|
|
102
|
+
if override_hint:
|
|
103
|
+
hints.append(
|
|
104
|
+
'Operator override: repeat with "override_claim": true '
|
|
105
|
+
"(recorded in the activity log)"
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
hints.append(
|
|
109
|
+
f"Operator override: POST /api/claims/{holder_board}"
|
|
110
|
+
"/force-release"
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
# Past its deadline, inside the grace period: only its holder may
|
|
114
|
+
# re-claim it, and release requests only act on active claims.
|
|
115
|
+
until = "(expired, in its grace period)"
|
|
116
|
+
hints = ["Retry once the grace period ends"]
|
|
117
|
+
return {
|
|
118
|
+
"error": "sbc_claimed",
|
|
119
|
+
"sbc_name": holder_board,
|
|
120
|
+
"message": f"SBC '{holder_board}' is claimed by '{holder}' {until}{shared}",
|
|
121
|
+
"claim": claim.to_dict(),
|
|
122
|
+
"hints": hints,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _refusal(exc: Exception, sbc_name: str, for_sbc: Optional[str]) -> Refusal:
|
|
127
|
+
if isinstance(exc, ClaimConflict):
|
|
128
|
+
return claimed_error(for_sbc or sbc_name, exc.claim), 409
|
|
129
|
+
if isinstance(exc, NotOperatorError):
|
|
130
|
+
return (
|
|
131
|
+
{
|
|
132
|
+
"error": "not_operator",
|
|
133
|
+
"sbc_name": sbc_name,
|
|
134
|
+
"message": f"{exc}; your user has role 'agent'",
|
|
135
|
+
"hints": [f"Ask for it: POST /api/claims/{sbc_name}/request-release"],
|
|
136
|
+
},
|
|
137
|
+
403,
|
|
138
|
+
)
|
|
139
|
+
assert isinstance(exc, ClaimRequiredError)
|
|
140
|
+
return (
|
|
141
|
+
{
|
|
142
|
+
"error": "claim_required",
|
|
143
|
+
"sbc_name": sbc_name,
|
|
144
|
+
"message": str(exc),
|
|
145
|
+
"hints": [f"Claim it first: POST /api/claims/{sbc_name}"],
|
|
146
|
+
},
|
|
147
|
+
409,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check(
|
|
152
|
+
sbc_name: str,
|
|
153
|
+
*,
|
|
154
|
+
shared: bool = True,
|
|
155
|
+
sharers: Optional[dict] = None,
|
|
156
|
+
) -> Optional[Refusal]:
|
|
157
|
+
"""Refusal for a change to ``sbc_name`` by this caller, or None to go
|
|
158
|
+
ahead (the caller's own claim is heartbeated; an override is logged).
|
|
159
|
+
|
|
160
|
+
``shared`` also checks boards sharing ``sbc_name``'s hardware; pass
|
|
161
|
+
False for changes to the board's own records. ``sharers`` is hardware
|
|
162
|
+
about to be wired to ``sbc_name`` (keyword arguments for
|
|
163
|
+
ResourceManager.sbcs_sharing_hardware): the boards that already have
|
|
164
|
+
it are checked first, so a claimed board's plug, SDWire or console
|
|
165
|
+
can't be driven through another board. ``claims.require_claim``
|
|
166
|
+
applies to REST calls, not to the web UI. An override is what the
|
|
167
|
+
request asks for (see override_requested).
|
|
168
|
+
"""
|
|
169
|
+
config = g.config
|
|
170
|
+
if not config.claims.enabled:
|
|
171
|
+
return None
|
|
172
|
+
override = override_requested()
|
|
173
|
+
manager = g.manager
|
|
174
|
+
actor = web_actor()
|
|
175
|
+
if sharers:
|
|
176
|
+
others = set(manager.sbcs_sharing_hardware(**sharers)) - {sbc_name}
|
|
177
|
+
try:
|
|
178
|
+
manager.check_claims_of(others, actor, override=override)
|
|
179
|
+
except (ClaimConflict, NotOperatorError) as exc:
|
|
180
|
+
return _refusal(exc, sbc_name, sbc_name)
|
|
181
|
+
try:
|
|
182
|
+
manager.check_claim(
|
|
183
|
+
sbc_name,
|
|
184
|
+
actor,
|
|
185
|
+
override=override,
|
|
186
|
+
require_claim=is_api_request() and config.claims.require_claim,
|
|
187
|
+
include_shared=shared,
|
|
188
|
+
)
|
|
189
|
+
except UnknownSBCError:
|
|
190
|
+
# Callers load the board first, so it was renamed or deleted
|
|
191
|
+
# meanwhile: don't act on what they loaded unchecked.
|
|
192
|
+
return (
|
|
193
|
+
{
|
|
194
|
+
"error": "sbc_changed",
|
|
195
|
+
"sbc_name": sbc_name,
|
|
196
|
+
"message": f"SBC '{sbc_name}' was renamed or removed; retry",
|
|
197
|
+
},
|
|
198
|
+
404,
|
|
199
|
+
)
|
|
200
|
+
except (ClaimConflict, NotOperatorError, ClaimRequiredError) as exc:
|
|
201
|
+
return _refusal(exc, sbc_name, None)
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def refusal_page(refusal: Refusal, sbc_name: str):
|
|
206
|
+
"""The web UI's answer to a refused change: the holder, how to ask for
|
|
207
|
+
the board, and (for operators, on an active claim) a button that
|
|
208
|
+
repeats the change with ``override_claim``."""
|
|
209
|
+
body, status = refusal
|
|
210
|
+
claim = body.get("claim") or {}
|
|
211
|
+
fields = [
|
|
212
|
+
(key, value)
|
|
213
|
+
for key, value in request.form.items(multi=True)
|
|
214
|
+
if key not in ("_csrf_token", "override_claim")
|
|
215
|
+
]
|
|
216
|
+
can_override = (
|
|
217
|
+
body["error"] == "sbc_claimed"
|
|
218
|
+
and web_actor().operator
|
|
219
|
+
and bool(claim.get("is_active", True))
|
|
220
|
+
)
|
|
221
|
+
return (
|
|
222
|
+
render_template(
|
|
223
|
+
"claim_refused.html",
|
|
224
|
+
body=body,
|
|
225
|
+
sbc_name=sbc_name,
|
|
226
|
+
fields=fields,
|
|
227
|
+
action=request.path,
|
|
228
|
+
can_override=can_override,
|
|
229
|
+
back=url_for("views.sbc_detail", name=sbc_name),
|
|
230
|
+
),
|
|
231
|
+
status,
|
|
232
|
+
)
|