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)
class ErrorCategory(enum.Enum):
55class ErrorCategory(enum.Enum):
56    ERROR = "ERROR"
57    WARNING = "WARNING"
ERROR = <ErrorCategory.ERROR: 'ERROR'>
WARNING = <ErrorCategory.WARNING: 'WARNING'>
def write_error(string):
60def write_error(string):
61    print(string, file=sys.stderr)
def tr(text: str, **langs) -> SRE.common.TranslatedText:
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.

def make_tr(default_lang: str, translations: dict | None = None):
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")
def no_tr(text: str) -> str:
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.

def lab_ipv6_default():
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.

@dataclass
class Flavor0:
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.

def to_dict(self):
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().

@classmethod
def from_dict(cls, d):
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]

Reconstruct a Flavor0 instance from a dict produced by to_dict().

@classmethod
def from_form_dict(cls, d: dict):
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.

def allowed_by_user(self) -> Tuple[bool, str]:
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.

class Data0:
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: named IPv4Interface values
  • self.nets — _IPv4NetContainer: named IPv4Network values
  • self.ips6 — _IPv6InterfaceContainer: named IPv6Interface values
  • self.nets6 — _IPv6NetContainer: named IPv6Network values
  • self.macs — _MacContainer: named EUI MAC-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.

@classmethod
def compute_pre_generate(cls, flavor=None):
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).

def compute_post_generate(self):
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).

def to_dict(self):
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.

@classmethod
def from_dict(cls, d):
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

Reconstruct a Data0 instance from a plain dict produced by to_dict().

The concrete subclass is resolved from d["__type__"] when cls is Data0 itself; otherwise the calling class is used directly. Raises ValueError for unknown type keys.

def pack(self):
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).

@classmethod
def unpack(cls, blob):
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.

def to_json(self):
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).

@classmethod
def from_json(cls, s):
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().

@classmethod
def load_from_json_file(cls, filename):
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.

def save_to_json_file(self, filename):
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).

def sre_state(fn=None, *, user_allowed=False, description='', multi_pass=False):
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 + 1 calls, like Grade.grade()) so that cmd() / host_cmd() return the real (output, code) of commands executed at earlier steps (see NetScheme0.iter_state_steps()). Default False: the method runs once and its ops are applied step by step; cmd() then always returns its placeholder.
class NetScheme0:
 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 switch allow_connection and vlans, see Network).
  • _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 — the Data0 instance 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>.

NetScheme0(data, running_lab_name, lab_hash=None)
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 Data0 instance containing lab-specific parameters.
  • running_lab_name: the runtime identifier {ts}@@@{lab}@@@{user}.
  • lab_hash: optional Kathara lab hash (resolved lazily if omitted).
data
running_lab_name
debug_project
instructor_mode
instructor_texts
ops_log
lab_name
lab_hash
current_srelab_file
informations
net_config
def reset_state_results(self):
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).

def once(self, key, factory):
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.

def host_interfaces_from_topology(self) -> dict:
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.

@sre_state(user_allowed=False)
def initial(self):
916    @sre_state(user_allowed=False)
917    def initial(self):
918        pass
@sre_state(user_allowed=False)
def save(self):
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.

@sre_state(user_allowed=False)
def restore(self):
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

Lifecycle state applied by sre restore on the new instance right after the containers have been redeployed from the saved filesystems. Only files are restored, not processes: relaunch here the daemons that initial() started with cmd(). Empty by default.

@classmethod
def get_state_methods(cls):
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.

@classmethod
def is_state_user_allowed(cls, state):
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).

@classmethod
def is_state_multi_pass(cls, state):
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).

@classmethod
def get_user_allowed_states(cls):
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.

def get_data(self):
975    def get_data(self):
976        return self.data
def get_machine(self, machine_name):
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.

def get_network(self, network_name):
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.

def get_machines(self):
996    def get_machines(self):
997        for _, value in self.__dict__.items():
998            if isinstance(value, Machine):
999                yield value
def get_machine_names(self):
1001    def get_machine_names(self):
1002        return [m.name for m in self.get_machines()]
def has_privileged_machines(self):
1004    def has_privileged_machines(self):
1005        return any(m.privileged for m in self.get_machines())
def get_accessible_machine_names(self):
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]
def get_visible_machine_names(self):
1011    def get_visible_machine_names(self):
1012        return [m.name for m in self.get_visibles_machines()]
def get_visibles_machines(self):
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
def get_networks(self):
1020    def get_networks(self):
1021        for _, value in self.__dict__.items():
1022            if isinstance(value, Network):
1023                yield value
def get_visible_networks(self):
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.

def get_topology(self):
1031    def get_topology(self):
1032        return self._resolve_spec('_topology', 'topology')
def get_machine_specs(self):
1034    def get_machine_specs(self):
1035        return self._resolve_spec('_machine_specs', 'machine_specs')
def get_network_specs(self):
1037    def get_network_specs(self):
1038        return self._resolve_spec('_network_specs', 'network_specs')
def cmd( self, machine, command, step=1, timeout: int = 120, default_value='', default_code: int = 0, allow_error: bool = False):
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.

def host_cmd( self, command, step=1, timeout: int = 120, default_value='', default_code: int = 0, allow_error: bool = False):
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.

def record_cmd_result(self, machine, step, command, timeout, output, code):
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).

def record_host_cmd_result(self, step, command, timeout, output, code):
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).

def is_cmd_error_allowed(self, machine, step, command, timeout):
1092    def is_cmd_error_allowed(self, machine, step, command, timeout):
1093        return self._allow_errors_in_cmds.get((machine, step, command, timeout), False)
def is_host_cmd_error_allowed(self, step, command, timeout):
1095    def is_host_cmd_error_allowed(self, step, command, timeout):
1096        return self._allow_errors_in_host_cmds.get((step, command, timeout), False)
def switch_cmd( self, network, command, step=1, default_value='', default_code: int = 0, allow_error: bool = False):
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.

def record_switch_cmd_result(self, step, network, command, output, code):
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).

def is_switch_cmd_error_allowed(self, step, network, command):
1132    def is_switch_cmd_error_allowed(self, step, network, command):
1133        return self._allow_errors_in_switch_cmds.get((step, network, command), False)
def host_callback(self, callback, step=1):
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).

def cp_from_host( self, src: str, machine: str, dest: str, owner: str = 'root:root', permissions: int = None, mtime: float = None, step=1):
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.

def cp_to_host( self, machine: str, path: str, dest: str, permissions: int = None, step=1):
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)
def file( self, machine, filename, content, permissions=420, owner='root:root', mtime=None, step=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
def append_to_file( self, machine, filename, content, permissions=None, owner=None, mtime=None, step=1):
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
def idempotent_append_to_file( self, machine, filename, content, permissions=None, owner=None, mtime=None, step=1):
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
def compute_state_ops(self, state):
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
def iter_state_steps(self, state):
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.

def machine_ipv6_enabled(self, machine):
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).

def get_new_lab_from_scheme(self):
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
def get_lab_hash(self):
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
def get_lab_from_kathara(self):
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)
def get_public_lab_dir(self):
1394    def get_public_lab_dir(self):
1395        return params.public_lab_dir(self.running_lab_name)
def get_private_lab_dir(self):
1397    def get_private_lab_dir(self):
1398        return params.private_lab_dir(self.running_lab_name)
def get_files_dir(self):
1400    def get_files_dir(self):
1401        return params.files_dir(self.running_lab_name)
def get_private_mount_dir(self):
1403    def get_private_mount_dir(self):
1404        return params.private_mount_dir(self.running_lab_name)
def get_user_public_dir(self):
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))
def get_shared_dir(self):
1412    def get_shared_dir(self):
1413        return f"{self.get_user_public_dir()}/{params.shared_dir_name}"
def answers_dir(self):
1415    def answers_dir(self):
1416        return params.answers_dir(self.running_lab_name)
def answers_file(self):
1418    def answers_file(self):
1419        return params.answers_filename(self.running_lab_name)
class NetAdapter:
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}"
NetAdapter(network, machine, interface, mac=None, addresses=None)
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
network
machine
interface
mac
addresses
vlan
tagged_vlans
def switch_port_label(self) -> str:
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.

def parse_port_vlans(spec, where: str) -> tuple:
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.

class Network:
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). True by 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.
Network( name, color=None, shape=None, mode=None, allow_connection=True, vlans=None)
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 = {}
name
color
shape
mode
allow_connection
vlans
net_adapters
def is_managed(self) -> bool:
1524    def is_managed(self) -> bool:
1525        return self.mode == params.network_mode_managed
def get_machines(self):
1527    def get_machines(self):
1528        for m in self.net_adapters.keys():
1529            yield m
MACHINE_NAME_RE = re.compile('[a-z0-9_]{1,30}')
MACHINE_NAME_RULE = '1 to 30 characters among a-z, 0-9 and _ (lowercase only)'
class Machine:
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-level ipv6 lab option decides (and when that one is absent too, Kathara's own enable_ipv6 setting, False by default); True/False = per-machine override. With IPv6 enabled Kathara starts the container with net.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.
Machine( name, image='sysreseval/base:1.33', bridged=False, x11_host=False, mem='', cpus=None, ipv6=None, exec_commands=[], sysctls={}, envs={}, ports=None, ulimits={}, volumes=[], shell=None, kathara_shell=None, privileged=None, entrypoint=None, args=[], hidden=False, allow_connection=True, color=None, shape=None)
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")
name
image
bridged
x11_host
mem
cpus
ipv6
exec_commands
sysctls
envs
ports
ulimits
volumes
shell
kathara_shell
privileged
entrypoint
args
hidden
allow_connection
shape
color
net_adapters
def build_exetests_string(cmds) -> str:
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.

def parse_exetests_output(output: bytes) -> list:
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.

def run_host_command(command: str, timeout: int, cwd=None) -> tuple:
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.

class Grade0:
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:

  1. Call grade() to register commands (they return placeholder values).
  2. Execute all registered commands in containers (via exetests).
  3. Repeat for each additional step (self.max_step).
  4. Call grade() a final time — commands now return real results.
  5. Save the archive.

Key instance attributes available inside grade():

  • self.data — the Data0 instance (via net_scheme).
  • self.step — current step number (1-based).
  • self.max_step — highest step number registered so far.
Grade0(net_scheme)
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()
net_scheme
archive_dirs
files_to_save_in_archives
step
max_step
section_fmt
auto_eval_count
def full_reset(self):
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__).

def reset_before_grade(self):
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().

def grade(self):
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().

def mark_self_eval(self):
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).

def mark_exo_eval(self):
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.

def get_data(self):
1812    def get_data(self):
1813        return self.net_scheme.get_data()
def get_errors(self):
1815    def get_errors(self):
1816        return self._errors
def get_grade_list(self):
1818    def get_grade_list(self):
1819        return self._grade_list
def get_grade_parts(self):
1821    def get_grade_parts(self):
1822        return self._grade_parts
def get_answers(self):
1824    def get_answers(self):
1825        return self._answers
def get_cheat_answers(self, state: str):
1827    def get_cheat_answers(self, state: str):
1828        return self._cheat_answers.get(state)
def get_tests(self):
1830    def get_tests(self):
1831        return self._tests
def get_exetests_strings(self, step: int):
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
def get_running_lab_name(self):
1843    def get_running_lab_name(self):
1844        return self.net_scheme.running_lab_name
def increment_section_counter(self, level: int):
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
def set_section_counter(self, level: int, value: int):
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
def section(self, level: int = 0, fmt=None, show: int = None, pad: str = None):
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)
def current_section(self, level: int = 0, fmt=None, show: int = None, pad: str = None):
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) + ". "
def load_answers(self):
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.

def get_questions_ordered(self):
1935    def get_questions_ordered(self):
1936        q = []
1937        for _, v in sorted(self._questions_order.items()):
1938            q += v
1939        return q
def export_questions(self):
1941    def export_questions(self):
1942        pass
def test_host( self, command, step=1, timeout: int = 20, default_value='', default_code: int = 0, allow_error: bool = False):
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.

def test( self, machine_name, command, step=1, timeout: int = 20, default_value='', default_code: int = 0, allow_error: bool = False):
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.

def test_switch( self, network_name, command, step=1, default_value='', default_code: int = 0, allow_error: bool = False):
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.

def question_text( self, title, section='', description='', hash=None, order=None, default_answer='', cheat_answers=None):
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.

def question_form( self, title, section='', description='', hash=None, order=None, cheat_answers=None):
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, ...}}

def question_dummy(self, title, section='', description='', hash=None, order=None):
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).

def add_grade_part(self, title, description=''):
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.

def add_grade_element( self, title, max_grade, description='', grade=0, scope=3, grade_part=None, bonus=False):
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".

def set_grade(self, title, grade):
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.

def save_lab_info(self):
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)
def add_error(self, error, category=<ErrorCategory.ERROR: 'ERROR'>, step: int = 1):
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))
def add_warning(self, warning, step: int = 1):
2419    def add_warning(self, warning, step: int = 1):
2420        self.add_error(warning, category=ErrorCategory.WARNING, step=step)
@staticmethod
def run_tests_on_machine(machine_name, machine, exetests):
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
def run_tests(self):
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}")
def compute_total(self):
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).

def save_tests(self):
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}")
def save_tests_on_file(self, filename: str):
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)