SRE.lib_sre
1import datetime 2import enum 3import fnmatch 4import re as _re 5import os 6import shlex 7import socket 8import subprocess 9import sys 10import tempfile 11import math 12 13from dataclasses import asdict 14 15from concurrent.futures import ThreadPoolExecutor, as_completed 16from typing import Tuple 17 18import zstandard as zstd 19 20from pathlib import Path 21 22import msgpack 23import json 24import random 25from netaddr import EUI 26from dataclasses import dataclass, fields 27from ipaddress import ( 28 ip_address, 29 ip_interface, 30 ip_network, 31 IPv4Address, IPv6Address, 32 IPv4Interface, IPv6Interface, 33 IPv4Network, IPv6Network 34) 35 36from Kathara.manager.Kathara import Kathara 37from Kathara.model.Lab import Lab 38 39from . import params 40from .form_spec import FIELD_RE as _FORM_SPEC_FIELD_RE, form_fields as _form_fields 41from .common import (QuestionText, QuestionDummy, GradeElement, GradePart, InfoMachine, InfoLab, InfoInterface, 42 InfoSwitch, TranslatedText, _tt_hash_str) 43from .utils import log_error, error_quit, log_debug 44from .instructor_text import instructor, set_instructor_context, strip_instructor 45from .informations import resolve_informations, resolve_text, info_subtitle, info_tag, info_ref, info_toc # noqa: F401 (the info_* functions are re-exported for the labs) 46from .operations_log import OperationsLog, format_grade, format_total 47from .switch_console import SwitchConsole 48from .params import SRE 49 50# timeout slot of a switch test in Grade0._tests (the console of a switch has its own timeout) 51_SWITCH_TEST_TIMEOUT = 0 52 53 54class ErrorCategory(enum.Enum): 55 ERROR = "ERROR" 56 WARNING = "WARNING" 57 58 59def write_error(string): 60 print(string, file=sys.stderr) 61 62 63def _lookup_translations(caller_globals: dict, text: str, inline: dict) -> dict: 64 """Merge ``_TRANSLATIONS`` from a module's globals with inline kwargs. 65 66 Inline kwargs take priority. ``None`` values in ``_TRANSLATIONS`` are 67 skipped (they mark strings not yet translated). 68 """ 69 result = {} 70 for lang, strings in caller_globals.get('_TRANSLATIONS', {}).items(): 71 val = strings.get(text) 72 if val is not None and lang not in inline: 73 result[lang] = val 74 result.update(inline) 75 return result 76 77 78def tr(text: str, **langs) -> TranslatedText: 79 """Build a TranslatedText with 'en' as the default language key. 80 81 Looks up the caller module's ``_TRANSLATIONS`` dict for any language not 82 already supplied as a keyword argument. Inline kwargs take priority. 83 ``None`` values in ``_TRANSLATIONS`` are skipped (placeholder for 84 untranslated strings). 85 86 ``_TRANSLATIONS`` must be defined **before** the first ``tr()`` call in 87 the file, because module-level expressions are evaluated at import time. 88 89 Usage in lab files:: 90 91 title = tr("My lab") # translation from _TRANSLATIONS 92 title = tr("My lab", fr="Mon TP") # inline, retro-compatible 93 94 For labs whose primary language is not English, use :func:`make_tr` instead. 95 """ 96 merged = _lookup_translations(sys._getframe(1).f_globals, text, langs) 97 return TranslatedText({'en': text, **merged}) 98 99 100def make_tr(default_lang: str, translations: dict | None = None): 101 """Return a tr() function bound to *default_lang* as the first-positional language. 102 103 If *translations* is supplied explicitly it is used directly (no frame 104 inspection). Otherwise the caller module's globals are inspected for a 105 ``_TRANSLATIONS`` dict at each call. 106 107 In both cases ``_TRANSLATIONS`` (or the dict passed as *translations*) must 108 be defined **before** the first ``tr()`` call in the file, because 109 module-level expressions are evaluated at import time. 110 111 Usage with explicit dict (no frame inspection):: 112 113 _TRANSLATIONS = {'fr': {"Mon TP": "My lab"}} 114 tr = make_tr('fr', translations=_TRANSLATIONS) 115 title = tr("Mon TP") # 'en' from _TRANSLATIONS 116 117 Usage with frame inspection:: 118 119 tr = make_tr('fr') 120 _TRANSLATIONS = {'en': {"Mon TP": "My lab"}} # must come before tr() calls 121 title = tr("Mon TP") 122 """ 123 if translations is not None: 124 _wrapped = {'_TRANSLATIONS': translations} 125 126 def _tr(text: str, **langs) -> TranslatedText: 127 merged = _lookup_translations(_wrapped, text, langs) 128 return TranslatedText({default_lang: text, **merged}) 129 else: 130 caller_globals = sys._getframe(1).f_globals 131 132 def _tr(text: str, **langs) -> TranslatedText: 133 merged = _lookup_translations(caller_globals, text, langs) 134 return TranslatedText({default_lang: text, **merged}) 135 136 return _tr 137 138 139def no_tr(text: str) -> str: 140 """Marker for prepare-sre-translations: leave this string untranslated. 141 142 Identity passthrough at runtime (returns *text* unchanged). It is purely a 143 hint to the translation tooling so the wrapped string is never wrapped in 144 tr() nor registered in _TRANSLATIONS — use it for short internal labels 145 (e.g. grade-element titles) that are not natural-language prose. 146 """ 147 return text 148 149 150_PORT_WILDCARD_RE = _re.compile(r'^(\d*)(X+):(\d+)(?:/(\w+))?$', _re.IGNORECASE) 151 152 153def _is_port_free(port: int, proto: str) -> bool: 154 sock_type = socket.SOCK_DGRAM if proto == 'udp' else socket.SOCK_STREAM 155 with socket.socket(socket.AF_INET, sock_type) as s: 156 try: 157 s.bind(('', port)) 158 return True 159 except OSError: 160 return False 161 162 163def _resolve_port(spec: str, used: set) -> str: 164 """Resolve a port spec, replacing trailing X wildcards with a free host port. 165 166 E.g. '80XX:80/tcp' -> '8042:80/tcp' (first free port in 8000-8099). 167 Specs without wildcards are returned unchanged. 168 `used` is updated in-place to track ports already allocated in this call. 169 """ 170 m = _PORT_WILDCARD_RE.match(spec) 171 if not m: 172 return spec 173 prefix_str, x_str, container_port, proto = m.group(1), m.group(2), m.group(3), (m.group(4) or 'tcp').lower() 174 range_size = 10 ** len(x_str) 175 base = (int(prefix_str) if prefix_str else 0) * range_size 176 for offset in range(range_size): 177 host_port = base + offset 178 key = (host_port, proto) 179 if key not in used and _is_port_free(host_port, proto): 180 used.add(key) 181 return f"{host_port}:{container_port}/{proto}" 182 error_quit(f"no free {proto} port in range {base}-{base + range_size - 1}") 183 184 185def _lab_module(): 186 """The imported srelab module (``sys.modules['srelab']``), or ``None`` when no lab is loaded 187 (unit tests, GUI).""" 188 return sys.modules.get(params.srelab_py_name.removesuffix(".py")) 189 190 191def lab_ipv6_default(): 192 """Module-level ``ipv6`` option of the current lab (``params.default_ipv6`` when absent). 193 194 ``None`` means "not specified": Kathara then applies its own ``enable_ipv6`` setting. 195 """ 196 return getattr(_lab_module(), 'ipv6', params.default_ipv6) 197 198 199def _resolve_xauth_cookie(): 200 """Return the validated hex X11 magic cookie from $SRE_XAUTH_COOKIE, or None. 201 202 The env var is set by sre-wrapper (from `xauth list`) and preserved across 203 sudo via env_keep. When `sre start --xauth-file <file>` is used, 204 action_start parses that file early (with privileges) and overrides this 205 env var with the file's cookie before NetScheme is built. 206 """ 207 cookie = os.environ.get(params.sre_xauth_cookie_env_variable) 208 return cookie if cookie and _re.fullmatch(r'[0-9A-Fa-f]+', cookie) else None 209 210 211def _resolve_x11_display(): 212 """Return the validated X display number from $SRE_X11_DISPLAY (digits), or None. 213 214 The env var is set by the web agent, which runs one virtual X server per project on the 215 pool host: the x11_host machines then get SRE_DISPLAY=<sre_host_ip>:<number> and their 216 .bashrc points DISPLAY at it instead of <sre_host_ip>:0. Never set on a workstation. 217 """ 218 number = os.environ.get(params.sre_x11_display_env_variable) 219 return number if number and _re.fullmatch(r'[0-9]{1,4}', number) else None 220 221 222class _AppendOp: 223 """A file-append operation registered via NetScheme0.append_to_file().""" 224 __slots__ = ('filename', 'content', 'permissions', 'owner', 'mtime') 225 226 def __init__(self, filename: str, content: bytes, 227 permissions: int | None, owner: str | None, mtime: float | None): 228 self.filename = filename 229 self.content = content 230 self.permissions = permissions 231 self.owner = owner 232 self.mtime = mtime 233 234 235class _IdempotentAppendOp: 236 """An idempotent file-append operation registered via NetScheme0.idempotent_append_to_file(). 237 238 Appends content only if the file does not already end with it. 239 Optionally sets permissions, ownership, and mtime regardless of whether content was appended. 240 """ 241 __slots__ = ('filename', 'content', 'permissions', 'owner', 'mtime') 242 243 def __init__(self, filename: str, content: bytes, 244 permissions: int | None, owner: str | None, mtime: float | None): 245 self.filename = filename 246 self.content = content 247 self.permissions = permissions 248 self.owner = owner 249 self.mtime = mtime 250 251 252class _FileOp: 253 """A file-write operation registered via NetScheme0.file().""" 254 __slots__ = ('filename', 'content', 'permissions', 'owner', 'mtime') 255 256 def __init__(self, filename: str, content: bytes, permissions: int, owner: str, mtime: float): 257 self.filename = filename 258 self.content = content 259 self.permissions = permissions 260 self.owner = owner 261 self.mtime = mtime 262 263 264class _CmdOp(str): 265 """A container command registered via NetScheme0.cmd(). 266 267 A ``str`` subclass (so every consumer that checks ``isinstance(op, str)`` or writes 268 the op into a startup script keeps working) carrying the command's ``timeout`` in 269 seconds (``0`` = no timeout). 270 """ 271 272 def __new__(cls, command: str, timeout: int = 0): 273 op = super().__new__(cls, command) 274 op.timeout = timeout 275 return op 276 277 278class _HostCmdOp: 279 """A host-side command registered via NetScheme0.host_cmd().""" 280 __slots__ = ('command', 'timeout') 281 282 def __init__(self, command: str, timeout: int = 0): 283 self.command = command 284 self.timeout = timeout 285 286 287class _SwitchCmdOp: 288 """A console command of a managed switch registered via NetScheme0.switch_cmd(). 289 290 Kept with the host operations of its step: it is run by ``sre`` itself, not in a container. 291 """ 292 __slots__ = ('network', 'command') 293 294 def __init__(self, network: str, command: str): 295 self.network = network 296 self.command = command 297 298 299class _CpFromHostOp: 300 """A deferred file-copy-from-host operation; file is read at execution time.""" 301 __slots__ = ('src_path', 'dest', 'permissions', 'owner', 'mtime') 302 303 def __init__(self, src_path, dest: str, permissions, owner: str, mtime): 304 self.src_path = src_path 305 self.dest = dest 306 self.permissions = permissions 307 self.owner = owner 308 self.mtime = mtime 309 310 311class _CpToHostOp: 312 """Copy a file from a container to the host files_dir.""" 313 __slots__ = ('src_path', 'dest_path', 'permissions') 314 315 def __init__(self, src_path: str, dest_path: str, permissions: int = None): 316 self.src_path = src_path # absolute path inside the container 317 self.dest_path = dest_path # resolved absolute path on the host 318 self.permissions = permissions # mode to chmod on the host (None = leave as-is) 319 320 321class _HostCallbackOp: 322 """A host-side Python callback registered via NetScheme0.host_callback().""" 323 __slots__ = ('callback',) 324 325 def __init__(self, callback): 326 self.callback = callback 327 328 329class _TypedContainer: 330 """Dynamic attribute bag accepting only values of ``_item_type`` (see the subclasses). 331 332 Values are serialised with ``str()`` by :meth:`to_dict`; the ``Data0`` containers are 333 rebuilt from those strings by ``Data0.from_dict``. 334 """ 335 _item_type = object 336 _type_name = "object" 337 338 def __setattr__(self, name, value): 339 if not isinstance(value, self._item_type): 340 raise TypeError(f"{name}: expected {self._type_name}, got {type(value).__name__}") 341 super().__setattr__(name, value) 342 343 def __getitem__(self, name): 344 return getattr(self, name) 345 346 def to_dict(self): 347 return {k: str(v) for k, v in self.__dict__.items()} 348 349 350class _IPv4InterfaceContainer(_TypedContainer): 351 """Holds named IPv4Interface attributes. Automatically available as Data0.ips.""" 352 _item_type = IPv4Interface 353 _type_name = "IPv4Interface" 354 355 356class _IPv4NetContainer(_TypedContainer): 357 """Holds named IPv4Network attributes. Automatically available as Data0.nets.""" 358 _item_type = IPv4Network 359 _type_name = "IPv4Network" 360 361 362class _IPv6InterfaceContainer(_TypedContainer): 363 """Holds named IPv6Interface attributes. Automatically available as Data0.ips6.""" 364 _item_type = IPv6Interface 365 _type_name = "IPv6Interface" 366 367 368class _IPv6NetContainer(_TypedContainer): 369 """Holds named IPv6Network attributes. Automatically available as Data0.nets6.""" 370 _item_type = IPv6Network 371 _type_name = "IPv6Network" 372 373 374class _MacContainer(_TypedContainer): 375 """Holds named EUI MAC address attributes. Automatically available as Data0.macs.""" 376 _item_type = EUI 377 _type_name = "EUI" 378 379 380@dataclass 381class Flavor0: 382 """Base class for optional lab-parameterisation dataclasses. 383 384 Subclass with ``@dataclass(slots=True)`` and declare your fields. If the 385 module defines ``flavor_form_at_startup = True``, the GUI renders the 386 ``flavor_form`` string (containing ``@@{field:regex}@@`` markers) as a form 387 before starting the lab. 388 389 Named presets can be attached as class attributes, e.g.:: 390 391 Flavor.easy = Flavor(nb=1) 392 393 and referenced on the CLI with ``--set-flavor-name easy``. 394 395 Override :meth:`allowed_by_user` to restrict which values students may choose. 396 """ 397 398 _registry = {} 399 400 def __init_subclass__(cls, **kwargs): 401 super().__init_subclass__(**kwargs) 402 key = f"{cls.__module__}.{cls.__qualname__}" 403 cls._type_key = key 404 Flavor0._registry[key] = cls 405 406 def to_dict(self): 407 """Serialise dataclass fields to a plain dict using :meth:`Data0._encode_value`.""" 408 return {f.name: Data0._encode_value(getattr(self, f.name)) for f in fields(self)} 409 410 @classmethod 411 def from_dict(cls, d): 412 """Reconstruct a ``Flavor0`` instance from a dict produced by :meth:`to_dict`.""" 413 decoded = {k: Data0._decode_value(v) for k, v in d.items()} 414 return cls(**decoded) # type: ignore[call-arg] 415 416 @classmethod 417 def from_form_dict(cls, d: dict): 418 """Build a Flavor from form field values (strings from text inputs, bools from 419 submit buttons). Declared dataclass fields are coerced to their declared type. 420 Extra keys in d (form fields not declared in the dataclass) are set as plain 421 attributes on the instance so that allowed_by_user() can read them. 422 """ 423 from typing import get_type_hints 424 hints = get_type_hints(cls) 425 declared = {f.name for f in fields(cls)} 426 coerced = {} 427 for f in fields(cls): 428 v = d.get(f.name) 429 if v is None: 430 continue 431 t = hints.get(f.name, str) 432 if not isinstance(t, type) or isinstance(v, t): 433 coerced[f.name] = v 434 elif t == bool: 435 coerced[f.name] = str(v).lower() in ('true', '1', 'yes') 436 elif t == int: 437 coerced[f.name] = int(v) 438 elif t == float: 439 coerced[f.name] = float(v) 440 else: 441 coerced[f.name] = str(v) 442 obj = cls(**coerced) 443 for k, v in d.items(): 444 if k not in declared: 445 setattr(obj, k, v) 446 return obj 447 448 def allowed_by_user(self) -> Tuple[bool, str]: 449 """Return ``(True, "")`` if the student may use this flavor; ``(False, reason)`` otherwise. 450 451 Override in subclasses to restrict which values students can choose. 452 """ 453 return True, "" 454 455 456class Data0: 457 """Base class for lab-specific parameter dataclasses. 458 459 Subclass with ``@dataclass(slots=True)`` and declare your fields normally. 460 Five dynamic containers are injected automatically into every instance by 461 ``__post_init__`` (do not declare fields with these names): 462 463 * ``self.ips`` — :class:`_IPv4InterfaceContainer`: named ``IPv4Interface`` values 464 * ``self.nets`` — :class:`_IPv4NetContainer`: named ``IPv4Network`` values 465 * ``self.ips6`` — :class:`_IPv6InterfaceContainer`: named ``IPv6Interface`` values 466 * ``self.nets6`` — :class:`_IPv6NetContainer`: named ``IPv6Network`` values 467 * ``self.macs`` — :class:`_MacContainer`: named ``EUI`` MAC-address values 468 469 The class-level ``_registry`` maps ``"module.ClassName"`` keys to concrete 470 subclasses so that ``from_dict``/``unpack``/``from_json`` can reconstruct the 471 correct type from serialised data without knowing it in advance. 472 473 Override ``generate(flavor=None)`` as a ``@classmethod`` to produce a fresh 474 randomised instance. The result is serialised to ``data.json`` at lab start 475 and reloaded for each evaluation. 476 """ 477 478 _registry = {} 479 _rng = random.Random() 480 481 def __init_subclass__(cls, **kwargs): 482 super().__init_subclass__(**kwargs) 483 key = f"{cls.__module__}.{cls.__qualname__}" 484 cls._type_key = key 485 Data0._registry[key] = cls 486 487 def __post_init__(self): 488 """Inject ``ips``, ``nets``, ``ips6``, ``nets6``, ``macs``, and ``flavor`` into every instance. 489 490 Called automatically by the dataclass ``__init__``. Uses 491 ``object.__setattr__`` so the injection works even when the subclass 492 declares ``slots=True``. 493 """ 494 # Inject ips/nets/flavor into __dict__ (available even with slots=True subclasses 495 # because Data0 itself has no __slots__, so __dict__ is always inherited). 496 object.__setattr__(self, 'ips', _IPv4InterfaceContainer()) 497 object.__setattr__(self, 'nets', _IPv4NetContainer()) 498 object.__setattr__(self, 'ips6', _IPv6InterfaceContainer()) 499 object.__setattr__(self, 'nets6', _IPv6NetContainer()) 500 object.__setattr__(self, 'macs', _MacContainer()) 501 object.__setattr__(self, 'flavor', None) 502 object.__setattr__(self, '__flavor_name', None) 503 object.__setattr__(self, '__current_srelab_file', None) 504 505 # ---------------- lifecycle hooks ---------------- 506 @classmethod 507 def compute_pre_generate(cls, flavor=None): 508 """Hook called just before generate() and after JSON/msgpack deserialization. 509 510 Override in a subclass to set class-level derived state from *flavor*. 511 The default implementation is a no-op (fully retro-compatible). 512 """ 513 pass 514 515 def compute_post_generate(self): 516 """Hook called after generate() and after JSON/msgpack deserialization. 517 518 Override in a subclass to set instance-level derived state from data fields. 519 The default implementation is a no-op (fully retro-compatible). 520 """ 521 pass 522 523 # ---------------- dict conversion ---------------- 524 def to_dict(self): 525 """Serialise to a plain dict (JSON-safe). 526 527 Dataclass fields are encoded via :meth:`_encode_value`. ``ips``, ``nets``, 528 ``ips6``, ``nets6`` and ``macs`` are stored as nested dicts of strings. An 529 attached ``Flavor`` is stored under the ``"flavor"`` key with its type key. 530 """ 531 result = {} 532 for f in fields(self): 533 v = getattr(self, f.name) 534 result[f.name] = self._encode_value(v) 535 result['ips'] = self.ips.to_dict() 536 result['nets'] = self.nets.to_dict() 537 result[params.data_json_ips6_key] = self.ips6.to_dict() 538 result[params.data_json_nets6_key] = self.nets6.to_dict() 539 result['macs'] = self.macs.to_dict() 540 if self.flavor is not None: 541 result['flavor'] = {"__flavor_type__": self.flavor._type_key, "data": self.flavor.to_dict()} 542 flavor_name = getattr(self, '__flavor_name', None) 543 if flavor_name is not None: 544 result['__flavor_name'] = flavor_name 545 current_srelab_file = getattr(self, '__current_srelab_file', None) 546 if current_srelab_file is not None: 547 result['__current_srelab_file'] = current_srelab_file 548 return result 549 550 @classmethod 551 def from_dict(cls, d): 552 """Reconstruct a ``Data0`` instance from a plain dict produced by :meth:`to_dict`. 553 554 The concrete subclass is resolved from ``d["__type__"]`` when *cls* is 555 ``Data0`` itself; otherwise the calling class is used directly. 556 Raises ``ValueError`` for unknown type keys. 557 """ 558 d = dict(d) 559 ips_data = d.pop('ips', {}) 560 nets_data = d.pop('nets', {}) 561 # files written before IPv6 support have no ips6/nets6 keys: empty containers 562 ips6_data = d.pop(params.data_json_ips6_key, {}) 563 nets6_data = d.pop(params.data_json_nets6_key, {}) 564 macs_data = d.pop('macs', {}) 565 flavor_data = d.pop('flavor', None) 566 flavor_name = d.pop('__flavor_name', None) 567 current_srelab_file = d.pop('__current_srelab_file', None) 568 decoded = {k: cls._decode_value(v) for k, v in d.items()} 569 obj = cls(**decoded) 570 for k, v in ips_data.items(): 571 setattr(obj.ips, k, IPv4Interface(v)) 572 for k, v in nets_data.items(): 573 setattr(obj.nets, k, IPv4Network(v)) 574 for k, v in ips6_data.items(): 575 setattr(obj.ips6, k, IPv6Interface(v)) 576 for k, v in nets6_data.items(): 577 setattr(obj.nets6, k, IPv6Network(v)) 578 for k, v in macs_data.items(): 579 setattr(obj.macs, k, EUI(v)) 580 if flavor_data is not None: 581 flavor_key = flavor_data.get("__flavor_type__") 582 if flavor_key not in Flavor0._registry: 583 raise ValueError(f"unknown Flavor0 type: {flavor_key!r}") 584 flavor_cls = Flavor0._registry[flavor_key] 585 object.__setattr__(obj, 'flavor', flavor_cls.from_dict(flavor_data["data"])) 586 object.__setattr__(obj, '__flavor_name', flavor_name) 587 object.__setattr__(obj, '__current_srelab_file', current_srelab_file) 588 return obj 589 590 # ---------------- value encoding ---------------- 591 @staticmethod 592 def _encode_value(v): 593 """Encode a field value for JSON/msgpack storage. 594 595 * ``IPv4Interface`` / ``IPv6Interface`` → ``{"__iface__": "..."}`` (checked before the 596 addresses: an Interface *is* an Address, and ``ip_address()`` rejects a prefix) 597 * ``IPv4Address`` / ``IPv6Address`` → ``{"__ip__": "..."}`` 598 * ``IPv4Network`` / ``IPv6Network`` → ``{"__net__": "..."}`` 599 * ``EUI`` → ``{"__mac__": "..."}`` 600 * nested ``Data0`` → ``{"__type__": "...", "data": {...}}`` 601 * all other values are returned unchanged. 602 """ 603 if isinstance(v, (IPv4Interface, IPv6Interface)): 604 return {params.data_json_iface_marker: str(v)} 605 if isinstance(v, (IPv4Address, IPv6Address)): 606 return {"__ip__": str(v)} 607 if isinstance(v, (IPv4Network, IPv6Network)): 608 return {"__net__": str(v)} 609 if isinstance(v, EUI): 610 return {params.data_json_mac_marker: str(v)} 611 612 if isinstance(v, Data0): 613 return { 614 "__type__": v._type_key, 615 "data": v.to_dict() 616 } 617 return v 618 619 @staticmethod 620 def _decode_value(v): 621 """Decode a value produced by :meth:`_encode_value` back to its Python type.""" 622 if isinstance(v, dict): 623 if params.data_json_iface_marker in v: 624 return ip_interface(v[params.data_json_iface_marker]) 625 if "__ip__" in v: 626 return ip_address(v["__ip__"]) 627 if "__net__" in v: 628 return ip_network(v["__net__"]) 629 if params.data_json_mac_marker in v: 630 return EUI(v[params.data_json_mac_marker]) 631 if "__type__" in v: 632 type_key = v["__type__"] 633 if type_key not in Data0._registry: 634 raise ValueError(f"unknown Data0 type: {type_key!r}") 635 return Data0._registry[type_key].from_dict(v["data"]) 636 return v 637 638 # ---------------- msgpack ---------------- 639 def pack(self): 640 """Serialise to a msgpack binary blob (includes type key for polymorphic reload).""" 641 return msgpack.packb( 642 { 643 "__type__": self._type_key, 644 "data": self.to_dict() 645 }, 646 use_bin_type=True 647 ) 648 649 @classmethod 650 def unpack(cls, blob): 651 """Deserialise a msgpack blob produced by :meth:`pack`. Raises ``ValueError`` for unknown types.""" 652 obj = msgpack.unpackb(blob, raw=False) 653 type_key = obj.get("__type__") 654 if type_key not in Data0._registry: 655 raise ValueError(f"unknown Data0 type: {type_key!r}") 656 result = Data0._registry[type_key].from_dict(obj["data"]) 657 type(result).compute_pre_generate(result.flavor) 658 result.compute_post_generate() 659 return result 660 661 # ---------------- JSON ---------------- 662 def to_json(self): 663 """Serialise to a JSON string (includes type key for polymorphic reload).""" 664 return json.dumps({ 665 "__type__": self._type_key, 666 "data": self.to_dict() 667 }) 668 669 @classmethod 670 def from_json(cls, s): 671 """Deserialise a JSON string produced by :meth:`to_json`.""" 672 obj = json.loads(s) 673 concrete = Data0._registry[obj["__type__"]] 674 result = concrete.from_dict(obj["data"]) 675 type(result).compute_pre_generate(result.flavor) 676 result.compute_post_generate() 677 return result 678 679 @classmethod 680 def load_from_json_file(cls, filename): 681 """ 682 Load a Data0-derived object from a JSON file. 683 The concrete class is resolved automatically. 684 """ 685 686 path = Path(filename) 687 with path.open("r", encoding="utf-8") as f: 688 obj = json.load(f) 689 concrete = Data0._registry[obj["__type__"]] 690 result = concrete.from_dict(obj["data"]) 691 type(result).compute_pre_generate(result.flavor) 692 result.compute_post_generate() 693 return result 694 695 def save_to_json_file(self, filename): 696 """Write the instance to *filename* as JSON, mode 0o600 (lab secrets stay private).""" 697 path = Path(filename) 698 fd = os.open( 699 path, 700 os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 701 0o600 702 ) 703 with os.fdopen(fd, "w", encoding="utf-8") as f: 704 json.dump( 705 { 706 "__type__": self._type_key, 707 "data": self.to_dict() 708 }, 709 f, 710 indent=2 # optional but operationally useful 711 ) 712 713 714def sre_state(fn=None, *, user_allowed=False, description='', multi_pass=False): 715 """Mark a ``NetScheme`` method as a state. 716 717 Args: 718 user_allowed: students may apply this state themselves (``sre state``). 719 description: label shown in the GUI. 720 multi_pass: re-run the state method before each step and once more after the 721 last one (``max_step + 1`` calls, like ``Grade.grade()``) so that 722 ``cmd()`` / ``host_cmd()`` return the real ``(output, code)`` of 723 commands executed at earlier steps (see 724 :meth:`NetScheme0.iter_state_steps`). Default ``False``: the method 725 runs once and its ops are applied step by step; ``cmd()`` then always 726 returns its placeholder. 727 """ 728 def decorator(f): 729 f._is_sre_state = True 730 f._sre_state_user_allowed = user_allowed 731 f._sre_state_description = description 732 f._sre_state_multi_pass = multi_pass 733 return f 734 735 if fn is not None: 736 return decorator(fn) 737 return decorator 738 739 740class NetScheme0: 741 """Base class for lab network topology definitions. 742 743 Subclasses declare the topology as class-level dicts and implement state 744 methods decorated with :func:`sre_state`. 745 746 Class-level attributes: 747 748 * ``_machine_specs`` — ``{name: {Machine kwargs}}`` — one entry per container. 749 * ``_network_specs`` — ``{net_name: {Network kwargs}}`` — optional: display hints 750 (``color``, ``shape``) and type of switch (``mode``: ``'hub'`` by default, ``'switch'`` or 751 ``'managed'``; for a managed switch ``allow_connection`` and ``vlans``, see :class:`Network`). 752 * ``_topology`` — ``{net_name: [machine, ...]}`` or ``{net_name: {machine: iface}}`` 753 — which machines connect to each network and on which interface. 754 755 If any of these three attributes is not defined in the subclass (neither as a class 756 variable nor as a property), the corresponding attribute is read from ``data`` 757 (``data.topology``, ``data.machine_specs``, ``data.network_specs``). 758 Explicit subclass definitions always take precedence. 759 760 Instance attributes set by ``__init__``: 761 762 * ``self.data`` — the ``Data0`` instance for this lab run. 763 * ``self.informations`` — Markdown text shown in the Informations tab (set this in ``__init__``). 764 * Named machine and network objects (e.g. ``self.router``, ``self.lan``). 765 766 The ``initial`` state method is always called at ``sre start``. Additional 767 states are applied with ``sre state <lab> <state_name>``. 768 """ 769 770 _machine_specs = {} 771 _network_specs = {} # {net_name: {'color': ..., 'mode': ...}} — optional Network kwargs 772 _topology = {} # {net_name: [m, ...] or {m: iface, ...}} — mixed allowed 773 774 def _resolve_spec(self, attr: str, data_attr: str): 775 """Return the topology/spec dict for *attr*. 776 777 Resolution order: 778 1. If the concrete subclass (or any class before ``NetScheme0`` in the MRO) 779 defines *attr* as a class variable or property, return that value. 780 2. Otherwise look for *data_attr* on ``self.data`` (checks instance attributes 781 first, then class-level attributes set e.g. by ``compute_pre_generate``). 782 3. If neither source provides the attribute, return the ``NetScheme0`` default 783 (an empty dict), which is equivalent to "no topology/specs defined". 784 """ 785 for cls in type(self).__mro__: 786 if cls is NetScheme0: 787 break 788 if attr in cls.__dict__: 789 return getattr(self, attr) 790 return getattr(self.data, data_attr, getattr(NetScheme0, attr)) 791 792 def __init__(self, data, running_lab_name, lab_hash=None): 793 """Build all ``Machine``, ``Network``, and ``NetAdapter`` objects from the class-level specs. 794 795 Args: 796 data: a ``Data0`` instance containing lab-specific parameters. 797 running_lab_name: the runtime identifier ``{ts}@@@{lab}@@@{user}``. 798 lab_hash: optional Kathara lab hash (resolved lazily if omitted). 799 """ 800 self.data = data 801 self.running_lab_name = running_lab_name 802 self.debug_project = os.path.exists(params.debug_project_marker_filename(running_lab_name)) 803 self.instructor_mode = os.path.exists(params.instructor_mode_marker_filename(running_lab_name)) 804 # instructor() texts of the lab's __init__ and grade() are kept for the projects of the 805 # instructor: instructor mode, or debug project 806 self.instructor_texts = self.debug_project or self.instructor_mode 807 set_instructor_context(self.instructor_texts) 808 # operations log (GUI "Log" tab), opened on first write: `sre state` / `sre eval` of a debug 809 # project, `sre state` only of an instructor-mode project 810 self.ops_log = OperationsLog(running_lab_name, enabled=self.debug_project or self.instructor_mode, 811 log_evaluations=self.debug_project) 812 self.lab_name = params.get_lab_name_from_running_lab_name(running_lab_name) 813 self.lab_hash = lab_hash 814 self.current_srelab_file = params.get_current_srelab_file_from_running_lab_name(running_lab_name) 815 self.informations = "" 816 817 _machine_specs = self._resolve_spec('_machine_specs', 'machine_specs') 818 _network_specs = self._resolve_spec('_network_specs', 'network_specs') 819 _topology = self._resolve_spec('_topology', 'topology') 820 821 for name, parameters in _machine_specs.items(): 822 setattr(self, name, Machine(name=name, **parameters)) 823 824 # Create networks from _network_specs (with optional color), then remaining from _topology 825 for name, parameters in _network_specs.items(): 826 setattr(self, name, Network(name=name, **parameters)) 827 828 for net_name in _topology: 829 if not hasattr(self, net_name): 830 setattr(self, net_name, Network(name=net_name)) 831 832 # Create NetAdapters from _topology. 833 # Each value is either a list (auto interface) or a dict {machine: iface_spec}. 834 # iface_spec can be: None (auto), an int, or a (int, mac) tuple. 835 # Interface auto-assignment counts prior connections per machine across all networks. 836 _iface_counter: dict[str, int] = {} 837 for net_name, machines in _topology.items(): 838 net = getattr(self, net_name) 839 if isinstance(machines, dict): 840 items = machines.items() 841 else: 842 items = ((m, None) for m in machines) 843 for mname, iface_spec in items: 844 machine = getattr(self, mname) 845 if isinstance(iface_spec, tuple): 846 iface, mac = iface_spec 847 else: 848 iface, mac = iface_spec, None 849 if iface is None: 850 iface = _iface_counter.get(mname, 0) 851 _iface_counter[mname] = max(_iface_counter.get(mname, 0), iface) + 1 852 NetAdapter(network=net, machine=machine, interface=iface, mac=mac) 853 854 # VLANs of the ports of the managed switches (`vlans` of their _network_specs entry) 855 for net in list(self.get_networks()): 856 for mname, spec in net.vlans.items(): 857 machine = self.get_machine(mname) 858 adapter = net.net_adapters.get(machine) if machine is not None else None 859 if adapter is None: 860 raise ValueError(f"network '{net.name}': 'vlans' names '{mname}', " 861 f"which is not a machine of this network") 862 adapter.vlan, adapter.tagged_vlans = parse_port_vlans( 863 spec, f"network '{net.name}', machine '{mname}'") 864 865 self._ops: dict[int, dict[str, list]] = {} # step → machine → [_CmdOp | _FileOp, ...] 866 self._host_ops: dict[int, list] = {} # step → [_HostCmdOp | _HostCallbackOp | _SwitchCmdOp, ...] 867 self.reset_state_results() 868 869 self.net_config = None 870 871 def reset_state_results(self): 872 """Forget every command result and pass counter (called before a state is applied).""" 873 self.step = 0 # number of steps already applied 874 self.max_step = 1 875 self._multi_pass = False # True while iter_state_steps() runs a multi_pass state 876 self._cmd_results = {} # (machine, step) -> {(command, timeout): (output, code)} 877 self._host_cmd_results = {} # step -> {(command, timeout): (output, code)} 878 self._allow_errors_in_cmds = {} # (machine, step, command, timeout) -> True 879 self._allow_errors_in_host_cmds = {} # (step, command, timeout) -> True 880 self._switch_cmd_results = {} # step -> {(network, command): (output, code)} 881 self._allow_errors_in_switch_cmds = {} # (step, network, command) -> True 882 self._once_cache = {} 883 884 def once(self, key, factory): 885 """Return a value computed once per state application, whatever the number of passes. 886 887 In a ``multi_pass`` state the state method runs several times; anything drawn at 888 random or mutated later (e.g. a dict filled by a ``host_callback``) must stay the 889 same object across passes. ``once(key, factory)`` calls ``factory()`` the first 890 time *key* is seen and returns the cached value afterwards. 891 """ 892 if key not in self._once_cache: 893 self._once_cache[key] = factory() 894 return self._once_cache[key] 895 896 def _log_op(self, step, message): 897 """Debug-print a registered op once: on every call in single-pass mode, only in the 898 pass right before its step is applied in multi-pass mode.""" 899 if not SRE.args.debug: 900 return 901 if self._multi_pass and step != self.step + 1: 902 return 903 print(f"[state] {message}", file=sys.stderr) 904 905 def host_interfaces_from_topology(self) -> dict: 906 """Return {machine_name: [net_name, ...]} derived from the resolved topology.""" 907 topology = self._resolve_spec('_topology', 'topology') 908 result = {} 909 for net_name, machines in topology.items(): 910 names = machines.keys() if isinstance(machines, dict) else machines 911 for mname in names: 912 result.setdefault(mname, []).append(net_name) 913 return result 914 915 @sre_state(user_allowed=False) 916 def initial(self): 917 pass 918 919 @sre_state(user_allowed=False) 920 def save(self): 921 """Lifecycle state applied by ``sre save`` just before the container 922 filesystems are captured (e.g. flush a database to disk). Empty by default.""" 923 pass 924 925 @sre_state(user_allowed=False) 926 def restore(self): 927 """Lifecycle state applied by ``sre restore`` on the new instance right after 928 the containers have been redeployed from the saved filesystems. Only files are 929 restored, not processes: relaunch here the daemons that ``initial()`` started 930 with ``cmd()``. Empty by default.""" 931 pass 932 933 @classmethod 934 def get_state_methods(cls): 935 """Return a sorted list of all ``@sre_state``-decorated method names in this class hierarchy.""" 936 names = set() 937 for klass in cls.__mro__: 938 for name, fn in klass.__dict__.items(): 939 if callable(fn) and getattr(fn, '_is_sre_state', False): 940 names.add(name) 941 return sorted(names) 942 943 @classmethod 944 def is_state_user_allowed(cls, state): 945 """Return ``True`` if *state* was decorated with ``@sre_state(user_allowed=True)``.""" 946 for klass in cls.__mro__: 947 fn = klass.__dict__.get(state) 948 if fn is not None and getattr(fn, '_is_sre_state', False): 949 return getattr(fn, '_sre_state_user_allowed', False) 950 return False 951 952 @classmethod 953 def is_state_multi_pass(cls, state): 954 """Return ``True`` if *state* was decorated with ``@sre_state(multi_pass=True)``.""" 955 for klass in cls.__mro__: 956 fn = klass.__dict__.get(state) 957 if fn is not None and getattr(fn, '_is_sre_state', False): 958 return getattr(fn, '_sre_state_multi_pass', False) 959 return False 960 961 @classmethod 962 def get_user_allowed_states(cls): 963 """Return ``{state_name: description}`` for all user-allowed states.""" 964 result = {} 965 for state in cls.get_state_methods(): 966 for klass in cls.__mro__: 967 fn = klass.__dict__.get(state) 968 if fn is not None and getattr(fn, '_is_sre_state', False): 969 if getattr(fn, '_sre_state_user_allowed', False): 970 result[state] = getattr(fn, '_sre_state_description', '') 971 break 972 return result 973 974 def get_data(self): 975 return self.data 976 977 def get_machine(self, machine_name): 978 """Return the :class:`Machine` with *machine_name*, or ``None`` if not found.""" 979 if not hasattr(self, machine_name): 980 return None 981 machine = getattr(self, machine_name) 982 if not isinstance(machine, Machine): 983 return None 984 return machine 985 986 def get_network(self, network_name): 987 """Return the :class:`Network` with *network_name*, or ``None`` if not found.""" 988 if not hasattr(self, network_name): 989 return None 990 machine = getattr(self, network_name) 991 if not isinstance(machine, Network): 992 return None 993 return machine 994 995 def get_machines(self): 996 for _, value in self.__dict__.items(): 997 if isinstance(value, Machine): 998 yield value 999 1000 def get_machine_names(self): 1001 return [m.name for m in self.get_machines()] 1002 1003 def has_privileged_machines(self): 1004 return any(m.privileged for m in self.get_machines()) 1005 1006 def get_accessible_machine_names(self): 1007 return [m.name for m in self.get_machines() 1008 if not m.hidden and m.allow_connection] 1009 1010 def get_visible_machine_names(self): 1011 return [m.name for m in self.get_visibles_machines()] 1012 1013 def get_visibles_machines(self): 1014 for _, value in self.__dict__.items(): 1015 if isinstance(value, Machine): 1016 if not value.hidden: 1017 yield value 1018 1019 def get_networks(self): 1020 for _, value in self.__dict__.items(): 1021 if isinstance(value, Network): 1022 yield value 1023 1024 def get_visible_networks(self): 1025 """Networks with at least one machine that is not hidden: the ones a student sees.""" 1026 for net in self.get_networks(): 1027 if any(not m.hidden for m in net.get_machines()): 1028 yield net 1029 1030 def get_topology(self): 1031 return self._resolve_spec('_topology', 'topology') 1032 1033 def get_machine_specs(self): 1034 return self._resolve_spec('_machine_specs', 'machine_specs') 1035 1036 def get_network_specs(self): 1037 return self._resolve_spec('_network_specs', 'network_specs') 1038 1039 def cmd(self, machine, command, step=1, timeout: int = params.default_state_cmd_timeout, 1040 default_value='', default_code: int = 0, allow_error: bool = False): 1041 """Register a shell command to execute inside *machine*'s container at *step*. 1042 1043 Returns ``(output, exit_code)``. The command runs under ``sh`` through 1044 ``exetests.py`` with *timeout* seconds (``0`` = no timeout; ``-1`` is returned 1045 as exit code on timeout). Until the command has run the placeholder 1046 ``(default_value, default_code)`` is returned; the real result is only visible 1047 in a ``@sre_state(multi_pass=True)`` state, from the next call of the state 1048 method on (its final call sees every result). A non-zero exit code is reported 1049 on stderr unless *allow_error* is set. 1050 """ 1051 if self.max_step < step: 1052 self.max_step = step 1053 self._log_op(step, f"[{machine}] CMD (step={step}, timeout={timeout}): {command}") 1054 self._ops.setdefault(step, {}).setdefault(machine, []).append(_CmdOp(command, timeout)) 1055 if allow_error: 1056 self._allow_errors_in_cmds[(machine, step, command, timeout)] = True 1057 results = self._cmd_results.setdefault((machine, step), {}) 1058 if (command, timeout) not in results: 1059 results[(command, timeout)] = (default_value, default_code) 1060 return results[(command, timeout)] 1061 1062 def host_cmd(self, command, step=1, timeout: int = params.default_state_cmd_timeout, 1063 default_value='', default_code: int = 0, allow_error: bool = False): 1064 """Register a shell command to execute on the **host** (not inside a container) at *step*. 1065 1066 Same contract as :meth:`cmd` (returns ``(stdout, exit_code)``, placeholder until the 1067 command has run). Requires ``params.execute_commands_on_host`` to be enabled; aborts 1068 otherwise. 1069 """ 1070 if params.execute_commands_on_host is False: 1071 sys.exit("host_cmd() is disabled by params.execute_commands_on_host") 1072 if self.max_step < step: 1073 self.max_step = step 1074 self._log_op(step, f"HOST_CMD (step={step}, timeout={timeout}): {command}") 1075 self._host_ops.setdefault(step, []).append(_HostCmdOp(command, timeout)) 1076 if allow_error: 1077 self._allow_errors_in_host_cmds[(step, command, timeout)] = True 1078 results = self._host_cmd_results.setdefault(step, {}) 1079 if (command, timeout) not in results: 1080 results[(command, timeout)] = (default_value, default_code) 1081 return results[(command, timeout)] 1082 1083 def record_cmd_result(self, machine, step, command, timeout, output, code): 1084 """Store the result of a container command run at *step* (called by ``sre state``).""" 1085 self._cmd_results.setdefault((machine, step), {})[(command, timeout)] = (output, code) 1086 1087 def record_host_cmd_result(self, step, command, timeout, output, code): 1088 """Store the result of a host command run at *step* (called by ``sre state``).""" 1089 self._host_cmd_results.setdefault(step, {})[(command, timeout)] = (output, code) 1090 1091 def is_cmd_error_allowed(self, machine, step, command, timeout): 1092 return self._allow_errors_in_cmds.get((machine, step, command, timeout), False) 1093 1094 def is_host_cmd_error_allowed(self, step, command, timeout): 1095 return self._allow_errors_in_host_cmds.get((step, command, timeout), False) 1096 1097 def switch_cmd(self, network, command, step=1, default_value='', default_code: int = 0, 1098 allow_error: bool = False): 1099 """Register a command of the management console of the managed switch *network* at *step*. 1100 1101 *network* is the name of a network declared with ``'mode': 'managed'`` in 1102 ``_network_specs``; *command* is one line of the ``vde_switch`` console, e.g. 1103 ``vlan/create 10``. A word ``@machine`` stands for the number of the port *machine* is 1104 plugged into: ``port/setvlan @pc1 10``. 1105 1106 Same contract as :meth:`host_cmd`: returns ``(output, code)``, the placeholder 1107 ``(default_value, default_code)`` until the command has run. *code* is ``0``, the error 1108 number given by the switch (e.g. ``17``: the VLAN already exists) or ``-2`` when the 1109 command could not be run at all. A non-zero code is reported on stderr unless 1110 *allow_error* is set. The switch commands of a step run with its host operations, in 1111 registration order, before the operations on the containers. 1112 """ 1113 net = self.get_network(network) 1114 if net is None or not net.is_managed(): 1115 raise ValueError(f"switch_cmd(): '{network}' is not a managed switch of this lab") 1116 if self.max_step < step: 1117 self.max_step = step 1118 self._log_op(step, f"[switch {network}] CMD (step={step}): {command}") 1119 self._host_ops.setdefault(step, []).append(_SwitchCmdOp(network, command)) 1120 if allow_error: 1121 self._allow_errors_in_switch_cmds[(step, network, command)] = True 1122 results = self._switch_cmd_results.setdefault(step, {}) 1123 if (network, command) not in results: 1124 results[(network, command)] = (default_value, default_code) 1125 return results[(network, command)] 1126 1127 def record_switch_cmd_result(self, step, network, command, output, code): 1128 """Store the result of a switch command run at *step* (called by ``sre state``).""" 1129 self._switch_cmd_results.setdefault(step, {})[(network, command)] = (output, code) 1130 1131 def is_switch_cmd_error_allowed(self, step, network, command): 1132 return self._allow_errors_in_switch_cmds.get((step, network, command), False) 1133 1134 def host_callback(self, callback, step=1): 1135 """Register a Python callable to invoke on the host at *step* (called with no arguments).""" 1136 self._log_op(step, f"HOST_CALLBACK (step={step}): {getattr(callback, '__name__', repr(callback))}") 1137 self._host_ops.setdefault(step, []).append(_HostCallbackOp(callback)) 1138 1139 def cp_from_host(self, src: str, machine: str, dest: str, owner: str = "root:root", permissions: int = None, 1140 mtime: float = None, step=1): 1141 """Register a file copy from the host into *machine*'s container at *step*. 1142 1143 *src* may be relative (resolved against ``params.files_dir``). 1144 *dest* is the absolute path inside the container. 1145 """ 1146 orig_path = Path(src) 1147 if not orig_path.is_absolute(): 1148 orig_path = Path(params.files_dir(self.running_lab_name)) / orig_path 1149 self._log_op(step, f"[{machine}] CP (step={step}): {orig_path} -> {dest} (owner={owner})") 1150 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1151 _CpFromHostOp(orig_path, dest, permissions, owner, mtime) 1152 ) 1153 1154 def cp_to_host(self, machine: str, path: str, dest: str, permissions: int = None, step=1): 1155 """Copy file `path` from `machine` to the host files_dir. 1156 1157 Args: 1158 machine: container name 1159 path: absolute path of the file inside the container 1160 dest: relative destination path inside params.files_dir(running_lab_name) 1161 permissions: file mode to apply on the host copy (default: None, leave as written) 1162 step: execution step (default 1) 1163 """ 1164 files_dir = Path(params.files_dir(self.running_lab_name)).resolve() 1165 dest_path = (files_dir / dest).resolve() 1166 if not dest_path.is_relative_to(files_dir): 1167 error_quit(f"cp_to_host: dest '{dest}' is outside files_dir '{files_dir}'") 1168 self._log_op(step, f"[{machine}] CP_TO_HOST (step={step}): {path} -> {dest_path}") 1169 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1170 _CpToHostOp(path, str(dest_path), permissions) 1171 ) 1172 1173 def file(self, machine, filename, content, permissions=0o644, owner="root:root", mtime=None, step=1): 1174 """Register a file to create/overwrite on `machine` during the current state. 1175 1176 Args: 1177 machine: machine name (str) 1178 filename: absolute path inside the container (e.g. "/etc/myconfig") 1179 content: file content (str or bytes) 1180 permissions: octal mode (default 0o644) 1181 owner: "user:group" string (default "root:root") 1182 mtime: modification time as a Unix timestamp (float); defaults to now 1183 step: execution step (default 1); higher steps run after lower ones 1184 """ 1185 import time as _time 1186 raw = content.encode() if isinstance(content, str) else content 1187 self._log_op(step, f"[{machine}] FILE (step={step}): {filename} " 1188 f"(permissions={permissions:#o}, owner={owner}, size={len(raw)}B)") 1189 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1190 _FileOp(filename, raw, permissions, owner, mtime if mtime is not None else _time.time()) 1191 ) 1192 1193 def append_to_file(self, machine, filename, content, permissions=None, owner=None, mtime=None, step=1): 1194 """Append content to a file on `machine`; create the file if it does not exist. 1195 1196 Args: 1197 machine: machine name (str) 1198 filename: absolute path inside the container (e.g. "/etc/hosts") 1199 content: content to append (str or bytes) 1200 permissions: octal mode to set after appending (default: leave unchanged) 1201 owner: "user:group" to set after appending (default: leave unchanged) 1202 mtime: modification time as a Unix timestamp (default: leave unchanged) 1203 step: execution step (default 1); higher steps run after lower ones 1204 """ 1205 raw = content.encode() if isinstance(content, str) else content 1206 self._log_op(step, f"[{machine}] APPEND (step={step}): {filename} (size={len(raw)}B)") 1207 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1208 _AppendOp(filename, raw, permissions, owner, mtime) 1209 ) 1210 1211 def idempotent_append_to_file(self, machine, filename, content, permissions=None, owner=None, mtime=None, step=1): 1212 """Append content to a file on `machine` only if the file does not already end with it. 1213 1214 Idempotent version of append_to_file: safe to call multiple times — the content 1215 is appended at most once per application. If permissions, owner, or mtime are 1216 provided they are applied regardless of whether content was appended. 1217 1218 Args: 1219 machine: machine name (str) 1220 filename: absolute path inside the container (e.g. "/etc/hosts") 1221 content: content to append (str or bytes) 1222 permissions: octal mode to set after the check (default: leave unchanged) 1223 owner: "user:group" to set after the check (default: leave unchanged) 1224 mtime: modification time as a Unix timestamp (default: leave unchanged) 1225 step: execution step (default 1); higher steps run after lower ones 1226 """ 1227 raw = content.encode() if isinstance(content, str) else content 1228 self._log_op(step, f"[{machine}] IDEMPOTENT_APPEND (step={step}): {filename} (size={len(raw)}B)") 1229 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1230 _IdempotentAppendOp(filename, raw, permissions, owner, mtime) 1231 ) 1232 1233 def compute_state_ops(self, state): 1234 self._ops = {} 1235 self._host_ops = {} 1236 if not hasattr(self, state): 1237 error_quit(f"state method {state} does not exist") 1238 method = getattr(self, state) 1239 if not callable(method): 1240 error_quit(f"state method {state} not callable") 1241 try: 1242 method() 1243 except Exception as e: 1244 error_quit(f"error during {state} execution: {e}") 1245 self.max_step = max([self.max_step, *self._ops, *self._host_ops]) 1246 return self._ops, self._host_ops 1247 1248 def iter_state_steps(self, state): 1249 """Yield ``(step, {machine: [ops]}, [host_ops])`` for every step of *state*, in order. 1250 1251 Single-pass state (default): the state method is called once and its ops are 1252 yielded step by step. 1253 1254 ``@sre_state(multi_pass=True)``: the state method is called again before each 1255 step, so ``cmd()`` / ``host_cmd()`` calls registered at earlier steps return their 1256 real ``(output, code)``. The method runs ``max_step + 1`` times, like 1257 ``Grade.grade()`` in ``run_tests()``: the call that registers the ops of step N+1 1258 sees the results of steps 1..N, and a final call after the last step sees every 1259 result. If that final call registers a new, higher step the loop goes on with it. 1260 Ops re-registered for an already applied step are ignored. 1261 1262 The caller applies the yielded ops and records command results with 1263 :meth:`record_cmd_result` / :meth:`record_host_cmd_result` before asking for the next 1264 step. 1265 """ 1266 self.reset_state_results() 1267 self._multi_pass = type(self).is_state_multi_pass(state) 1268 if not self._multi_pass: 1269 ops_by_step, host_ops_by_step = self.compute_state_ops(state) 1270 for step in sorted(set(ops_by_step) | set(host_ops_by_step)): 1271 self.step = step 1272 yield step, ops_by_step.get(step, {}), host_ops_by_step.get(step, []) 1273 return 1274 while True: 1275 ops_by_step, host_ops_by_step = self.compute_state_ops(state) # may bump self.max_step 1276 if self.step >= self.max_step: 1277 break # final pass: every result was visible, nothing left to apply 1278 self.step += 1 1279 yield self.step, ops_by_step.get(self.step, {}), host_ops_by_step.get(self.step, []) 1280 1281 def machine_ipv6_enabled(self, machine): 1282 """Effective IPv6 flag of *machine*: ``Machine(ipv6=True/False)`` wins, else the module-level 1283 ``ipv6`` lab option (:func:`lab_ipv6_default`). ``None`` = not specified (Kathara's own 1284 ``enable_ipv6`` setting applies, ``False`` by default).""" 1285 if machine.ipv6 is not None: 1286 return bool(machine.ipv6) 1287 default = lab_ipv6_default() 1288 return None if default is None else bool(default) 1289 1290 def get_new_lab_from_scheme(self): 1291 lab = Lab(name=self.running_lab_name) 1292 # Type of switch of the networks. Nothing is said about a hub, so that a lab with hubs 1293 # only makes the same Kathara calls as before (it also runs with a Kathara or a network 1294 # plugin that knows no switch mode). 1295 for net in self.get_networks(): 1296 if net.mode == params.network_mode_hub or not net.net_adapters: 1297 continue 1298 try: 1299 lab.get_or_new_link(net.name).mode = net.mode 1300 except AttributeError: 1301 error_quit(f"network '{net.name}': mode '{net.mode}' needs a Kathara with switch modes " 1302 f"({params.kathara_hint})") 1303 abb_lab_name = params.get_abbreviated_lab_name_from_running_lab_name(self.running_lab_name) 1304 _used_ports: set = set() 1305 xauth_cookie = _resolve_xauth_cookie() 1306 x11_display = _resolve_x11_display() 1307 for m in self.get_machines(): 1308 m.ports = [_resolve_port(p, _used_ports) for p in m.ports] 1309 m.envs[f"{params.sre_name_env_variable}={abb_lab_name}"] = True 1310 if m.x11_host: 1311 m.envs[f"{params.sre_host_ip_env_variable}={params.sre_host_ip}"] = True 1312 if xauth_cookie: 1313 m.envs[f"{params.sre_xauth_cookie_env_variable}={xauth_cookie}"] = True 1314 if x11_display: 1315 m.envs[f"{params.sre_display_env_variable}={params.sre_host_ip}:{x11_display}"] = True 1316 volumes = [] 1317 if len(m.volumes) > 0: 1318 has_private_volume = False 1319 for v in m.volumes: 1320 if len(v) > 2 and v[3] == 'private': 1321 has_private_volume = True 1322 break 1323 1324 if has_private_volume and params.disable_volume_mount_on_root_partition: 1325 private_mount_dir = self.get_private_mount_dir() 1326 p = Path(private_mount_dir) 1327 while not p.exists(): 1328 p = p.parent 1329 if os.stat(p).st_dev == os.stat('/').st_dev: 1330 error_quit( 1331 f"Machine '{m.name}': private mount dir '{private_mount_dir}' is on the root partition (disable_volume_mount_on_root_partition=True)") 1332 1333 for v in m.volumes: 1334 if len(v) > 2 and v[3] == 'private': 1335 if v[0].startswith('/'): 1336 error_quit(f"volume {v[0]} is private and have an absolute path") 1337 v_host = f"{self.get_private_mount_dir()}/{v[0]}" 1338 os.makedirs(v_host, exist_ok=True) 1339 os.chmod(v_host, 0o777) 1340 else: 1341 if v[0].startswith('/'): 1342 v_host = v[0] 1343 else: 1344 v_host = f"{self.get_user_public_dir()}/{v[0]}" 1345 os.makedirs(v_host, exist_ok=True) 1346 os.chmod(v_host, 0o777) 1347 if v[2] not in ['rw', 'ro']: 1348 error_quit(f"volume mode {v[2]} not supported (only rw and ro)") 1349 1350 volumes.append(f"{v_host}|{v[1]}|{v[2]}") 1351 machine_kwargs = dict(exec_commands=m.exec_commands, 1352 sysctls=m.sysctls, 1353 envs=m.envs, 1354 ports=m.ports, 1355 ulimits=m.ulimits, 1356 volumes=volumes, 1357 shell=m.kathara_shell, 1358 image=m.image, 1359 bridged=m.bridged, 1360 privileged=m.privileged, 1361 entrypoint=m.entrypoint, 1362 ) 1363 # Kathara disables IPv6 in the container unless its `ipv6` meta is true; only say 1364 # something when the lab did (module-level `ipv6` option or Machine(ipv6=...)). 1365 ipv6 = self.machine_ipv6_enabled(m) 1366 if ipv6 is not None: 1367 machine_kwargs['ipv6'] = ipv6 1368 lab.new_machine(m.name, **machine_kwargs) 1369 for net, netAdapter in m.net_adapters.items(): 1370 # VLANs of a port of a managed switch: only passed when the lab declared some 1371 vlan_kwargs = {} 1372 if netAdapter.vlan is not None: 1373 vlan_kwargs['vlan'] = netAdapter.vlan 1374 if netAdapter.tagged_vlans: 1375 vlan_kwargs['tagged_vlans'] = list(netAdapter.tagged_vlans) 1376 lab.connect_machine_to_link(m.name, net.name, machine_iface_number=netAdapter.interface, 1377 mac_address=str(netAdapter.mac).replace('-', 1378 ':').lower() if netAdapter.mac is not None else None, 1379 **vlan_kwargs) 1380 self.lab_hash = lab.hash 1381 return lab 1382 1383 def get_lab_hash(self): 1384 if self.lab_hash is None: 1385 lab = Lab(name=self.running_lab_name) 1386 self.lab_hash = lab.hash 1387 return self.lab_hash 1388 1389 def get_lab_from_kathara(self): 1390 lab_hash = self.get_lab_hash() 1391 return Kathara.get_instance().get_lab_from_api(lab_hash=lab_hash) 1392 1393 def get_public_lab_dir(self): 1394 return params.public_lab_dir(self.running_lab_name) 1395 1396 def get_private_lab_dir(self): 1397 return params.private_lab_dir(self.running_lab_name) 1398 1399 def get_files_dir(self): 1400 return params.files_dir(self.running_lab_name) 1401 1402 def get_private_mount_dir(self): 1403 return params.private_mount_dir(self.running_lab_name) 1404 1405 def get_user_public_dir(self): 1406 try: 1407 return Path(params.link_to_user_public_dir(self.running_lab_name)).readlink() 1408 except OSError: 1409 return Path(params.link_to_user_public_dir(self.running_lab_name)) 1410 1411 def get_shared_dir(self): 1412 return f"{self.get_user_public_dir()}/{params.shared_dir_name}" 1413 1414 def answers_dir(self): 1415 return params.answers_dir(self.running_lab_name) 1416 1417 def answers_file(self): 1418 return params.answers_filename(self.running_lab_name) 1419 1420 1421class NetAdapter: 1422 def __init__(self, network, machine, interface, mac=None, addresses=None): 1423 if addresses is None: 1424 addresses = [] 1425 if not isinstance(machine, Machine): 1426 raise ValueError("machine must an object of class Machine") 1427 if not isinstance(network, Network): 1428 raise ValueError("network must be an instance of Network") 1429 if mac is not None: 1430 first_byte = int(str(mac).replace('-', ':').split(':')[0], 16) 1431 if first_byte & 1: 1432 raise ValueError( 1433 f"MAC address {mac} on machine '{machine.name}': " 1434 f"multicast bit (LSB of first byte) is set — use a unicast address " 1435 f"(first byte must be even, e.g. replace '{hex(first_byte)}' with '{hex(first_byte & 0xfe)}')." 1436 ) 1437 self.network = network 1438 self.machine = machine 1439 self.interface = interface 1440 self.mac = mac 1441 self.addresses = addresses 1442 # port of a managed switch (`vlans` of the network): VLAN of the untagged frames, VLANs 1443 # exchanged tagged; None / [] = default VLAN of the switch 1444 self.vlan = None 1445 self.tagged_vlans = [] 1446 machine.net_adapters[network] = self 1447 network.net_adapters[machine] = self 1448 1449 def switch_port_label(self) -> str: 1450 """Name of this interface on the port of a managed switch (``port/print``): ``m1:eth0``.""" 1451 return f"{self.machine.name}:eth{self.interface}" 1452 1453 1454def _check_vlan_id(value, where): 1455 if type(value) is not int or not params.min_vlan_id <= value <= params.max_vlan_id: 1456 raise ValueError(f"{where}: VLAN {value!r} is invalid (an integer between {params.min_vlan_id} " 1457 f"and {params.max_vlan_id} is expected)") 1458 return value 1459 1460 1461def parse_port_vlans(spec, where: str) -> tuple: 1462 """``(vlan, tagged_vlans)`` of one entry of the ``vlans`` dict of a managed network. 1463 1464 An int is an access port (VLAN of its untagged frames), a list / tuple / set a trunk port 1465 (VLANs exchanged tagged), a dict ``{'vlan': 1, 'trunk': [10, 20]}`` gives both (native VLAN 1466 and tagged VLANs). *where* starts the message of the ``ValueError`` raised on a bad entry. 1467 """ 1468 if isinstance(spec, dict): 1469 unknown = sorted(set(spec) - {'vlan', 'trunk'}) 1470 if unknown: 1471 raise ValueError(f"{where}: unknown key(s) {', '.join(map(repr, unknown))} " 1472 f"('vlan' and 'trunk' are expected)") 1473 vlan, tagged = spec.get('vlan'), spec.get('trunk') 1474 elif isinstance(spec, (list, tuple, set, frozenset)): 1475 vlan, tagged = None, spec 1476 else: 1477 vlan, tagged = spec, None 1478 if tagged is None: 1479 tagged = [] 1480 elif not isinstance(tagged, (list, tuple, set, frozenset)): 1481 tagged = [tagged] 1482 if vlan is not None: 1483 _check_vlan_id(vlan, where) 1484 tagged = sorted({_check_vlan_id(v, where) for v in tagged}) 1485 if vlan is None and not tagged: 1486 raise ValueError(f"{where}: no VLAN given") 1487 if vlan in tagged: 1488 raise ValueError(f"{where}: VLAN {vlan} cannot be both untagged and tagged") 1489 return vlan, tagged 1490 1491 1492class Network: 1493 """One network of the lab (built from a ``NetScheme._network_specs`` entry, or from its 1494 ``_topology`` key alone). 1495 1496 * ``color`` / ``shape``: display hints of the schema. 1497 * ``mode``: the type of switch the machines are plugged into: ``'hub'`` (default, every frame 1498 goes to every machine), ``'switch'`` (it learns the MAC addresses) or ``'managed'`` (a 1499 switch with VLANs and a management console). 1500 * ``allow_connection``: students may open the console of a managed switch (``sre connect``, 1501 *Connect* button of the Networks tab). ``True`` by default. 1502 * ``vlans``: ports of a managed switch, by machine: ``{'pc1': 10}`` (access port), 1503 ``{'r1': [10, 20]}`` (trunk), ``{'r2': {'vlan': 1, 'trunk': [10, 20]}}`` (native VLAN and 1504 trunk). A machine that is not named stays in the default VLAN of the switch. 1505 """ 1506 1507 def __init__(self, name, color=None, shape=None, mode=None, allow_connection=True, vlans=None): 1508 if mode is None: 1509 mode = params.default_network_mode 1510 if mode not in params.network_modes: 1511 raise ValueError(f"network '{name}': mode {mode!r} is invalid " 1512 f"(one of {', '.join(params.network_modes)} is expected)") 1513 if vlans and mode != params.network_mode_managed: 1514 raise ValueError(f"network '{name}': 'vlans' needs 'mode': '{params.network_mode_managed}'") 1515 self.name = name 1516 self.color = color 1517 self.shape = shape 1518 self.mode = mode 1519 self.allow_connection = allow_connection 1520 self.vlans = dict(vlans or {}) 1521 self.net_adapters = {} 1522 1523 def is_managed(self) -> bool: 1524 return self.mode == params.network_mode_managed 1525 1526 def get_machines(self): 1527 for m in self.net_adapters.keys(): 1528 yield m 1529 1530 1531# Kathara's rule for a device name (``Kathara.model.Machine.__init__``), checked by ``Machine()`` so that 1532# ``sre check`` refuses a bad name before any deployment: an uppercase letter, a dash or a dot would 1533# otherwise only fail in ``get_new_lab_from_scheme()``, at ``sre start``. 1534MACHINE_NAME_RE = _re.compile(r'[a-z0-9_]{1,30}') 1535MACHINE_NAME_RULE = "1 to 30 characters among a-z, 0-9 and _ (lowercase only)" 1536 1537 1538class Machine: 1539 """One container of the lab (built from ``NetScheme._machine_specs`` entries). 1540 1541 The name must follow Kathara's device-name rule (``MACHINE_NAME_RULE``): ``ValueError`` otherwise. 1542 1543 Networking options: 1544 1545 * ``ipv6``: ``None`` (default) = the module-level ``ipv6`` lab option decides (and when that 1546 one is absent too, Kathara's own ``enable_ipv6`` setting, ``False`` by default); 1547 ``True``/``False`` = per-machine override. With IPv6 enabled Kathara starts the container 1548 with ``net.ipv6.conf.all.disable_ipv6=0``, ``forwarding=1``, ``accept_ra=0``; otherwise 1549 IPv6 is disabled on every interface. 1550 * ``sysctls``: iterable of ``"net.<key>=<value>"`` strings (a dict is iterated over its 1551 keys, so ``{"net.ipv6.conf.all.accept_ra=2": True}`` works; a ``{name: value}`` dict is 1552 rejected by Kathara). They are merged after Kathara's own defaults and win. 1553 """ 1554 1555 def __init__(self, name, image=params.default_docker_image, bridged=False, x11_host=False, mem="", 1556 cpus=None, ipv6=None, 1557 exec_commands=[], 1558 sysctls={}, 1559 envs={}, 1560 ports=None, ulimits={}, volumes=[], 1561 shell=None, 1562 kathara_shell=None, 1563 privileged=None, entrypoint=None, args=[], 1564 hidden=False, 1565 allow_connection=True, 1566 color=None, 1567 shape=None): 1568 if ports is None: 1569 ports = [] 1570 if not isinstance(name, str) or not MACHINE_NAME_RE.fullmatch(name): 1571 raise ValueError(f"invalid machine name {name!r}: Kathara device names are {MACHINE_NAME_RULE}") 1572 self.name = name 1573 self.image = image 1574 self.bridged = bridged 1575 self.x11_host = x11_host 1576 self.mem = mem 1577 self.cpus = cpus 1578 self.ipv6 = ipv6 1579 self.exec_commands = exec_commands 1580 self.sysctls = sysctls 1581 # Copy: the constructor's mutable default {} is shared across Machines; 1582 # post-construction mutations in get_new_lab_from_scheme (e.g. SRE_HOST_IP 1583 # for x11_host machines) must not leak into machines that didn't opt in. 1584 self.envs = dict(envs) 1585 self.ports = ports 1586 self.ulimits = ulimits 1587 self.volumes = volumes 1588 self.shell = shell 1589 self.kathara_shell = kathara_shell 1590 self.privileged = privileged 1591 self.entrypoint = entrypoint 1592 self.args = args 1593 self.hidden = hidden 1594 self.allow_connection = allow_connection 1595 self.shape = shape 1596 self.color = color 1597 1598 self.net_adapters = {} 1599 1600 if params.disable_volume_mount_on_root_partition: 1601 for v in self.volumes: 1602 v_host = v[0] 1603 if v_host.startswith('/'): 1604 p = Path(v_host) 1605 while not p.exists(): 1606 p = p.parent 1607 if os.stat(p).st_dev == os.stat('/').st_dev: 1608 error_quit( 1609 f"Machine '{self.name}': volume host path '{v_host}' is on the root partition (disable_volume_mount_on_root_partition=True)") 1610 1611 if self.privileged and not params.allow_privileged_machines: 1612 error_quit( 1613 f"Machine '{self.name}': privileged mode is disabled (allow_privileged_machines=False). Can't run in privileged mode") 1614 1615 if not self.bridged and len(self.ports) > 0: 1616 error_quit("To add ports in a machine, you need to activate bridged mode") 1617 1618 1619def build_exetests_string(cmds) -> str: 1620 """Build the ``EXETESTS`` value for *cmds*, an iterable of ``(command, timeout)`` pairs.""" 1621 return params.exetests_separator.join(f"{timeout}:{cmd}" for (cmd, timeout) in cmds) 1622 1623 1624def parse_exetests_output(output: bytes) -> list: 1625 """Parse the stdout of ``exetests.py`` into ``[(command, timeout, result, code), ...]``. 1626 1627 *code* is the command's exit code, ``-1`` on timeout, ``-2`` when exetests printed 1628 something that is not an integer (an exception). A truncated trailer stops the parse. 1629 """ 1630 parsed = [] 1631 output1 = output.decode("utf-8") 1632 separator, _, rest = output1.partition("\n") 1633 output2 = rest.split(f"\n{separator}\n") 1634 for i in range(0, len(output2) - 1, 2): 1635 ligne1 = "" 1636 try: 1637 ligne1, date1, result = output2[i].split("\n", 2) 1638 except ValueError: 1639 result = "" 1640 if not ligne1: 1641 continue 1642 timeout_s, cmd = ligne1.split(":", 1) 1643 timeout = int(timeout_s) 1644 date2, code_s = output2[i + 1].split("\n", 1) 1645 try: 1646 code = int(code_s.strip()) 1647 except ValueError: 1648 code = -2 1649 parsed.append((cmd, timeout, result, code)) 1650 return parsed 1651 1652 1653def run_host_command(command: str, timeout: int, cwd=None) -> tuple: 1654 """Run *command* on the host as the sre user and return ``(stdout, exit_code)``. 1655 1656 Honours ``params.execute_commands_on_host`` (``"shell"`` or ``"split"``). Returns 1657 ``-1`` as exit code on timeout (``timeout <= 0`` disables it) and ``-2`` on any other 1658 failure to run the command. 1659 """ 1660 from .utils_privileges import preexec_drop_to_sre 1661 run_cmd = shlex.split(command) if params.execute_commands_on_host == "split" else command 1662 use_shell = params.execute_commands_on_host == "shell" 1663 try: 1664 proc = subprocess.run( 1665 run_cmd, shell=use_shell, capture_output=True, text=True, 1666 timeout=timeout if timeout > 0 else None, 1667 cwd=cwd, 1668 preexec_fn=preexec_drop_to_sre, 1669 ) 1670 return proc.stdout, proc.returncode 1671 except subprocess.TimeoutExpired: 1672 return '', -1 1673 except Exception: 1674 return '', -2 1675 1676 1677class Grade0: 1678 """Base class for lab evaluation logic. 1679 1680 Subclasses override :meth:`grade` to *register* tests and questions. The 1681 actual execution happens later in :meth:`run_tests`, which drives a 1682 multi-step loop: 1683 1684 1. Call :meth:`grade` to register commands (they return placeholder values). 1685 2. Execute all registered commands in containers (via :mod:`exetests`). 1686 3. Repeat for each additional step (``self.max_step``). 1687 4. Call :meth:`grade` a final time — commands now return real results. 1688 5. Save the archive. 1689 1690 Key instance attributes available inside ``grade()``: 1691 1692 * ``self.data`` — the ``Data0`` instance (via ``net_scheme``). 1693 * ``self.step`` — current step number (1-based). 1694 * ``self.max_step`` — highest step number registered so far. 1695 """ 1696 1697 def __init__(self, net_scheme): 1698 self.net_scheme = net_scheme 1699 self.archive_dirs = [] 1700 self.files_to_save_in_archives = [] 1701 self._tests = None 1702 self._allow_errors_in_tests = None 1703 self.step = 0 1704 self.max_step = 1 1705 self._questions = None 1706 self._questions_order = None 1707 self._questions_current_order = None 1708 self._cheat_answers = {} 1709 self._answers = {} 1710 self._grade_list = [] 1711 self._grades = {} 1712 self._grade_parts: list[GradePart] = [] 1713 self._errors = [] 1714 self._total_grade_self_eval = 0 1715 self._total_max_self_eval = 0 1716 self._total_grade_exo_eval = 0 1717 self._total_max_exo_eval = 0 1718 self._bonus_in_self_eval = False # a bonus element is in the scope: the mark is capped 1719 self._bonus_in_exo_eval = False 1720 self._maximum_mark = params.default_maximum_mark 1721 self._use_numerical_marks = params.use_numerical_marks_by_default 1722 self._display_marks_in_auto_evaluations = params.display_marks_in_auto_evaluations_by_default 1723 self._mark_self_eval = None 1724 self._mark_exo_eval = None 1725 self._section_counter = [] 1726 self.section_fmt = [('R', 1), ('N', 1), ('l', 2), ('N', 3)] 1727 self._eval_date = None 1728 self._re_eval_date = None 1729 self._default_language = 'en' 1730 self.auto_eval_count = 0 1731 self._exam_json = None 1732 self.full_reset() 1733 1734 def full_reset(self): 1735 """Reset all state including tests and answers (called from ``__init__``).""" 1736 self.step = 0 1737 self.max_step = 1 1738 self._tests = {} # keys : (machine,step)->(cmd, timeout)->(result, code)) 1739 self._allow_errors_in_tests = {} 1740 self._switch_tests = set() # (network, step) keys of _tests registered by test_switch() 1741 self._host_tests = {} # step -> {(command, timeout): (result, code)} 1742 self._allow_errors_in_host_tests = {} # (step, command, timeout) -> True 1743 self._answers = {} # hash -> answer 1744 self._eval_date = None 1745 self.reset_before_grade() 1746 1747 def reset_before_grade(self): 1748 """Clear questions, grade list, and section counters before each call to :meth:`grade`.""" 1749 # instructor() texts follow the mode of this project (`is True`: net_scheme may be a stand-in) 1750 set_instructor_context(getattr(self.net_scheme, 'instructor_texts', False) is True) 1751 self._questions = dict() # hash->Question 1752 self._questions_order = dict() # order -> [Question1, Question2, ...] 1753 self._questions_current_order = 100 1754 self._cheat_answers = {} # state->(question_hash->answer) 1755 self._grade_list = [] 1756 self._grades = dict() # title -> GradeElement 1757 self._grade_parts = [] 1758 self._section_counter = [] 1759 1760 def grade(self): 1761 """Override to register tests, questions, and grade elements. 1762 1763 Called multiple times during :meth:`run_tests` — once per step plus a 1764 final time when all results are available. Do **not** produce side-effects 1765 here; only call ``self.test()``, ``self.question_*()`` and 1766 ``self.add_grade_element()`` / ``self.set_grade()``. 1767 """ 1768 self._grade_list = [] 1769 self._grade_parts = [] 1770 1771 def _compute_mark(self, total_grade, total_max, cap=False): 1772 """Compute a mark from a ``(total_grade, total_max)`` pair. 1773 1774 Returns ``None`` if ``total_max == 0``. 1775 Numerical mode (default): rounded to one decimal, scaled to ``_maximum_mark``. 1776 Letter mode: A+/A/B/C/D/F. 1777 With *cap* (bonus elements in the scope), *total_grade* is clamped to *total_max*: 1778 the mark never exceeds ``_maximum_mark``. 1779 """ 1780 if total_max == 0: 1781 return None 1782 if cap: 1783 total_grade = min(total_grade, total_max) 1784 if self._use_numerical_marks: 1785 return math.ceil(10 * self._maximum_mark * total_grade / total_max) / 10 1786 else: 1787 ratio = total_grade / total_max 1788 if ratio >= 18 / 20: 1789 return "A+" 1790 elif ratio >= 16 / 20: 1791 return "A" 1792 elif ratio >= 14 / 20: 1793 return "B" 1794 elif ratio >= 12 / 20: 1795 return "C" 1796 elif ratio >= 10 / 20: 1797 return "D" 1798 else: 1799 return "F" 1800 1801 def mark_self_eval(self): 1802 """Final mark over elements visible in self-eval (scope & SELF_EVAL_SCOPE).""" 1803 return self._compute_mark(self._total_grade_self_eval, self._total_max_self_eval, 1804 cap=self._bonus_in_self_eval) 1805 1806 def mark_exo_eval(self): 1807 """Final mark over elements visible in non-auto eval / outline / sheet.""" 1808 return self._compute_mark(self._total_grade_exo_eval, self._total_max_exo_eval, 1809 cap=self._bonus_in_exo_eval) 1810 1811 def get_data(self): 1812 return self.net_scheme.get_data() 1813 1814 def get_errors(self): 1815 return self._errors 1816 1817 def get_grade_list(self): 1818 return self._grade_list 1819 1820 def get_grade_parts(self): 1821 return self._grade_parts 1822 1823 def get_answers(self): 1824 return self._answers 1825 1826 def get_cheat_answers(self, state: str): 1827 return self._cheat_answers.get(state) 1828 1829 def get_tests(self): 1830 return self._tests 1831 1832 def get_exetests_strings(self, step: int): 1833 result = dict() 1834 for (machine, step1) in self._tests.keys(): 1835 if step != step1: 1836 continue 1837 if (machine, step1) in self._switch_tests: 1838 continue # console commands of a managed switch, not run through exetests 1839 result[machine] = build_exetests_string(self._tests[(machine, step)].keys()) 1840 return result 1841 1842 def get_running_lab_name(self): 1843 return self.net_scheme.running_lab_name 1844 1845 def increment_section_counter(self, level: int): 1846 if len(self._section_counter) < level + 1: 1847 self._section_counter += [0 for i in range(level + 1 - len(self._section_counter))] 1848 else: 1849 self._section_counter = self._section_counter[:(level + 1)] 1850 self._section_counter[level] += 1 1851 1852 def set_section_counter(self, level: int, value: int): 1853 if len(self._section_counter) < level + 1: 1854 self._section_counter += [0 for i in range(level + 1 - len(self._section_counter))] 1855 else: 1856 self._section_counter = self._section_counter[:(level + 1)] 1857 self._section_counter[level] = value 1858 1859 def section(self, level: int = 0, fmt=None, show: int = None, pad: str = None): 1860 self.increment_section_counter(level) 1861 return self.current_section(level, fmt, show, pad) 1862 1863 def current_section(self, level: int = 0, fmt=None, show: int = None, pad: str = None): 1864 # fmt is a list of (type, depth[, prefix]) tuples, one per level. 1865 # type: R=ROMAN, r=roman, L=Letters, l=letters, N=number 1866 # depth: how many consecutive counters to display, ending at this level 1867 # prefix: optional string prepended to the result (default: '') 1868 # e.g. [('R',1),('N',2,' ')] → level 0: "I." / level 1: " I.1." 1869 if fmt is None: 1870 fmt = self.section_fmt 1871 1872 def _to_roman(n: int) -> str: 1873 vals = [ 1874 (1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), 1875 (100, 'C'), (90, 'XC'), (50, 'L'), (40, 'XL'), 1876 (10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I'), 1877 ] 1878 result = '' 1879 for value, numeral in vals: 1880 while n >= value: 1881 result += numeral 1882 n -= value 1883 return result 1884 1885 def _to_capital_letter(n: int) -> str: 1886 result = '' 1887 while n > 0: 1888 n, r = divmod(n - 1, 26) 1889 result = chr(ord('A') + r) + result 1890 return result 1891 1892 def _to_lowercase_letter(n: int) -> str: 1893 result = '' 1894 while n > 0: 1895 n, r = divmod(n - 1, 26) 1896 result = chr(ord('a') + r) + result 1897 return result 1898 1899 def _convert(n: int, fmt_type: str) -> str: 1900 match fmt_type: 1901 case 'R': 1902 return _to_roman(n) 1903 case 'r': 1904 return _to_roman(n).lower() 1905 case 'L': 1906 return _to_capital_letter(n) 1907 case 'l': 1908 return _to_lowercase_letter(n) 1909 case _: 1910 return str(n) 1911 1912 entry = fmt[level] if level < len(fmt) else ('N', level + 1) 1913 _, depth = entry[:2] 1914 prefix = pad if pad is not None else (entry[2] if len(entry) > 2 else '') 1915 if show is not None: 1916 depth = show 1917 start = max(0, level - depth + 1) 1918 parts = [_convert(self._section_counter[j] if j < len(self._section_counter) else 0, 1919 fmt[j][0] if j < len(fmt) else 'N') 1920 for j in range(start, level + 1)] 1921 return prefix + ".".join(parts) + ". " 1922 1923 def load_answers(self): 1924 """Load student answers from ``answers.json`` into ``self._answers``.""" 1925 self._answers = {} 1926 try: 1927 fd = os.open(params.answers_filename(running_lab_name=self.get_running_lab_name()), 1928 os.O_RDONLY | os.O_NOFOLLOW) 1929 with os.fdopen(fd) as f: 1930 self._answers = json.load(f) 1931 except (OSError, json.JSONDecodeError): 1932 self._answers = {} 1933 1934 def get_questions_ordered(self): 1935 q = [] 1936 for _, v in sorted(self._questions_order.items()): 1937 q += v 1938 return q 1939 1940 def export_questions(self): 1941 pass 1942 1943 def test_host(self, command, step=1, timeout: int = params.default_timeout, default_value='', 1944 default_code: int = 0, allow_error: bool = False): 1945 """Register and retrieve the result of a host-side test command. 1946 1947 On the first call (registration pass) returns *default_value* / *default_code*. 1948 On subsequent calls (result pass) returns the actual ``(stdout, exit_code)``. 1949 Set *allow_error* to suppress error recording on non-zero exit. 1950 """ 1951 if params.execute_commands_on_host is False: 1952 sys.exit("test_host() is disabled by params.execute_commands_on_host") 1953 if self.max_step < step: 1954 self.max_step = step 1955 if step not in self._host_tests: 1956 self._host_tests[step] = {} 1957 if allow_error and (step, command, timeout) not in self._allow_errors_in_host_tests: 1958 self._allow_errors_in_host_tests[(step, command, timeout)] = True 1959 if (command, timeout) not in self._host_tests[step]: 1960 self._host_tests[step][(command, timeout)] = (default_value, default_code) 1961 return default_value, default_code 1962 return self._host_tests[step][(command, timeout)] 1963 1964 def _ops_log(self) -> OperationsLog: 1965 """The project's :class:`OperationsLog` for an evaluation: a disabled one when ``net_scheme`` 1966 is a stand-in without it, or when the log keeps the states only (instructor mode).""" 1967 ops_log = getattr(self.net_scheme, 'ops_log', None) 1968 if not ops_log or getattr(ops_log, 'log_evaluations', True) is False: 1969 return OperationsLog.disabled() 1970 return ops_log 1971 1972 def _log_grade_results(self, ops_log) -> None: 1973 """Append the grade elements of the last :meth:`grade` pass, then the totals and marks of 1974 both scopes, to the operations log (debug projects only; nothing without grade element).""" 1975 if not ops_log.enabled or not self._grade_list: 1976 return 1977 1978 def text(value): 1979 return TranslatedText.from_value(value, self._default_language).resolve(self._default_language) 1980 1981 scope_labels = {params.SELF_EVAL_SCOPE: "self-eval only", params.EXO_EVAL_SCOPE: "exo-eval only"} 1982 lines = [format_grade(text(g.title), g.grade, g.max_grade, 1983 part=text(g.grade_part) if g.grade_part else None, 1984 scope=scope_labels.get(g.scope), bonus=g.bonus) 1985 for g in self._grade_list] 1986 maximum = self._maximum_mark if self._use_numerical_marks else None 1987 lines.append(format_total("self-eval", self._total_grade_self_eval, self._total_max_self_eval, 1988 self._mark_self_eval, maximum)) 1989 lines.append(format_total("exo-eval", self._total_grade_exo_eval, self._total_max_exo_eval, 1990 self._mark_exo_eval, maximum)) 1991 ops_log.lines(lines) 1992 1993 def test(self, machine_name, command, step=1, timeout: int = params.default_timeout, default_value='', 1994 default_code: int = 0, allow_error: bool = False): 1995 """Register and retrieve the result of a command executed inside *machine_name*. 1996 1997 On the first call (registration pass) returns *default_value* / *default_code*. 1998 On subsequent calls (result pass) returns the actual ``(stdout, exit_code)``. 1999 *timeout* is in seconds. Set *allow_error* to suppress error recording. 2000 """ 2001 if self.max_step < step: 2002 self.max_step = step 2003 if (machine_name, step) not in self._tests: 2004 self._tests[(machine_name, step)] = {} 2005 if allow_error and (machine_name, step, command, timeout) not in self._allow_errors_in_tests: 2006 self._allow_errors_in_tests[(machine_name, step, command, timeout)] = True 2007 if (command, timeout) not in self._tests[(machine_name, step)]: 2008 self._tests[(machine_name, step)][(command, timeout)] = (default_value, default_code) 2009 return default_value, default_code 2010 return self._tests[(machine_name, step)][(command, timeout)] 2011 2012 def test_switch(self, network_name, command, step=1, default_value='', default_code: int = 0, 2013 allow_error: bool = False): 2014 """Register and retrieve the result of a console command of the managed switch *network_name*. 2015 2016 *network_name* is a network declared with ``'mode': 'managed'``; *command* is one line of 2017 its management console, e.g. ``vlan/print``. A word ``@machine`` stands for the number of 2018 the port *machine* is plugged into (``port/print @pc1``). 2019 2020 Same contract as :meth:`test`: *default_value* / *default_code* on the registration pass, 2021 the actual ``(output, code)`` afterwards. *code* is ``0``, the error number given by the 2022 switch, or ``-2`` when the command could not be run. The result is stored with the tests 2023 of the machines (under the name of the network), so archives keep it. 2024 """ 2025 get_network = getattr(self.net_scheme, 'get_network', None) 2026 if get_network is not None: 2027 net = get_network(network_name) 2028 if net is None or not net.is_managed(): 2029 raise ValueError(f"test_switch(): '{network_name}' is not a managed switch of this lab") 2030 self._switch_tests.add((network_name, step)) 2031 return self.test(network_name, command, step=step, timeout=_SWITCH_TEST_TIMEOUT, 2032 default_value=default_value, default_code=default_code, allow_error=allow_error) 2033 2034 @staticmethod 2035 def _apply_section(section: str, title) -> TranslatedText: 2036 """Prepend *section* to every language value in *title*.""" 2037 tt = TranslatedText.from_value(title) 2038 if not section: 2039 return tt 2040 return TranslatedText({lang: section + text for lang, text in tt.items()}) 2041 2042 def question_text(self, title, section='', description='', hash=None, order=None, default_answer='', 2043 cheat_answers=None): 2044 """Register a free-text question and return the student's current answer string. 2045 2046 Returns *default_answer* if the student has not answered yet. 2047 *cheat_answers* maps state names to answer strings used in automated testing. 2048 """ 2049 title = self._apply_section(section, title) 2050 if order is None: 2051 order1 = self._questions_current_order 2052 self._questions_current_order = ((self._questions_current_order // 100) + 1) * 100 2053 else: 2054 order1 = order 2055 2056 q = QuestionText(title, description, hash, order1) 2057 if order1 not in self._questions_order: 2058 self._questions_order[order1] = [] 2059 self._questions_order[order1].append(q) 2060 2061 if q.question_hash in self._questions: 2062 write_error(f"Duplicate question hash {q.question_hash}") 2063 self._questions[q.question_hash] = q 2064 2065 if cheat_answers is not None: 2066 for state, answer in cheat_answers.items(): 2067 if state not in self._cheat_answers: 2068 self._cheat_answers[state] = {} 2069 self._cheat_answers[state][q.question_hash] = answer 2070 2071 if q.question_hash in self._answers: 2072 return self._answers[q.question_hash] 2073 return default_answer 2074 2075 _FORM_FIELD_RE = _FORM_SPEC_FIELD_RE # the one regex of the grammar (SRE.form_spec.FIELD_RE) 2076 2077 def question_form(self, title, section='', description='', hash=None, order=None, cheat_answers=None): 2078 """Register a form question with inline @@{field_name:spec}@@ fields. 2079 2080 spec: a regex (single-line text input), '>a|b' or 'Label>>>value|...' (dropdown), 2081 '?' / '?true' (checkbox), '+' / '+N' (multi-line free text of N visible lines, 2082 params.default_form_field_lines by default); the grammar is that of SRE.form_spec. 2083 Returns the student's answers as a dict {field_name: value}, or {} if none yet. 2084 cheat_answers format: {state_name: {field_name: value, ...}} 2085 """ 2086 title = self._apply_section(section, title) 2087 from .common import QuestionForm 2088 2089 # The @@{field:spec}@@ markers are language-independent, so when the description is a 2090 # TranslatedText (wrapped in tr()) the fields come from one of its values. The full 2091 # TranslatedText is still stored on the question for per-language rendering. 2092 fields = _form_fields(description) 2093 2094 if order is None: 2095 order1 = self._questions_current_order 2096 self._questions_current_order = ((self._questions_current_order // 100) + 1) * 100 2097 else: 2098 order1 = order 2099 2100 q = QuestionForm(title, description, hash, order1, fields) 2101 if order1 not in self._questions_order: 2102 self._questions_order[order1] = [] 2103 self._questions_order[order1].append(q) 2104 2105 if q.question_hash in self._questions: 2106 write_error(f"Duplicate question hash {q.question_hash}") 2107 self._questions[q.question_hash] = q 2108 2109 if cheat_answers is not None: 2110 for state, field_answers in cheat_answers.items(): 2111 if state not in self._cheat_answers: 2112 self._cheat_answers[state] = {} 2113 self._cheat_answers[state][q.question_hash] = json.dumps( 2114 field_answers, ensure_ascii=False 2115 ) 2116 2117 if q.question_hash in self._answers: 2118 try: 2119 return json.loads(self._answers[q.question_hash]) 2120 except (json.JSONDecodeError, TypeError): 2121 return {} 2122 return {} 2123 2124 def question_dummy(self, title, section='', description='', hash=None, order=None): 2125 """Register a display-only block (no answer widget shown to the student).""" 2126 title = self._apply_section(section, title) 2127 if order is None: 2128 order1 = self._questions_current_order 2129 self._questions_current_order = ((self._questions_current_order // 100) + 1) * 100 2130 else: 2131 order1 = order 2132 2133 q = QuestionDummy(title, description, hash, order1) 2134 if order1 not in self._questions_order: 2135 self._questions_order[order1] = [] 2136 self._questions_order[order1].append(q) 2137 2138 if q.question_hash in self._questions: 2139 write_error(f"Duplicate question hash {q.question_hash}") 2140 self._questions[q.question_hash] = q 2141 2142 def add_grade_part(self, title, description=''): 2143 """Register a new :class:`GradePart` group and return it. 2144 2145 Pass the returned object to ``add_grade_element(..., grade_part=...)`` 2146 to associate elements with this part. Parts are displayed in 2147 registration order (with a subtotal row per part) in the GUI 2148 evaluation view and in ``sre outline`` PDFs. 2149 """ 2150 if description: 2151 description = TranslatedText.from_value(description, self._default_language) 2152 gp = GradePart(title=title, description=description) 2153 if any(p.title == title for p in self._grade_parts): 2154 write_error(f"Duplicate grade part title = {title}") 2155 self._grade_parts.append(gp) 2156 return gp 2157 2158 def add_grade_element(self, title, max_grade, description='', grade=0, scope=params.BOTH_EVAL_SCOPE, 2159 grade_part=None, bonus=False): 2160 """Add a graded rubric item. Initial *grade* defaults to 0; use :meth:`set_grade` to update it. 2161 2162 ``scope`` is a bitmask: ``SELF_EVAL_SCOPE`` (1) for self-eval only, 2163 ``EXO_EVAL_SCOPE`` (2) for non-auto eval / outline / sheet only, 2164 ``BOTH_EVAL_SCOPE`` (3, default) for both audiences. 2165 2166 ``grade_part`` optionally associates this element with a 2167 :class:`GradePart` previously returned by :meth:`add_grade_part`. 2168 2169 ``bonus`` makes a bonus element: its grade counts in the totals but its 2170 *max_grade* does not (neither in the total nor in the subtotal of its part), 2171 and the mark is capped at the maximum mark. The reports still show its 2172 *max_grade*, with the word "Bonus". 2173 """ 2174 if scope not in params.grade_scopes: 2175 raise ValueError(f"Invalid scope {scope!r}; expected one of {params.grade_scopes}") 2176 if description: 2177 description = TranslatedText.from_value(description, self._default_language) 2178 grade_part_title = None 2179 if grade_part is not None: 2180 if not isinstance(grade_part, GradePart): 2181 raise TypeError(f"grade_part must be a GradePart, got {type(grade_part).__name__}") 2182 if grade_part not in self._grade_parts: 2183 write_error(f"Unregistered grade part {grade_part.title!r} passed to add_grade_element") 2184 grade_part_title = grade_part.title 2185 g = GradeElement(title=title, max_grade=max_grade, description=description, grade=grade, scope=scope, 2186 grade_part=grade_part_title, bonus=bool(bonus)) 2187 self._grade_list.append(g) 2188 key = _tt_hash_str(title) 2189 if key in self._grades: 2190 write_error(f"Duplicate grade title = {title}") 2191 self._grades[key] = g 2192 2193 def set_grade(self, title, grade): 2194 """Set the numeric *grade* for the element previously registered under *title*.""" 2195 self._grades[_tt_hash_str(title)].grade = grade 2196 2197 def save_lab_info(self): 2198 debug_project = os.path.exists( 2199 params.debug_project_marker_filename(self.net_scheme.running_lab_name) 2200 ) 2201 instructor_mode = os.path.exists( 2202 params.instructor_mode_marker_filename(self.net_scheme.running_lab_name) 2203 ) 2204 if debug_project: 2205 visible_machines = list(self.net_scheme.get_machines()) 2206 else: 2207 visible_machines = [m for m in self.net_scheme.get_machines() if not m.hidden] 2208 lab_hash = self.net_scheme.get_lab_hash() 2209 2210 def _get_stats(m): 2211 s = next(Kathara.get_instance().get_machine_stats(lab_hash=lab_hash, machine_name=m.name), None) 2212 return m, s 2213 2214 stats_map = {} 2215 with ThreadPoolExecutor(max_workers=min(params.max_docker_concurrency, len(visible_machines))) as executor: 2216 for m, s in executor.map(_get_stats, visible_machines): 2217 stats_map[m.name] = (m, s) 2218 2219 info_machines = [] 2220 for m in visible_machines: 2221 m, s = stats_map[m.name] 2222 interfaces = [] 2223 for net, netAdapter in m.net_adapters.items(): 2224 interfaces.append(InfoInterface(network=net.name, interface_name=f"eth{netAdapter.interface}")) 2225 info_machines.append( 2226 InfoMachine(name=m.name, status=s.status if s is not None else "", hidden=m.hidden, 2227 bridged=m.bridged, 2228 x11_host=m.x11_host, 2229 ports=m.ports, 2230 allow_connection=m.allow_connection, 2231 color=m.color or "", 2232 shape=m.shape or "", 2233 interfaces=interfaces)) 2234 2235 network_colors = { 2236 net.name: net.color 2237 for m in visible_machines 2238 for net in m.net_adapters 2239 if net.color 2240 } 2241 network_shapes = { 2242 net.name: net.shape 2243 for m in visible_machines 2244 for net in m.net_adapters 2245 if net.shape 2246 } 2247 # the networks a student sees (every network of a debug project), as switches 2248 visible_networks = (self.net_scheme.get_networks() if debug_project 2249 else self.net_scheme.get_visible_networks()) 2250 info_switches = [InfoSwitch(name=net.name, mode=net.mode, allow_connection=bool(net.allow_connection)) 2251 for net in visible_networks if net.net_adapters] 2252 2253 module_rvlab = sys.modules[params.srelab_py_name.removesuffix(".py")] 2254 default_language = getattr(module_rvlab, 'default_language', 'en') 2255 self._default_language = default_language 2256 show_nat_network = getattr(module_rvlab, 'show_nat_network', params.default_show_nat_network) 2257 nat_network_name = getattr(module_rvlab, 'host_network_name', params.default_host_network_name) 2258 nat_network_color = getattr(module_rvlab, 'host_network_color', params.default_host_network_color) 2259 host_network_exploded = getattr(module_rvlab, 'host_network_exploded', 2260 params.default_host_network_exploded) 2261 host_network_edge_relative_length = float(getattr( 2262 module_rvlab, 'host_network_edge_relative_length', 2263 params.default_host_network_edge_relative_length)) 2264 schema_splines = getattr(module_rvlab, 'schema_splines', params.graphviz_default_splines) 2265 schema_overlap = getattr(module_rvlab, 'schema_overlap', params.graphviz_default_overlap) 2266 2267 if self.step == 0: 2268 self.grade() 2269 self.step += 1 2270 2271 questions = self.get_questions_ordered() 2272 2273 if hasattr(module_rvlab, 'title'): 2274 title = module_rvlab.title 2275 else: 2276 lab_name = params.get_lab_name_from_running_lab_name(self.get_running_lab_name()) 2277 title = lab_name.removesuffix('.py') 2278 title = TranslatedText.from_value(title, default_language) 2279 2280 if hasattr(module_rvlab, 'delay_between_self_grade'): 2281 delay_between_self_grade = module_rvlab.delay_between_self_grade 2282 else: 2283 delay_between_self_grade = 0 2284 2285 if hasattr(module_rvlab, 'allow_self_grade'): 2286 allow_self_grade = module_rvlab.allow_self_grade 2287 else: 2288 allow_self_grade = False 2289 2290 if debug_project: 2291 delay_between_self_grade = 0 2292 allow_self_grade = True 2293 if hasattr(module_rvlab, 'export_kathara_project'): 2294 export_kathara_project = module_rvlab.export_kathara_project 2295 else: 2296 export_kathara_project = False 2297 2298 eval_interval_without_exam_mode = getattr(module_rvlab, 'eval_interval_without_exam_mode', params.default_eval_interval_without_exam_mode) 2299 eval_before_exit = getattr(module_rvlab, 'eval_before_exit', False) 2300 allow_save_restore = getattr(module_rvlab, 'allow_save_restore', False) 2301 export_schema_pdf = bool(getattr(module_rvlab, 'export_schema_pdf', True)) 2302 2303 informations = TranslatedText.from_value(self.net_scheme.informations, default_language) 2304 for q in questions: 2305 q.title = TranslatedText.from_value(q.title, default_language) 2306 q.description = TranslatedText.from_value(q.description, default_language) 2307 # numbered sections, references and table of contents (markers of SRE.informations): 2308 # the numbers come from the course, the questions refer to them (bare numbers, no link) 2309 informations, numbering = resolve_informations(informations) 2310 for q in questions: 2311 q.title = resolve_text(q.title, numbering) 2312 q.description = resolve_text(q.description, numbering) 2313 2314 net_scheme_cls = type(self.net_scheme) 2315 # debug and instructor-mode projects: every state can be applied; the ones a student 2316 # could not apply are listed in admin_only_states 2317 all_states_allowed = debug_project or instructor_mode 2318 if all_states_allowed: 2319 user_allowed_states_raw = {} 2320 for state in net_scheme_cls.get_state_methods(): 2321 if state in params.lifecycle_state_names: 2322 continue 2323 desc = '' 2324 for klass in net_scheme_cls.__mro__: 2325 fn = klass.__dict__.get(state) 2326 if fn is not None and getattr(fn, '_is_sre_state', False): 2327 desc = getattr(fn, '_sre_state_description', '') 2328 break 2329 user_allowed_states_raw[state] = desc 2330 else: 2331 user_allowed_states_raw = ( 2332 net_scheme_cls.get_user_allowed_states() 2333 if getattr(module_rvlab, 'allow_user_states', False) else {} 2334 ) 2335 user_allowed_states = { 2336 state: resolve_text(TranslatedText.from_value(desc, default_language), numbering) if desc else desc 2337 for state, desc in user_allowed_states_raw.items() 2338 } 2339 2340 if not (instructor_mode or debug_project): 2341 # info.json is readable by the students: whatever the instructor() calls returned, 2342 # no instructor fragment leaves a project that is neither in instructor mode nor a 2343 # debug project 2344 title = strip_instructor(title) 2345 informations = strip_instructor(informations) 2346 for q in questions: 2347 q.title = strip_instructor(q.title) 2348 q.description = strip_instructor(q.description) 2349 user_allowed_states = {state: strip_instructor(desc) 2350 for state, desc in user_allowed_states.items()} 2351 2352 if all_states_allowed: 2353 module_allows_user_states = getattr(module_rvlab, 'allow_user_states', False) 2354 admin_only_states = [ 2355 state for state in user_allowed_states_raw 2356 if not module_allows_user_states 2357 or not net_scheme_cls.is_state_user_allowed(state) 2358 ] 2359 else: 2360 admin_only_states = [] 2361 2362 info = InfoLab(lab_name=self.net_scheme.lab_name, lab_hash=self.net_scheme.lab_hash, 2363 title=title, machines=info_machines, delay_between_self_grade=delay_between_self_grade, 2364 questions=questions, informations=informations, 2365 export_kathara_project=export_kathara_project, allow_self_grade=allow_self_grade, 2366 debug_project=debug_project, 2367 instructor_mode=instructor_mode, 2368 eval_interval_without_exam_mode=eval_interval_without_exam_mode, 2369 eval_before_exit=eval_before_exit, 2370 allow_save_restore=allow_save_restore, 2371 export_schema_pdf=export_schema_pdf, 2372 default_language=default_language, 2373 user_allowed_states=user_allowed_states, 2374 admin_only_states=admin_only_states, 2375 network_colors=network_colors, 2376 network_shapes=network_shapes, 2377 show_nat_network=show_nat_network, 2378 nat_network_name=nat_network_name, 2379 nat_network_color=nat_network_color, 2380 host_network_exploded=host_network_exploded, 2381 host_network_edge_relative_length=host_network_edge_relative_length, 2382 schema_splines=schema_splines, 2383 schema_overlap=schema_overlap, 2384 switches=info_switches) 2385 2386 info_json = info.to_json() 2387 info_filename = params.info_filename(self.net_scheme.running_lab_name) 2388 2389 save_info_json = None 2390 try: 2391 with open(info_filename, "r") as f: 2392 save_info_json = f.read() 2393 except FileNotFoundError: 2394 pass 2395 if save_info_json == info_json: 2396 return 2397 2398 temp_file = tempfile.NamedTemporaryFile( 2399 delete=False, 2400 dir=Path(self.net_scheme.get_public_lab_dir()).parent) 2401 with open(temp_file.name, "w") as f: 2402 print(info_json, file=f) 2403 f.flush() 2404 os.fsync(f.fileno()) 2405 os.chmod(temp_file.name, 0o644) 2406 if os.geteuid() == 0: 2407 # root is only dropped temporarily for a lab with privileged machines, and the 2408 # Kathara calls above leave the euid at 0: info.json stays sre's 2409 os.fchown(f.fileno(), params.sre_uid, -1) 2410 os.replace(temp_file.name, info_filename) 2411 2412 def add_error(self, error, category=ErrorCategory.ERROR, step: int = 1): 2413 if self.step != step: 2414 return 2415 log_error(error) 2416 self._errors.append((category.value, error)) 2417 2418 def add_warning(self, warning, step: int = 1): 2419 self.add_error(warning, category=ErrorCategory.WARNING, step=step) 2420 2421 @staticmethod 2422 def run_tests_on_machine(machine_name, machine, exetests): 2423 environment = {params.exetests_env_name: exetests} 2424 code, output = machine.api_object.exec_run([params.exetests_machines_path], 2425 stdin=False, 2426 stdout=True, 2427 stderr=False, 2428 tty=False, 2429 environment=environment, 2430 workdir="/", 2431 ) 2432 return machine_name, code, output 2433 2434 def run_tests(self): 2435 self._tests = {} 2436 self._section_counter = [] 2437 lab = self.net_scheme.get_lab_from_kathara() 2438 self._errors = [] 2439 self.load_answers() 2440 self._eval_date = datetime.datetime.now().isoformat() 2441 ops_log = self._ops_log() 2442 ops_log.begin("evaluation") 2443 switch_console = SwitchConsole(self.net_scheme) 2444 2445 while self.step <= self.max_step: 2446 self.reset_before_grade() 2447 self.grade() 2448 self.step += 1 2449 tests = self.get_tests() 2450 exetests_by_machine = self.get_exetests_strings(self.step) 2451 if SRE.args.debug and self.step <= self.max_step: 2452 log_debug(f"Commands step {self.step} (after running grade on step {self.step - 1}):") 2453 for machine, cmds in exetests_by_machine.items(): 2454 c = cmds.split(params.exetests_separator) 2455 c1 = " - ".join(c) 2456 log_debug(f"{machine}: {c1}") 2457 2458 results = {} 2459 with ThreadPoolExecutor( 2460 max_workers=max(1, min(params.max_docker_concurrency, len(lab.machines)))) as executor: 2461 futures_to_machines = { 2462 executor.submit( 2463 Grade0.run_tests_on_machine, 2464 machine_name, 2465 machine, 2466 exetests_by_machine[machine_name], 2467 ): machine_name 2468 for machine_name, machine in lab.machines.items() 2469 if machine_name in exetests_by_machine and len(exetests_by_machine[machine_name]) > 0 2470 } 2471 for future in as_completed(futures_to_machines): 2472 machine_name = futures_to_machines[future] 2473 try: 2474 machine_name, code, output = future.result() 2475 results[machine_name] = (code, output) 2476 except Exception as e: 2477 self.add_error(f"error during test execution on machine {machine_name}: {e}", step=self.step) 2478 ops_log.op(self.step, machine_name, f"error during test execution: {e}") 2479 continue 2480 2481 for machine_name, (exetests_code, output) in results.items(): 2482 if (machine_name, self.step) not in self._tests: 2483 self._tests[(machine_name, self.step)] = {} 2484 if exetests_code != 0: 2485 self.add_error( 2486 f"exetests error on {machine_name}: {exetests_by_machine[machine_name]} -- return code {exetests_code}", 2487 step=self.step) 2488 ops_log.op(self.step, machine_name, f"exetests error: return code {exetests_code}") 2489 parsed = parse_exetests_output(output) 2490 for cmd, timeout, result, code in parsed: 2491 if code == -2: 2492 self.add_error(f"test error on {machine_name}:{self.step}:{cmd} illegal error code", 2493 step=self.step) 2494 if code != 0: 2495 if not self._allow_errors_in_tests.get((machine_name, self.step, cmd, timeout), False): 2496 self.add_error(f"test error on {machine_name}:{cmd} code={code}", step=self.step) 2497 self._tests[(machine_name, self.step)][cmd, timeout] = (result, code) 2498 ops_log.cmds(self.step, machine_name, [(cmd, result, code) for cmd, _timeout, result, code in parsed]) 2499 if SRE.args.debug: 2500 for machine_name in lab.machines: 2501 if (machine_name, self.step) not in self._tests: 2502 continue 2503 for (cmd, timeout) in self._tests[(machine_name, self.step)]: 2504 log_debug(f"machine {machine_name} - step {self.step} - command {cmd} - timeout {timeout}:") 2505 result, code = self._tests[(machine_name, self.step)][cmd, timeout] 2506 log_debug(result) 2507 log_debug(f"-------- exit code {code}\n") 2508 2509 # console commands of the managed switches (test_switch()), run by sre itself 2510 for network_name in sorted(n for n, s in self._switch_tests if s == self.step): 2511 switch_cmds = self._tests.get((network_name, self.step), {}) 2512 entries = [] 2513 for cmd, timeout in list(switch_cmds): 2514 result, code = switch_console.run(network_name, cmd) 2515 if code != 0 and not self._allow_errors_in_tests.get( 2516 (network_name, self.step, cmd, timeout), False): 2517 self.add_error(f"test error on switch {network_name}:{cmd} code={code}", step=self.step) 2518 switch_cmds[(cmd, timeout)] = (result, code) 2519 entries.append((cmd, result, code)) 2520 if SRE.args.debug: 2521 log_debug(f"switch {network_name} - step {self.step} - command {cmd}:") 2522 log_debug(result) 2523 log_debug(f"-------- exit code {code}\n") 2524 ops_log.cmds(self.step, OperationsLog.switch(network_name), entries) 2525 2526 host_step_cmds = self._host_tests.get(self.step, {}) 2527 if host_step_cmds: 2528 def _run_host_cmd(cmd, t): 2529 return (cmd, t, *run_host_command(cmd, t)) 2530 2531 with ThreadPoolExecutor(max_workers=min(params.max_docker_concurrency, 2532 len(host_step_cmds))) as executor: 2533 futures = {executor.submit(_run_host_cmd, cmd, t): (cmd, t) 2534 for (cmd, t) in host_step_cmds} 2535 for future in as_completed(futures): 2536 cmd, t, result, code = future.result() 2537 if code != 0: 2538 if not self._allow_errors_in_host_tests.get((self.step, cmd, t), False): 2539 self.add_error(f"host test error: {cmd} code={code}", step=self.step) 2540 self._host_tests[self.step][(cmd, t)] = (result, code) 2541 ops_log.cmd(self.step, OperationsLog.HOST, cmd, result, code) 2542 if SRE.args.debug: 2543 log_debug(f"host - step {self.step} - command {cmd} - timeout {t}:") 2544 log_debug(result) 2545 log_debug(f"-------- exit code {code}\n") 2546 2547 if SRE.args.debug and len(self._errors) > 0: 2548 log_debug(f"{len(self._errors)} errors:\n " + "\n".join(f"[{e[0]}] {e[1]}" for e in self._errors)) 2549 self.compute_total() 2550 self._mark_self_eval = self.mark_self_eval() 2551 self._mark_exo_eval = self.mark_exo_eval() 2552 self._log_grade_results(ops_log) 2553 # log_error(f"DEBUG _tests at save: {len(self._tests)} machine-step entries") 2554 # for (machine, step), cmds in self._tests.items(): 2555 # for (cmd, timeout), (result, code) in cmds.items(): 2556 # log_error(f" [{machine}][step={step}] cmd={cmd!r} result_len={len(result)} code={code}") 2557 2558 def compute_total(self): 2559 """Accumulate per-scope totals in one pass; BOTH-scope elements contribute to both. 2560 2561 The grade of a bonus element counts, its max_grade does not; the scope then 2562 remembers it has bonus elements (its mark is capped at the maximum mark).""" 2563 self._total_grade_self_eval = 0 2564 self._total_max_self_eval = 0 2565 self._total_grade_exo_eval = 0 2566 self._total_max_exo_eval = 0 2567 self._bonus_in_self_eval = False 2568 self._bonus_in_exo_eval = False 2569 for g in self._grade_list: 2570 if g.scope & params.SELF_EVAL_SCOPE: 2571 self._total_grade_self_eval += g.grade 2572 if g.bonus: 2573 self._bonus_in_self_eval = True 2574 else: 2575 self._total_max_self_eval += g.max_grade 2576 if g.scope & params.EXO_EVAL_SCOPE: 2577 self._total_grade_exo_eval += g.grade 2578 if g.bonus: 2579 self._bonus_in_exo_eval = True 2580 else: 2581 self._total_max_exo_eval += g.max_grade 2582 2583 def save_tests(self): 2584 now = datetime.datetime.now() 2585 if self._exam_json is None: 2586 exam_path = Path(params.sre_pub_dir) / params.exam_json_name 2587 try: 2588 self._exam_json = json.loads(exam_path.read_text()) 2589 except FileNotFoundError: 2590 pass 2591 except Exception as e: 2592 log_error(f"can't read {exam_path}: {e}") 2593 for d1 in self.archive_dirs: 2594 d = Path(d1).expanduser().resolve() 2595 try: 2596 if not d.exists(): 2597 try: 2598 os.mkdir(d, 0o700) # not makedirs: a missing parent stays an error 2599 except FileExistsError: 2600 pass # created meanwhile by another evaluation (sre eval-all) 2601 else: 2602 os.listdir(d) # trigger mount / catch stale handle early 2603 permissions = os.stat(d).st_mode 2604 if permissions != 0o700: 2605 os.chmod(d, 0o700) 2606 filename = d / params.get_archive_name(self.net_scheme.running_lab_name, now) 2607 self.save_tests_on_file(str(filename)) 2608 except Exception as e: 2609 log_error(f"can't save archive to {d}: {e}") 2610 2611 def save_tests_on_file(self, filename: str): 2612 files_content = {} 2613 if self.files_to_save_in_archives: 2614 fdir = Path(params.files_dir(self.get_running_lab_name())) 2615 if fdir.is_dir(): 2616 for pattern in self.files_to_save_in_archives: 2617 for fpath in fdir.iterdir(): 2618 if fpath.is_file() and fnmatch.fnmatch(fpath.name, pattern): 2619 try: 2620 files_content[fpath.name] = fpath.read_bytes() 2621 except Exception: 2622 pass 2623 archive = { 2624 params.running_lab_name_keyword: self.get_running_lab_name(), 2625 params.eval_date_keyword: self._eval_date, 2626 params.re_eval_date_keyword: self._re_eval_date, 2627 'data_json': self.get_data().to_json(), 2628 'tests': self.get_tests(), 2629 'errors': self.get_errors(), 2630 'answers': self.get_answers(), 2631 'grade_list': [asdict(e) for e in self._grade_list], 2632 'grade_parts': [asdict(p) for p in self._grade_parts], 2633 'total_grade_self_eval': self._total_grade_self_eval, 2634 'total_max_self_eval': self._total_max_self_eval, 2635 'mark_self_eval': self._mark_self_eval, 2636 'total_grade_exo_eval': self._total_grade_exo_eval, 2637 'total_max_exo_eval': self._total_max_exo_eval, 2638 'mark_exo_eval': self._mark_exo_eval, 2639 'maximum_mark': self._maximum_mark, 2640 'files': files_content, 2641 } 2642 if self._exam_json is not None: 2643 archive[params.exam_json_keyword] = self._exam_json 2644 cctx = zstd.ZstdCompressor(level=6) 2645 with tempfile.NamedTemporaryFile( 2646 mode="wb", 2647 dir=os.path.dirname(filename), 2648 delete=False 2649 ) as f: 2650 with cctx.stream_writer(f) as compressor: 2651 packed = msgpack.packb(archive, use_bin_type=True) 2652 compressor.write(packed) 2653 # f.flush() 2654 # os.fsync(f.fileno()) 2655 temp_name = f.name 2656 os.replace(temp_name, filename)
79def tr(text: str, **langs) -> TranslatedText: 80 """Build a TranslatedText with 'en' as the default language key. 81 82 Looks up the caller module's ``_TRANSLATIONS`` dict for any language not 83 already supplied as a keyword argument. Inline kwargs take priority. 84 ``None`` values in ``_TRANSLATIONS`` are skipped (placeholder for 85 untranslated strings). 86 87 ``_TRANSLATIONS`` must be defined **before** the first ``tr()`` call in 88 the file, because module-level expressions are evaluated at import time. 89 90 Usage in lab files:: 91 92 title = tr("My lab") # translation from _TRANSLATIONS 93 title = tr("My lab", fr="Mon TP") # inline, retro-compatible 94 95 For labs whose primary language is not English, use :func:`make_tr` instead. 96 """ 97 merged = _lookup_translations(sys._getframe(1).f_globals, text, langs) 98 return TranslatedText({'en': text, **merged})
Build a TranslatedText with 'en' as the default language key.
Looks up the caller module's _TRANSLATIONS dict for any language not
already supplied as a keyword argument. Inline kwargs take priority.
None values in _TRANSLATIONS are skipped (placeholder for
untranslated strings).
_TRANSLATIONS must be defined before the first tr() call in
the file, because module-level expressions are evaluated at import time.
Usage in lab files::
title = tr("My lab") # translation from _TRANSLATIONS
title = tr("My lab", fr="Mon TP") # inline, retro-compatible
For labs whose primary language is not English, use make_tr() instead.
101def make_tr(default_lang: str, translations: dict | None = None): 102 """Return a tr() function bound to *default_lang* as the first-positional language. 103 104 If *translations* is supplied explicitly it is used directly (no frame 105 inspection). Otherwise the caller module's globals are inspected for a 106 ``_TRANSLATIONS`` dict at each call. 107 108 In both cases ``_TRANSLATIONS`` (or the dict passed as *translations*) must 109 be defined **before** the first ``tr()`` call in the file, because 110 module-level expressions are evaluated at import time. 111 112 Usage with explicit dict (no frame inspection):: 113 114 _TRANSLATIONS = {'fr': {"Mon TP": "My lab"}} 115 tr = make_tr('fr', translations=_TRANSLATIONS) 116 title = tr("Mon TP") # 'en' from _TRANSLATIONS 117 118 Usage with frame inspection:: 119 120 tr = make_tr('fr') 121 _TRANSLATIONS = {'en': {"Mon TP": "My lab"}} # must come before tr() calls 122 title = tr("Mon TP") 123 """ 124 if translations is not None: 125 _wrapped = {'_TRANSLATIONS': translations} 126 127 def _tr(text: str, **langs) -> TranslatedText: 128 merged = _lookup_translations(_wrapped, text, langs) 129 return TranslatedText({default_lang: text, **merged}) 130 else: 131 caller_globals = sys._getframe(1).f_globals 132 133 def _tr(text: str, **langs) -> TranslatedText: 134 merged = _lookup_translations(caller_globals, text, langs) 135 return TranslatedText({default_lang: text, **merged}) 136 137 return _tr
Return a tr() function bound to default_lang as the first-positional language.
If translations is supplied explicitly it is used directly (no frame
inspection). Otherwise the caller module's globals are inspected for a
_TRANSLATIONS dict at each call.
In both cases _TRANSLATIONS (or the dict passed as translations) must
be defined before the first tr() call in the file, because
module-level expressions are evaluated at import time.
Usage with explicit dict (no frame inspection)::
_TRANSLATIONS = {'fr': {"Mon TP": "My lab"}}
tr = make_tr('fr', translations=_TRANSLATIONS)
title = tr("Mon TP") # 'en' from _TRANSLATIONS
Usage with frame inspection::
tr = make_tr('fr')
_TRANSLATIONS = {'en': {"Mon TP": "My lab"}} # must come before tr() calls
title = tr("Mon TP")
140def no_tr(text: str) -> str: 141 """Marker for prepare-sre-translations: leave this string untranslated. 142 143 Identity passthrough at runtime (returns *text* unchanged). It is purely a 144 hint to the translation tooling so the wrapped string is never wrapped in 145 tr() nor registered in _TRANSLATIONS — use it for short internal labels 146 (e.g. grade-element titles) that are not natural-language prose. 147 """ 148 return text
Marker for prepare-sre-translations: leave this string untranslated.
Identity passthrough at runtime (returns text unchanged). It is purely a hint to the translation tooling so the wrapped string is never wrapped in tr() nor registered in _TRANSLATIONS — use it for short internal labels (e.g. grade-element titles) that are not natural-language prose.
192def lab_ipv6_default(): 193 """Module-level ``ipv6`` option of the current lab (``params.default_ipv6`` when absent). 194 195 ``None`` means "not specified": Kathara then applies its own ``enable_ipv6`` setting. 196 """ 197 return getattr(_lab_module(), 'ipv6', params.default_ipv6)
Module-level ipv6 option of the current lab (params.default_ipv6 when absent).
None means "not specified": Kathara then applies its own enable_ipv6 setting.
381@dataclass 382class Flavor0: 383 """Base class for optional lab-parameterisation dataclasses. 384 385 Subclass with ``@dataclass(slots=True)`` and declare your fields. If the 386 module defines ``flavor_form_at_startup = True``, the GUI renders the 387 ``flavor_form`` string (containing ``@@{field:regex}@@`` markers) as a form 388 before starting the lab. 389 390 Named presets can be attached as class attributes, e.g.:: 391 392 Flavor.easy = Flavor(nb=1) 393 394 and referenced on the CLI with ``--set-flavor-name easy``. 395 396 Override :meth:`allowed_by_user` to restrict which values students may choose. 397 """ 398 399 _registry = {} 400 401 def __init_subclass__(cls, **kwargs): 402 super().__init_subclass__(**kwargs) 403 key = f"{cls.__module__}.{cls.__qualname__}" 404 cls._type_key = key 405 Flavor0._registry[key] = cls 406 407 def to_dict(self): 408 """Serialise dataclass fields to a plain dict using :meth:`Data0._encode_value`.""" 409 return {f.name: Data0._encode_value(getattr(self, f.name)) for f in fields(self)} 410 411 @classmethod 412 def from_dict(cls, d): 413 """Reconstruct a ``Flavor0`` instance from a dict produced by :meth:`to_dict`.""" 414 decoded = {k: Data0._decode_value(v) for k, v in d.items()} 415 return cls(**decoded) # type: ignore[call-arg] 416 417 @classmethod 418 def from_form_dict(cls, d: dict): 419 """Build a Flavor from form field values (strings from text inputs, bools from 420 submit buttons). Declared dataclass fields are coerced to their declared type. 421 Extra keys in d (form fields not declared in the dataclass) are set as plain 422 attributes on the instance so that allowed_by_user() can read them. 423 """ 424 from typing import get_type_hints 425 hints = get_type_hints(cls) 426 declared = {f.name for f in fields(cls)} 427 coerced = {} 428 for f in fields(cls): 429 v = d.get(f.name) 430 if v is None: 431 continue 432 t = hints.get(f.name, str) 433 if not isinstance(t, type) or isinstance(v, t): 434 coerced[f.name] = v 435 elif t == bool: 436 coerced[f.name] = str(v).lower() in ('true', '1', 'yes') 437 elif t == int: 438 coerced[f.name] = int(v) 439 elif t == float: 440 coerced[f.name] = float(v) 441 else: 442 coerced[f.name] = str(v) 443 obj = cls(**coerced) 444 for k, v in d.items(): 445 if k not in declared: 446 setattr(obj, k, v) 447 return obj 448 449 def allowed_by_user(self) -> Tuple[bool, str]: 450 """Return ``(True, "")`` if the student may use this flavor; ``(False, reason)`` otherwise. 451 452 Override in subclasses to restrict which values students can choose. 453 """ 454 return True, ""
Base class for optional lab-parameterisation dataclasses.
Subclass with @dataclass(slots=True) and declare your fields. If the
module defines flavor_form_at_startup = True, the GUI renders the
flavor_form string (containing @@{field:regex}@@ markers) as a form
before starting the lab.
Named presets can be attached as class attributes, e.g.::
Flavor.easy = Flavor(nb=1)
and referenced on the CLI with --set-flavor-name easy.
Override allowed_by_user() to restrict which values students may choose.
407 def to_dict(self): 408 """Serialise dataclass fields to a plain dict using :meth:`Data0._encode_value`.""" 409 return {f.name: Data0._encode_value(getattr(self, f.name)) for f in fields(self)}
Serialise dataclass fields to a plain dict using Data0._encode_value().
417 @classmethod 418 def from_form_dict(cls, d: dict): 419 """Build a Flavor from form field values (strings from text inputs, bools from 420 submit buttons). Declared dataclass fields are coerced to their declared type. 421 Extra keys in d (form fields not declared in the dataclass) are set as plain 422 attributes on the instance so that allowed_by_user() can read them. 423 """ 424 from typing import get_type_hints 425 hints = get_type_hints(cls) 426 declared = {f.name for f in fields(cls)} 427 coerced = {} 428 for f in fields(cls): 429 v = d.get(f.name) 430 if v is None: 431 continue 432 t = hints.get(f.name, str) 433 if not isinstance(t, type) or isinstance(v, t): 434 coerced[f.name] = v 435 elif t == bool: 436 coerced[f.name] = str(v).lower() in ('true', '1', 'yes') 437 elif t == int: 438 coerced[f.name] = int(v) 439 elif t == float: 440 coerced[f.name] = float(v) 441 else: 442 coerced[f.name] = str(v) 443 obj = cls(**coerced) 444 for k, v in d.items(): 445 if k not in declared: 446 setattr(obj, k, v) 447 return obj
Build a Flavor from form field values (strings from text inputs, bools from submit buttons). Declared dataclass fields are coerced to their declared type. Extra keys in d (form fields not declared in the dataclass) are set as plain attributes on the instance so that allowed_by_user() can read them.
449 def allowed_by_user(self) -> Tuple[bool, str]: 450 """Return ``(True, "")`` if the student may use this flavor; ``(False, reason)`` otherwise. 451 452 Override in subclasses to restrict which values students can choose. 453 """ 454 return True, ""
Return (True, "") if the student may use this flavor; (False, reason) otherwise.
Override in subclasses to restrict which values students can choose.
457class Data0: 458 """Base class for lab-specific parameter dataclasses. 459 460 Subclass with ``@dataclass(slots=True)`` and declare your fields normally. 461 Five dynamic containers are injected automatically into every instance by 462 ``__post_init__`` (do not declare fields with these names): 463 464 * ``self.ips`` — :class:`_IPv4InterfaceContainer`: named ``IPv4Interface`` values 465 * ``self.nets`` — :class:`_IPv4NetContainer`: named ``IPv4Network`` values 466 * ``self.ips6`` — :class:`_IPv6InterfaceContainer`: named ``IPv6Interface`` values 467 * ``self.nets6`` — :class:`_IPv6NetContainer`: named ``IPv6Network`` values 468 * ``self.macs`` — :class:`_MacContainer`: named ``EUI`` MAC-address values 469 470 The class-level ``_registry`` maps ``"module.ClassName"`` keys to concrete 471 subclasses so that ``from_dict``/``unpack``/``from_json`` can reconstruct the 472 correct type from serialised data without knowing it in advance. 473 474 Override ``generate(flavor=None)`` as a ``@classmethod`` to produce a fresh 475 randomised instance. The result is serialised to ``data.json`` at lab start 476 and reloaded for each evaluation. 477 """ 478 479 _registry = {} 480 _rng = random.Random() 481 482 def __init_subclass__(cls, **kwargs): 483 super().__init_subclass__(**kwargs) 484 key = f"{cls.__module__}.{cls.__qualname__}" 485 cls._type_key = key 486 Data0._registry[key] = cls 487 488 def __post_init__(self): 489 """Inject ``ips``, ``nets``, ``ips6``, ``nets6``, ``macs``, and ``flavor`` into every instance. 490 491 Called automatically by the dataclass ``__init__``. Uses 492 ``object.__setattr__`` so the injection works even when the subclass 493 declares ``slots=True``. 494 """ 495 # Inject ips/nets/flavor into __dict__ (available even with slots=True subclasses 496 # because Data0 itself has no __slots__, so __dict__ is always inherited). 497 object.__setattr__(self, 'ips', _IPv4InterfaceContainer()) 498 object.__setattr__(self, 'nets', _IPv4NetContainer()) 499 object.__setattr__(self, 'ips6', _IPv6InterfaceContainer()) 500 object.__setattr__(self, 'nets6', _IPv6NetContainer()) 501 object.__setattr__(self, 'macs', _MacContainer()) 502 object.__setattr__(self, 'flavor', None) 503 object.__setattr__(self, '__flavor_name', None) 504 object.__setattr__(self, '__current_srelab_file', None) 505 506 # ---------------- lifecycle hooks ---------------- 507 @classmethod 508 def compute_pre_generate(cls, flavor=None): 509 """Hook called just before generate() and after JSON/msgpack deserialization. 510 511 Override in a subclass to set class-level derived state from *flavor*. 512 The default implementation is a no-op (fully retro-compatible). 513 """ 514 pass 515 516 def compute_post_generate(self): 517 """Hook called after generate() and after JSON/msgpack deserialization. 518 519 Override in a subclass to set instance-level derived state from data fields. 520 The default implementation is a no-op (fully retro-compatible). 521 """ 522 pass 523 524 # ---------------- dict conversion ---------------- 525 def to_dict(self): 526 """Serialise to a plain dict (JSON-safe). 527 528 Dataclass fields are encoded via :meth:`_encode_value`. ``ips``, ``nets``, 529 ``ips6``, ``nets6`` and ``macs`` are stored as nested dicts of strings. An 530 attached ``Flavor`` is stored under the ``"flavor"`` key with its type key. 531 """ 532 result = {} 533 for f in fields(self): 534 v = getattr(self, f.name) 535 result[f.name] = self._encode_value(v) 536 result['ips'] = self.ips.to_dict() 537 result['nets'] = self.nets.to_dict() 538 result[params.data_json_ips6_key] = self.ips6.to_dict() 539 result[params.data_json_nets6_key] = self.nets6.to_dict() 540 result['macs'] = self.macs.to_dict() 541 if self.flavor is not None: 542 result['flavor'] = {"__flavor_type__": self.flavor._type_key, "data": self.flavor.to_dict()} 543 flavor_name = getattr(self, '__flavor_name', None) 544 if flavor_name is not None: 545 result['__flavor_name'] = flavor_name 546 current_srelab_file = getattr(self, '__current_srelab_file', None) 547 if current_srelab_file is not None: 548 result['__current_srelab_file'] = current_srelab_file 549 return result 550 551 @classmethod 552 def from_dict(cls, d): 553 """Reconstruct a ``Data0`` instance from a plain dict produced by :meth:`to_dict`. 554 555 The concrete subclass is resolved from ``d["__type__"]`` when *cls* is 556 ``Data0`` itself; otherwise the calling class is used directly. 557 Raises ``ValueError`` for unknown type keys. 558 """ 559 d = dict(d) 560 ips_data = d.pop('ips', {}) 561 nets_data = d.pop('nets', {}) 562 # files written before IPv6 support have no ips6/nets6 keys: empty containers 563 ips6_data = d.pop(params.data_json_ips6_key, {}) 564 nets6_data = d.pop(params.data_json_nets6_key, {}) 565 macs_data = d.pop('macs', {}) 566 flavor_data = d.pop('flavor', None) 567 flavor_name = d.pop('__flavor_name', None) 568 current_srelab_file = d.pop('__current_srelab_file', None) 569 decoded = {k: cls._decode_value(v) for k, v in d.items()} 570 obj = cls(**decoded) 571 for k, v in ips_data.items(): 572 setattr(obj.ips, k, IPv4Interface(v)) 573 for k, v in nets_data.items(): 574 setattr(obj.nets, k, IPv4Network(v)) 575 for k, v in ips6_data.items(): 576 setattr(obj.ips6, k, IPv6Interface(v)) 577 for k, v in nets6_data.items(): 578 setattr(obj.nets6, k, IPv6Network(v)) 579 for k, v in macs_data.items(): 580 setattr(obj.macs, k, EUI(v)) 581 if flavor_data is not None: 582 flavor_key = flavor_data.get("__flavor_type__") 583 if flavor_key not in Flavor0._registry: 584 raise ValueError(f"unknown Flavor0 type: {flavor_key!r}") 585 flavor_cls = Flavor0._registry[flavor_key] 586 object.__setattr__(obj, 'flavor', flavor_cls.from_dict(flavor_data["data"])) 587 object.__setattr__(obj, '__flavor_name', flavor_name) 588 object.__setattr__(obj, '__current_srelab_file', current_srelab_file) 589 return obj 590 591 # ---------------- value encoding ---------------- 592 @staticmethod 593 def _encode_value(v): 594 """Encode a field value for JSON/msgpack storage. 595 596 * ``IPv4Interface`` / ``IPv6Interface`` → ``{"__iface__": "..."}`` (checked before the 597 addresses: an Interface *is* an Address, and ``ip_address()`` rejects a prefix) 598 * ``IPv4Address`` / ``IPv6Address`` → ``{"__ip__": "..."}`` 599 * ``IPv4Network`` / ``IPv6Network`` → ``{"__net__": "..."}`` 600 * ``EUI`` → ``{"__mac__": "..."}`` 601 * nested ``Data0`` → ``{"__type__": "...", "data": {...}}`` 602 * all other values are returned unchanged. 603 """ 604 if isinstance(v, (IPv4Interface, IPv6Interface)): 605 return {params.data_json_iface_marker: str(v)} 606 if isinstance(v, (IPv4Address, IPv6Address)): 607 return {"__ip__": str(v)} 608 if isinstance(v, (IPv4Network, IPv6Network)): 609 return {"__net__": str(v)} 610 if isinstance(v, EUI): 611 return {params.data_json_mac_marker: str(v)} 612 613 if isinstance(v, Data0): 614 return { 615 "__type__": v._type_key, 616 "data": v.to_dict() 617 } 618 return v 619 620 @staticmethod 621 def _decode_value(v): 622 """Decode a value produced by :meth:`_encode_value` back to its Python type.""" 623 if isinstance(v, dict): 624 if params.data_json_iface_marker in v: 625 return ip_interface(v[params.data_json_iface_marker]) 626 if "__ip__" in v: 627 return ip_address(v["__ip__"]) 628 if "__net__" in v: 629 return ip_network(v["__net__"]) 630 if params.data_json_mac_marker in v: 631 return EUI(v[params.data_json_mac_marker]) 632 if "__type__" in v: 633 type_key = v["__type__"] 634 if type_key not in Data0._registry: 635 raise ValueError(f"unknown Data0 type: {type_key!r}") 636 return Data0._registry[type_key].from_dict(v["data"]) 637 return v 638 639 # ---------------- msgpack ---------------- 640 def pack(self): 641 """Serialise to a msgpack binary blob (includes type key for polymorphic reload).""" 642 return msgpack.packb( 643 { 644 "__type__": self._type_key, 645 "data": self.to_dict() 646 }, 647 use_bin_type=True 648 ) 649 650 @classmethod 651 def unpack(cls, blob): 652 """Deserialise a msgpack blob produced by :meth:`pack`. Raises ``ValueError`` for unknown types.""" 653 obj = msgpack.unpackb(blob, raw=False) 654 type_key = obj.get("__type__") 655 if type_key not in Data0._registry: 656 raise ValueError(f"unknown Data0 type: {type_key!r}") 657 result = Data0._registry[type_key].from_dict(obj["data"]) 658 type(result).compute_pre_generate(result.flavor) 659 result.compute_post_generate() 660 return result 661 662 # ---------------- JSON ---------------- 663 def to_json(self): 664 """Serialise to a JSON string (includes type key for polymorphic reload).""" 665 return json.dumps({ 666 "__type__": self._type_key, 667 "data": self.to_dict() 668 }) 669 670 @classmethod 671 def from_json(cls, s): 672 """Deserialise a JSON string produced by :meth:`to_json`.""" 673 obj = json.loads(s) 674 concrete = Data0._registry[obj["__type__"]] 675 result = concrete.from_dict(obj["data"]) 676 type(result).compute_pre_generate(result.flavor) 677 result.compute_post_generate() 678 return result 679 680 @classmethod 681 def load_from_json_file(cls, filename): 682 """ 683 Load a Data0-derived object from a JSON file. 684 The concrete class is resolved automatically. 685 """ 686 687 path = Path(filename) 688 with path.open("r", encoding="utf-8") as f: 689 obj = json.load(f) 690 concrete = Data0._registry[obj["__type__"]] 691 result = concrete.from_dict(obj["data"]) 692 type(result).compute_pre_generate(result.flavor) 693 result.compute_post_generate() 694 return result 695 696 def save_to_json_file(self, filename): 697 """Write the instance to *filename* as JSON, mode 0o600 (lab secrets stay private).""" 698 path = Path(filename) 699 fd = os.open( 700 path, 701 os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 702 0o600 703 ) 704 with os.fdopen(fd, "w", encoding="utf-8") as f: 705 json.dump( 706 { 707 "__type__": self._type_key, 708 "data": self.to_dict() 709 }, 710 f, 711 indent=2 # optional but operationally useful 712 )
Base class for lab-specific parameter dataclasses.
Subclass with @dataclass(slots=True) and declare your fields normally.
Five dynamic containers are injected automatically into every instance by
__post_init__ (do not declare fields with these names):
self.ips—_IPv4InterfaceContainer: namedIPv4Interfacevaluesself.nets—_IPv4NetContainer: namedIPv4Networkvaluesself.ips6—_IPv6InterfaceContainer: namedIPv6Interfacevaluesself.nets6—_IPv6NetContainer: namedIPv6Networkvaluesself.macs—_MacContainer: namedEUIMAC-address values
The class-level _registry maps "module.ClassName" keys to concrete
subclasses so that from_dict/unpack/from_json can reconstruct the
correct type from serialised data without knowing it in advance.
Override generate(flavor=None) as a @classmethod to produce a fresh
randomised instance. The result is serialised to data.json at lab start
and reloaded for each evaluation.
507 @classmethod 508 def compute_pre_generate(cls, flavor=None): 509 """Hook called just before generate() and after JSON/msgpack deserialization. 510 511 Override in a subclass to set class-level derived state from *flavor*. 512 The default implementation is a no-op (fully retro-compatible). 513 """ 514 pass
Hook called just before generate() and after JSON/msgpack deserialization.
Override in a subclass to set class-level derived state from flavor. The default implementation is a no-op (fully retro-compatible).
516 def compute_post_generate(self): 517 """Hook called after generate() and after JSON/msgpack deserialization. 518 519 Override in a subclass to set instance-level derived state from data fields. 520 The default implementation is a no-op (fully retro-compatible). 521 """ 522 pass
Hook called after generate() and after JSON/msgpack deserialization.
Override in a subclass to set instance-level derived state from data fields. The default implementation is a no-op (fully retro-compatible).
525 def to_dict(self): 526 """Serialise to a plain dict (JSON-safe). 527 528 Dataclass fields are encoded via :meth:`_encode_value`. ``ips``, ``nets``, 529 ``ips6``, ``nets6`` and ``macs`` are stored as nested dicts of strings. An 530 attached ``Flavor`` is stored under the ``"flavor"`` key with its type key. 531 """ 532 result = {} 533 for f in fields(self): 534 v = getattr(self, f.name) 535 result[f.name] = self._encode_value(v) 536 result['ips'] = self.ips.to_dict() 537 result['nets'] = self.nets.to_dict() 538 result[params.data_json_ips6_key] = self.ips6.to_dict() 539 result[params.data_json_nets6_key] = self.nets6.to_dict() 540 result['macs'] = self.macs.to_dict() 541 if self.flavor is not None: 542 result['flavor'] = {"__flavor_type__": self.flavor._type_key, "data": self.flavor.to_dict()} 543 flavor_name = getattr(self, '__flavor_name', None) 544 if flavor_name is not None: 545 result['__flavor_name'] = flavor_name 546 current_srelab_file = getattr(self, '__current_srelab_file', None) 547 if current_srelab_file is not None: 548 result['__current_srelab_file'] = current_srelab_file 549 return result
Serialise to a plain dict (JSON-safe).
Dataclass fields are encoded via _encode_value(). ips, nets,
ips6, nets6 and macs are stored as nested dicts of strings. An
attached Flavor is stored under the "flavor" key with its type key.
551 @classmethod 552 def from_dict(cls, d): 553 """Reconstruct a ``Data0`` instance from a plain dict produced by :meth:`to_dict`. 554 555 The concrete subclass is resolved from ``d["__type__"]`` when *cls* is 556 ``Data0`` itself; otherwise the calling class is used directly. 557 Raises ``ValueError`` for unknown type keys. 558 """ 559 d = dict(d) 560 ips_data = d.pop('ips', {}) 561 nets_data = d.pop('nets', {}) 562 # files written before IPv6 support have no ips6/nets6 keys: empty containers 563 ips6_data = d.pop(params.data_json_ips6_key, {}) 564 nets6_data = d.pop(params.data_json_nets6_key, {}) 565 macs_data = d.pop('macs', {}) 566 flavor_data = d.pop('flavor', None) 567 flavor_name = d.pop('__flavor_name', None) 568 current_srelab_file = d.pop('__current_srelab_file', None) 569 decoded = {k: cls._decode_value(v) for k, v in d.items()} 570 obj = cls(**decoded) 571 for k, v in ips_data.items(): 572 setattr(obj.ips, k, IPv4Interface(v)) 573 for k, v in nets_data.items(): 574 setattr(obj.nets, k, IPv4Network(v)) 575 for k, v in ips6_data.items(): 576 setattr(obj.ips6, k, IPv6Interface(v)) 577 for k, v in nets6_data.items(): 578 setattr(obj.nets6, k, IPv6Network(v)) 579 for k, v in macs_data.items(): 580 setattr(obj.macs, k, EUI(v)) 581 if flavor_data is not None: 582 flavor_key = flavor_data.get("__flavor_type__") 583 if flavor_key not in Flavor0._registry: 584 raise ValueError(f"unknown Flavor0 type: {flavor_key!r}") 585 flavor_cls = Flavor0._registry[flavor_key] 586 object.__setattr__(obj, 'flavor', flavor_cls.from_dict(flavor_data["data"])) 587 object.__setattr__(obj, '__flavor_name', flavor_name) 588 object.__setattr__(obj, '__current_srelab_file', current_srelab_file) 589 return obj
640 def pack(self): 641 """Serialise to a msgpack binary blob (includes type key for polymorphic reload).""" 642 return msgpack.packb( 643 { 644 "__type__": self._type_key, 645 "data": self.to_dict() 646 }, 647 use_bin_type=True 648 )
Serialise to a msgpack binary blob (includes type key for polymorphic reload).
650 @classmethod 651 def unpack(cls, blob): 652 """Deserialise a msgpack blob produced by :meth:`pack`. Raises ``ValueError`` for unknown types.""" 653 obj = msgpack.unpackb(blob, raw=False) 654 type_key = obj.get("__type__") 655 if type_key not in Data0._registry: 656 raise ValueError(f"unknown Data0 type: {type_key!r}") 657 result = Data0._registry[type_key].from_dict(obj["data"]) 658 type(result).compute_pre_generate(result.flavor) 659 result.compute_post_generate() 660 return result
Deserialise a msgpack blob produced by pack(). Raises ValueError for unknown types.
663 def to_json(self): 664 """Serialise to a JSON string (includes type key for polymorphic reload).""" 665 return json.dumps({ 666 "__type__": self._type_key, 667 "data": self.to_dict() 668 })
Serialise to a JSON string (includes type key for polymorphic reload).
670 @classmethod 671 def from_json(cls, s): 672 """Deserialise a JSON string produced by :meth:`to_json`.""" 673 obj = json.loads(s) 674 concrete = Data0._registry[obj["__type__"]] 675 result = concrete.from_dict(obj["data"]) 676 type(result).compute_pre_generate(result.flavor) 677 result.compute_post_generate() 678 return result
Deserialise a JSON string produced by to_json().
680 @classmethod 681 def load_from_json_file(cls, filename): 682 """ 683 Load a Data0-derived object from a JSON file. 684 The concrete class is resolved automatically. 685 """ 686 687 path = Path(filename) 688 with path.open("r", encoding="utf-8") as f: 689 obj = json.load(f) 690 concrete = Data0._registry[obj["__type__"]] 691 result = concrete.from_dict(obj["data"]) 692 type(result).compute_pre_generate(result.flavor) 693 result.compute_post_generate() 694 return result
Load a Data0-derived object from a JSON file. The concrete class is resolved automatically.
696 def save_to_json_file(self, filename): 697 """Write the instance to *filename* as JSON, mode 0o600 (lab secrets stay private).""" 698 path = Path(filename) 699 fd = os.open( 700 path, 701 os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 702 0o600 703 ) 704 with os.fdopen(fd, "w", encoding="utf-8") as f: 705 json.dump( 706 { 707 "__type__": self._type_key, 708 "data": self.to_dict() 709 }, 710 f, 711 indent=2 # optional but operationally useful 712 )
Write the instance to filename as JSON, mode 0o600 (lab secrets stay private).
715def sre_state(fn=None, *, user_allowed=False, description='', multi_pass=False): 716 """Mark a ``NetScheme`` method as a state. 717 718 Args: 719 user_allowed: students may apply this state themselves (``sre state``). 720 description: label shown in the GUI. 721 multi_pass: re-run the state method before each step and once more after the 722 last one (``max_step + 1`` calls, like ``Grade.grade()``) so that 723 ``cmd()`` / ``host_cmd()`` return the real ``(output, code)`` of 724 commands executed at earlier steps (see 725 :meth:`NetScheme0.iter_state_steps`). Default ``False``: the method 726 runs once and its ops are applied step by step; ``cmd()`` then always 727 returns its placeholder. 728 """ 729 def decorator(f): 730 f._is_sre_state = True 731 f._sre_state_user_allowed = user_allowed 732 f._sre_state_description = description 733 f._sre_state_multi_pass = multi_pass 734 return f 735 736 if fn is not None: 737 return decorator(fn) 738 return decorator
Mark a NetScheme method as a state.
Arguments:
- user_allowed: students may apply this state themselves (
sre state). - description: label shown in the GUI.
- multi_pass: re-run the state method before each step and once more after the
last one (
max_step + 1calls, likeGrade.grade()) so thatcmd()/host_cmd()return the real(output, code)of commands executed at earlier steps (seeNetScheme0.iter_state_steps()). DefaultFalse: the method runs once and its ops are applied step by step;cmd()then always returns its placeholder.
741class NetScheme0: 742 """Base class for lab network topology definitions. 743 744 Subclasses declare the topology as class-level dicts and implement state 745 methods decorated with :func:`sre_state`. 746 747 Class-level attributes: 748 749 * ``_machine_specs`` — ``{name: {Machine kwargs}}`` — one entry per container. 750 * ``_network_specs`` — ``{net_name: {Network kwargs}}`` — optional: display hints 751 (``color``, ``shape``) and type of switch (``mode``: ``'hub'`` by default, ``'switch'`` or 752 ``'managed'``; for a managed switch ``allow_connection`` and ``vlans``, see :class:`Network`). 753 * ``_topology`` — ``{net_name: [machine, ...]}`` or ``{net_name: {machine: iface}}`` 754 — which machines connect to each network and on which interface. 755 756 If any of these three attributes is not defined in the subclass (neither as a class 757 variable nor as a property), the corresponding attribute is read from ``data`` 758 (``data.topology``, ``data.machine_specs``, ``data.network_specs``). 759 Explicit subclass definitions always take precedence. 760 761 Instance attributes set by ``__init__``: 762 763 * ``self.data`` — the ``Data0`` instance for this lab run. 764 * ``self.informations`` — Markdown text shown in the Informations tab (set this in ``__init__``). 765 * Named machine and network objects (e.g. ``self.router``, ``self.lan``). 766 767 The ``initial`` state method is always called at ``sre start``. Additional 768 states are applied with ``sre state <lab> <state_name>``. 769 """ 770 771 _machine_specs = {} 772 _network_specs = {} # {net_name: {'color': ..., 'mode': ...}} — optional Network kwargs 773 _topology = {} # {net_name: [m, ...] or {m: iface, ...}} — mixed allowed 774 775 def _resolve_spec(self, attr: str, data_attr: str): 776 """Return the topology/spec dict for *attr*. 777 778 Resolution order: 779 1. If the concrete subclass (or any class before ``NetScheme0`` in the MRO) 780 defines *attr* as a class variable or property, return that value. 781 2. Otherwise look for *data_attr* on ``self.data`` (checks instance attributes 782 first, then class-level attributes set e.g. by ``compute_pre_generate``). 783 3. If neither source provides the attribute, return the ``NetScheme0`` default 784 (an empty dict), which is equivalent to "no topology/specs defined". 785 """ 786 for cls in type(self).__mro__: 787 if cls is NetScheme0: 788 break 789 if attr in cls.__dict__: 790 return getattr(self, attr) 791 return getattr(self.data, data_attr, getattr(NetScheme0, attr)) 792 793 def __init__(self, data, running_lab_name, lab_hash=None): 794 """Build all ``Machine``, ``Network``, and ``NetAdapter`` objects from the class-level specs. 795 796 Args: 797 data: a ``Data0`` instance containing lab-specific parameters. 798 running_lab_name: the runtime identifier ``{ts}@@@{lab}@@@{user}``. 799 lab_hash: optional Kathara lab hash (resolved lazily if omitted). 800 """ 801 self.data = data 802 self.running_lab_name = running_lab_name 803 self.debug_project = os.path.exists(params.debug_project_marker_filename(running_lab_name)) 804 self.instructor_mode = os.path.exists(params.instructor_mode_marker_filename(running_lab_name)) 805 # instructor() texts of the lab's __init__ and grade() are kept for the projects of the 806 # instructor: instructor mode, or debug project 807 self.instructor_texts = self.debug_project or self.instructor_mode 808 set_instructor_context(self.instructor_texts) 809 # operations log (GUI "Log" tab), opened on first write: `sre state` / `sre eval` of a debug 810 # project, `sre state` only of an instructor-mode project 811 self.ops_log = OperationsLog(running_lab_name, enabled=self.debug_project or self.instructor_mode, 812 log_evaluations=self.debug_project) 813 self.lab_name = params.get_lab_name_from_running_lab_name(running_lab_name) 814 self.lab_hash = lab_hash 815 self.current_srelab_file = params.get_current_srelab_file_from_running_lab_name(running_lab_name) 816 self.informations = "" 817 818 _machine_specs = self._resolve_spec('_machine_specs', 'machine_specs') 819 _network_specs = self._resolve_spec('_network_specs', 'network_specs') 820 _topology = self._resolve_spec('_topology', 'topology') 821 822 for name, parameters in _machine_specs.items(): 823 setattr(self, name, Machine(name=name, **parameters)) 824 825 # Create networks from _network_specs (with optional color), then remaining from _topology 826 for name, parameters in _network_specs.items(): 827 setattr(self, name, Network(name=name, **parameters)) 828 829 for net_name in _topology: 830 if not hasattr(self, net_name): 831 setattr(self, net_name, Network(name=net_name)) 832 833 # Create NetAdapters from _topology. 834 # Each value is either a list (auto interface) or a dict {machine: iface_spec}. 835 # iface_spec can be: None (auto), an int, or a (int, mac) tuple. 836 # Interface auto-assignment counts prior connections per machine across all networks. 837 _iface_counter: dict[str, int] = {} 838 for net_name, machines in _topology.items(): 839 net = getattr(self, net_name) 840 if isinstance(machines, dict): 841 items = machines.items() 842 else: 843 items = ((m, None) for m in machines) 844 for mname, iface_spec in items: 845 machine = getattr(self, mname) 846 if isinstance(iface_spec, tuple): 847 iface, mac = iface_spec 848 else: 849 iface, mac = iface_spec, None 850 if iface is None: 851 iface = _iface_counter.get(mname, 0) 852 _iface_counter[mname] = max(_iface_counter.get(mname, 0), iface) + 1 853 NetAdapter(network=net, machine=machine, interface=iface, mac=mac) 854 855 # VLANs of the ports of the managed switches (`vlans` of their _network_specs entry) 856 for net in list(self.get_networks()): 857 for mname, spec in net.vlans.items(): 858 machine = self.get_machine(mname) 859 adapter = net.net_adapters.get(machine) if machine is not None else None 860 if adapter is None: 861 raise ValueError(f"network '{net.name}': 'vlans' names '{mname}', " 862 f"which is not a machine of this network") 863 adapter.vlan, adapter.tagged_vlans = parse_port_vlans( 864 spec, f"network '{net.name}', machine '{mname}'") 865 866 self._ops: dict[int, dict[str, list]] = {} # step → machine → [_CmdOp | _FileOp, ...] 867 self._host_ops: dict[int, list] = {} # step → [_HostCmdOp | _HostCallbackOp | _SwitchCmdOp, ...] 868 self.reset_state_results() 869 870 self.net_config = None 871 872 def reset_state_results(self): 873 """Forget every command result and pass counter (called before a state is applied).""" 874 self.step = 0 # number of steps already applied 875 self.max_step = 1 876 self._multi_pass = False # True while iter_state_steps() runs a multi_pass state 877 self._cmd_results = {} # (machine, step) -> {(command, timeout): (output, code)} 878 self._host_cmd_results = {} # step -> {(command, timeout): (output, code)} 879 self._allow_errors_in_cmds = {} # (machine, step, command, timeout) -> True 880 self._allow_errors_in_host_cmds = {} # (step, command, timeout) -> True 881 self._switch_cmd_results = {} # step -> {(network, command): (output, code)} 882 self._allow_errors_in_switch_cmds = {} # (step, network, command) -> True 883 self._once_cache = {} 884 885 def once(self, key, factory): 886 """Return a value computed once per state application, whatever the number of passes. 887 888 In a ``multi_pass`` state the state method runs several times; anything drawn at 889 random or mutated later (e.g. a dict filled by a ``host_callback``) must stay the 890 same object across passes. ``once(key, factory)`` calls ``factory()`` the first 891 time *key* is seen and returns the cached value afterwards. 892 """ 893 if key not in self._once_cache: 894 self._once_cache[key] = factory() 895 return self._once_cache[key] 896 897 def _log_op(self, step, message): 898 """Debug-print a registered op once: on every call in single-pass mode, only in the 899 pass right before its step is applied in multi-pass mode.""" 900 if not SRE.args.debug: 901 return 902 if self._multi_pass and step != self.step + 1: 903 return 904 print(f"[state] {message}", file=sys.stderr) 905 906 def host_interfaces_from_topology(self) -> dict: 907 """Return {machine_name: [net_name, ...]} derived from the resolved topology.""" 908 topology = self._resolve_spec('_topology', 'topology') 909 result = {} 910 for net_name, machines in topology.items(): 911 names = machines.keys() if isinstance(machines, dict) else machines 912 for mname in names: 913 result.setdefault(mname, []).append(net_name) 914 return result 915 916 @sre_state(user_allowed=False) 917 def initial(self): 918 pass 919 920 @sre_state(user_allowed=False) 921 def save(self): 922 """Lifecycle state applied by ``sre save`` just before the container 923 filesystems are captured (e.g. flush a database to disk). Empty by default.""" 924 pass 925 926 @sre_state(user_allowed=False) 927 def restore(self): 928 """Lifecycle state applied by ``sre restore`` on the new instance right after 929 the containers have been redeployed from the saved filesystems. Only files are 930 restored, not processes: relaunch here the daemons that ``initial()`` started 931 with ``cmd()``. Empty by default.""" 932 pass 933 934 @classmethod 935 def get_state_methods(cls): 936 """Return a sorted list of all ``@sre_state``-decorated method names in this class hierarchy.""" 937 names = set() 938 for klass in cls.__mro__: 939 for name, fn in klass.__dict__.items(): 940 if callable(fn) and getattr(fn, '_is_sre_state', False): 941 names.add(name) 942 return sorted(names) 943 944 @classmethod 945 def is_state_user_allowed(cls, state): 946 """Return ``True`` if *state* was decorated with ``@sre_state(user_allowed=True)``.""" 947 for klass in cls.__mro__: 948 fn = klass.__dict__.get(state) 949 if fn is not None and getattr(fn, '_is_sre_state', False): 950 return getattr(fn, '_sre_state_user_allowed', False) 951 return False 952 953 @classmethod 954 def is_state_multi_pass(cls, state): 955 """Return ``True`` if *state* was decorated with ``@sre_state(multi_pass=True)``.""" 956 for klass in cls.__mro__: 957 fn = klass.__dict__.get(state) 958 if fn is not None and getattr(fn, '_is_sre_state', False): 959 return getattr(fn, '_sre_state_multi_pass', False) 960 return False 961 962 @classmethod 963 def get_user_allowed_states(cls): 964 """Return ``{state_name: description}`` for all user-allowed states.""" 965 result = {} 966 for state in cls.get_state_methods(): 967 for klass in cls.__mro__: 968 fn = klass.__dict__.get(state) 969 if fn is not None and getattr(fn, '_is_sre_state', False): 970 if getattr(fn, '_sre_state_user_allowed', False): 971 result[state] = getattr(fn, '_sre_state_description', '') 972 break 973 return result 974 975 def get_data(self): 976 return self.data 977 978 def get_machine(self, machine_name): 979 """Return the :class:`Machine` with *machine_name*, or ``None`` if not found.""" 980 if not hasattr(self, machine_name): 981 return None 982 machine = getattr(self, machine_name) 983 if not isinstance(machine, Machine): 984 return None 985 return machine 986 987 def get_network(self, network_name): 988 """Return the :class:`Network` with *network_name*, or ``None`` if not found.""" 989 if not hasattr(self, network_name): 990 return None 991 machine = getattr(self, network_name) 992 if not isinstance(machine, Network): 993 return None 994 return machine 995 996 def get_machines(self): 997 for _, value in self.__dict__.items(): 998 if isinstance(value, Machine): 999 yield value 1000 1001 def get_machine_names(self): 1002 return [m.name for m in self.get_machines()] 1003 1004 def has_privileged_machines(self): 1005 return any(m.privileged for m in self.get_machines()) 1006 1007 def get_accessible_machine_names(self): 1008 return [m.name for m in self.get_machines() 1009 if not m.hidden and m.allow_connection] 1010 1011 def get_visible_machine_names(self): 1012 return [m.name for m in self.get_visibles_machines()] 1013 1014 def get_visibles_machines(self): 1015 for _, value in self.__dict__.items(): 1016 if isinstance(value, Machine): 1017 if not value.hidden: 1018 yield value 1019 1020 def get_networks(self): 1021 for _, value in self.__dict__.items(): 1022 if isinstance(value, Network): 1023 yield value 1024 1025 def get_visible_networks(self): 1026 """Networks with at least one machine that is not hidden: the ones a student sees.""" 1027 for net in self.get_networks(): 1028 if any(not m.hidden for m in net.get_machines()): 1029 yield net 1030 1031 def get_topology(self): 1032 return self._resolve_spec('_topology', 'topology') 1033 1034 def get_machine_specs(self): 1035 return self._resolve_spec('_machine_specs', 'machine_specs') 1036 1037 def get_network_specs(self): 1038 return self._resolve_spec('_network_specs', 'network_specs') 1039 1040 def cmd(self, machine, command, step=1, timeout: int = params.default_state_cmd_timeout, 1041 default_value='', default_code: int = 0, allow_error: bool = False): 1042 """Register a shell command to execute inside *machine*'s container at *step*. 1043 1044 Returns ``(output, exit_code)``. The command runs under ``sh`` through 1045 ``exetests.py`` with *timeout* seconds (``0`` = no timeout; ``-1`` is returned 1046 as exit code on timeout). Until the command has run the placeholder 1047 ``(default_value, default_code)`` is returned; the real result is only visible 1048 in a ``@sre_state(multi_pass=True)`` state, from the next call of the state 1049 method on (its final call sees every result). A non-zero exit code is reported 1050 on stderr unless *allow_error* is set. 1051 """ 1052 if self.max_step < step: 1053 self.max_step = step 1054 self._log_op(step, f"[{machine}] CMD (step={step}, timeout={timeout}): {command}") 1055 self._ops.setdefault(step, {}).setdefault(machine, []).append(_CmdOp(command, timeout)) 1056 if allow_error: 1057 self._allow_errors_in_cmds[(machine, step, command, timeout)] = True 1058 results = self._cmd_results.setdefault((machine, step), {}) 1059 if (command, timeout) not in results: 1060 results[(command, timeout)] = (default_value, default_code) 1061 return results[(command, timeout)] 1062 1063 def host_cmd(self, command, step=1, timeout: int = params.default_state_cmd_timeout, 1064 default_value='', default_code: int = 0, allow_error: bool = False): 1065 """Register a shell command to execute on the **host** (not inside a container) at *step*. 1066 1067 Same contract as :meth:`cmd` (returns ``(stdout, exit_code)``, placeholder until the 1068 command has run). Requires ``params.execute_commands_on_host`` to be enabled; aborts 1069 otherwise. 1070 """ 1071 if params.execute_commands_on_host is False: 1072 sys.exit("host_cmd() is disabled by params.execute_commands_on_host") 1073 if self.max_step < step: 1074 self.max_step = step 1075 self._log_op(step, f"HOST_CMD (step={step}, timeout={timeout}): {command}") 1076 self._host_ops.setdefault(step, []).append(_HostCmdOp(command, timeout)) 1077 if allow_error: 1078 self._allow_errors_in_host_cmds[(step, command, timeout)] = True 1079 results = self._host_cmd_results.setdefault(step, {}) 1080 if (command, timeout) not in results: 1081 results[(command, timeout)] = (default_value, default_code) 1082 return results[(command, timeout)] 1083 1084 def record_cmd_result(self, machine, step, command, timeout, output, code): 1085 """Store the result of a container command run at *step* (called by ``sre state``).""" 1086 self._cmd_results.setdefault((machine, step), {})[(command, timeout)] = (output, code) 1087 1088 def record_host_cmd_result(self, step, command, timeout, output, code): 1089 """Store the result of a host command run at *step* (called by ``sre state``).""" 1090 self._host_cmd_results.setdefault(step, {})[(command, timeout)] = (output, code) 1091 1092 def is_cmd_error_allowed(self, machine, step, command, timeout): 1093 return self._allow_errors_in_cmds.get((machine, step, command, timeout), False) 1094 1095 def is_host_cmd_error_allowed(self, step, command, timeout): 1096 return self._allow_errors_in_host_cmds.get((step, command, timeout), False) 1097 1098 def switch_cmd(self, network, command, step=1, default_value='', default_code: int = 0, 1099 allow_error: bool = False): 1100 """Register a command of the management console of the managed switch *network* at *step*. 1101 1102 *network* is the name of a network declared with ``'mode': 'managed'`` in 1103 ``_network_specs``; *command* is one line of the ``vde_switch`` console, e.g. 1104 ``vlan/create 10``. A word ``@machine`` stands for the number of the port *machine* is 1105 plugged into: ``port/setvlan @pc1 10``. 1106 1107 Same contract as :meth:`host_cmd`: returns ``(output, code)``, the placeholder 1108 ``(default_value, default_code)`` until the command has run. *code* is ``0``, the error 1109 number given by the switch (e.g. ``17``: the VLAN already exists) or ``-2`` when the 1110 command could not be run at all. A non-zero code is reported on stderr unless 1111 *allow_error* is set. The switch commands of a step run with its host operations, in 1112 registration order, before the operations on the containers. 1113 """ 1114 net = self.get_network(network) 1115 if net is None or not net.is_managed(): 1116 raise ValueError(f"switch_cmd(): '{network}' is not a managed switch of this lab") 1117 if self.max_step < step: 1118 self.max_step = step 1119 self._log_op(step, f"[switch {network}] CMD (step={step}): {command}") 1120 self._host_ops.setdefault(step, []).append(_SwitchCmdOp(network, command)) 1121 if allow_error: 1122 self._allow_errors_in_switch_cmds[(step, network, command)] = True 1123 results = self._switch_cmd_results.setdefault(step, {}) 1124 if (network, command) not in results: 1125 results[(network, command)] = (default_value, default_code) 1126 return results[(network, command)] 1127 1128 def record_switch_cmd_result(self, step, network, command, output, code): 1129 """Store the result of a switch command run at *step* (called by ``sre state``).""" 1130 self._switch_cmd_results.setdefault(step, {})[(network, command)] = (output, code) 1131 1132 def is_switch_cmd_error_allowed(self, step, network, command): 1133 return self._allow_errors_in_switch_cmds.get((step, network, command), False) 1134 1135 def host_callback(self, callback, step=1): 1136 """Register a Python callable to invoke on the host at *step* (called with no arguments).""" 1137 self._log_op(step, f"HOST_CALLBACK (step={step}): {getattr(callback, '__name__', repr(callback))}") 1138 self._host_ops.setdefault(step, []).append(_HostCallbackOp(callback)) 1139 1140 def cp_from_host(self, src: str, machine: str, dest: str, owner: str = "root:root", permissions: int = None, 1141 mtime: float = None, step=1): 1142 """Register a file copy from the host into *machine*'s container at *step*. 1143 1144 *src* may be relative (resolved against ``params.files_dir``). 1145 *dest* is the absolute path inside the container. 1146 """ 1147 orig_path = Path(src) 1148 if not orig_path.is_absolute(): 1149 orig_path = Path(params.files_dir(self.running_lab_name)) / orig_path 1150 self._log_op(step, f"[{machine}] CP (step={step}): {orig_path} -> {dest} (owner={owner})") 1151 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1152 _CpFromHostOp(orig_path, dest, permissions, owner, mtime) 1153 ) 1154 1155 def cp_to_host(self, machine: str, path: str, dest: str, permissions: int = None, step=1): 1156 """Copy file `path` from `machine` to the host files_dir. 1157 1158 Args: 1159 machine: container name 1160 path: absolute path of the file inside the container 1161 dest: relative destination path inside params.files_dir(running_lab_name) 1162 permissions: file mode to apply on the host copy (default: None, leave as written) 1163 step: execution step (default 1) 1164 """ 1165 files_dir = Path(params.files_dir(self.running_lab_name)).resolve() 1166 dest_path = (files_dir / dest).resolve() 1167 if not dest_path.is_relative_to(files_dir): 1168 error_quit(f"cp_to_host: dest '{dest}' is outside files_dir '{files_dir}'") 1169 self._log_op(step, f"[{machine}] CP_TO_HOST (step={step}): {path} -> {dest_path}") 1170 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1171 _CpToHostOp(path, str(dest_path), permissions) 1172 ) 1173 1174 def file(self, machine, filename, content, permissions=0o644, owner="root:root", mtime=None, step=1): 1175 """Register a file to create/overwrite on `machine` during the current state. 1176 1177 Args: 1178 machine: machine name (str) 1179 filename: absolute path inside the container (e.g. "/etc/myconfig") 1180 content: file content (str or bytes) 1181 permissions: octal mode (default 0o644) 1182 owner: "user:group" string (default "root:root") 1183 mtime: modification time as a Unix timestamp (float); defaults to now 1184 step: execution step (default 1); higher steps run after lower ones 1185 """ 1186 import time as _time 1187 raw = content.encode() if isinstance(content, str) else content 1188 self._log_op(step, f"[{machine}] FILE (step={step}): {filename} " 1189 f"(permissions={permissions:#o}, owner={owner}, size={len(raw)}B)") 1190 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1191 _FileOp(filename, raw, permissions, owner, mtime if mtime is not None else _time.time()) 1192 ) 1193 1194 def append_to_file(self, machine, filename, content, permissions=None, owner=None, mtime=None, step=1): 1195 """Append content to a file on `machine`; create the file if it does not exist. 1196 1197 Args: 1198 machine: machine name (str) 1199 filename: absolute path inside the container (e.g. "/etc/hosts") 1200 content: content to append (str or bytes) 1201 permissions: octal mode to set after appending (default: leave unchanged) 1202 owner: "user:group" to set after appending (default: leave unchanged) 1203 mtime: modification time as a Unix timestamp (default: leave unchanged) 1204 step: execution step (default 1); higher steps run after lower ones 1205 """ 1206 raw = content.encode() if isinstance(content, str) else content 1207 self._log_op(step, f"[{machine}] APPEND (step={step}): {filename} (size={len(raw)}B)") 1208 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1209 _AppendOp(filename, raw, permissions, owner, mtime) 1210 ) 1211 1212 def idempotent_append_to_file(self, machine, filename, content, permissions=None, owner=None, mtime=None, step=1): 1213 """Append content to a file on `machine` only if the file does not already end with it. 1214 1215 Idempotent version of append_to_file: safe to call multiple times — the content 1216 is appended at most once per application. If permissions, owner, or mtime are 1217 provided they are applied regardless of whether content was appended. 1218 1219 Args: 1220 machine: machine name (str) 1221 filename: absolute path inside the container (e.g. "/etc/hosts") 1222 content: content to append (str or bytes) 1223 permissions: octal mode to set after the check (default: leave unchanged) 1224 owner: "user:group" to set after the check (default: leave unchanged) 1225 mtime: modification time as a Unix timestamp (default: leave unchanged) 1226 step: execution step (default 1); higher steps run after lower ones 1227 """ 1228 raw = content.encode() if isinstance(content, str) else content 1229 self._log_op(step, f"[{machine}] IDEMPOTENT_APPEND (step={step}): {filename} (size={len(raw)}B)") 1230 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1231 _IdempotentAppendOp(filename, raw, permissions, owner, mtime) 1232 ) 1233 1234 def compute_state_ops(self, state): 1235 self._ops = {} 1236 self._host_ops = {} 1237 if not hasattr(self, state): 1238 error_quit(f"state method {state} does not exist") 1239 method = getattr(self, state) 1240 if not callable(method): 1241 error_quit(f"state method {state} not callable") 1242 try: 1243 method() 1244 except Exception as e: 1245 error_quit(f"error during {state} execution: {e}") 1246 self.max_step = max([self.max_step, *self._ops, *self._host_ops]) 1247 return self._ops, self._host_ops 1248 1249 def iter_state_steps(self, state): 1250 """Yield ``(step, {machine: [ops]}, [host_ops])`` for every step of *state*, in order. 1251 1252 Single-pass state (default): the state method is called once and its ops are 1253 yielded step by step. 1254 1255 ``@sre_state(multi_pass=True)``: the state method is called again before each 1256 step, so ``cmd()`` / ``host_cmd()`` calls registered at earlier steps return their 1257 real ``(output, code)``. The method runs ``max_step + 1`` times, like 1258 ``Grade.grade()`` in ``run_tests()``: the call that registers the ops of step N+1 1259 sees the results of steps 1..N, and a final call after the last step sees every 1260 result. If that final call registers a new, higher step the loop goes on with it. 1261 Ops re-registered for an already applied step are ignored. 1262 1263 The caller applies the yielded ops and records command results with 1264 :meth:`record_cmd_result` / :meth:`record_host_cmd_result` before asking for the next 1265 step. 1266 """ 1267 self.reset_state_results() 1268 self._multi_pass = type(self).is_state_multi_pass(state) 1269 if not self._multi_pass: 1270 ops_by_step, host_ops_by_step = self.compute_state_ops(state) 1271 for step in sorted(set(ops_by_step) | set(host_ops_by_step)): 1272 self.step = step 1273 yield step, ops_by_step.get(step, {}), host_ops_by_step.get(step, []) 1274 return 1275 while True: 1276 ops_by_step, host_ops_by_step = self.compute_state_ops(state) # may bump self.max_step 1277 if self.step >= self.max_step: 1278 break # final pass: every result was visible, nothing left to apply 1279 self.step += 1 1280 yield self.step, ops_by_step.get(self.step, {}), host_ops_by_step.get(self.step, []) 1281 1282 def machine_ipv6_enabled(self, machine): 1283 """Effective IPv6 flag of *machine*: ``Machine(ipv6=True/False)`` wins, else the module-level 1284 ``ipv6`` lab option (:func:`lab_ipv6_default`). ``None`` = not specified (Kathara's own 1285 ``enable_ipv6`` setting applies, ``False`` by default).""" 1286 if machine.ipv6 is not None: 1287 return bool(machine.ipv6) 1288 default = lab_ipv6_default() 1289 return None if default is None else bool(default) 1290 1291 def get_new_lab_from_scheme(self): 1292 lab = Lab(name=self.running_lab_name) 1293 # Type of switch of the networks. Nothing is said about a hub, so that a lab with hubs 1294 # only makes the same Kathara calls as before (it also runs with a Kathara or a network 1295 # plugin that knows no switch mode). 1296 for net in self.get_networks(): 1297 if net.mode == params.network_mode_hub or not net.net_adapters: 1298 continue 1299 try: 1300 lab.get_or_new_link(net.name).mode = net.mode 1301 except AttributeError: 1302 error_quit(f"network '{net.name}': mode '{net.mode}' needs a Kathara with switch modes " 1303 f"({params.kathara_hint})") 1304 abb_lab_name = params.get_abbreviated_lab_name_from_running_lab_name(self.running_lab_name) 1305 _used_ports: set = set() 1306 xauth_cookie = _resolve_xauth_cookie() 1307 x11_display = _resolve_x11_display() 1308 for m in self.get_machines(): 1309 m.ports = [_resolve_port(p, _used_ports) for p in m.ports] 1310 m.envs[f"{params.sre_name_env_variable}={abb_lab_name}"] = True 1311 if m.x11_host: 1312 m.envs[f"{params.sre_host_ip_env_variable}={params.sre_host_ip}"] = True 1313 if xauth_cookie: 1314 m.envs[f"{params.sre_xauth_cookie_env_variable}={xauth_cookie}"] = True 1315 if x11_display: 1316 m.envs[f"{params.sre_display_env_variable}={params.sre_host_ip}:{x11_display}"] = True 1317 volumes = [] 1318 if len(m.volumes) > 0: 1319 has_private_volume = False 1320 for v in m.volumes: 1321 if len(v) > 2 and v[3] == 'private': 1322 has_private_volume = True 1323 break 1324 1325 if has_private_volume and params.disable_volume_mount_on_root_partition: 1326 private_mount_dir = self.get_private_mount_dir() 1327 p = Path(private_mount_dir) 1328 while not p.exists(): 1329 p = p.parent 1330 if os.stat(p).st_dev == os.stat('/').st_dev: 1331 error_quit( 1332 f"Machine '{m.name}': private mount dir '{private_mount_dir}' is on the root partition (disable_volume_mount_on_root_partition=True)") 1333 1334 for v in m.volumes: 1335 if len(v) > 2 and v[3] == 'private': 1336 if v[0].startswith('/'): 1337 error_quit(f"volume {v[0]} is private and have an absolute path") 1338 v_host = f"{self.get_private_mount_dir()}/{v[0]}" 1339 os.makedirs(v_host, exist_ok=True) 1340 os.chmod(v_host, 0o777) 1341 else: 1342 if v[0].startswith('/'): 1343 v_host = v[0] 1344 else: 1345 v_host = f"{self.get_user_public_dir()}/{v[0]}" 1346 os.makedirs(v_host, exist_ok=True) 1347 os.chmod(v_host, 0o777) 1348 if v[2] not in ['rw', 'ro']: 1349 error_quit(f"volume mode {v[2]} not supported (only rw and ro)") 1350 1351 volumes.append(f"{v_host}|{v[1]}|{v[2]}") 1352 machine_kwargs = dict(exec_commands=m.exec_commands, 1353 sysctls=m.sysctls, 1354 envs=m.envs, 1355 ports=m.ports, 1356 ulimits=m.ulimits, 1357 volumes=volumes, 1358 shell=m.kathara_shell, 1359 image=m.image, 1360 bridged=m.bridged, 1361 privileged=m.privileged, 1362 entrypoint=m.entrypoint, 1363 ) 1364 # Kathara disables IPv6 in the container unless its `ipv6` meta is true; only say 1365 # something when the lab did (module-level `ipv6` option or Machine(ipv6=...)). 1366 ipv6 = self.machine_ipv6_enabled(m) 1367 if ipv6 is not None: 1368 machine_kwargs['ipv6'] = ipv6 1369 lab.new_machine(m.name, **machine_kwargs) 1370 for net, netAdapter in m.net_adapters.items(): 1371 # VLANs of a port of a managed switch: only passed when the lab declared some 1372 vlan_kwargs = {} 1373 if netAdapter.vlan is not None: 1374 vlan_kwargs['vlan'] = netAdapter.vlan 1375 if netAdapter.tagged_vlans: 1376 vlan_kwargs['tagged_vlans'] = list(netAdapter.tagged_vlans) 1377 lab.connect_machine_to_link(m.name, net.name, machine_iface_number=netAdapter.interface, 1378 mac_address=str(netAdapter.mac).replace('-', 1379 ':').lower() if netAdapter.mac is not None else None, 1380 **vlan_kwargs) 1381 self.lab_hash = lab.hash 1382 return lab 1383 1384 def get_lab_hash(self): 1385 if self.lab_hash is None: 1386 lab = Lab(name=self.running_lab_name) 1387 self.lab_hash = lab.hash 1388 return self.lab_hash 1389 1390 def get_lab_from_kathara(self): 1391 lab_hash = self.get_lab_hash() 1392 return Kathara.get_instance().get_lab_from_api(lab_hash=lab_hash) 1393 1394 def get_public_lab_dir(self): 1395 return params.public_lab_dir(self.running_lab_name) 1396 1397 def get_private_lab_dir(self): 1398 return params.private_lab_dir(self.running_lab_name) 1399 1400 def get_files_dir(self): 1401 return params.files_dir(self.running_lab_name) 1402 1403 def get_private_mount_dir(self): 1404 return params.private_mount_dir(self.running_lab_name) 1405 1406 def get_user_public_dir(self): 1407 try: 1408 return Path(params.link_to_user_public_dir(self.running_lab_name)).readlink() 1409 except OSError: 1410 return Path(params.link_to_user_public_dir(self.running_lab_name)) 1411 1412 def get_shared_dir(self): 1413 return f"{self.get_user_public_dir()}/{params.shared_dir_name}" 1414 1415 def answers_dir(self): 1416 return params.answers_dir(self.running_lab_name) 1417 1418 def answers_file(self): 1419 return params.answers_filename(self.running_lab_name)
Base class for lab network topology definitions.
Subclasses declare the topology as class-level dicts and implement state
methods decorated with sre_state().
Class-level attributes:
_machine_specs—{name: {Machine kwargs}}— one entry per container._network_specs—{net_name: {Network kwargs}}— optional: display hints (color,shape) and type of switch (mode:'hub'by default,'switch'or'managed'; for a managed switchallow_connectionandvlans, seeNetwork)._topology—{net_name: [machine, ...]}or{net_name: {machine: iface}}— which machines connect to each network and on which interface.
If any of these three attributes is not defined in the subclass (neither as a class
variable nor as a property), the corresponding attribute is read from data
(data.topology, data.machine_specs, data.network_specs).
Explicit subclass definitions always take precedence.
Instance attributes set by __init__:
self.data— theData0instance for this lab run.self.informations— Markdown text shown in the Informations tab (set this in__init__).- Named machine and network objects (e.g.
self.router,self.lan).
The initial state method is always called at sre start. Additional
states are applied with sre state <lab> <state_name>.
793 def __init__(self, data, running_lab_name, lab_hash=None): 794 """Build all ``Machine``, ``Network``, and ``NetAdapter`` objects from the class-level specs. 795 796 Args: 797 data: a ``Data0`` instance containing lab-specific parameters. 798 running_lab_name: the runtime identifier ``{ts}@@@{lab}@@@{user}``. 799 lab_hash: optional Kathara lab hash (resolved lazily if omitted). 800 """ 801 self.data = data 802 self.running_lab_name = running_lab_name 803 self.debug_project = os.path.exists(params.debug_project_marker_filename(running_lab_name)) 804 self.instructor_mode = os.path.exists(params.instructor_mode_marker_filename(running_lab_name)) 805 # instructor() texts of the lab's __init__ and grade() are kept for the projects of the 806 # instructor: instructor mode, or debug project 807 self.instructor_texts = self.debug_project or self.instructor_mode 808 set_instructor_context(self.instructor_texts) 809 # operations log (GUI "Log" tab), opened on first write: `sre state` / `sre eval` of a debug 810 # project, `sre state` only of an instructor-mode project 811 self.ops_log = OperationsLog(running_lab_name, enabled=self.debug_project or self.instructor_mode, 812 log_evaluations=self.debug_project) 813 self.lab_name = params.get_lab_name_from_running_lab_name(running_lab_name) 814 self.lab_hash = lab_hash 815 self.current_srelab_file = params.get_current_srelab_file_from_running_lab_name(running_lab_name) 816 self.informations = "" 817 818 _machine_specs = self._resolve_spec('_machine_specs', 'machine_specs') 819 _network_specs = self._resolve_spec('_network_specs', 'network_specs') 820 _topology = self._resolve_spec('_topology', 'topology') 821 822 for name, parameters in _machine_specs.items(): 823 setattr(self, name, Machine(name=name, **parameters)) 824 825 # Create networks from _network_specs (with optional color), then remaining from _topology 826 for name, parameters in _network_specs.items(): 827 setattr(self, name, Network(name=name, **parameters)) 828 829 for net_name in _topology: 830 if not hasattr(self, net_name): 831 setattr(self, net_name, Network(name=net_name)) 832 833 # Create NetAdapters from _topology. 834 # Each value is either a list (auto interface) or a dict {machine: iface_spec}. 835 # iface_spec can be: None (auto), an int, or a (int, mac) tuple. 836 # Interface auto-assignment counts prior connections per machine across all networks. 837 _iface_counter: dict[str, int] = {} 838 for net_name, machines in _topology.items(): 839 net = getattr(self, net_name) 840 if isinstance(machines, dict): 841 items = machines.items() 842 else: 843 items = ((m, None) for m in machines) 844 for mname, iface_spec in items: 845 machine = getattr(self, mname) 846 if isinstance(iface_spec, tuple): 847 iface, mac = iface_spec 848 else: 849 iface, mac = iface_spec, None 850 if iface is None: 851 iface = _iface_counter.get(mname, 0) 852 _iface_counter[mname] = max(_iface_counter.get(mname, 0), iface) + 1 853 NetAdapter(network=net, machine=machine, interface=iface, mac=mac) 854 855 # VLANs of the ports of the managed switches (`vlans` of their _network_specs entry) 856 for net in list(self.get_networks()): 857 for mname, spec in net.vlans.items(): 858 machine = self.get_machine(mname) 859 adapter = net.net_adapters.get(machine) if machine is not None else None 860 if adapter is None: 861 raise ValueError(f"network '{net.name}': 'vlans' names '{mname}', " 862 f"which is not a machine of this network") 863 adapter.vlan, adapter.tagged_vlans = parse_port_vlans( 864 spec, f"network '{net.name}', machine '{mname}'") 865 866 self._ops: dict[int, dict[str, list]] = {} # step → machine → [_CmdOp | _FileOp, ...] 867 self._host_ops: dict[int, list] = {} # step → [_HostCmdOp | _HostCallbackOp | _SwitchCmdOp, ...] 868 self.reset_state_results() 869 870 self.net_config = None
Build all Machine, Network, and NetAdapter objects from the class-level specs.
Arguments:
- data: a
Data0instance containing lab-specific parameters. - running_lab_name: the runtime identifier
{ts}@@@{lab}@@@{user}. - lab_hash: optional Kathara lab hash (resolved lazily if omitted).
872 def reset_state_results(self): 873 """Forget every command result and pass counter (called before a state is applied).""" 874 self.step = 0 # number of steps already applied 875 self.max_step = 1 876 self._multi_pass = False # True while iter_state_steps() runs a multi_pass state 877 self._cmd_results = {} # (machine, step) -> {(command, timeout): (output, code)} 878 self._host_cmd_results = {} # step -> {(command, timeout): (output, code)} 879 self._allow_errors_in_cmds = {} # (machine, step, command, timeout) -> True 880 self._allow_errors_in_host_cmds = {} # (step, command, timeout) -> True 881 self._switch_cmd_results = {} # step -> {(network, command): (output, code)} 882 self._allow_errors_in_switch_cmds = {} # (step, network, command) -> True 883 self._once_cache = {}
Forget every command result and pass counter (called before a state is applied).
885 def once(self, key, factory): 886 """Return a value computed once per state application, whatever the number of passes. 887 888 In a ``multi_pass`` state the state method runs several times; anything drawn at 889 random or mutated later (e.g. a dict filled by a ``host_callback``) must stay the 890 same object across passes. ``once(key, factory)`` calls ``factory()`` the first 891 time *key* is seen and returns the cached value afterwards. 892 """ 893 if key not in self._once_cache: 894 self._once_cache[key] = factory() 895 return self._once_cache[key]
Return a value computed once per state application, whatever the number of passes.
In a multi_pass state the state method runs several times; anything drawn at
random or mutated later (e.g. a dict filled by a host_callback) must stay the
same object across passes. once(key, factory) calls factory() the first
time key is seen and returns the cached value afterwards.
906 def host_interfaces_from_topology(self) -> dict: 907 """Return {machine_name: [net_name, ...]} derived from the resolved topology.""" 908 topology = self._resolve_spec('_topology', 'topology') 909 result = {} 910 for net_name, machines in topology.items(): 911 names = machines.keys() if isinstance(machines, dict) else machines 912 for mname in names: 913 result.setdefault(mname, []).append(net_name) 914 return result
Return {machine_name: [net_name, ...]} derived from the resolved topology.
920 @sre_state(user_allowed=False) 921 def save(self): 922 """Lifecycle state applied by ``sre save`` just before the container 923 filesystems are captured (e.g. flush a database to disk). Empty by default.""" 924 pass
Lifecycle state applied by sre save just before the container
filesystems are captured (e.g. flush a database to disk). Empty by default.
926 @sre_state(user_allowed=False) 927 def restore(self): 928 """Lifecycle state applied by ``sre restore`` on the new instance right after 929 the containers have been redeployed from the saved filesystems. Only files are 930 restored, not processes: relaunch here the daemons that ``initial()`` started 931 with ``cmd()``. Empty by default.""" 932 pass
934 @classmethod 935 def get_state_methods(cls): 936 """Return a sorted list of all ``@sre_state``-decorated method names in this class hierarchy.""" 937 names = set() 938 for klass in cls.__mro__: 939 for name, fn in klass.__dict__.items(): 940 if callable(fn) and getattr(fn, '_is_sre_state', False): 941 names.add(name) 942 return sorted(names)
Return a sorted list of all @sre_state-decorated method names in this class hierarchy.
944 @classmethod 945 def is_state_user_allowed(cls, state): 946 """Return ``True`` if *state* was decorated with ``@sre_state(user_allowed=True)``.""" 947 for klass in cls.__mro__: 948 fn = klass.__dict__.get(state) 949 if fn is not None and getattr(fn, '_is_sre_state', False): 950 return getattr(fn, '_sre_state_user_allowed', False) 951 return False
Return True if state was decorated with @sre_state(user_allowed=True).
953 @classmethod 954 def is_state_multi_pass(cls, state): 955 """Return ``True`` if *state* was decorated with ``@sre_state(multi_pass=True)``.""" 956 for klass in cls.__mro__: 957 fn = klass.__dict__.get(state) 958 if fn is not None and getattr(fn, '_is_sre_state', False): 959 return getattr(fn, '_sre_state_multi_pass', False) 960 return False
Return True if state was decorated with @sre_state(multi_pass=True).
962 @classmethod 963 def get_user_allowed_states(cls): 964 """Return ``{state_name: description}`` for all user-allowed states.""" 965 result = {} 966 for state in cls.get_state_methods(): 967 for klass in cls.__mro__: 968 fn = klass.__dict__.get(state) 969 if fn is not None and getattr(fn, '_is_sre_state', False): 970 if getattr(fn, '_sre_state_user_allowed', False): 971 result[state] = getattr(fn, '_sre_state_description', '') 972 break 973 return result
Return {state_name: description} for all user-allowed states.
978 def get_machine(self, machine_name): 979 """Return the :class:`Machine` with *machine_name*, or ``None`` if not found.""" 980 if not hasattr(self, machine_name): 981 return None 982 machine = getattr(self, machine_name) 983 if not isinstance(machine, Machine): 984 return None 985 return machine
Return the Machine with machine_name, or None if not found.
987 def get_network(self, network_name): 988 """Return the :class:`Network` with *network_name*, or ``None`` if not found.""" 989 if not hasattr(self, network_name): 990 return None 991 machine = getattr(self, network_name) 992 if not isinstance(machine, Network): 993 return None 994 return machine
Return the Network with network_name, or None if not found.
1025 def get_visible_networks(self): 1026 """Networks with at least one machine that is not hidden: the ones a student sees.""" 1027 for net in self.get_networks(): 1028 if any(not m.hidden for m in net.get_machines()): 1029 yield net
Networks with at least one machine that is not hidden: the ones a student sees.
1040 def cmd(self, machine, command, step=1, timeout: int = params.default_state_cmd_timeout, 1041 default_value='', default_code: int = 0, allow_error: bool = False): 1042 """Register a shell command to execute inside *machine*'s container at *step*. 1043 1044 Returns ``(output, exit_code)``. The command runs under ``sh`` through 1045 ``exetests.py`` with *timeout* seconds (``0`` = no timeout; ``-1`` is returned 1046 as exit code on timeout). Until the command has run the placeholder 1047 ``(default_value, default_code)`` is returned; the real result is only visible 1048 in a ``@sre_state(multi_pass=True)`` state, from the next call of the state 1049 method on (its final call sees every result). A non-zero exit code is reported 1050 on stderr unless *allow_error* is set. 1051 """ 1052 if self.max_step < step: 1053 self.max_step = step 1054 self._log_op(step, f"[{machine}] CMD (step={step}, timeout={timeout}): {command}") 1055 self._ops.setdefault(step, {}).setdefault(machine, []).append(_CmdOp(command, timeout)) 1056 if allow_error: 1057 self._allow_errors_in_cmds[(machine, step, command, timeout)] = True 1058 results = self._cmd_results.setdefault((machine, step), {}) 1059 if (command, timeout) not in results: 1060 results[(command, timeout)] = (default_value, default_code) 1061 return results[(command, timeout)]
Register a shell command to execute inside machine's container at step.
Returns (output, exit_code). The command runs under sh through
exetests.py with timeout seconds (0 = no timeout; -1 is returned
as exit code on timeout). Until the command has run the placeholder
(default_value, default_code) is returned; the real result is only visible
in a @sre_state(multi_pass=True) state, from the next call of the state
method on (its final call sees every result). A non-zero exit code is reported
on stderr unless allow_error is set.
1063 def host_cmd(self, command, step=1, timeout: int = params.default_state_cmd_timeout, 1064 default_value='', default_code: int = 0, allow_error: bool = False): 1065 """Register a shell command to execute on the **host** (not inside a container) at *step*. 1066 1067 Same contract as :meth:`cmd` (returns ``(stdout, exit_code)``, placeholder until the 1068 command has run). Requires ``params.execute_commands_on_host`` to be enabled; aborts 1069 otherwise. 1070 """ 1071 if params.execute_commands_on_host is False: 1072 sys.exit("host_cmd() is disabled by params.execute_commands_on_host") 1073 if self.max_step < step: 1074 self.max_step = step 1075 self._log_op(step, f"HOST_CMD (step={step}, timeout={timeout}): {command}") 1076 self._host_ops.setdefault(step, []).append(_HostCmdOp(command, timeout)) 1077 if allow_error: 1078 self._allow_errors_in_host_cmds[(step, command, timeout)] = True 1079 results = self._host_cmd_results.setdefault(step, {}) 1080 if (command, timeout) not in results: 1081 results[(command, timeout)] = (default_value, default_code) 1082 return results[(command, timeout)]
Register a shell command to execute on the host (not inside a container) at step.
Same contract as cmd() (returns (stdout, exit_code), placeholder until the
command has run). Requires params.execute_commands_on_host to be enabled; aborts
otherwise.
1084 def record_cmd_result(self, machine, step, command, timeout, output, code): 1085 """Store the result of a container command run at *step* (called by ``sre state``).""" 1086 self._cmd_results.setdefault((machine, step), {})[(command, timeout)] = (output, code)
Store the result of a container command run at step (called by sre state).
1088 def record_host_cmd_result(self, step, command, timeout, output, code): 1089 """Store the result of a host command run at *step* (called by ``sre state``).""" 1090 self._host_cmd_results.setdefault(step, {})[(command, timeout)] = (output, code)
Store the result of a host command run at step (called by sre state).
1098 def switch_cmd(self, network, command, step=1, default_value='', default_code: int = 0, 1099 allow_error: bool = False): 1100 """Register a command of the management console of the managed switch *network* at *step*. 1101 1102 *network* is the name of a network declared with ``'mode': 'managed'`` in 1103 ``_network_specs``; *command* is one line of the ``vde_switch`` console, e.g. 1104 ``vlan/create 10``. A word ``@machine`` stands for the number of the port *machine* is 1105 plugged into: ``port/setvlan @pc1 10``. 1106 1107 Same contract as :meth:`host_cmd`: returns ``(output, code)``, the placeholder 1108 ``(default_value, default_code)`` until the command has run. *code* is ``0``, the error 1109 number given by the switch (e.g. ``17``: the VLAN already exists) or ``-2`` when the 1110 command could not be run at all. A non-zero code is reported on stderr unless 1111 *allow_error* is set. The switch commands of a step run with its host operations, in 1112 registration order, before the operations on the containers. 1113 """ 1114 net = self.get_network(network) 1115 if net is None or not net.is_managed(): 1116 raise ValueError(f"switch_cmd(): '{network}' is not a managed switch of this lab") 1117 if self.max_step < step: 1118 self.max_step = step 1119 self._log_op(step, f"[switch {network}] CMD (step={step}): {command}") 1120 self._host_ops.setdefault(step, []).append(_SwitchCmdOp(network, command)) 1121 if allow_error: 1122 self._allow_errors_in_switch_cmds[(step, network, command)] = True 1123 results = self._switch_cmd_results.setdefault(step, {}) 1124 if (network, command) not in results: 1125 results[(network, command)] = (default_value, default_code) 1126 return results[(network, command)]
Register a command of the management console of the managed switch network at step.
network is the name of a network declared with 'mode': 'managed' in
_network_specs; command is one line of the vde_switch console, e.g.
vlan/create 10. A word @machine stands for the number of the port machine is
plugged into: port/setvlan @pc1 10.
Same contract as host_cmd(): returns (output, code), the placeholder
(default_value, default_code) until the command has run. code is 0, the error
number given by the switch (e.g. 17: the VLAN already exists) or -2 when the
command could not be run at all. A non-zero code is reported on stderr unless
allow_error is set. The switch commands of a step run with its host operations, in
registration order, before the operations on the containers.
1128 def record_switch_cmd_result(self, step, network, command, output, code): 1129 """Store the result of a switch command run at *step* (called by ``sre state``).""" 1130 self._switch_cmd_results.setdefault(step, {})[(network, command)] = (output, code)
Store the result of a switch command run at step (called by sre state).
1135 def host_callback(self, callback, step=1): 1136 """Register a Python callable to invoke on the host at *step* (called with no arguments).""" 1137 self._log_op(step, f"HOST_CALLBACK (step={step}): {getattr(callback, '__name__', repr(callback))}") 1138 self._host_ops.setdefault(step, []).append(_HostCallbackOp(callback))
Register a Python callable to invoke on the host at step (called with no arguments).
1140 def cp_from_host(self, src: str, machine: str, dest: str, owner: str = "root:root", permissions: int = None, 1141 mtime: float = None, step=1): 1142 """Register a file copy from the host into *machine*'s container at *step*. 1143 1144 *src* may be relative (resolved against ``params.files_dir``). 1145 *dest* is the absolute path inside the container. 1146 """ 1147 orig_path = Path(src) 1148 if not orig_path.is_absolute(): 1149 orig_path = Path(params.files_dir(self.running_lab_name)) / orig_path 1150 self._log_op(step, f"[{machine}] CP (step={step}): {orig_path} -> {dest} (owner={owner})") 1151 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1152 _CpFromHostOp(orig_path, dest, permissions, owner, mtime) 1153 )
Register a file copy from the host into machine's container at step.
src may be relative (resolved against params.files_dir).
dest is the absolute path inside the container.
1155 def cp_to_host(self, machine: str, path: str, dest: str, permissions: int = None, step=1): 1156 """Copy file `path` from `machine` to the host files_dir. 1157 1158 Args: 1159 machine: container name 1160 path: absolute path of the file inside the container 1161 dest: relative destination path inside params.files_dir(running_lab_name) 1162 permissions: file mode to apply on the host copy (default: None, leave as written) 1163 step: execution step (default 1) 1164 """ 1165 files_dir = Path(params.files_dir(self.running_lab_name)).resolve() 1166 dest_path = (files_dir / dest).resolve() 1167 if not dest_path.is_relative_to(files_dir): 1168 error_quit(f"cp_to_host: dest '{dest}' is outside files_dir '{files_dir}'") 1169 self._log_op(step, f"[{machine}] CP_TO_HOST (step={step}): {path} -> {dest_path}") 1170 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1171 _CpToHostOp(path, str(dest_path), permissions) 1172 )
Copy file path from machine to the host files_dir.
Arguments:
- machine: container name
- path: absolute path of the file inside the container
- dest: relative destination path inside params.files_dir(running_lab_name)
- permissions: file mode to apply on the host copy (default: None, leave as written)
- step: execution step (default 1)
1174 def file(self, machine, filename, content, permissions=0o644, owner="root:root", mtime=None, step=1): 1175 """Register a file to create/overwrite on `machine` during the current state. 1176 1177 Args: 1178 machine: machine name (str) 1179 filename: absolute path inside the container (e.g. "/etc/myconfig") 1180 content: file content (str or bytes) 1181 permissions: octal mode (default 0o644) 1182 owner: "user:group" string (default "root:root") 1183 mtime: modification time as a Unix timestamp (float); defaults to now 1184 step: execution step (default 1); higher steps run after lower ones 1185 """ 1186 import time as _time 1187 raw = content.encode() if isinstance(content, str) else content 1188 self._log_op(step, f"[{machine}] FILE (step={step}): {filename} " 1189 f"(permissions={permissions:#o}, owner={owner}, size={len(raw)}B)") 1190 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1191 _FileOp(filename, raw, permissions, owner, mtime if mtime is not None else _time.time()) 1192 )
Register a file to create/overwrite on machine during the current state.
Arguments:
- machine: machine name (str)
- filename: absolute path inside the container (e.g. "/etc/myconfig")
- content: file content (str or bytes)
- permissions: octal mode (default 0o644)
- owner: "user:group" string (default "root:root")
- mtime: modification time as a Unix timestamp (float); defaults to now
- step: execution step (default 1); higher steps run after lower ones
1194 def append_to_file(self, machine, filename, content, permissions=None, owner=None, mtime=None, step=1): 1195 """Append content to a file on `machine`; create the file if it does not exist. 1196 1197 Args: 1198 machine: machine name (str) 1199 filename: absolute path inside the container (e.g. "/etc/hosts") 1200 content: content to append (str or bytes) 1201 permissions: octal mode to set after appending (default: leave unchanged) 1202 owner: "user:group" to set after appending (default: leave unchanged) 1203 mtime: modification time as a Unix timestamp (default: leave unchanged) 1204 step: execution step (default 1); higher steps run after lower ones 1205 """ 1206 raw = content.encode() if isinstance(content, str) else content 1207 self._log_op(step, f"[{machine}] APPEND (step={step}): {filename} (size={len(raw)}B)") 1208 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1209 _AppendOp(filename, raw, permissions, owner, mtime) 1210 )
Append content to a file on machine; create the file if it does not exist.
Arguments:
- machine: machine name (str)
- filename: absolute path inside the container (e.g. "/etc/hosts")
- content: content to append (str or bytes)
- permissions: octal mode to set after appending (default: leave unchanged)
- owner: "user:group" to set after appending (default: leave unchanged)
- mtime: modification time as a Unix timestamp (default: leave unchanged)
- step: execution step (default 1); higher steps run after lower ones
1212 def idempotent_append_to_file(self, machine, filename, content, permissions=None, owner=None, mtime=None, step=1): 1213 """Append content to a file on `machine` only if the file does not already end with it. 1214 1215 Idempotent version of append_to_file: safe to call multiple times — the content 1216 is appended at most once per application. If permissions, owner, or mtime are 1217 provided they are applied regardless of whether content was appended. 1218 1219 Args: 1220 machine: machine name (str) 1221 filename: absolute path inside the container (e.g. "/etc/hosts") 1222 content: content to append (str or bytes) 1223 permissions: octal mode to set after the check (default: leave unchanged) 1224 owner: "user:group" to set after the check (default: leave unchanged) 1225 mtime: modification time as a Unix timestamp (default: leave unchanged) 1226 step: execution step (default 1); higher steps run after lower ones 1227 """ 1228 raw = content.encode() if isinstance(content, str) else content 1229 self._log_op(step, f"[{machine}] IDEMPOTENT_APPEND (step={step}): {filename} (size={len(raw)}B)") 1230 self._ops.setdefault(step, {}).setdefault(machine, []).append( 1231 _IdempotentAppendOp(filename, raw, permissions, owner, mtime) 1232 )
Append content to a file on machine only if the file does not already end with it.
Idempotent version of append_to_file: safe to call multiple times — the content is appended at most once per application. If permissions, owner, or mtime are provided they are applied regardless of whether content was appended.
Arguments:
- machine: machine name (str)
- filename: absolute path inside the container (e.g. "/etc/hosts")
- content: content to append (str or bytes)
- permissions: octal mode to set after the check (default: leave unchanged)
- owner: "user:group" to set after the check (default: leave unchanged)
- mtime: modification time as a Unix timestamp (default: leave unchanged)
- step: execution step (default 1); higher steps run after lower ones
1234 def compute_state_ops(self, state): 1235 self._ops = {} 1236 self._host_ops = {} 1237 if not hasattr(self, state): 1238 error_quit(f"state method {state} does not exist") 1239 method = getattr(self, state) 1240 if not callable(method): 1241 error_quit(f"state method {state} not callable") 1242 try: 1243 method() 1244 except Exception as e: 1245 error_quit(f"error during {state} execution: {e}") 1246 self.max_step = max([self.max_step, *self._ops, *self._host_ops]) 1247 return self._ops, self._host_ops
1249 def iter_state_steps(self, state): 1250 """Yield ``(step, {machine: [ops]}, [host_ops])`` for every step of *state*, in order. 1251 1252 Single-pass state (default): the state method is called once and its ops are 1253 yielded step by step. 1254 1255 ``@sre_state(multi_pass=True)``: the state method is called again before each 1256 step, so ``cmd()`` / ``host_cmd()`` calls registered at earlier steps return their 1257 real ``(output, code)``. The method runs ``max_step + 1`` times, like 1258 ``Grade.grade()`` in ``run_tests()``: the call that registers the ops of step N+1 1259 sees the results of steps 1..N, and a final call after the last step sees every 1260 result. If that final call registers a new, higher step the loop goes on with it. 1261 Ops re-registered for an already applied step are ignored. 1262 1263 The caller applies the yielded ops and records command results with 1264 :meth:`record_cmd_result` / :meth:`record_host_cmd_result` before asking for the next 1265 step. 1266 """ 1267 self.reset_state_results() 1268 self._multi_pass = type(self).is_state_multi_pass(state) 1269 if not self._multi_pass: 1270 ops_by_step, host_ops_by_step = self.compute_state_ops(state) 1271 for step in sorted(set(ops_by_step) | set(host_ops_by_step)): 1272 self.step = step 1273 yield step, ops_by_step.get(step, {}), host_ops_by_step.get(step, []) 1274 return 1275 while True: 1276 ops_by_step, host_ops_by_step = self.compute_state_ops(state) # may bump self.max_step 1277 if self.step >= self.max_step: 1278 break # final pass: every result was visible, nothing left to apply 1279 self.step += 1 1280 yield self.step, ops_by_step.get(self.step, {}), host_ops_by_step.get(self.step, [])
Yield (step, {machine: [ops]}, [host_ops]) for every step of state, in order.
Single-pass state (default): the state method is called once and its ops are yielded step by step.
@sre_state(multi_pass=True): the state method is called again before each
step, so cmd() / host_cmd() calls registered at earlier steps return their
real (output, code). The method runs max_step + 1 times, like
Grade.grade() in run_tests(): the call that registers the ops of step N+1
sees the results of steps 1..N, and a final call after the last step sees every
result. If that final call registers a new, higher step the loop goes on with it.
Ops re-registered for an already applied step are ignored.
The caller applies the yielded ops and records command results with
record_cmd_result() / record_host_cmd_result() before asking for the next
step.
1282 def machine_ipv6_enabled(self, machine): 1283 """Effective IPv6 flag of *machine*: ``Machine(ipv6=True/False)`` wins, else the module-level 1284 ``ipv6`` lab option (:func:`lab_ipv6_default`). ``None`` = not specified (Kathara's own 1285 ``enable_ipv6`` setting applies, ``False`` by default).""" 1286 if machine.ipv6 is not None: 1287 return bool(machine.ipv6) 1288 default = lab_ipv6_default() 1289 return None if default is None else bool(default)
Effective IPv6 flag of machine: Machine(ipv6=True/False) wins, else the module-level
ipv6 lab option (lab_ipv6_default()). None = not specified (Kathara's own
enable_ipv6 setting applies, False by default).
1291 def get_new_lab_from_scheme(self): 1292 lab = Lab(name=self.running_lab_name) 1293 # Type of switch of the networks. Nothing is said about a hub, so that a lab with hubs 1294 # only makes the same Kathara calls as before (it also runs with a Kathara or a network 1295 # plugin that knows no switch mode). 1296 for net in self.get_networks(): 1297 if net.mode == params.network_mode_hub or not net.net_adapters: 1298 continue 1299 try: 1300 lab.get_or_new_link(net.name).mode = net.mode 1301 except AttributeError: 1302 error_quit(f"network '{net.name}': mode '{net.mode}' needs a Kathara with switch modes " 1303 f"({params.kathara_hint})") 1304 abb_lab_name = params.get_abbreviated_lab_name_from_running_lab_name(self.running_lab_name) 1305 _used_ports: set = set() 1306 xauth_cookie = _resolve_xauth_cookie() 1307 x11_display = _resolve_x11_display() 1308 for m in self.get_machines(): 1309 m.ports = [_resolve_port(p, _used_ports) for p in m.ports] 1310 m.envs[f"{params.sre_name_env_variable}={abb_lab_name}"] = True 1311 if m.x11_host: 1312 m.envs[f"{params.sre_host_ip_env_variable}={params.sre_host_ip}"] = True 1313 if xauth_cookie: 1314 m.envs[f"{params.sre_xauth_cookie_env_variable}={xauth_cookie}"] = True 1315 if x11_display: 1316 m.envs[f"{params.sre_display_env_variable}={params.sre_host_ip}:{x11_display}"] = True 1317 volumes = [] 1318 if len(m.volumes) > 0: 1319 has_private_volume = False 1320 for v in m.volumes: 1321 if len(v) > 2 and v[3] == 'private': 1322 has_private_volume = True 1323 break 1324 1325 if has_private_volume and params.disable_volume_mount_on_root_partition: 1326 private_mount_dir = self.get_private_mount_dir() 1327 p = Path(private_mount_dir) 1328 while not p.exists(): 1329 p = p.parent 1330 if os.stat(p).st_dev == os.stat('/').st_dev: 1331 error_quit( 1332 f"Machine '{m.name}': private mount dir '{private_mount_dir}' is on the root partition (disable_volume_mount_on_root_partition=True)") 1333 1334 for v in m.volumes: 1335 if len(v) > 2 and v[3] == 'private': 1336 if v[0].startswith('/'): 1337 error_quit(f"volume {v[0]} is private and have an absolute path") 1338 v_host = f"{self.get_private_mount_dir()}/{v[0]}" 1339 os.makedirs(v_host, exist_ok=True) 1340 os.chmod(v_host, 0o777) 1341 else: 1342 if v[0].startswith('/'): 1343 v_host = v[0] 1344 else: 1345 v_host = f"{self.get_user_public_dir()}/{v[0]}" 1346 os.makedirs(v_host, exist_ok=True) 1347 os.chmod(v_host, 0o777) 1348 if v[2] not in ['rw', 'ro']: 1349 error_quit(f"volume mode {v[2]} not supported (only rw and ro)") 1350 1351 volumes.append(f"{v_host}|{v[1]}|{v[2]}") 1352 machine_kwargs = dict(exec_commands=m.exec_commands, 1353 sysctls=m.sysctls, 1354 envs=m.envs, 1355 ports=m.ports, 1356 ulimits=m.ulimits, 1357 volumes=volumes, 1358 shell=m.kathara_shell, 1359 image=m.image, 1360 bridged=m.bridged, 1361 privileged=m.privileged, 1362 entrypoint=m.entrypoint, 1363 ) 1364 # Kathara disables IPv6 in the container unless its `ipv6` meta is true; only say 1365 # something when the lab did (module-level `ipv6` option or Machine(ipv6=...)). 1366 ipv6 = self.machine_ipv6_enabled(m) 1367 if ipv6 is not None: 1368 machine_kwargs['ipv6'] = ipv6 1369 lab.new_machine(m.name, **machine_kwargs) 1370 for net, netAdapter in m.net_adapters.items(): 1371 # VLANs of a port of a managed switch: only passed when the lab declared some 1372 vlan_kwargs = {} 1373 if netAdapter.vlan is not None: 1374 vlan_kwargs['vlan'] = netAdapter.vlan 1375 if netAdapter.tagged_vlans: 1376 vlan_kwargs['tagged_vlans'] = list(netAdapter.tagged_vlans) 1377 lab.connect_machine_to_link(m.name, net.name, machine_iface_number=netAdapter.interface, 1378 mac_address=str(netAdapter.mac).replace('-', 1379 ':').lower() if netAdapter.mac is not None else None, 1380 **vlan_kwargs) 1381 self.lab_hash = lab.hash 1382 return lab
1422class NetAdapter: 1423 def __init__(self, network, machine, interface, mac=None, addresses=None): 1424 if addresses is None: 1425 addresses = [] 1426 if not isinstance(machine, Machine): 1427 raise ValueError("machine must an object of class Machine") 1428 if not isinstance(network, Network): 1429 raise ValueError("network must be an instance of Network") 1430 if mac is not None: 1431 first_byte = int(str(mac).replace('-', ':').split(':')[0], 16) 1432 if first_byte & 1: 1433 raise ValueError( 1434 f"MAC address {mac} on machine '{machine.name}': " 1435 f"multicast bit (LSB of first byte) is set — use a unicast address " 1436 f"(first byte must be even, e.g. replace '{hex(first_byte)}' with '{hex(first_byte & 0xfe)}')." 1437 ) 1438 self.network = network 1439 self.machine = machine 1440 self.interface = interface 1441 self.mac = mac 1442 self.addresses = addresses 1443 # port of a managed switch (`vlans` of the network): VLAN of the untagged frames, VLANs 1444 # exchanged tagged; None / [] = default VLAN of the switch 1445 self.vlan = None 1446 self.tagged_vlans = [] 1447 machine.net_adapters[network] = self 1448 network.net_adapters[machine] = self 1449 1450 def switch_port_label(self) -> str: 1451 """Name of this interface on the port of a managed switch (``port/print``): ``m1:eth0``.""" 1452 return f"{self.machine.name}:eth{self.interface}"
1423 def __init__(self, network, machine, interface, mac=None, addresses=None): 1424 if addresses is None: 1425 addresses = [] 1426 if not isinstance(machine, Machine): 1427 raise ValueError("machine must an object of class Machine") 1428 if not isinstance(network, Network): 1429 raise ValueError("network must be an instance of Network") 1430 if mac is not None: 1431 first_byte = int(str(mac).replace('-', ':').split(':')[0], 16) 1432 if first_byte & 1: 1433 raise ValueError( 1434 f"MAC address {mac} on machine '{machine.name}': " 1435 f"multicast bit (LSB of first byte) is set — use a unicast address " 1436 f"(first byte must be even, e.g. replace '{hex(first_byte)}' with '{hex(first_byte & 0xfe)}')." 1437 ) 1438 self.network = network 1439 self.machine = machine 1440 self.interface = interface 1441 self.mac = mac 1442 self.addresses = addresses 1443 # port of a managed switch (`vlans` of the network): VLAN of the untagged frames, VLANs 1444 # exchanged tagged; None / [] = default VLAN of the switch 1445 self.vlan = None 1446 self.tagged_vlans = [] 1447 machine.net_adapters[network] = self 1448 network.net_adapters[machine] = self
1450 def switch_port_label(self) -> str: 1451 """Name of this interface on the port of a managed switch (``port/print``): ``m1:eth0``.""" 1452 return f"{self.machine.name}:eth{self.interface}"
Name of this interface on the port of a managed switch (port/print): m1:eth0.
1462def parse_port_vlans(spec, where: str) -> tuple: 1463 """``(vlan, tagged_vlans)`` of one entry of the ``vlans`` dict of a managed network. 1464 1465 An int is an access port (VLAN of its untagged frames), a list / tuple / set a trunk port 1466 (VLANs exchanged tagged), a dict ``{'vlan': 1, 'trunk': [10, 20]}`` gives both (native VLAN 1467 and tagged VLANs). *where* starts the message of the ``ValueError`` raised on a bad entry. 1468 """ 1469 if isinstance(spec, dict): 1470 unknown = sorted(set(spec) - {'vlan', 'trunk'}) 1471 if unknown: 1472 raise ValueError(f"{where}: unknown key(s) {', '.join(map(repr, unknown))} " 1473 f"('vlan' and 'trunk' are expected)") 1474 vlan, tagged = spec.get('vlan'), spec.get('trunk') 1475 elif isinstance(spec, (list, tuple, set, frozenset)): 1476 vlan, tagged = None, spec 1477 else: 1478 vlan, tagged = spec, None 1479 if tagged is None: 1480 tagged = [] 1481 elif not isinstance(tagged, (list, tuple, set, frozenset)): 1482 tagged = [tagged] 1483 if vlan is not None: 1484 _check_vlan_id(vlan, where) 1485 tagged = sorted({_check_vlan_id(v, where) for v in tagged}) 1486 if vlan is None and not tagged: 1487 raise ValueError(f"{where}: no VLAN given") 1488 if vlan in tagged: 1489 raise ValueError(f"{where}: VLAN {vlan} cannot be both untagged and tagged") 1490 return vlan, tagged
(vlan, tagged_vlans) of one entry of the vlans dict of a managed network.
An int is an access port (VLAN of its untagged frames), a list / tuple / set a trunk port
(VLANs exchanged tagged), a dict {'vlan': 1, 'trunk': [10, 20]} gives both (native VLAN
and tagged VLANs). where starts the message of the ValueError raised on a bad entry.
1493class Network: 1494 """One network of the lab (built from a ``NetScheme._network_specs`` entry, or from its 1495 ``_topology`` key alone). 1496 1497 * ``color`` / ``shape``: display hints of the schema. 1498 * ``mode``: the type of switch the machines are plugged into: ``'hub'`` (default, every frame 1499 goes to every machine), ``'switch'`` (it learns the MAC addresses) or ``'managed'`` (a 1500 switch with VLANs and a management console). 1501 * ``allow_connection``: students may open the console of a managed switch (``sre connect``, 1502 *Connect* button of the Networks tab). ``True`` by default. 1503 * ``vlans``: ports of a managed switch, by machine: ``{'pc1': 10}`` (access port), 1504 ``{'r1': [10, 20]}`` (trunk), ``{'r2': {'vlan': 1, 'trunk': [10, 20]}}`` (native VLAN and 1505 trunk). A machine that is not named stays in the default VLAN of the switch. 1506 """ 1507 1508 def __init__(self, name, color=None, shape=None, mode=None, allow_connection=True, vlans=None): 1509 if mode is None: 1510 mode = params.default_network_mode 1511 if mode not in params.network_modes: 1512 raise ValueError(f"network '{name}': mode {mode!r} is invalid " 1513 f"(one of {', '.join(params.network_modes)} is expected)") 1514 if vlans and mode != params.network_mode_managed: 1515 raise ValueError(f"network '{name}': 'vlans' needs 'mode': '{params.network_mode_managed}'") 1516 self.name = name 1517 self.color = color 1518 self.shape = shape 1519 self.mode = mode 1520 self.allow_connection = allow_connection 1521 self.vlans = dict(vlans or {}) 1522 self.net_adapters = {} 1523 1524 def is_managed(self) -> bool: 1525 return self.mode == params.network_mode_managed 1526 1527 def get_machines(self): 1528 for m in self.net_adapters.keys(): 1529 yield m
One network of the lab (built from a NetScheme._network_specs entry, or from its
_topology key alone).
color/shape: display hints of the schema.mode: the type of switch the machines are plugged into:'hub'(default, every frame goes to every machine),'switch'(it learns the MAC addresses) or'managed'(a switch with VLANs and a management console).allow_connection: students may open the console of a managed switch (sre connect, Connect button of the Networks tab).Trueby default.vlans: ports of a managed switch, by machine:{'pc1': 10}(access port),{'r1': [10, 20]}(trunk),{'r2': {'vlan': 1, 'trunk': [10, 20]}}(native VLAN and trunk). A machine that is not named stays in the default VLAN of the switch.
1508 def __init__(self, name, color=None, shape=None, mode=None, allow_connection=True, vlans=None): 1509 if mode is None: 1510 mode = params.default_network_mode 1511 if mode not in params.network_modes: 1512 raise ValueError(f"network '{name}': mode {mode!r} is invalid " 1513 f"(one of {', '.join(params.network_modes)} is expected)") 1514 if vlans and mode != params.network_mode_managed: 1515 raise ValueError(f"network '{name}': 'vlans' needs 'mode': '{params.network_mode_managed}'") 1516 self.name = name 1517 self.color = color 1518 self.shape = shape 1519 self.mode = mode 1520 self.allow_connection = allow_connection 1521 self.vlans = dict(vlans or {}) 1522 self.net_adapters = {}
1539class Machine: 1540 """One container of the lab (built from ``NetScheme._machine_specs`` entries). 1541 1542 The name must follow Kathara's device-name rule (``MACHINE_NAME_RULE``): ``ValueError`` otherwise. 1543 1544 Networking options: 1545 1546 * ``ipv6``: ``None`` (default) = the module-level ``ipv6`` lab option decides (and when that 1547 one is absent too, Kathara's own ``enable_ipv6`` setting, ``False`` by default); 1548 ``True``/``False`` = per-machine override. With IPv6 enabled Kathara starts the container 1549 with ``net.ipv6.conf.all.disable_ipv6=0``, ``forwarding=1``, ``accept_ra=0``; otherwise 1550 IPv6 is disabled on every interface. 1551 * ``sysctls``: iterable of ``"net.<key>=<value>"`` strings (a dict is iterated over its 1552 keys, so ``{"net.ipv6.conf.all.accept_ra=2": True}`` works; a ``{name: value}`` dict is 1553 rejected by Kathara). They are merged after Kathara's own defaults and win. 1554 """ 1555 1556 def __init__(self, name, image=params.default_docker_image, bridged=False, x11_host=False, mem="", 1557 cpus=None, ipv6=None, 1558 exec_commands=[], 1559 sysctls={}, 1560 envs={}, 1561 ports=None, ulimits={}, volumes=[], 1562 shell=None, 1563 kathara_shell=None, 1564 privileged=None, entrypoint=None, args=[], 1565 hidden=False, 1566 allow_connection=True, 1567 color=None, 1568 shape=None): 1569 if ports is None: 1570 ports = [] 1571 if not isinstance(name, str) or not MACHINE_NAME_RE.fullmatch(name): 1572 raise ValueError(f"invalid machine name {name!r}: Kathara device names are {MACHINE_NAME_RULE}") 1573 self.name = name 1574 self.image = image 1575 self.bridged = bridged 1576 self.x11_host = x11_host 1577 self.mem = mem 1578 self.cpus = cpus 1579 self.ipv6 = ipv6 1580 self.exec_commands = exec_commands 1581 self.sysctls = sysctls 1582 # Copy: the constructor's mutable default {} is shared across Machines; 1583 # post-construction mutations in get_new_lab_from_scheme (e.g. SRE_HOST_IP 1584 # for x11_host machines) must not leak into machines that didn't opt in. 1585 self.envs = dict(envs) 1586 self.ports = ports 1587 self.ulimits = ulimits 1588 self.volumes = volumes 1589 self.shell = shell 1590 self.kathara_shell = kathara_shell 1591 self.privileged = privileged 1592 self.entrypoint = entrypoint 1593 self.args = args 1594 self.hidden = hidden 1595 self.allow_connection = allow_connection 1596 self.shape = shape 1597 self.color = color 1598 1599 self.net_adapters = {} 1600 1601 if params.disable_volume_mount_on_root_partition: 1602 for v in self.volumes: 1603 v_host = v[0] 1604 if v_host.startswith('/'): 1605 p = Path(v_host) 1606 while not p.exists(): 1607 p = p.parent 1608 if os.stat(p).st_dev == os.stat('/').st_dev: 1609 error_quit( 1610 f"Machine '{self.name}': volume host path '{v_host}' is on the root partition (disable_volume_mount_on_root_partition=True)") 1611 1612 if self.privileged and not params.allow_privileged_machines: 1613 error_quit( 1614 f"Machine '{self.name}': privileged mode is disabled (allow_privileged_machines=False). Can't run in privileged mode") 1615 1616 if not self.bridged and len(self.ports) > 0: 1617 error_quit("To add ports in a machine, you need to activate bridged mode")
One container of the lab (built from NetScheme._machine_specs entries).
The name must follow Kathara's device-name rule (MACHINE_NAME_RULE): ValueError otherwise.
Networking options:
ipv6:None(default) = the module-levelipv6lab option decides (and when that one is absent too, Kathara's ownenable_ipv6setting,Falseby default);True/False= per-machine override. With IPv6 enabled Kathara starts the container withnet.ipv6.conf.all.disable_ipv6=0,forwarding=1,accept_ra=0; otherwise IPv6 is disabled on every interface.sysctls: iterable of"net.<key>=<value>"strings (a dict is iterated over its keys, so{"net.ipv6.conf.all.accept_ra=2": True}works; a{name: value}dict is rejected by Kathara). They are merged after Kathara's own defaults and win.
1556 def __init__(self, name, image=params.default_docker_image, bridged=False, x11_host=False, mem="", 1557 cpus=None, ipv6=None, 1558 exec_commands=[], 1559 sysctls={}, 1560 envs={}, 1561 ports=None, ulimits={}, volumes=[], 1562 shell=None, 1563 kathara_shell=None, 1564 privileged=None, entrypoint=None, args=[], 1565 hidden=False, 1566 allow_connection=True, 1567 color=None, 1568 shape=None): 1569 if ports is None: 1570 ports = [] 1571 if not isinstance(name, str) or not MACHINE_NAME_RE.fullmatch(name): 1572 raise ValueError(f"invalid machine name {name!r}: Kathara device names are {MACHINE_NAME_RULE}") 1573 self.name = name 1574 self.image = image 1575 self.bridged = bridged 1576 self.x11_host = x11_host 1577 self.mem = mem 1578 self.cpus = cpus 1579 self.ipv6 = ipv6 1580 self.exec_commands = exec_commands 1581 self.sysctls = sysctls 1582 # Copy: the constructor's mutable default {} is shared across Machines; 1583 # post-construction mutations in get_new_lab_from_scheme (e.g. SRE_HOST_IP 1584 # for x11_host machines) must not leak into machines that didn't opt in. 1585 self.envs = dict(envs) 1586 self.ports = ports 1587 self.ulimits = ulimits 1588 self.volumes = volumes 1589 self.shell = shell 1590 self.kathara_shell = kathara_shell 1591 self.privileged = privileged 1592 self.entrypoint = entrypoint 1593 self.args = args 1594 self.hidden = hidden 1595 self.allow_connection = allow_connection 1596 self.shape = shape 1597 self.color = color 1598 1599 self.net_adapters = {} 1600 1601 if params.disable_volume_mount_on_root_partition: 1602 for v in self.volumes: 1603 v_host = v[0] 1604 if v_host.startswith('/'): 1605 p = Path(v_host) 1606 while not p.exists(): 1607 p = p.parent 1608 if os.stat(p).st_dev == os.stat('/').st_dev: 1609 error_quit( 1610 f"Machine '{self.name}': volume host path '{v_host}' is on the root partition (disable_volume_mount_on_root_partition=True)") 1611 1612 if self.privileged and not params.allow_privileged_machines: 1613 error_quit( 1614 f"Machine '{self.name}': privileged mode is disabled (allow_privileged_machines=False). Can't run in privileged mode") 1615 1616 if not self.bridged and len(self.ports) > 0: 1617 error_quit("To add ports in a machine, you need to activate bridged mode")
1620def build_exetests_string(cmds) -> str: 1621 """Build the ``EXETESTS`` value for *cmds*, an iterable of ``(command, timeout)`` pairs.""" 1622 return params.exetests_separator.join(f"{timeout}:{cmd}" for (cmd, timeout) in cmds)
Build the EXETESTS value for cmds, an iterable of (command, timeout) pairs.
1625def parse_exetests_output(output: bytes) -> list: 1626 """Parse the stdout of ``exetests.py`` into ``[(command, timeout, result, code), ...]``. 1627 1628 *code* is the command's exit code, ``-1`` on timeout, ``-2`` when exetests printed 1629 something that is not an integer (an exception). A truncated trailer stops the parse. 1630 """ 1631 parsed = [] 1632 output1 = output.decode("utf-8") 1633 separator, _, rest = output1.partition("\n") 1634 output2 = rest.split(f"\n{separator}\n") 1635 for i in range(0, len(output2) - 1, 2): 1636 ligne1 = "" 1637 try: 1638 ligne1, date1, result = output2[i].split("\n", 2) 1639 except ValueError: 1640 result = "" 1641 if not ligne1: 1642 continue 1643 timeout_s, cmd = ligne1.split(":", 1) 1644 timeout = int(timeout_s) 1645 date2, code_s = output2[i + 1].split("\n", 1) 1646 try: 1647 code = int(code_s.strip()) 1648 except ValueError: 1649 code = -2 1650 parsed.append((cmd, timeout, result, code)) 1651 return parsed
Parse the stdout of exetests.py into [(command, timeout, result, code), ...].
code is the command's exit code, -1 on timeout, -2 when exetests printed
something that is not an integer (an exception). A truncated trailer stops the parse.
1654def run_host_command(command: str, timeout: int, cwd=None) -> tuple: 1655 """Run *command* on the host as the sre user and return ``(stdout, exit_code)``. 1656 1657 Honours ``params.execute_commands_on_host`` (``"shell"`` or ``"split"``). Returns 1658 ``-1`` as exit code on timeout (``timeout <= 0`` disables it) and ``-2`` on any other 1659 failure to run the command. 1660 """ 1661 from .utils_privileges import preexec_drop_to_sre 1662 run_cmd = shlex.split(command) if params.execute_commands_on_host == "split" else command 1663 use_shell = params.execute_commands_on_host == "shell" 1664 try: 1665 proc = subprocess.run( 1666 run_cmd, shell=use_shell, capture_output=True, text=True, 1667 timeout=timeout if timeout > 0 else None, 1668 cwd=cwd, 1669 preexec_fn=preexec_drop_to_sre, 1670 ) 1671 return proc.stdout, proc.returncode 1672 except subprocess.TimeoutExpired: 1673 return '', -1 1674 except Exception: 1675 return '', -2
Run command on the host as the sre user and return (stdout, exit_code).
Honours params.execute_commands_on_host ("shell" or "split"). Returns
-1 as exit code on timeout (timeout <= 0 disables it) and -2 on any other
failure to run the command.
1678class Grade0: 1679 """Base class for lab evaluation logic. 1680 1681 Subclasses override :meth:`grade` to *register* tests and questions. The 1682 actual execution happens later in :meth:`run_tests`, which drives a 1683 multi-step loop: 1684 1685 1. Call :meth:`grade` to register commands (they return placeholder values). 1686 2. Execute all registered commands in containers (via :mod:`exetests`). 1687 3. Repeat for each additional step (``self.max_step``). 1688 4. Call :meth:`grade` a final time — commands now return real results. 1689 5. Save the archive. 1690 1691 Key instance attributes available inside ``grade()``: 1692 1693 * ``self.data`` — the ``Data0`` instance (via ``net_scheme``). 1694 * ``self.step`` — current step number (1-based). 1695 * ``self.max_step`` — highest step number registered so far. 1696 """ 1697 1698 def __init__(self, net_scheme): 1699 self.net_scheme = net_scheme 1700 self.archive_dirs = [] 1701 self.files_to_save_in_archives = [] 1702 self._tests = None 1703 self._allow_errors_in_tests = None 1704 self.step = 0 1705 self.max_step = 1 1706 self._questions = None 1707 self._questions_order = None 1708 self._questions_current_order = None 1709 self._cheat_answers = {} 1710 self._answers = {} 1711 self._grade_list = [] 1712 self._grades = {} 1713 self._grade_parts: list[GradePart] = [] 1714 self._errors = [] 1715 self._total_grade_self_eval = 0 1716 self._total_max_self_eval = 0 1717 self._total_grade_exo_eval = 0 1718 self._total_max_exo_eval = 0 1719 self._bonus_in_self_eval = False # a bonus element is in the scope: the mark is capped 1720 self._bonus_in_exo_eval = False 1721 self._maximum_mark = params.default_maximum_mark 1722 self._use_numerical_marks = params.use_numerical_marks_by_default 1723 self._display_marks_in_auto_evaluations = params.display_marks_in_auto_evaluations_by_default 1724 self._mark_self_eval = None 1725 self._mark_exo_eval = None 1726 self._section_counter = [] 1727 self.section_fmt = [('R', 1), ('N', 1), ('l', 2), ('N', 3)] 1728 self._eval_date = None 1729 self._re_eval_date = None 1730 self._default_language = 'en' 1731 self.auto_eval_count = 0 1732 self._exam_json = None 1733 self.full_reset() 1734 1735 def full_reset(self): 1736 """Reset all state including tests and answers (called from ``__init__``).""" 1737 self.step = 0 1738 self.max_step = 1 1739 self._tests = {} # keys : (machine,step)->(cmd, timeout)->(result, code)) 1740 self._allow_errors_in_tests = {} 1741 self._switch_tests = set() # (network, step) keys of _tests registered by test_switch() 1742 self._host_tests = {} # step -> {(command, timeout): (result, code)} 1743 self._allow_errors_in_host_tests = {} # (step, command, timeout) -> True 1744 self._answers = {} # hash -> answer 1745 self._eval_date = None 1746 self.reset_before_grade() 1747 1748 def reset_before_grade(self): 1749 """Clear questions, grade list, and section counters before each call to :meth:`grade`.""" 1750 # instructor() texts follow the mode of this project (`is True`: net_scheme may be a stand-in) 1751 set_instructor_context(getattr(self.net_scheme, 'instructor_texts', False) is True) 1752 self._questions = dict() # hash->Question 1753 self._questions_order = dict() # order -> [Question1, Question2, ...] 1754 self._questions_current_order = 100 1755 self._cheat_answers = {} # state->(question_hash->answer) 1756 self._grade_list = [] 1757 self._grades = dict() # title -> GradeElement 1758 self._grade_parts = [] 1759 self._section_counter = [] 1760 1761 def grade(self): 1762 """Override to register tests, questions, and grade elements. 1763 1764 Called multiple times during :meth:`run_tests` — once per step plus a 1765 final time when all results are available. Do **not** produce side-effects 1766 here; only call ``self.test()``, ``self.question_*()`` and 1767 ``self.add_grade_element()`` / ``self.set_grade()``. 1768 """ 1769 self._grade_list = [] 1770 self._grade_parts = [] 1771 1772 def _compute_mark(self, total_grade, total_max, cap=False): 1773 """Compute a mark from a ``(total_grade, total_max)`` pair. 1774 1775 Returns ``None`` if ``total_max == 0``. 1776 Numerical mode (default): rounded to one decimal, scaled to ``_maximum_mark``. 1777 Letter mode: A+/A/B/C/D/F. 1778 With *cap* (bonus elements in the scope), *total_grade* is clamped to *total_max*: 1779 the mark never exceeds ``_maximum_mark``. 1780 """ 1781 if total_max == 0: 1782 return None 1783 if cap: 1784 total_grade = min(total_grade, total_max) 1785 if self._use_numerical_marks: 1786 return math.ceil(10 * self._maximum_mark * total_grade / total_max) / 10 1787 else: 1788 ratio = total_grade / total_max 1789 if ratio >= 18 / 20: 1790 return "A+" 1791 elif ratio >= 16 / 20: 1792 return "A" 1793 elif ratio >= 14 / 20: 1794 return "B" 1795 elif ratio >= 12 / 20: 1796 return "C" 1797 elif ratio >= 10 / 20: 1798 return "D" 1799 else: 1800 return "F" 1801 1802 def mark_self_eval(self): 1803 """Final mark over elements visible in self-eval (scope & SELF_EVAL_SCOPE).""" 1804 return self._compute_mark(self._total_grade_self_eval, self._total_max_self_eval, 1805 cap=self._bonus_in_self_eval) 1806 1807 def mark_exo_eval(self): 1808 """Final mark over elements visible in non-auto eval / outline / sheet.""" 1809 return self._compute_mark(self._total_grade_exo_eval, self._total_max_exo_eval, 1810 cap=self._bonus_in_exo_eval) 1811 1812 def get_data(self): 1813 return self.net_scheme.get_data() 1814 1815 def get_errors(self): 1816 return self._errors 1817 1818 def get_grade_list(self): 1819 return self._grade_list 1820 1821 def get_grade_parts(self): 1822 return self._grade_parts 1823 1824 def get_answers(self): 1825 return self._answers 1826 1827 def get_cheat_answers(self, state: str): 1828 return self._cheat_answers.get(state) 1829 1830 def get_tests(self): 1831 return self._tests 1832 1833 def get_exetests_strings(self, step: int): 1834 result = dict() 1835 for (machine, step1) in self._tests.keys(): 1836 if step != step1: 1837 continue 1838 if (machine, step1) in self._switch_tests: 1839 continue # console commands of a managed switch, not run through exetests 1840 result[machine] = build_exetests_string(self._tests[(machine, step)].keys()) 1841 return result 1842 1843 def get_running_lab_name(self): 1844 return self.net_scheme.running_lab_name 1845 1846 def increment_section_counter(self, level: int): 1847 if len(self._section_counter) < level + 1: 1848 self._section_counter += [0 for i in range(level + 1 - len(self._section_counter))] 1849 else: 1850 self._section_counter = self._section_counter[:(level + 1)] 1851 self._section_counter[level] += 1 1852 1853 def set_section_counter(self, level: int, value: int): 1854 if len(self._section_counter) < level + 1: 1855 self._section_counter += [0 for i in range(level + 1 - len(self._section_counter))] 1856 else: 1857 self._section_counter = self._section_counter[:(level + 1)] 1858 self._section_counter[level] = value 1859 1860 def section(self, level: int = 0, fmt=None, show: int = None, pad: str = None): 1861 self.increment_section_counter(level) 1862 return self.current_section(level, fmt, show, pad) 1863 1864 def current_section(self, level: int = 0, fmt=None, show: int = None, pad: str = None): 1865 # fmt is a list of (type, depth[, prefix]) tuples, one per level. 1866 # type: R=ROMAN, r=roman, L=Letters, l=letters, N=number 1867 # depth: how many consecutive counters to display, ending at this level 1868 # prefix: optional string prepended to the result (default: '') 1869 # e.g. [('R',1),('N',2,' ')] → level 0: "I." / level 1: " I.1." 1870 if fmt is None: 1871 fmt = self.section_fmt 1872 1873 def _to_roman(n: int) -> str: 1874 vals = [ 1875 (1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), 1876 (100, 'C'), (90, 'XC'), (50, 'L'), (40, 'XL'), 1877 (10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I'), 1878 ] 1879 result = '' 1880 for value, numeral in vals: 1881 while n >= value: 1882 result += numeral 1883 n -= value 1884 return result 1885 1886 def _to_capital_letter(n: int) -> str: 1887 result = '' 1888 while n > 0: 1889 n, r = divmod(n - 1, 26) 1890 result = chr(ord('A') + r) + result 1891 return result 1892 1893 def _to_lowercase_letter(n: int) -> str: 1894 result = '' 1895 while n > 0: 1896 n, r = divmod(n - 1, 26) 1897 result = chr(ord('a') + r) + result 1898 return result 1899 1900 def _convert(n: int, fmt_type: str) -> str: 1901 match fmt_type: 1902 case 'R': 1903 return _to_roman(n) 1904 case 'r': 1905 return _to_roman(n).lower() 1906 case 'L': 1907 return _to_capital_letter(n) 1908 case 'l': 1909 return _to_lowercase_letter(n) 1910 case _: 1911 return str(n) 1912 1913 entry = fmt[level] if level < len(fmt) else ('N', level + 1) 1914 _, depth = entry[:2] 1915 prefix = pad if pad is not None else (entry[2] if len(entry) > 2 else '') 1916 if show is not None: 1917 depth = show 1918 start = max(0, level - depth + 1) 1919 parts = [_convert(self._section_counter[j] if j < len(self._section_counter) else 0, 1920 fmt[j][0] if j < len(fmt) else 'N') 1921 for j in range(start, level + 1)] 1922 return prefix + ".".join(parts) + ". " 1923 1924 def load_answers(self): 1925 """Load student answers from ``answers.json`` into ``self._answers``.""" 1926 self._answers = {} 1927 try: 1928 fd = os.open(params.answers_filename(running_lab_name=self.get_running_lab_name()), 1929 os.O_RDONLY | os.O_NOFOLLOW) 1930 with os.fdopen(fd) as f: 1931 self._answers = json.load(f) 1932 except (OSError, json.JSONDecodeError): 1933 self._answers = {} 1934 1935 def get_questions_ordered(self): 1936 q = [] 1937 for _, v in sorted(self._questions_order.items()): 1938 q += v 1939 return q 1940 1941 def export_questions(self): 1942 pass 1943 1944 def test_host(self, command, step=1, timeout: int = params.default_timeout, default_value='', 1945 default_code: int = 0, allow_error: bool = False): 1946 """Register and retrieve the result of a host-side test command. 1947 1948 On the first call (registration pass) returns *default_value* / *default_code*. 1949 On subsequent calls (result pass) returns the actual ``(stdout, exit_code)``. 1950 Set *allow_error* to suppress error recording on non-zero exit. 1951 """ 1952 if params.execute_commands_on_host is False: 1953 sys.exit("test_host() is disabled by params.execute_commands_on_host") 1954 if self.max_step < step: 1955 self.max_step = step 1956 if step not in self._host_tests: 1957 self._host_tests[step] = {} 1958 if allow_error and (step, command, timeout) not in self._allow_errors_in_host_tests: 1959 self._allow_errors_in_host_tests[(step, command, timeout)] = True 1960 if (command, timeout) not in self._host_tests[step]: 1961 self._host_tests[step][(command, timeout)] = (default_value, default_code) 1962 return default_value, default_code 1963 return self._host_tests[step][(command, timeout)] 1964 1965 def _ops_log(self) -> OperationsLog: 1966 """The project's :class:`OperationsLog` for an evaluation: a disabled one when ``net_scheme`` 1967 is a stand-in without it, or when the log keeps the states only (instructor mode).""" 1968 ops_log = getattr(self.net_scheme, 'ops_log', None) 1969 if not ops_log or getattr(ops_log, 'log_evaluations', True) is False: 1970 return OperationsLog.disabled() 1971 return ops_log 1972 1973 def _log_grade_results(self, ops_log) -> None: 1974 """Append the grade elements of the last :meth:`grade` pass, then the totals and marks of 1975 both scopes, to the operations log (debug projects only; nothing without grade element).""" 1976 if not ops_log.enabled or not self._grade_list: 1977 return 1978 1979 def text(value): 1980 return TranslatedText.from_value(value, self._default_language).resolve(self._default_language) 1981 1982 scope_labels = {params.SELF_EVAL_SCOPE: "self-eval only", params.EXO_EVAL_SCOPE: "exo-eval only"} 1983 lines = [format_grade(text(g.title), g.grade, g.max_grade, 1984 part=text(g.grade_part) if g.grade_part else None, 1985 scope=scope_labels.get(g.scope), bonus=g.bonus) 1986 for g in self._grade_list] 1987 maximum = self._maximum_mark if self._use_numerical_marks else None 1988 lines.append(format_total("self-eval", self._total_grade_self_eval, self._total_max_self_eval, 1989 self._mark_self_eval, maximum)) 1990 lines.append(format_total("exo-eval", self._total_grade_exo_eval, self._total_max_exo_eval, 1991 self._mark_exo_eval, maximum)) 1992 ops_log.lines(lines) 1993 1994 def test(self, machine_name, command, step=1, timeout: int = params.default_timeout, default_value='', 1995 default_code: int = 0, allow_error: bool = False): 1996 """Register and retrieve the result of a command executed inside *machine_name*. 1997 1998 On the first call (registration pass) returns *default_value* / *default_code*. 1999 On subsequent calls (result pass) returns the actual ``(stdout, exit_code)``. 2000 *timeout* is in seconds. Set *allow_error* to suppress error recording. 2001 """ 2002 if self.max_step < step: 2003 self.max_step = step 2004 if (machine_name, step) not in self._tests: 2005 self._tests[(machine_name, step)] = {} 2006 if allow_error and (machine_name, step, command, timeout) not in self._allow_errors_in_tests: 2007 self._allow_errors_in_tests[(machine_name, step, command, timeout)] = True 2008 if (command, timeout) not in self._tests[(machine_name, step)]: 2009 self._tests[(machine_name, step)][(command, timeout)] = (default_value, default_code) 2010 return default_value, default_code 2011 return self._tests[(machine_name, step)][(command, timeout)] 2012 2013 def test_switch(self, network_name, command, step=1, default_value='', default_code: int = 0, 2014 allow_error: bool = False): 2015 """Register and retrieve the result of a console command of the managed switch *network_name*. 2016 2017 *network_name* is a network declared with ``'mode': 'managed'``; *command* is one line of 2018 its management console, e.g. ``vlan/print``. A word ``@machine`` stands for the number of 2019 the port *machine* is plugged into (``port/print @pc1``). 2020 2021 Same contract as :meth:`test`: *default_value* / *default_code* on the registration pass, 2022 the actual ``(output, code)`` afterwards. *code* is ``0``, the error number given by the 2023 switch, or ``-2`` when the command could not be run. The result is stored with the tests 2024 of the machines (under the name of the network), so archives keep it. 2025 """ 2026 get_network = getattr(self.net_scheme, 'get_network', None) 2027 if get_network is not None: 2028 net = get_network(network_name) 2029 if net is None or not net.is_managed(): 2030 raise ValueError(f"test_switch(): '{network_name}' is not a managed switch of this lab") 2031 self._switch_tests.add((network_name, step)) 2032 return self.test(network_name, command, step=step, timeout=_SWITCH_TEST_TIMEOUT, 2033 default_value=default_value, default_code=default_code, allow_error=allow_error) 2034 2035 @staticmethod 2036 def _apply_section(section: str, title) -> TranslatedText: 2037 """Prepend *section* to every language value in *title*.""" 2038 tt = TranslatedText.from_value(title) 2039 if not section: 2040 return tt 2041 return TranslatedText({lang: section + text for lang, text in tt.items()}) 2042 2043 def question_text(self, title, section='', description='', hash=None, order=None, default_answer='', 2044 cheat_answers=None): 2045 """Register a free-text question and return the student's current answer string. 2046 2047 Returns *default_answer* if the student has not answered yet. 2048 *cheat_answers* maps state names to answer strings used in automated testing. 2049 """ 2050 title = self._apply_section(section, title) 2051 if order is None: 2052 order1 = self._questions_current_order 2053 self._questions_current_order = ((self._questions_current_order // 100) + 1) * 100 2054 else: 2055 order1 = order 2056 2057 q = QuestionText(title, description, hash, order1) 2058 if order1 not in self._questions_order: 2059 self._questions_order[order1] = [] 2060 self._questions_order[order1].append(q) 2061 2062 if q.question_hash in self._questions: 2063 write_error(f"Duplicate question hash {q.question_hash}") 2064 self._questions[q.question_hash] = q 2065 2066 if cheat_answers is not None: 2067 for state, answer in cheat_answers.items(): 2068 if state not in self._cheat_answers: 2069 self._cheat_answers[state] = {} 2070 self._cheat_answers[state][q.question_hash] = answer 2071 2072 if q.question_hash in self._answers: 2073 return self._answers[q.question_hash] 2074 return default_answer 2075 2076 _FORM_FIELD_RE = _FORM_SPEC_FIELD_RE # the one regex of the grammar (SRE.form_spec.FIELD_RE) 2077 2078 def question_form(self, title, section='', description='', hash=None, order=None, cheat_answers=None): 2079 """Register a form question with inline @@{field_name:spec}@@ fields. 2080 2081 spec: a regex (single-line text input), '>a|b' or 'Label>>>value|...' (dropdown), 2082 '?' / '?true' (checkbox), '+' / '+N' (multi-line free text of N visible lines, 2083 params.default_form_field_lines by default); the grammar is that of SRE.form_spec. 2084 Returns the student's answers as a dict {field_name: value}, or {} if none yet. 2085 cheat_answers format: {state_name: {field_name: value, ...}} 2086 """ 2087 title = self._apply_section(section, title) 2088 from .common import QuestionForm 2089 2090 # The @@{field:spec}@@ markers are language-independent, so when the description is a 2091 # TranslatedText (wrapped in tr()) the fields come from one of its values. The full 2092 # TranslatedText is still stored on the question for per-language rendering. 2093 fields = _form_fields(description) 2094 2095 if order is None: 2096 order1 = self._questions_current_order 2097 self._questions_current_order = ((self._questions_current_order // 100) + 1) * 100 2098 else: 2099 order1 = order 2100 2101 q = QuestionForm(title, description, hash, order1, fields) 2102 if order1 not in self._questions_order: 2103 self._questions_order[order1] = [] 2104 self._questions_order[order1].append(q) 2105 2106 if q.question_hash in self._questions: 2107 write_error(f"Duplicate question hash {q.question_hash}") 2108 self._questions[q.question_hash] = q 2109 2110 if cheat_answers is not None: 2111 for state, field_answers in cheat_answers.items(): 2112 if state not in self._cheat_answers: 2113 self._cheat_answers[state] = {} 2114 self._cheat_answers[state][q.question_hash] = json.dumps( 2115 field_answers, ensure_ascii=False 2116 ) 2117 2118 if q.question_hash in self._answers: 2119 try: 2120 return json.loads(self._answers[q.question_hash]) 2121 except (json.JSONDecodeError, TypeError): 2122 return {} 2123 return {} 2124 2125 def question_dummy(self, title, section='', description='', hash=None, order=None): 2126 """Register a display-only block (no answer widget shown to the student).""" 2127 title = self._apply_section(section, title) 2128 if order is None: 2129 order1 = self._questions_current_order 2130 self._questions_current_order = ((self._questions_current_order // 100) + 1) * 100 2131 else: 2132 order1 = order 2133 2134 q = QuestionDummy(title, description, hash, order1) 2135 if order1 not in self._questions_order: 2136 self._questions_order[order1] = [] 2137 self._questions_order[order1].append(q) 2138 2139 if q.question_hash in self._questions: 2140 write_error(f"Duplicate question hash {q.question_hash}") 2141 self._questions[q.question_hash] = q 2142 2143 def add_grade_part(self, title, description=''): 2144 """Register a new :class:`GradePart` group and return it. 2145 2146 Pass the returned object to ``add_grade_element(..., grade_part=...)`` 2147 to associate elements with this part. Parts are displayed in 2148 registration order (with a subtotal row per part) in the GUI 2149 evaluation view and in ``sre outline`` PDFs. 2150 """ 2151 if description: 2152 description = TranslatedText.from_value(description, self._default_language) 2153 gp = GradePart(title=title, description=description) 2154 if any(p.title == title for p in self._grade_parts): 2155 write_error(f"Duplicate grade part title = {title}") 2156 self._grade_parts.append(gp) 2157 return gp 2158 2159 def add_grade_element(self, title, max_grade, description='', grade=0, scope=params.BOTH_EVAL_SCOPE, 2160 grade_part=None, bonus=False): 2161 """Add a graded rubric item. Initial *grade* defaults to 0; use :meth:`set_grade` to update it. 2162 2163 ``scope`` is a bitmask: ``SELF_EVAL_SCOPE`` (1) for self-eval only, 2164 ``EXO_EVAL_SCOPE`` (2) for non-auto eval / outline / sheet only, 2165 ``BOTH_EVAL_SCOPE`` (3, default) for both audiences. 2166 2167 ``grade_part`` optionally associates this element with a 2168 :class:`GradePart` previously returned by :meth:`add_grade_part`. 2169 2170 ``bonus`` makes a bonus element: its grade counts in the totals but its 2171 *max_grade* does not (neither in the total nor in the subtotal of its part), 2172 and the mark is capped at the maximum mark. The reports still show its 2173 *max_grade*, with the word "Bonus". 2174 """ 2175 if scope not in params.grade_scopes: 2176 raise ValueError(f"Invalid scope {scope!r}; expected one of {params.grade_scopes}") 2177 if description: 2178 description = TranslatedText.from_value(description, self._default_language) 2179 grade_part_title = None 2180 if grade_part is not None: 2181 if not isinstance(grade_part, GradePart): 2182 raise TypeError(f"grade_part must be a GradePart, got {type(grade_part).__name__}") 2183 if grade_part not in self._grade_parts: 2184 write_error(f"Unregistered grade part {grade_part.title!r} passed to add_grade_element") 2185 grade_part_title = grade_part.title 2186 g = GradeElement(title=title, max_grade=max_grade, description=description, grade=grade, scope=scope, 2187 grade_part=grade_part_title, bonus=bool(bonus)) 2188 self._grade_list.append(g) 2189 key = _tt_hash_str(title) 2190 if key in self._grades: 2191 write_error(f"Duplicate grade title = {title}") 2192 self._grades[key] = g 2193 2194 def set_grade(self, title, grade): 2195 """Set the numeric *grade* for the element previously registered under *title*.""" 2196 self._grades[_tt_hash_str(title)].grade = grade 2197 2198 def save_lab_info(self): 2199 debug_project = os.path.exists( 2200 params.debug_project_marker_filename(self.net_scheme.running_lab_name) 2201 ) 2202 instructor_mode = os.path.exists( 2203 params.instructor_mode_marker_filename(self.net_scheme.running_lab_name) 2204 ) 2205 if debug_project: 2206 visible_machines = list(self.net_scheme.get_machines()) 2207 else: 2208 visible_machines = [m for m in self.net_scheme.get_machines() if not m.hidden] 2209 lab_hash = self.net_scheme.get_lab_hash() 2210 2211 def _get_stats(m): 2212 s = next(Kathara.get_instance().get_machine_stats(lab_hash=lab_hash, machine_name=m.name), None) 2213 return m, s 2214 2215 stats_map = {} 2216 with ThreadPoolExecutor(max_workers=min(params.max_docker_concurrency, len(visible_machines))) as executor: 2217 for m, s in executor.map(_get_stats, visible_machines): 2218 stats_map[m.name] = (m, s) 2219 2220 info_machines = [] 2221 for m in visible_machines: 2222 m, s = stats_map[m.name] 2223 interfaces = [] 2224 for net, netAdapter in m.net_adapters.items(): 2225 interfaces.append(InfoInterface(network=net.name, interface_name=f"eth{netAdapter.interface}")) 2226 info_machines.append( 2227 InfoMachine(name=m.name, status=s.status if s is not None else "", hidden=m.hidden, 2228 bridged=m.bridged, 2229 x11_host=m.x11_host, 2230 ports=m.ports, 2231 allow_connection=m.allow_connection, 2232 color=m.color or "", 2233 shape=m.shape or "", 2234 interfaces=interfaces)) 2235 2236 network_colors = { 2237 net.name: net.color 2238 for m in visible_machines 2239 for net in m.net_adapters 2240 if net.color 2241 } 2242 network_shapes = { 2243 net.name: net.shape 2244 for m in visible_machines 2245 for net in m.net_adapters 2246 if net.shape 2247 } 2248 # the networks a student sees (every network of a debug project), as switches 2249 visible_networks = (self.net_scheme.get_networks() if debug_project 2250 else self.net_scheme.get_visible_networks()) 2251 info_switches = [InfoSwitch(name=net.name, mode=net.mode, allow_connection=bool(net.allow_connection)) 2252 for net in visible_networks if net.net_adapters] 2253 2254 module_rvlab = sys.modules[params.srelab_py_name.removesuffix(".py")] 2255 default_language = getattr(module_rvlab, 'default_language', 'en') 2256 self._default_language = default_language 2257 show_nat_network = getattr(module_rvlab, 'show_nat_network', params.default_show_nat_network) 2258 nat_network_name = getattr(module_rvlab, 'host_network_name', params.default_host_network_name) 2259 nat_network_color = getattr(module_rvlab, 'host_network_color', params.default_host_network_color) 2260 host_network_exploded = getattr(module_rvlab, 'host_network_exploded', 2261 params.default_host_network_exploded) 2262 host_network_edge_relative_length = float(getattr( 2263 module_rvlab, 'host_network_edge_relative_length', 2264 params.default_host_network_edge_relative_length)) 2265 schema_splines = getattr(module_rvlab, 'schema_splines', params.graphviz_default_splines) 2266 schema_overlap = getattr(module_rvlab, 'schema_overlap', params.graphviz_default_overlap) 2267 2268 if self.step == 0: 2269 self.grade() 2270 self.step += 1 2271 2272 questions = self.get_questions_ordered() 2273 2274 if hasattr(module_rvlab, 'title'): 2275 title = module_rvlab.title 2276 else: 2277 lab_name = params.get_lab_name_from_running_lab_name(self.get_running_lab_name()) 2278 title = lab_name.removesuffix('.py') 2279 title = TranslatedText.from_value(title, default_language) 2280 2281 if hasattr(module_rvlab, 'delay_between_self_grade'): 2282 delay_between_self_grade = module_rvlab.delay_between_self_grade 2283 else: 2284 delay_between_self_grade = 0 2285 2286 if hasattr(module_rvlab, 'allow_self_grade'): 2287 allow_self_grade = module_rvlab.allow_self_grade 2288 else: 2289 allow_self_grade = False 2290 2291 if debug_project: 2292 delay_between_self_grade = 0 2293 allow_self_grade = True 2294 if hasattr(module_rvlab, 'export_kathara_project'): 2295 export_kathara_project = module_rvlab.export_kathara_project 2296 else: 2297 export_kathara_project = False 2298 2299 eval_interval_without_exam_mode = getattr(module_rvlab, 'eval_interval_without_exam_mode', params.default_eval_interval_without_exam_mode) 2300 eval_before_exit = getattr(module_rvlab, 'eval_before_exit', False) 2301 allow_save_restore = getattr(module_rvlab, 'allow_save_restore', False) 2302 export_schema_pdf = bool(getattr(module_rvlab, 'export_schema_pdf', True)) 2303 2304 informations = TranslatedText.from_value(self.net_scheme.informations, default_language) 2305 for q in questions: 2306 q.title = TranslatedText.from_value(q.title, default_language) 2307 q.description = TranslatedText.from_value(q.description, default_language) 2308 # numbered sections, references and table of contents (markers of SRE.informations): 2309 # the numbers come from the course, the questions refer to them (bare numbers, no link) 2310 informations, numbering = resolve_informations(informations) 2311 for q in questions: 2312 q.title = resolve_text(q.title, numbering) 2313 q.description = resolve_text(q.description, numbering) 2314 2315 net_scheme_cls = type(self.net_scheme) 2316 # debug and instructor-mode projects: every state can be applied; the ones a student 2317 # could not apply are listed in admin_only_states 2318 all_states_allowed = debug_project or instructor_mode 2319 if all_states_allowed: 2320 user_allowed_states_raw = {} 2321 for state in net_scheme_cls.get_state_methods(): 2322 if state in params.lifecycle_state_names: 2323 continue 2324 desc = '' 2325 for klass in net_scheme_cls.__mro__: 2326 fn = klass.__dict__.get(state) 2327 if fn is not None and getattr(fn, '_is_sre_state', False): 2328 desc = getattr(fn, '_sre_state_description', '') 2329 break 2330 user_allowed_states_raw[state] = desc 2331 else: 2332 user_allowed_states_raw = ( 2333 net_scheme_cls.get_user_allowed_states() 2334 if getattr(module_rvlab, 'allow_user_states', False) else {} 2335 ) 2336 user_allowed_states = { 2337 state: resolve_text(TranslatedText.from_value(desc, default_language), numbering) if desc else desc 2338 for state, desc in user_allowed_states_raw.items() 2339 } 2340 2341 if not (instructor_mode or debug_project): 2342 # info.json is readable by the students: whatever the instructor() calls returned, 2343 # no instructor fragment leaves a project that is neither in instructor mode nor a 2344 # debug project 2345 title = strip_instructor(title) 2346 informations = strip_instructor(informations) 2347 for q in questions: 2348 q.title = strip_instructor(q.title) 2349 q.description = strip_instructor(q.description) 2350 user_allowed_states = {state: strip_instructor(desc) 2351 for state, desc in user_allowed_states.items()} 2352 2353 if all_states_allowed: 2354 module_allows_user_states = getattr(module_rvlab, 'allow_user_states', False) 2355 admin_only_states = [ 2356 state for state in user_allowed_states_raw 2357 if not module_allows_user_states 2358 or not net_scheme_cls.is_state_user_allowed(state) 2359 ] 2360 else: 2361 admin_only_states = [] 2362 2363 info = InfoLab(lab_name=self.net_scheme.lab_name, lab_hash=self.net_scheme.lab_hash, 2364 title=title, machines=info_machines, delay_between_self_grade=delay_between_self_grade, 2365 questions=questions, informations=informations, 2366 export_kathara_project=export_kathara_project, allow_self_grade=allow_self_grade, 2367 debug_project=debug_project, 2368 instructor_mode=instructor_mode, 2369 eval_interval_without_exam_mode=eval_interval_without_exam_mode, 2370 eval_before_exit=eval_before_exit, 2371 allow_save_restore=allow_save_restore, 2372 export_schema_pdf=export_schema_pdf, 2373 default_language=default_language, 2374 user_allowed_states=user_allowed_states, 2375 admin_only_states=admin_only_states, 2376 network_colors=network_colors, 2377 network_shapes=network_shapes, 2378 show_nat_network=show_nat_network, 2379 nat_network_name=nat_network_name, 2380 nat_network_color=nat_network_color, 2381 host_network_exploded=host_network_exploded, 2382 host_network_edge_relative_length=host_network_edge_relative_length, 2383 schema_splines=schema_splines, 2384 schema_overlap=schema_overlap, 2385 switches=info_switches) 2386 2387 info_json = info.to_json() 2388 info_filename = params.info_filename(self.net_scheme.running_lab_name) 2389 2390 save_info_json = None 2391 try: 2392 with open(info_filename, "r") as f: 2393 save_info_json = f.read() 2394 except FileNotFoundError: 2395 pass 2396 if save_info_json == info_json: 2397 return 2398 2399 temp_file = tempfile.NamedTemporaryFile( 2400 delete=False, 2401 dir=Path(self.net_scheme.get_public_lab_dir()).parent) 2402 with open(temp_file.name, "w") as f: 2403 print(info_json, file=f) 2404 f.flush() 2405 os.fsync(f.fileno()) 2406 os.chmod(temp_file.name, 0o644) 2407 if os.geteuid() == 0: 2408 # root is only dropped temporarily for a lab with privileged machines, and the 2409 # Kathara calls above leave the euid at 0: info.json stays sre's 2410 os.fchown(f.fileno(), params.sre_uid, -1) 2411 os.replace(temp_file.name, info_filename) 2412 2413 def add_error(self, error, category=ErrorCategory.ERROR, step: int = 1): 2414 if self.step != step: 2415 return 2416 log_error(error) 2417 self._errors.append((category.value, error)) 2418 2419 def add_warning(self, warning, step: int = 1): 2420 self.add_error(warning, category=ErrorCategory.WARNING, step=step) 2421 2422 @staticmethod 2423 def run_tests_on_machine(machine_name, machine, exetests): 2424 environment = {params.exetests_env_name: exetests} 2425 code, output = machine.api_object.exec_run([params.exetests_machines_path], 2426 stdin=False, 2427 stdout=True, 2428 stderr=False, 2429 tty=False, 2430 environment=environment, 2431 workdir="/", 2432 ) 2433 return machine_name, code, output 2434 2435 def run_tests(self): 2436 self._tests = {} 2437 self._section_counter = [] 2438 lab = self.net_scheme.get_lab_from_kathara() 2439 self._errors = [] 2440 self.load_answers() 2441 self._eval_date = datetime.datetime.now().isoformat() 2442 ops_log = self._ops_log() 2443 ops_log.begin("evaluation") 2444 switch_console = SwitchConsole(self.net_scheme) 2445 2446 while self.step <= self.max_step: 2447 self.reset_before_grade() 2448 self.grade() 2449 self.step += 1 2450 tests = self.get_tests() 2451 exetests_by_machine = self.get_exetests_strings(self.step) 2452 if SRE.args.debug and self.step <= self.max_step: 2453 log_debug(f"Commands step {self.step} (after running grade on step {self.step - 1}):") 2454 for machine, cmds in exetests_by_machine.items(): 2455 c = cmds.split(params.exetests_separator) 2456 c1 = " - ".join(c) 2457 log_debug(f"{machine}: {c1}") 2458 2459 results = {} 2460 with ThreadPoolExecutor( 2461 max_workers=max(1, min(params.max_docker_concurrency, len(lab.machines)))) as executor: 2462 futures_to_machines = { 2463 executor.submit( 2464 Grade0.run_tests_on_machine, 2465 machine_name, 2466 machine, 2467 exetests_by_machine[machine_name], 2468 ): machine_name 2469 for machine_name, machine in lab.machines.items() 2470 if machine_name in exetests_by_machine and len(exetests_by_machine[machine_name]) > 0 2471 } 2472 for future in as_completed(futures_to_machines): 2473 machine_name = futures_to_machines[future] 2474 try: 2475 machine_name, code, output = future.result() 2476 results[machine_name] = (code, output) 2477 except Exception as e: 2478 self.add_error(f"error during test execution on machine {machine_name}: {e}", step=self.step) 2479 ops_log.op(self.step, machine_name, f"error during test execution: {e}") 2480 continue 2481 2482 for machine_name, (exetests_code, output) in results.items(): 2483 if (machine_name, self.step) not in self._tests: 2484 self._tests[(machine_name, self.step)] = {} 2485 if exetests_code != 0: 2486 self.add_error( 2487 f"exetests error on {machine_name}: {exetests_by_machine[machine_name]} -- return code {exetests_code}", 2488 step=self.step) 2489 ops_log.op(self.step, machine_name, f"exetests error: return code {exetests_code}") 2490 parsed = parse_exetests_output(output) 2491 for cmd, timeout, result, code in parsed: 2492 if code == -2: 2493 self.add_error(f"test error on {machine_name}:{self.step}:{cmd} illegal error code", 2494 step=self.step) 2495 if code != 0: 2496 if not self._allow_errors_in_tests.get((machine_name, self.step, cmd, timeout), False): 2497 self.add_error(f"test error on {machine_name}:{cmd} code={code}", step=self.step) 2498 self._tests[(machine_name, self.step)][cmd, timeout] = (result, code) 2499 ops_log.cmds(self.step, machine_name, [(cmd, result, code) for cmd, _timeout, result, code in parsed]) 2500 if SRE.args.debug: 2501 for machine_name in lab.machines: 2502 if (machine_name, self.step) not in self._tests: 2503 continue 2504 for (cmd, timeout) in self._tests[(machine_name, self.step)]: 2505 log_debug(f"machine {machine_name} - step {self.step} - command {cmd} - timeout {timeout}:") 2506 result, code = self._tests[(machine_name, self.step)][cmd, timeout] 2507 log_debug(result) 2508 log_debug(f"-------- exit code {code}\n") 2509 2510 # console commands of the managed switches (test_switch()), run by sre itself 2511 for network_name in sorted(n for n, s in self._switch_tests if s == self.step): 2512 switch_cmds = self._tests.get((network_name, self.step), {}) 2513 entries = [] 2514 for cmd, timeout in list(switch_cmds): 2515 result, code = switch_console.run(network_name, cmd) 2516 if code != 0 and not self._allow_errors_in_tests.get( 2517 (network_name, self.step, cmd, timeout), False): 2518 self.add_error(f"test error on switch {network_name}:{cmd} code={code}", step=self.step) 2519 switch_cmds[(cmd, timeout)] = (result, code) 2520 entries.append((cmd, result, code)) 2521 if SRE.args.debug: 2522 log_debug(f"switch {network_name} - step {self.step} - command {cmd}:") 2523 log_debug(result) 2524 log_debug(f"-------- exit code {code}\n") 2525 ops_log.cmds(self.step, OperationsLog.switch(network_name), entries) 2526 2527 host_step_cmds = self._host_tests.get(self.step, {}) 2528 if host_step_cmds: 2529 def _run_host_cmd(cmd, t): 2530 return (cmd, t, *run_host_command(cmd, t)) 2531 2532 with ThreadPoolExecutor(max_workers=min(params.max_docker_concurrency, 2533 len(host_step_cmds))) as executor: 2534 futures = {executor.submit(_run_host_cmd, cmd, t): (cmd, t) 2535 for (cmd, t) in host_step_cmds} 2536 for future in as_completed(futures): 2537 cmd, t, result, code = future.result() 2538 if code != 0: 2539 if not self._allow_errors_in_host_tests.get((self.step, cmd, t), False): 2540 self.add_error(f"host test error: {cmd} code={code}", step=self.step) 2541 self._host_tests[self.step][(cmd, t)] = (result, code) 2542 ops_log.cmd(self.step, OperationsLog.HOST, cmd, result, code) 2543 if SRE.args.debug: 2544 log_debug(f"host - step {self.step} - command {cmd} - timeout {t}:") 2545 log_debug(result) 2546 log_debug(f"-------- exit code {code}\n") 2547 2548 if SRE.args.debug and len(self._errors) > 0: 2549 log_debug(f"{len(self._errors)} errors:\n " + "\n".join(f"[{e[0]}] {e[1]}" for e in self._errors)) 2550 self.compute_total() 2551 self._mark_self_eval = self.mark_self_eval() 2552 self._mark_exo_eval = self.mark_exo_eval() 2553 self._log_grade_results(ops_log) 2554 # log_error(f"DEBUG _tests at save: {len(self._tests)} machine-step entries") 2555 # for (machine, step), cmds in self._tests.items(): 2556 # for (cmd, timeout), (result, code) in cmds.items(): 2557 # log_error(f" [{machine}][step={step}] cmd={cmd!r} result_len={len(result)} code={code}") 2558 2559 def compute_total(self): 2560 """Accumulate per-scope totals in one pass; BOTH-scope elements contribute to both. 2561 2562 The grade of a bonus element counts, its max_grade does not; the scope then 2563 remembers it has bonus elements (its mark is capped at the maximum mark).""" 2564 self._total_grade_self_eval = 0 2565 self._total_max_self_eval = 0 2566 self._total_grade_exo_eval = 0 2567 self._total_max_exo_eval = 0 2568 self._bonus_in_self_eval = False 2569 self._bonus_in_exo_eval = False 2570 for g in self._grade_list: 2571 if g.scope & params.SELF_EVAL_SCOPE: 2572 self._total_grade_self_eval += g.grade 2573 if g.bonus: 2574 self._bonus_in_self_eval = True 2575 else: 2576 self._total_max_self_eval += g.max_grade 2577 if g.scope & params.EXO_EVAL_SCOPE: 2578 self._total_grade_exo_eval += g.grade 2579 if g.bonus: 2580 self._bonus_in_exo_eval = True 2581 else: 2582 self._total_max_exo_eval += g.max_grade 2583 2584 def save_tests(self): 2585 now = datetime.datetime.now() 2586 if self._exam_json is None: 2587 exam_path = Path(params.sre_pub_dir) / params.exam_json_name 2588 try: 2589 self._exam_json = json.loads(exam_path.read_text()) 2590 except FileNotFoundError: 2591 pass 2592 except Exception as e: 2593 log_error(f"can't read {exam_path}: {e}") 2594 for d1 in self.archive_dirs: 2595 d = Path(d1).expanduser().resolve() 2596 try: 2597 if not d.exists(): 2598 try: 2599 os.mkdir(d, 0o700) # not makedirs: a missing parent stays an error 2600 except FileExistsError: 2601 pass # created meanwhile by another evaluation (sre eval-all) 2602 else: 2603 os.listdir(d) # trigger mount / catch stale handle early 2604 permissions = os.stat(d).st_mode 2605 if permissions != 0o700: 2606 os.chmod(d, 0o700) 2607 filename = d / params.get_archive_name(self.net_scheme.running_lab_name, now) 2608 self.save_tests_on_file(str(filename)) 2609 except Exception as e: 2610 log_error(f"can't save archive to {d}: {e}") 2611 2612 def save_tests_on_file(self, filename: str): 2613 files_content = {} 2614 if self.files_to_save_in_archives: 2615 fdir = Path(params.files_dir(self.get_running_lab_name())) 2616 if fdir.is_dir(): 2617 for pattern in self.files_to_save_in_archives: 2618 for fpath in fdir.iterdir(): 2619 if fpath.is_file() and fnmatch.fnmatch(fpath.name, pattern): 2620 try: 2621 files_content[fpath.name] = fpath.read_bytes() 2622 except Exception: 2623 pass 2624 archive = { 2625 params.running_lab_name_keyword: self.get_running_lab_name(), 2626 params.eval_date_keyword: self._eval_date, 2627 params.re_eval_date_keyword: self._re_eval_date, 2628 'data_json': self.get_data().to_json(), 2629 'tests': self.get_tests(), 2630 'errors': self.get_errors(), 2631 'answers': self.get_answers(), 2632 'grade_list': [asdict(e) for e in self._grade_list], 2633 'grade_parts': [asdict(p) for p in self._grade_parts], 2634 'total_grade_self_eval': self._total_grade_self_eval, 2635 'total_max_self_eval': self._total_max_self_eval, 2636 'mark_self_eval': self._mark_self_eval, 2637 'total_grade_exo_eval': self._total_grade_exo_eval, 2638 'total_max_exo_eval': self._total_max_exo_eval, 2639 'mark_exo_eval': self._mark_exo_eval, 2640 'maximum_mark': self._maximum_mark, 2641 'files': files_content, 2642 } 2643 if self._exam_json is not None: 2644 archive[params.exam_json_keyword] = self._exam_json 2645 cctx = zstd.ZstdCompressor(level=6) 2646 with tempfile.NamedTemporaryFile( 2647 mode="wb", 2648 dir=os.path.dirname(filename), 2649 delete=False 2650 ) as f: 2651 with cctx.stream_writer(f) as compressor: 2652 packed = msgpack.packb(archive, use_bin_type=True) 2653 compressor.write(packed) 2654 # f.flush() 2655 # os.fsync(f.fileno()) 2656 temp_name = f.name 2657 os.replace(temp_name, filename)
Base class for lab evaluation logic.
Subclasses override grade() to register tests and questions. The
actual execution happens later in run_tests(), which drives a
multi-step loop:
- Call
grade()to register commands (they return placeholder values). - Execute all registered commands in containers (via
exetests). - Repeat for each additional step (
self.max_step). - Call
grade()a final time — commands now return real results. - Save the archive.
Key instance attributes available inside grade():
self.data— theData0instance (vianet_scheme).self.step— current step number (1-based).self.max_step— highest step number registered so far.
1698 def __init__(self, net_scheme): 1699 self.net_scheme = net_scheme 1700 self.archive_dirs = [] 1701 self.files_to_save_in_archives = [] 1702 self._tests = None 1703 self._allow_errors_in_tests = None 1704 self.step = 0 1705 self.max_step = 1 1706 self._questions = None 1707 self._questions_order = None 1708 self._questions_current_order = None 1709 self._cheat_answers = {} 1710 self._answers = {} 1711 self._grade_list = [] 1712 self._grades = {} 1713 self._grade_parts: list[GradePart] = [] 1714 self._errors = [] 1715 self._total_grade_self_eval = 0 1716 self._total_max_self_eval = 0 1717 self._total_grade_exo_eval = 0 1718 self._total_max_exo_eval = 0 1719 self._bonus_in_self_eval = False # a bonus element is in the scope: the mark is capped 1720 self._bonus_in_exo_eval = False 1721 self._maximum_mark = params.default_maximum_mark 1722 self._use_numerical_marks = params.use_numerical_marks_by_default 1723 self._display_marks_in_auto_evaluations = params.display_marks_in_auto_evaluations_by_default 1724 self._mark_self_eval = None 1725 self._mark_exo_eval = None 1726 self._section_counter = [] 1727 self.section_fmt = [('R', 1), ('N', 1), ('l', 2), ('N', 3)] 1728 self._eval_date = None 1729 self._re_eval_date = None 1730 self._default_language = 'en' 1731 self.auto_eval_count = 0 1732 self._exam_json = None 1733 self.full_reset()
1735 def full_reset(self): 1736 """Reset all state including tests and answers (called from ``__init__``).""" 1737 self.step = 0 1738 self.max_step = 1 1739 self._tests = {} # keys : (machine,step)->(cmd, timeout)->(result, code)) 1740 self._allow_errors_in_tests = {} 1741 self._switch_tests = set() # (network, step) keys of _tests registered by test_switch() 1742 self._host_tests = {} # step -> {(command, timeout): (result, code)} 1743 self._allow_errors_in_host_tests = {} # (step, command, timeout) -> True 1744 self._answers = {} # hash -> answer 1745 self._eval_date = None 1746 self.reset_before_grade()
Reset all state including tests and answers (called from __init__).
1748 def reset_before_grade(self): 1749 """Clear questions, grade list, and section counters before each call to :meth:`grade`.""" 1750 # instructor() texts follow the mode of this project (`is True`: net_scheme may be a stand-in) 1751 set_instructor_context(getattr(self.net_scheme, 'instructor_texts', False) is True) 1752 self._questions = dict() # hash->Question 1753 self._questions_order = dict() # order -> [Question1, Question2, ...] 1754 self._questions_current_order = 100 1755 self._cheat_answers = {} # state->(question_hash->answer) 1756 self._grade_list = [] 1757 self._grades = dict() # title -> GradeElement 1758 self._grade_parts = [] 1759 self._section_counter = []
Clear questions, grade list, and section counters before each call to grade().
1761 def grade(self): 1762 """Override to register tests, questions, and grade elements. 1763 1764 Called multiple times during :meth:`run_tests` — once per step plus a 1765 final time when all results are available. Do **not** produce side-effects 1766 here; only call ``self.test()``, ``self.question_*()`` and 1767 ``self.add_grade_element()`` / ``self.set_grade()``. 1768 """ 1769 self._grade_list = [] 1770 self._grade_parts = []
Override to register tests, questions, and grade elements.
Called multiple times during run_tests() — once per step plus a
final time when all results are available. Do not produce side-effects
here; only call self.test(), self.question_*() and
self.add_grade_element() / self.set_grade().
1802 def mark_self_eval(self): 1803 """Final mark over elements visible in self-eval (scope & SELF_EVAL_SCOPE).""" 1804 return self._compute_mark(self._total_grade_self_eval, self._total_max_self_eval, 1805 cap=self._bonus_in_self_eval)
Final mark over elements visible in self-eval (scope & SELF_EVAL_SCOPE).
1807 def mark_exo_eval(self): 1808 """Final mark over elements visible in non-auto eval / outline / sheet.""" 1809 return self._compute_mark(self._total_grade_exo_eval, self._total_max_exo_eval, 1810 cap=self._bonus_in_exo_eval)
Final mark over elements visible in non-auto eval / outline / sheet.
1833 def get_exetests_strings(self, step: int): 1834 result = dict() 1835 for (machine, step1) in self._tests.keys(): 1836 if step != step1: 1837 continue 1838 if (machine, step1) in self._switch_tests: 1839 continue # console commands of a managed switch, not run through exetests 1840 result[machine] = build_exetests_string(self._tests[(machine, step)].keys()) 1841 return result
1846 def increment_section_counter(self, level: int): 1847 if len(self._section_counter) < level + 1: 1848 self._section_counter += [0 for i in range(level + 1 - len(self._section_counter))] 1849 else: 1850 self._section_counter = self._section_counter[:(level + 1)] 1851 self._section_counter[level] += 1
1853 def set_section_counter(self, level: int, value: int): 1854 if len(self._section_counter) < level + 1: 1855 self._section_counter += [0 for i in range(level + 1 - len(self._section_counter))] 1856 else: 1857 self._section_counter = self._section_counter[:(level + 1)] 1858 self._section_counter[level] = value
1864 def current_section(self, level: int = 0, fmt=None, show: int = None, pad: str = None): 1865 # fmt is a list of (type, depth[, prefix]) tuples, one per level. 1866 # type: R=ROMAN, r=roman, L=Letters, l=letters, N=number 1867 # depth: how many consecutive counters to display, ending at this level 1868 # prefix: optional string prepended to the result (default: '') 1869 # e.g. [('R',1),('N',2,' ')] → level 0: "I." / level 1: " I.1." 1870 if fmt is None: 1871 fmt = self.section_fmt 1872 1873 def _to_roman(n: int) -> str: 1874 vals = [ 1875 (1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), 1876 (100, 'C'), (90, 'XC'), (50, 'L'), (40, 'XL'), 1877 (10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I'), 1878 ] 1879 result = '' 1880 for value, numeral in vals: 1881 while n >= value: 1882 result += numeral 1883 n -= value 1884 return result 1885 1886 def _to_capital_letter(n: int) -> str: 1887 result = '' 1888 while n > 0: 1889 n, r = divmod(n - 1, 26) 1890 result = chr(ord('A') + r) + result 1891 return result 1892 1893 def _to_lowercase_letter(n: int) -> str: 1894 result = '' 1895 while n > 0: 1896 n, r = divmod(n - 1, 26) 1897 result = chr(ord('a') + r) + result 1898 return result 1899 1900 def _convert(n: int, fmt_type: str) -> str: 1901 match fmt_type: 1902 case 'R': 1903 return _to_roman(n) 1904 case 'r': 1905 return _to_roman(n).lower() 1906 case 'L': 1907 return _to_capital_letter(n) 1908 case 'l': 1909 return _to_lowercase_letter(n) 1910 case _: 1911 return str(n) 1912 1913 entry = fmt[level] if level < len(fmt) else ('N', level + 1) 1914 _, depth = entry[:2] 1915 prefix = pad if pad is not None else (entry[2] if len(entry) > 2 else '') 1916 if show is not None: 1917 depth = show 1918 start = max(0, level - depth + 1) 1919 parts = [_convert(self._section_counter[j] if j < len(self._section_counter) else 0, 1920 fmt[j][0] if j < len(fmt) else 'N') 1921 for j in range(start, level + 1)] 1922 return prefix + ".".join(parts) + ". "
1924 def load_answers(self): 1925 """Load student answers from ``answers.json`` into ``self._answers``.""" 1926 self._answers = {} 1927 try: 1928 fd = os.open(params.answers_filename(running_lab_name=self.get_running_lab_name()), 1929 os.O_RDONLY | os.O_NOFOLLOW) 1930 with os.fdopen(fd) as f: 1931 self._answers = json.load(f) 1932 except (OSError, json.JSONDecodeError): 1933 self._answers = {}
Load student answers from answers.json into self._answers.
1944 def test_host(self, command, step=1, timeout: int = params.default_timeout, default_value='', 1945 default_code: int = 0, allow_error: bool = False): 1946 """Register and retrieve the result of a host-side test command. 1947 1948 On the first call (registration pass) returns *default_value* / *default_code*. 1949 On subsequent calls (result pass) returns the actual ``(stdout, exit_code)``. 1950 Set *allow_error* to suppress error recording on non-zero exit. 1951 """ 1952 if params.execute_commands_on_host is False: 1953 sys.exit("test_host() is disabled by params.execute_commands_on_host") 1954 if self.max_step < step: 1955 self.max_step = step 1956 if step not in self._host_tests: 1957 self._host_tests[step] = {} 1958 if allow_error and (step, command, timeout) not in self._allow_errors_in_host_tests: 1959 self._allow_errors_in_host_tests[(step, command, timeout)] = True 1960 if (command, timeout) not in self._host_tests[step]: 1961 self._host_tests[step][(command, timeout)] = (default_value, default_code) 1962 return default_value, default_code 1963 return self._host_tests[step][(command, timeout)]
Register and retrieve the result of a host-side test command.
On the first call (registration pass) returns default_value / default_code.
On subsequent calls (result pass) returns the actual (stdout, exit_code).
Set allow_error to suppress error recording on non-zero exit.
1994 def test(self, machine_name, command, step=1, timeout: int = params.default_timeout, default_value='', 1995 default_code: int = 0, allow_error: bool = False): 1996 """Register and retrieve the result of a command executed inside *machine_name*. 1997 1998 On the first call (registration pass) returns *default_value* / *default_code*. 1999 On subsequent calls (result pass) returns the actual ``(stdout, exit_code)``. 2000 *timeout* is in seconds. Set *allow_error* to suppress error recording. 2001 """ 2002 if self.max_step < step: 2003 self.max_step = step 2004 if (machine_name, step) not in self._tests: 2005 self._tests[(machine_name, step)] = {} 2006 if allow_error and (machine_name, step, command, timeout) not in self._allow_errors_in_tests: 2007 self._allow_errors_in_tests[(machine_name, step, command, timeout)] = True 2008 if (command, timeout) not in self._tests[(machine_name, step)]: 2009 self._tests[(machine_name, step)][(command, timeout)] = (default_value, default_code) 2010 return default_value, default_code 2011 return self._tests[(machine_name, step)][(command, timeout)]
Register and retrieve the result of a command executed inside machine_name.
On the first call (registration pass) returns default_value / default_code.
On subsequent calls (result pass) returns the actual (stdout, exit_code).
timeout is in seconds. Set allow_error to suppress error recording.
2013 def test_switch(self, network_name, command, step=1, default_value='', default_code: int = 0, 2014 allow_error: bool = False): 2015 """Register and retrieve the result of a console command of the managed switch *network_name*. 2016 2017 *network_name* is a network declared with ``'mode': 'managed'``; *command* is one line of 2018 its management console, e.g. ``vlan/print``. A word ``@machine`` stands for the number of 2019 the port *machine* is plugged into (``port/print @pc1``). 2020 2021 Same contract as :meth:`test`: *default_value* / *default_code* on the registration pass, 2022 the actual ``(output, code)`` afterwards. *code* is ``0``, the error number given by the 2023 switch, or ``-2`` when the command could not be run. The result is stored with the tests 2024 of the machines (under the name of the network), so archives keep it. 2025 """ 2026 get_network = getattr(self.net_scheme, 'get_network', None) 2027 if get_network is not None: 2028 net = get_network(network_name) 2029 if net is None or not net.is_managed(): 2030 raise ValueError(f"test_switch(): '{network_name}' is not a managed switch of this lab") 2031 self._switch_tests.add((network_name, step)) 2032 return self.test(network_name, command, step=step, timeout=_SWITCH_TEST_TIMEOUT, 2033 default_value=default_value, default_code=default_code, allow_error=allow_error)
Register and retrieve the result of a console command of the managed switch network_name.
network_name is a network declared with 'mode': 'managed'; command is one line of
its management console, e.g. vlan/print. A word @machine stands for the number of
the port machine is plugged into (port/print @pc1).
Same contract as test(): default_value / default_code on the registration pass,
the actual (output, code) afterwards. code is 0, the error number given by the
switch, or -2 when the command could not be run. The result is stored with the tests
of the machines (under the name of the network), so archives keep it.
2043 def question_text(self, title, section='', description='', hash=None, order=None, default_answer='', 2044 cheat_answers=None): 2045 """Register a free-text question and return the student's current answer string. 2046 2047 Returns *default_answer* if the student has not answered yet. 2048 *cheat_answers* maps state names to answer strings used in automated testing. 2049 """ 2050 title = self._apply_section(section, title) 2051 if order is None: 2052 order1 = self._questions_current_order 2053 self._questions_current_order = ((self._questions_current_order // 100) + 1) * 100 2054 else: 2055 order1 = order 2056 2057 q = QuestionText(title, description, hash, order1) 2058 if order1 not in self._questions_order: 2059 self._questions_order[order1] = [] 2060 self._questions_order[order1].append(q) 2061 2062 if q.question_hash in self._questions: 2063 write_error(f"Duplicate question hash {q.question_hash}") 2064 self._questions[q.question_hash] = q 2065 2066 if cheat_answers is not None: 2067 for state, answer in cheat_answers.items(): 2068 if state not in self._cheat_answers: 2069 self._cheat_answers[state] = {} 2070 self._cheat_answers[state][q.question_hash] = answer 2071 2072 if q.question_hash in self._answers: 2073 return self._answers[q.question_hash] 2074 return default_answer
Register a free-text question and return the student's current answer string.
Returns default_answer if the student has not answered yet. cheat_answers maps state names to answer strings used in automated testing.
2078 def question_form(self, title, section='', description='', hash=None, order=None, cheat_answers=None): 2079 """Register a form question with inline @@{field_name:spec}@@ fields. 2080 2081 spec: a regex (single-line text input), '>a|b' or 'Label>>>value|...' (dropdown), 2082 '?' / '?true' (checkbox), '+' / '+N' (multi-line free text of N visible lines, 2083 params.default_form_field_lines by default); the grammar is that of SRE.form_spec. 2084 Returns the student's answers as a dict {field_name: value}, or {} if none yet. 2085 cheat_answers format: {state_name: {field_name: value, ...}} 2086 """ 2087 title = self._apply_section(section, title) 2088 from .common import QuestionForm 2089 2090 # The @@{field:spec}@@ markers are language-independent, so when the description is a 2091 # TranslatedText (wrapped in tr()) the fields come from one of its values. The full 2092 # TranslatedText is still stored on the question for per-language rendering. 2093 fields = _form_fields(description) 2094 2095 if order is None: 2096 order1 = self._questions_current_order 2097 self._questions_current_order = ((self._questions_current_order // 100) + 1) * 100 2098 else: 2099 order1 = order 2100 2101 q = QuestionForm(title, description, hash, order1, fields) 2102 if order1 not in self._questions_order: 2103 self._questions_order[order1] = [] 2104 self._questions_order[order1].append(q) 2105 2106 if q.question_hash in self._questions: 2107 write_error(f"Duplicate question hash {q.question_hash}") 2108 self._questions[q.question_hash] = q 2109 2110 if cheat_answers is not None: 2111 for state, field_answers in cheat_answers.items(): 2112 if state not in self._cheat_answers: 2113 self._cheat_answers[state] = {} 2114 self._cheat_answers[state][q.question_hash] = json.dumps( 2115 field_answers, ensure_ascii=False 2116 ) 2117 2118 if q.question_hash in self._answers: 2119 try: 2120 return json.loads(self._answers[q.question_hash]) 2121 except (json.JSONDecodeError, TypeError): 2122 return {} 2123 return {}
Register a form question with inline @@{field_name:spec}@@ fields.
spec: a regex (single-line text input), '>a|b' or 'Label>>>value|...' (dropdown), '?' / '?true' (checkbox), '+' / '+N' (multi-line free text of N visible lines, params.default_form_field_lines by default); the grammar is that of SRE.form_spec. Returns the student's answers as a dict {field_name: value}, or {} if none yet. cheat_answers format: {state_name: {field_name: value, ...}}
2125 def question_dummy(self, title, section='', description='', hash=None, order=None): 2126 """Register a display-only block (no answer widget shown to the student).""" 2127 title = self._apply_section(section, title) 2128 if order is None: 2129 order1 = self._questions_current_order 2130 self._questions_current_order = ((self._questions_current_order // 100) + 1) * 100 2131 else: 2132 order1 = order 2133 2134 q = QuestionDummy(title, description, hash, order1) 2135 if order1 not in self._questions_order: 2136 self._questions_order[order1] = [] 2137 self._questions_order[order1].append(q) 2138 2139 if q.question_hash in self._questions: 2140 write_error(f"Duplicate question hash {q.question_hash}") 2141 self._questions[q.question_hash] = q
Register a display-only block (no answer widget shown to the student).
2143 def add_grade_part(self, title, description=''): 2144 """Register a new :class:`GradePart` group and return it. 2145 2146 Pass the returned object to ``add_grade_element(..., grade_part=...)`` 2147 to associate elements with this part. Parts are displayed in 2148 registration order (with a subtotal row per part) in the GUI 2149 evaluation view and in ``sre outline`` PDFs. 2150 """ 2151 if description: 2152 description = TranslatedText.from_value(description, self._default_language) 2153 gp = GradePart(title=title, description=description) 2154 if any(p.title == title for p in self._grade_parts): 2155 write_error(f"Duplicate grade part title = {title}") 2156 self._grade_parts.append(gp) 2157 return gp
Register a new GradePart group and return it.
Pass the returned object to add_grade_element(..., grade_part=...)
to associate elements with this part. Parts are displayed in
registration order (with a subtotal row per part) in the GUI
evaluation view and in sre outline PDFs.
2159 def add_grade_element(self, title, max_grade, description='', grade=0, scope=params.BOTH_EVAL_SCOPE, 2160 grade_part=None, bonus=False): 2161 """Add a graded rubric item. Initial *grade* defaults to 0; use :meth:`set_grade` to update it. 2162 2163 ``scope`` is a bitmask: ``SELF_EVAL_SCOPE`` (1) for self-eval only, 2164 ``EXO_EVAL_SCOPE`` (2) for non-auto eval / outline / sheet only, 2165 ``BOTH_EVAL_SCOPE`` (3, default) for both audiences. 2166 2167 ``grade_part`` optionally associates this element with a 2168 :class:`GradePart` previously returned by :meth:`add_grade_part`. 2169 2170 ``bonus`` makes a bonus element: its grade counts in the totals but its 2171 *max_grade* does not (neither in the total nor in the subtotal of its part), 2172 and the mark is capped at the maximum mark. The reports still show its 2173 *max_grade*, with the word "Bonus". 2174 """ 2175 if scope not in params.grade_scopes: 2176 raise ValueError(f"Invalid scope {scope!r}; expected one of {params.grade_scopes}") 2177 if description: 2178 description = TranslatedText.from_value(description, self._default_language) 2179 grade_part_title = None 2180 if grade_part is not None: 2181 if not isinstance(grade_part, GradePart): 2182 raise TypeError(f"grade_part must be a GradePart, got {type(grade_part).__name__}") 2183 if grade_part not in self._grade_parts: 2184 write_error(f"Unregistered grade part {grade_part.title!r} passed to add_grade_element") 2185 grade_part_title = grade_part.title 2186 g = GradeElement(title=title, max_grade=max_grade, description=description, grade=grade, scope=scope, 2187 grade_part=grade_part_title, bonus=bool(bonus)) 2188 self._grade_list.append(g) 2189 key = _tt_hash_str(title) 2190 if key in self._grades: 2191 write_error(f"Duplicate grade title = {title}") 2192 self._grades[key] = g
Add a graded rubric item. Initial grade defaults to 0; use set_grade() to update it.
scope is a bitmask: SELF_EVAL_SCOPE (1) for self-eval only,
EXO_EVAL_SCOPE (2) for non-auto eval / outline / sheet only,
BOTH_EVAL_SCOPE (3, default) for both audiences.
grade_part optionally associates this element with a
GradePart previously returned by add_grade_part().
bonus makes a bonus element: its grade counts in the totals but its
max_grade does not (neither in the total nor in the subtotal of its part),
and the mark is capped at the maximum mark. The reports still show its
max_grade, with the word "Bonus".
2194 def set_grade(self, title, grade): 2195 """Set the numeric *grade* for the element previously registered under *title*.""" 2196 self._grades[_tt_hash_str(title)].grade = grade
Set the numeric grade for the element previously registered under title.
2198 def save_lab_info(self): 2199 debug_project = os.path.exists( 2200 params.debug_project_marker_filename(self.net_scheme.running_lab_name) 2201 ) 2202 instructor_mode = os.path.exists( 2203 params.instructor_mode_marker_filename(self.net_scheme.running_lab_name) 2204 ) 2205 if debug_project: 2206 visible_machines = list(self.net_scheme.get_machines()) 2207 else: 2208 visible_machines = [m for m in self.net_scheme.get_machines() if not m.hidden] 2209 lab_hash = self.net_scheme.get_lab_hash() 2210 2211 def _get_stats(m): 2212 s = next(Kathara.get_instance().get_machine_stats(lab_hash=lab_hash, machine_name=m.name), None) 2213 return m, s 2214 2215 stats_map = {} 2216 with ThreadPoolExecutor(max_workers=min(params.max_docker_concurrency, len(visible_machines))) as executor: 2217 for m, s in executor.map(_get_stats, visible_machines): 2218 stats_map[m.name] = (m, s) 2219 2220 info_machines = [] 2221 for m in visible_machines: 2222 m, s = stats_map[m.name] 2223 interfaces = [] 2224 for net, netAdapter in m.net_adapters.items(): 2225 interfaces.append(InfoInterface(network=net.name, interface_name=f"eth{netAdapter.interface}")) 2226 info_machines.append( 2227 InfoMachine(name=m.name, status=s.status if s is not None else "", hidden=m.hidden, 2228 bridged=m.bridged, 2229 x11_host=m.x11_host, 2230 ports=m.ports, 2231 allow_connection=m.allow_connection, 2232 color=m.color or "", 2233 shape=m.shape or "", 2234 interfaces=interfaces)) 2235 2236 network_colors = { 2237 net.name: net.color 2238 for m in visible_machines 2239 for net in m.net_adapters 2240 if net.color 2241 } 2242 network_shapes = { 2243 net.name: net.shape 2244 for m in visible_machines 2245 for net in m.net_adapters 2246 if net.shape 2247 } 2248 # the networks a student sees (every network of a debug project), as switches 2249 visible_networks = (self.net_scheme.get_networks() if debug_project 2250 else self.net_scheme.get_visible_networks()) 2251 info_switches = [InfoSwitch(name=net.name, mode=net.mode, allow_connection=bool(net.allow_connection)) 2252 for net in visible_networks if net.net_adapters] 2253 2254 module_rvlab = sys.modules[params.srelab_py_name.removesuffix(".py")] 2255 default_language = getattr(module_rvlab, 'default_language', 'en') 2256 self._default_language = default_language 2257 show_nat_network = getattr(module_rvlab, 'show_nat_network', params.default_show_nat_network) 2258 nat_network_name = getattr(module_rvlab, 'host_network_name', params.default_host_network_name) 2259 nat_network_color = getattr(module_rvlab, 'host_network_color', params.default_host_network_color) 2260 host_network_exploded = getattr(module_rvlab, 'host_network_exploded', 2261 params.default_host_network_exploded) 2262 host_network_edge_relative_length = float(getattr( 2263 module_rvlab, 'host_network_edge_relative_length', 2264 params.default_host_network_edge_relative_length)) 2265 schema_splines = getattr(module_rvlab, 'schema_splines', params.graphviz_default_splines) 2266 schema_overlap = getattr(module_rvlab, 'schema_overlap', params.graphviz_default_overlap) 2267 2268 if self.step == 0: 2269 self.grade() 2270 self.step += 1 2271 2272 questions = self.get_questions_ordered() 2273 2274 if hasattr(module_rvlab, 'title'): 2275 title = module_rvlab.title 2276 else: 2277 lab_name = params.get_lab_name_from_running_lab_name(self.get_running_lab_name()) 2278 title = lab_name.removesuffix('.py') 2279 title = TranslatedText.from_value(title, default_language) 2280 2281 if hasattr(module_rvlab, 'delay_between_self_grade'): 2282 delay_between_self_grade = module_rvlab.delay_between_self_grade 2283 else: 2284 delay_between_self_grade = 0 2285 2286 if hasattr(module_rvlab, 'allow_self_grade'): 2287 allow_self_grade = module_rvlab.allow_self_grade 2288 else: 2289 allow_self_grade = False 2290 2291 if debug_project: 2292 delay_between_self_grade = 0 2293 allow_self_grade = True 2294 if hasattr(module_rvlab, 'export_kathara_project'): 2295 export_kathara_project = module_rvlab.export_kathara_project 2296 else: 2297 export_kathara_project = False 2298 2299 eval_interval_without_exam_mode = getattr(module_rvlab, 'eval_interval_without_exam_mode', params.default_eval_interval_without_exam_mode) 2300 eval_before_exit = getattr(module_rvlab, 'eval_before_exit', False) 2301 allow_save_restore = getattr(module_rvlab, 'allow_save_restore', False) 2302 export_schema_pdf = bool(getattr(module_rvlab, 'export_schema_pdf', True)) 2303 2304 informations = TranslatedText.from_value(self.net_scheme.informations, default_language) 2305 for q in questions: 2306 q.title = TranslatedText.from_value(q.title, default_language) 2307 q.description = TranslatedText.from_value(q.description, default_language) 2308 # numbered sections, references and table of contents (markers of SRE.informations): 2309 # the numbers come from the course, the questions refer to them (bare numbers, no link) 2310 informations, numbering = resolve_informations(informations) 2311 for q in questions: 2312 q.title = resolve_text(q.title, numbering) 2313 q.description = resolve_text(q.description, numbering) 2314 2315 net_scheme_cls = type(self.net_scheme) 2316 # debug and instructor-mode projects: every state can be applied; the ones a student 2317 # could not apply are listed in admin_only_states 2318 all_states_allowed = debug_project or instructor_mode 2319 if all_states_allowed: 2320 user_allowed_states_raw = {} 2321 for state in net_scheme_cls.get_state_methods(): 2322 if state in params.lifecycle_state_names: 2323 continue 2324 desc = '' 2325 for klass in net_scheme_cls.__mro__: 2326 fn = klass.__dict__.get(state) 2327 if fn is not None and getattr(fn, '_is_sre_state', False): 2328 desc = getattr(fn, '_sre_state_description', '') 2329 break 2330 user_allowed_states_raw[state] = desc 2331 else: 2332 user_allowed_states_raw = ( 2333 net_scheme_cls.get_user_allowed_states() 2334 if getattr(module_rvlab, 'allow_user_states', False) else {} 2335 ) 2336 user_allowed_states = { 2337 state: resolve_text(TranslatedText.from_value(desc, default_language), numbering) if desc else desc 2338 for state, desc in user_allowed_states_raw.items() 2339 } 2340 2341 if not (instructor_mode or debug_project): 2342 # info.json is readable by the students: whatever the instructor() calls returned, 2343 # no instructor fragment leaves a project that is neither in instructor mode nor a 2344 # debug project 2345 title = strip_instructor(title) 2346 informations = strip_instructor(informations) 2347 for q in questions: 2348 q.title = strip_instructor(q.title) 2349 q.description = strip_instructor(q.description) 2350 user_allowed_states = {state: strip_instructor(desc) 2351 for state, desc in user_allowed_states.items()} 2352 2353 if all_states_allowed: 2354 module_allows_user_states = getattr(module_rvlab, 'allow_user_states', False) 2355 admin_only_states = [ 2356 state for state in user_allowed_states_raw 2357 if not module_allows_user_states 2358 or not net_scheme_cls.is_state_user_allowed(state) 2359 ] 2360 else: 2361 admin_only_states = [] 2362 2363 info = InfoLab(lab_name=self.net_scheme.lab_name, lab_hash=self.net_scheme.lab_hash, 2364 title=title, machines=info_machines, delay_between_self_grade=delay_between_self_grade, 2365 questions=questions, informations=informations, 2366 export_kathara_project=export_kathara_project, allow_self_grade=allow_self_grade, 2367 debug_project=debug_project, 2368 instructor_mode=instructor_mode, 2369 eval_interval_without_exam_mode=eval_interval_without_exam_mode, 2370 eval_before_exit=eval_before_exit, 2371 allow_save_restore=allow_save_restore, 2372 export_schema_pdf=export_schema_pdf, 2373 default_language=default_language, 2374 user_allowed_states=user_allowed_states, 2375 admin_only_states=admin_only_states, 2376 network_colors=network_colors, 2377 network_shapes=network_shapes, 2378 show_nat_network=show_nat_network, 2379 nat_network_name=nat_network_name, 2380 nat_network_color=nat_network_color, 2381 host_network_exploded=host_network_exploded, 2382 host_network_edge_relative_length=host_network_edge_relative_length, 2383 schema_splines=schema_splines, 2384 schema_overlap=schema_overlap, 2385 switches=info_switches) 2386 2387 info_json = info.to_json() 2388 info_filename = params.info_filename(self.net_scheme.running_lab_name) 2389 2390 save_info_json = None 2391 try: 2392 with open(info_filename, "r") as f: 2393 save_info_json = f.read() 2394 except FileNotFoundError: 2395 pass 2396 if save_info_json == info_json: 2397 return 2398 2399 temp_file = tempfile.NamedTemporaryFile( 2400 delete=False, 2401 dir=Path(self.net_scheme.get_public_lab_dir()).parent) 2402 with open(temp_file.name, "w") as f: 2403 print(info_json, file=f) 2404 f.flush() 2405 os.fsync(f.fileno()) 2406 os.chmod(temp_file.name, 0o644) 2407 if os.geteuid() == 0: 2408 # root is only dropped temporarily for a lab with privileged machines, and the 2409 # Kathara calls above leave the euid at 0: info.json stays sre's 2410 os.fchown(f.fileno(), params.sre_uid, -1) 2411 os.replace(temp_file.name, info_filename)
2422 @staticmethod 2423 def run_tests_on_machine(machine_name, machine, exetests): 2424 environment = {params.exetests_env_name: exetests} 2425 code, output = machine.api_object.exec_run([params.exetests_machines_path], 2426 stdin=False, 2427 stdout=True, 2428 stderr=False, 2429 tty=False, 2430 environment=environment, 2431 workdir="/", 2432 ) 2433 return machine_name, code, output
2435 def run_tests(self): 2436 self._tests = {} 2437 self._section_counter = [] 2438 lab = self.net_scheme.get_lab_from_kathara() 2439 self._errors = [] 2440 self.load_answers() 2441 self._eval_date = datetime.datetime.now().isoformat() 2442 ops_log = self._ops_log() 2443 ops_log.begin("evaluation") 2444 switch_console = SwitchConsole(self.net_scheme) 2445 2446 while self.step <= self.max_step: 2447 self.reset_before_grade() 2448 self.grade() 2449 self.step += 1 2450 tests = self.get_tests() 2451 exetests_by_machine = self.get_exetests_strings(self.step) 2452 if SRE.args.debug and self.step <= self.max_step: 2453 log_debug(f"Commands step {self.step} (after running grade on step {self.step - 1}):") 2454 for machine, cmds in exetests_by_machine.items(): 2455 c = cmds.split(params.exetests_separator) 2456 c1 = " - ".join(c) 2457 log_debug(f"{machine}: {c1}") 2458 2459 results = {} 2460 with ThreadPoolExecutor( 2461 max_workers=max(1, min(params.max_docker_concurrency, len(lab.machines)))) as executor: 2462 futures_to_machines = { 2463 executor.submit( 2464 Grade0.run_tests_on_machine, 2465 machine_name, 2466 machine, 2467 exetests_by_machine[machine_name], 2468 ): machine_name 2469 for machine_name, machine in lab.machines.items() 2470 if machine_name in exetests_by_machine and len(exetests_by_machine[machine_name]) > 0 2471 } 2472 for future in as_completed(futures_to_machines): 2473 machine_name = futures_to_machines[future] 2474 try: 2475 machine_name, code, output = future.result() 2476 results[machine_name] = (code, output) 2477 except Exception as e: 2478 self.add_error(f"error during test execution on machine {machine_name}: {e}", step=self.step) 2479 ops_log.op(self.step, machine_name, f"error during test execution: {e}") 2480 continue 2481 2482 for machine_name, (exetests_code, output) in results.items(): 2483 if (machine_name, self.step) not in self._tests: 2484 self._tests[(machine_name, self.step)] = {} 2485 if exetests_code != 0: 2486 self.add_error( 2487 f"exetests error on {machine_name}: {exetests_by_machine[machine_name]} -- return code {exetests_code}", 2488 step=self.step) 2489 ops_log.op(self.step, machine_name, f"exetests error: return code {exetests_code}") 2490 parsed = parse_exetests_output(output) 2491 for cmd, timeout, result, code in parsed: 2492 if code == -2: 2493 self.add_error(f"test error on {machine_name}:{self.step}:{cmd} illegal error code", 2494 step=self.step) 2495 if code != 0: 2496 if not self._allow_errors_in_tests.get((machine_name, self.step, cmd, timeout), False): 2497 self.add_error(f"test error on {machine_name}:{cmd} code={code}", step=self.step) 2498 self._tests[(machine_name, self.step)][cmd, timeout] = (result, code) 2499 ops_log.cmds(self.step, machine_name, [(cmd, result, code) for cmd, _timeout, result, code in parsed]) 2500 if SRE.args.debug: 2501 for machine_name in lab.machines: 2502 if (machine_name, self.step) not in self._tests: 2503 continue 2504 for (cmd, timeout) in self._tests[(machine_name, self.step)]: 2505 log_debug(f"machine {machine_name} - step {self.step} - command {cmd} - timeout {timeout}:") 2506 result, code = self._tests[(machine_name, self.step)][cmd, timeout] 2507 log_debug(result) 2508 log_debug(f"-------- exit code {code}\n") 2509 2510 # console commands of the managed switches (test_switch()), run by sre itself 2511 for network_name in sorted(n for n, s in self._switch_tests if s == self.step): 2512 switch_cmds = self._tests.get((network_name, self.step), {}) 2513 entries = [] 2514 for cmd, timeout in list(switch_cmds): 2515 result, code = switch_console.run(network_name, cmd) 2516 if code != 0 and not self._allow_errors_in_tests.get( 2517 (network_name, self.step, cmd, timeout), False): 2518 self.add_error(f"test error on switch {network_name}:{cmd} code={code}", step=self.step) 2519 switch_cmds[(cmd, timeout)] = (result, code) 2520 entries.append((cmd, result, code)) 2521 if SRE.args.debug: 2522 log_debug(f"switch {network_name} - step {self.step} - command {cmd}:") 2523 log_debug(result) 2524 log_debug(f"-------- exit code {code}\n") 2525 ops_log.cmds(self.step, OperationsLog.switch(network_name), entries) 2526 2527 host_step_cmds = self._host_tests.get(self.step, {}) 2528 if host_step_cmds: 2529 def _run_host_cmd(cmd, t): 2530 return (cmd, t, *run_host_command(cmd, t)) 2531 2532 with ThreadPoolExecutor(max_workers=min(params.max_docker_concurrency, 2533 len(host_step_cmds))) as executor: 2534 futures = {executor.submit(_run_host_cmd, cmd, t): (cmd, t) 2535 for (cmd, t) in host_step_cmds} 2536 for future in as_completed(futures): 2537 cmd, t, result, code = future.result() 2538 if code != 0: 2539 if not self._allow_errors_in_host_tests.get((self.step, cmd, t), False): 2540 self.add_error(f"host test error: {cmd} code={code}", step=self.step) 2541 self._host_tests[self.step][(cmd, t)] = (result, code) 2542 ops_log.cmd(self.step, OperationsLog.HOST, cmd, result, code) 2543 if SRE.args.debug: 2544 log_debug(f"host - step {self.step} - command {cmd} - timeout {t}:") 2545 log_debug(result) 2546 log_debug(f"-------- exit code {code}\n") 2547 2548 if SRE.args.debug and len(self._errors) > 0: 2549 log_debug(f"{len(self._errors)} errors:\n " + "\n".join(f"[{e[0]}] {e[1]}" for e in self._errors)) 2550 self.compute_total() 2551 self._mark_self_eval = self.mark_self_eval() 2552 self._mark_exo_eval = self.mark_exo_eval() 2553 self._log_grade_results(ops_log) 2554 # log_error(f"DEBUG _tests at save: {len(self._tests)} machine-step entries") 2555 # for (machine, step), cmds in self._tests.items(): 2556 # for (cmd, timeout), (result, code) in cmds.items(): 2557 # log_error(f" [{machine}][step={step}] cmd={cmd!r} result_len={len(result)} code={code}")
2559 def compute_total(self): 2560 """Accumulate per-scope totals in one pass; BOTH-scope elements contribute to both. 2561 2562 The grade of a bonus element counts, its max_grade does not; the scope then 2563 remembers it has bonus elements (its mark is capped at the maximum mark).""" 2564 self._total_grade_self_eval = 0 2565 self._total_max_self_eval = 0 2566 self._total_grade_exo_eval = 0 2567 self._total_max_exo_eval = 0 2568 self._bonus_in_self_eval = False 2569 self._bonus_in_exo_eval = False 2570 for g in self._grade_list: 2571 if g.scope & params.SELF_EVAL_SCOPE: 2572 self._total_grade_self_eval += g.grade 2573 if g.bonus: 2574 self._bonus_in_self_eval = True 2575 else: 2576 self._total_max_self_eval += g.max_grade 2577 if g.scope & params.EXO_EVAL_SCOPE: 2578 self._total_grade_exo_eval += g.grade 2579 if g.bonus: 2580 self._bonus_in_exo_eval = True 2581 else: 2582 self._total_max_exo_eval += g.max_grade
Accumulate per-scope totals in one pass; BOTH-scope elements contribute to both.
The grade of a bonus element counts, its max_grade does not; the scope then remembers it has bonus elements (its mark is capped at the maximum mark).
2584 def save_tests(self): 2585 now = datetime.datetime.now() 2586 if self._exam_json is None: 2587 exam_path = Path(params.sre_pub_dir) / params.exam_json_name 2588 try: 2589 self._exam_json = json.loads(exam_path.read_text()) 2590 except FileNotFoundError: 2591 pass 2592 except Exception as e: 2593 log_error(f"can't read {exam_path}: {e}") 2594 for d1 in self.archive_dirs: 2595 d = Path(d1).expanduser().resolve() 2596 try: 2597 if not d.exists(): 2598 try: 2599 os.mkdir(d, 0o700) # not makedirs: a missing parent stays an error 2600 except FileExistsError: 2601 pass # created meanwhile by another evaluation (sre eval-all) 2602 else: 2603 os.listdir(d) # trigger mount / catch stale handle early 2604 permissions = os.stat(d).st_mode 2605 if permissions != 0o700: 2606 os.chmod(d, 0o700) 2607 filename = d / params.get_archive_name(self.net_scheme.running_lab_name, now) 2608 self.save_tests_on_file(str(filename)) 2609 except Exception as e: 2610 log_error(f"can't save archive to {d}: {e}")
2612 def save_tests_on_file(self, filename: str): 2613 files_content = {} 2614 if self.files_to_save_in_archives: 2615 fdir = Path(params.files_dir(self.get_running_lab_name())) 2616 if fdir.is_dir(): 2617 for pattern in self.files_to_save_in_archives: 2618 for fpath in fdir.iterdir(): 2619 if fpath.is_file() and fnmatch.fnmatch(fpath.name, pattern): 2620 try: 2621 files_content[fpath.name] = fpath.read_bytes() 2622 except Exception: 2623 pass 2624 archive = { 2625 params.running_lab_name_keyword: self.get_running_lab_name(), 2626 params.eval_date_keyword: self._eval_date, 2627 params.re_eval_date_keyword: self._re_eval_date, 2628 'data_json': self.get_data().to_json(), 2629 'tests': self.get_tests(), 2630 'errors': self.get_errors(), 2631 'answers': self.get_answers(), 2632 'grade_list': [asdict(e) for e in self._grade_list], 2633 'grade_parts': [asdict(p) for p in self._grade_parts], 2634 'total_grade_self_eval': self._total_grade_self_eval, 2635 'total_max_self_eval': self._total_max_self_eval, 2636 'mark_self_eval': self._mark_self_eval, 2637 'total_grade_exo_eval': self._total_grade_exo_eval, 2638 'total_max_exo_eval': self._total_max_exo_eval, 2639 'mark_exo_eval': self._mark_exo_eval, 2640 'maximum_mark': self._maximum_mark, 2641 'files': files_content, 2642 } 2643 if self._exam_json is not None: 2644 archive[params.exam_json_keyword] = self._exam_json 2645 cctx = zstd.ZstdCompressor(level=6) 2646 with tempfile.NamedTemporaryFile( 2647 mode="wb", 2648 dir=os.path.dirname(filename), 2649 delete=False 2650 ) as f: 2651 with cctx.stream_writer(f) as compressor: 2652 packed = msgpack.packb(archive, use_bin_type=True) 2653 compressor.write(packed) 2654 # f.flush() 2655 # os.fsync(f.fileno()) 2656 temp_name = f.name 2657 os.replace(temp_name, filename)