SRE.params

  1import grp
  2import os
  3import pwd
  4import re
  5import stat
  6from datetime import datetime
  7from typing import Literal
  8
  9from pathlib import Path
 10
 11#################################################################################
 12#
 13# Layout and site parameters
 14#
 15# Plain top-level assignments: scripts/install.sh patches them, `sre update` merges them (the
 16# values of the site are kept) and the site parameters file (site_params_file below) may set any
 17# of them.  A value of None means "the default of the installation layout", computed in the
 18# "Derived parameters" block below, after the site parameters file has been read.
 19#
 20#################################################################################
 21
 22# "checkout": a git tree (/opt/sre, scripts/install.sh, the venv); "fhs": the Debian package
 23# (set by debian/rules on the installed copy: /usr/lib/sysreseval, /usr/share/sysreseval,
 24# /etc/sysreseval, /var/lib/sre)
 25install_layout = "checkout"
 26
 27sre_user = "sre"
 28sre_uid = 1100          # None: the uid of sre_user
 29sre_gid = 1100          # None: the gid of sre_user
 30docker_group = "docker"
 31docker_gid = 988        # None: the gid of docker_group, else of docker_socket_path, else sre_gid
 32
 33admin_uids = []
 34admin_gids = [2000]
 35
 36main_sre_dir = "/opt/sre"
 37
 38# Override main_sre_dir with the actual install root derived from this file's
 39# location, so a single build/checkout works from any install path. Without
 40# this, lib_dir stays at the build-time value and `from utils import ...` in
 41# srelab.py files fails when sre is installed somewhere other than /opt/sre.
 42# The marker file (src/sre.py) sits in a world-readable directory so this
 43# probe works whether we're imported by the privileged CLI or by sysreseval
 44# running as an unprivileged student (lab/ and lib/ are sre:sre-only).
 45_candidate = Path(__file__).resolve().parent.parent.parent
 46if (_candidate / "src" / "sre.py").is_file():
 47    main_sre_dir = str(_candidate)
 48del _candidate
 49sre_pub_dir = "/var/lib/sre"
 50sre_user_public_dir = None   # None: /home/sre (checkout), sre_pub_dir/home (fhs)
 51
 52lab_dir = None               # None: main_sre_dir/lab (checkout), sre_pub_dir/lab (fhs)
 53shipped_lab_dir = None       # None: lab_dir (checkout), /usr/share/sysreseval/lab (fhs): the labs of the package
 54lib_dir = None               # None: main_sre_dir/lib
 55graphicdir = None            # None: main_sre_dir/graphics (checkout), /usr/share/sysreseval/graphics (fhs)
 56fontdir = None               # None: graphicdir/fonts (checkout), /usr/share/fonts/truetype/dejavu (fhs)
 57translations_dirs = None     # None: [<root>/translations, main_sre_dir/translations] (checkout), [/usr/share/sysreseval/translations] (fhs)
 58locale_dirs = None           # None: [<root>/locale, main_sre_dir/locale] (checkout), [/usr/share/locale] (fhs)
 59archive_dirs = None          # None: [sre_pub_dir/archives]
 60sre_wrapper = None           # None: main_sre_dir/bin/sre-wrapper (checkout), /usr/bin/sre-wrapper (fhs)
 61sysreseval_exe = None        # None: main_sre_dir/bin/sysreseval (checkout), /usr/bin/sysreseval (fhs)
 62sre_exe = None               # None: main_sre_dir/sbin/sre (checkout), /usr/sbin/sre (fhs)
 63docker_socket_path = "/var/run/docker.sock"   # absent: no Docker daemon on this host (`sre update` skips the wipe)
 64self_update_allowed = None   # None: True (checkout), False (fhs: the package is updated with apt); `sre update`
 65network_plugin_hint = None   # None: how to install the network plugin with the switch types, by layout
 66kathara_hint = None          # None: how to get a Kathara with the switch modes, by layout
 67web_dir = None               # None: main_sre_dir/web (checkout), /usr/share/sysreseval/web (fhs): templates and static files of sre-web
 68web_config_file = None       # None: /etc/sreweb/web.toml (checkout), /etc/sysreseval/web.toml (fhs): configuration of sre-web
 69agent_config_file = None     # None: /etc/sreweb/agent.toml (checkout), /etc/sysreseval/agent.toml (fhs): configuration of sre-agent
 70# Site parameters file: a Python file executed into this module after the parameters above (any
 71# of them may be set there) and before the derived ones.  It must be owned by root (or by the
 72# user running the program) and not writable by the group or others.  None: /etc/sysreseval/params.py
 73# in the fhs layout (shipped by the Debian package), no file on a checkout.
 74site_params_file = None
 75
 76# debug_mode:
 77# - allow environment variables SRE_WRAPPER and SRE_PUB_DIR to overcome the default value (insecure)
 78# - allow "sysreseval --debug"
 79debug_mode = False
 80
 81# If True, sysreseval will use the last field in the gecos string and save it in the answers
 82# of the user as email (to be used in the pdfs generated with "sre outline")
 83email_in_gecos_last_field: bool = True
 84
 85max_docker_concurrency = 16
 86
 87display_marks_in_auto_evaluations_by_default = False
 88use_numerical_marks_by_default = True
 89default_maximum_mark = 20
 90
 91# Scope bitmask values for GradeElement.scope. A grade element is visible in
 92# self-eval (sre eval --auto-eval) if scope & SELF_EVAL_SCOPE, and in non-auto
 93# eval / outline / sheet if scope & EXO_EVAL_SCOPE.
 94SELF_EVAL_SCOPE = 1
 95EXO_EVAL_SCOPE = 2
 96BOTH_EVAL_SCOPE = SELF_EVAL_SCOPE | EXO_EVAL_SCOPE  # 3
 97grade_scopes = (SELF_EVAL_SCOPE, EXO_EVAL_SCOPE, BOTH_EVAL_SCOPE)
 98
 99default_eval_interval_during_exams = 60  # default duration between two evaluations in exam mode
100default_eval_interval_without_exam_mode = 60  # default interval (seconds) for eval outside exam mode when the lab does not define eval_interval_without_exam_mode (0 for no periodic evals)
101default_save_record_interval_during_exams = 60     # use 0 to disable saving
102
103default_exam_duration = 90  # default exam duration in minutes when "duration" is absent from exam.json
104
105
106default_inactivity_threshold_in_watch_command = 90  # default inactivity (no eval) from a project before triggering an alert in watch
107default_dashboard_refresh_interval_in_watch_command = 1
108
109max_duration_between_exam_pre_start_and_start = 60  # duration in seconds
110
111default_exec_shell = "/bin/bash"
112
113# `sre update`: where the new versions of SRE come from (a GitHub repository) and what the
114# command refreshes in lab/ (the other entries of lab/ are the site's own labs and are kept)
115update_github_repository = "sysreseval/sysreseval"
116update_github_api_url = "https://api.github.com"
117update_tarball_url = "https://codeload.github.com/{repository}/tar.gz/{commit}"
118update_default_branch = "main"       # followed by `sre update --main` (the default is the latest release)
119update_http_timeout = 60             # seconds, per request
120update_lab_subdirs = ["sre"]         # entries of lab/ owned by the repository, reinstalled by `sre update`
121
122#################################################################################
123#
124# Security settings
125#
126#################################################################################
127
128allow_privileged_machines = True
129disable_volume_mount_on_root_partition = False
130
131# For identifying the users, use the environment variable SUDO_USER instead of USER_USERNAME
132# SUDO_USER is set by sudo itself, thus not spoofable (but in some configurations don't give the current USER variable)
133# USER_USERNAME is set by sre-wrapper from USER (spoofable by the user)
134use_sudo_user_for_username = True
135
136# In user mode (--user: sre-wrapper, hence the GUI, and the student path of the web agent) a
137# caller only sees and drives their own projects and those started by root or the sre user
138# (`sudo sre start ...`); the GUI lists the same projects.  False: every local user sees and
139# acts on every project of the host (the behaviour of the versions before 0.14).
140check_project_owner = True
141
142# Should projects be able to execute commands on the host (which might be useful)
143# shell : execution through subprocess.run with shell=True which allow pipes
144# split : execution through shlex.split to forbid pipes
145# False : no execution permitted on the host
146execute_commands_on_host: Literal["shell", "split", False] = "shell"
147
148
149authorized_src_dir = None    # None: [lab_dir, shipped_lab_dir, '/home']
150
151# External terminal emulator used to open machine connections.
152# The command is built as: terminal_cmd_prefix[:-1] + [terminal_title_opt, title] + terminal_cmd_prefix[-1:] + [sre_wrapper, "connect", project, machine]
153# The title is passed as a two-token pair (terminal_title_opt, title), so only
154# terminals that accept a separate "--title TITLE"-style option work here.
155# Uncomment ONE pair below (binary path may differ on your host; check with `command -v`):
156#   gnome-terminal:
157#     terminal_cmd_prefix = ["/usr/bin/gnome-terminal", "--"]
158#     terminal_title_opt = "--title"
159#   xfce4-terminal:
160#     terminal_cmd_prefix = ["/usr/bin/xfce4-terminal", "-x"]
161#     terminal_title_opt = "--title"
162#   xterm:
163#     terminal_cmd_prefix = ["/usr/bin/xterm", "-e"]
164#     terminal_title_opt = "-title"
165#   terminator:
166#     terminal_cmd_prefix = ["/usr/bin/terminator", "-x"]
167#     terminal_title_opt = "-T"
168terminal_cmd_prefix = ["/usr/bin/mate-terminal", "--"]
169terminal_title_opt = "--title"
170
171
172#################################################################################
173#
174# Site parameters file
175#
176#################################################################################
177
178def _load_site_params(path: str, namespace: dict) -> None:
179    """Execute the site parameters file *path* into *namespace* (the globals of this module).
180    The file is refused when it is not owned by root or by the current user, or when its group or
181    others may write it: `sre` runs it as root.  A failure stops the program with the file name
182    and the line."""
183    try:
184        st = os.stat(path)
185        if st.st_uid not in (0, os.geteuid()):
186            raise PermissionError(f"not owned by root (uid {st.st_uid})")
187        if st.st_mode & (stat.S_IWGRP | stat.S_IWOTH):
188            raise PermissionError("writable by the group or others")
189        with open(path, encoding='utf-8') as f:
190            code = compile(f.read(), path, 'exec')
191    except SyntaxError as e:
192        raise SystemExit(f"sre: {path}:{e.lineno}: {e.msg}") from e
193    except OSError as e:
194        raise SystemExit(f"sre: {path}: {e}") from e
195    try:
196        exec(code, namespace)
197    except SystemExit:
198        raise
199    except Exception as e:
200        lineno = e.__traceback__.tb_next.tb_lineno if e.__traceback__ and e.__traceback__.tb_next else '?'
201        raise SystemExit(f"sre: {path}:{lineno}: {type(e).__name__}: {e}") from e
202
203
204if site_params_file is None and install_layout == "fhs":
205    site_params_file = "/etc/sysreseval/params.py"
206if debug_mode:
207    site_params_file = os.environ.get("SRE_SITE_PARAMS", site_params_file) or None
208if site_params_file and os.path.isfile(site_params_file):
209    _load_site_params(site_params_file, globals())
210
211if debug_mode:
212    sre_wrapper = os.environ.get("SRE_WRAPPER", sre_wrapper)
213    sre_pub_dir = os.environ.get("SRE_PUB_DIR", sre_pub_dir)
214
215
216#################################################################################
217#
218# Derived parameters: the defaults of the installation layout for the parameters left to None
219# above (an `if` block each: not parameters of their own for `sre update`)
220#
221#################################################################################
222
223_fhs = install_layout == "fhs"
224_root = Path(__file__).resolve().parent.parent.parent   # the checkout (<root>/src/SRE/params.py)
225if _fhs:
226    main_sre_dir = "/usr/lib/sysreseval"
227if sre_uid is None:
228    try:
229        sre_uid = pwd.getpwnam(sre_user).pw_uid
230    except KeyError:
231        sre_uid = 1100
232if sre_gid is None:
233    try:
234        sre_gid = pwd.getpwnam(sre_user).pw_gid
235    except KeyError:
236        sre_gid = 1100
237if docker_gid is None:
238    try:
239        docker_gid = grp.getgrnam(docker_group).gr_gid
240    except KeyError:
241        try:
242            docker_gid = os.stat(docker_socket_path).st_gid
243        except OSError:
244            docker_gid = sre_gid
245if sre_user_public_dir is None:
246    sre_user_public_dir = sre_pub_dir + "/home" if _fhs else "/home/sre"
247if lab_dir is None:
248    lab_dir = sre_pub_dir + "/lab" if _fhs else main_sre_dir + "/lab"
249if shipped_lab_dir is None:
250    shipped_lab_dir = "/usr/share/sysreseval/lab" if _fhs else lab_dir
251if lib_dir is None:
252    lib_dir = main_sre_dir + "/lib"
253if graphicdir is None:
254    graphicdir = "/usr/share/sysreseval/graphics" if _fhs else main_sre_dir + "/graphics"
255if fontdir is None:
256    fontdir = "/usr/share/fonts/truetype/dejavu" if _fhs else graphicdir + "/fonts"
257if translations_dirs is None:
258    translations_dirs = ["/usr/share/sysreseval/translations"] if _fhs \
259        else [str(_root / "translations"), main_sre_dir + "/translations"]
260if locale_dirs is None:
261    locale_dirs = ["/usr/share/locale"] if _fhs else [str(_root / "locale"), main_sre_dir + "/locale"]
262if archive_dirs is None:
263    archive_dirs = [sre_pub_dir + '/archives']
264if sre_wrapper is None:
265    sre_wrapper = "/usr/bin/sre-wrapper" if _fhs else main_sre_dir + "/bin/sre-wrapper"
266if sysreseval_exe is None:
267    sysreseval_exe = "/usr/bin/sysreseval" if _fhs else main_sre_dir + "/bin/sysreseval"
268if sre_exe is None:
269    sre_exe = "/usr/sbin/sre" if _fhs else main_sre_dir + "/sbin/sre"
270if authorized_src_dir is None:
271    authorized_src_dir = list(dict.fromkeys([lab_dir, shipped_lab_dir, '/home']))
272if self_update_allowed is None:
273    self_update_allowed = not _fhs
274if network_plugin_hint is None:
275    network_plugin_hint = ("Install it with `sysreseval-network-plugin` (see the installation guide)." if _fhs
276                           else f"Install it with `make network-plugin` in {main_sre_dir} (see the installation guide).")
277if kathara_hint is None:
278    kathara_hint = "upgrade the python3-kathara package" if _fhs else "install it again with `make venv`"
279if web_dir is None:
280    web_dir = "/usr/share/sysreseval/web" if _fhs else main_sre_dir + "/web"
281if web_config_file is None:
282    web_config_file = "/etc/sysreseval/web.toml" if _fhs else "/etc/sreweb/web.toml"
283if agent_config_file is None:
284    agent_config_file = "/etc/sysreseval/agent.toml" if _fhs else "/etc/sreweb/agent.toml"
285del _fhs, _root
286
287
288#################################################################################
289#
290# Don't modify parameters below
291#
292#################################################################################
293
294sre_version = "0.16.1"
295default_docker_image_version = "1.33"
296sre_docker_namespace = "sysreseval"
297
298def sre_docker_image(image_name: str = "base") -> str:
299    return f"{sre_docker_namespace}/{image_name}:{default_docker_image_version}"
300
301default_docker_image = sre_docker_image()
302
303# `sre update`: state file of the installed version (commit, manifest of the installed files, pristine
304# upstream values of params.py), lock and temporary directory, all in main_sre_dir
305update_state_file_name = ".sre-update.json"
306update_lock_file_name = ".sre-update.lock"
307update_tmp_prefix = ".sre-update-"
308update_params_follow_new = ("sre_version", "default_docker_image_version")  # always take the new value
309update_params_backup_suffix = ".bak."   # params.py.bak.<YYYYmmdd-HHMMSS>, like scripts/install.sh
310update_system_templates_dir = "scripts/etc"   # files installed by hand on every host (sudoers, .desktop...)
311# keys of the state file
312update_state_commit = "commit"
313update_state_ref = "ref"
314update_state_updated_at = "updated_at"
315update_state_previous_commit = "previous_commit"
316update_state_files = "files"
317update_state_params_upstream = "params_upstream"
318
319sre_projects_dir = sre_pub_dir + "/projects"
320
321sre_name_env_variable = "SRE_LAB_NAME"
322sre_xauth_cookie_env_variable = "SRE_XAUTH_COOKIE"
323sre_host_ip_env_variable = "SRE_HOST_IP"
324# the web agent gives `sre start` the number of the virtual X display of the project (digits);
325# the x11_host machines then get SRE_DISPLAY=<sre_host_ip>:<number>, used by the images' .bashrc
326# in place of <sre_host_ip>:0 (the X server of a workstation)
327sre_x11_display_env_variable = "SRE_X11_DISPLAY"
328sre_display_env_variable = "SRE_DISPLAY"
329
330sre_host_ip = "172.17.0.1"
331
332exetests_path = lib_dir + "/exetests.py"
333exetests_machines_path = '/usr/local/sbin/exetests.py'
334exetests_env_name = 'EXETESTS'
335exetests_separator = '@@@'
336default_timeout = 20
337default_state_cmd_timeout = 120  # seconds, for NetScheme.cmd() / host_cmd() (0 = no timeout)
338
339exit_code_flavor_form_needed = 3
340exit_code_flavor_not_allowed = 4
341
342default_show_nat_network = True
343default_host_network_color = "deepskyblue"
344default_host_network_name = "Internet"
345default_host_network_shape = "hexagon"
346default_host_network_exploded = False
347default_host_network_edge_relative_length = 1.0
348
349# Module-level `ipv6` lab option: None = leave Kathara's own enable_ipv6 setting (False by default),
350# True/False = force IPv6 on/off in every machine; Machine(ipv6=...) overrides it per machine.
351default_ipv6 = None
352
353# Switch type of a network (`mode` of a `_network_specs` entry, Kathara collision domain mode):
354# a hub sends every frame to every machine, a switch learns the MAC addresses, a managed switch
355# also has VLANs and a management console.
356network_mode_hub = "hub"
357network_mode_switch = "switch"
358network_mode_managed = "managed"
359network_modes = (network_mode_hub, network_mode_switch, network_mode_managed)
360default_network_mode = network_mode_hub
361min_vlan_id = 1
362max_vlan_id = 4094
363# `@machine` in a command of NetScheme.switch_cmd() / Grade.test_switch() stands for the number of
364# the switch port the machine is plugged into.
365switch_port_reference_prefix = "@"
366switch_cmd_error_code = -2  # exit code of a switch command that could not be run at all
367# Console commands (first word of the line) a student may run on a managed switch.  Everything
368# else (plugin/*, port/create, port/remove, port/epclose, debug/*, ...) is refused in user mode.
369switch_user_commands = (
370    "help", "showinfo",
371    "port/showinfo", "port/print", "port/allprint", "port/setvlan",
372    "vlan/create", "vlan/remove", "vlan/addport", "vlan/delport", "vlan/print", "vlan/allprint",
373    "hash/showinfo", "hash/print", "hash/find",
374    "fstp/showinfo", "fstp/print", "fstp/setfstp", "fstp/setedge", "fstp/bonus",
375)
376switch_console_exit_commands = ("exit", "quit", "logout")
377# An open console checks this often (seconds) that its project still exists, and ends when it does not
378switch_console_watch_interval = 1.0
379
380default_machine_shape = "box"
381default_network_shape = "ellipse"
382
383pdf_schema_file = "schema.pdf"
384pdf_info_file = "informations.pdf"
385
386# Terminal appearance defaults (overridable via ~/.config/sysreseval)
387terminal_font_size = 12  # font size in points
388terminal_color_scheme = "black_on_white"  # "white_on_black" or "black_on_white"
389content_font_size = 12  # font size in points for information/questions views
390# default stylesheet of the documents drawn from lab markdown (Informations, Questions, form
391# questions): Qt draws the tables without cell padding, the columns touch each other otherwise
392content_style_sheet = "th, td { padding: 2px 8px; }"
393default_form_field_lines = 4  # visible lines of a @@{name:+}@@ multi-line form field
394system_font_size = 10  # font size in points for menus, titles, labels
395
396# Instructor mode (`sre start --instructor-mode`, `sre set-instructor-mode`): default colours of the
397# texts wrapped in instructor(); a call may override them (instructor(text, color=, background=))
398instructor_text_color = "#b00020"
399instructor_background_color = "#ffe3e3"
400# Delimiters of an instructor fragment inside a text of info.json (private-use characters):
401# <begin><color>;<background><args end><content><end>
402instructor_begin_mark = "\ue000"
403instructor_args_end_mark = "\ue001"
404instructor_end_mark = "\ue002"
405
406thats_all_folks_svg = graphicdir + "/Thats_all_folks.svg"
407sysreseval_logo_svg = graphicdir + "/sysreseval.svg"
408machine_icon_svg_file = graphicdir + "/machine.svg"
409machine_forbidden_icon_svg_file = graphicdir + "/machine-forbidden.svg"
410switch_icon_svg_file = graphicdir + "/switch.svg"
411cloud_icon_svg_file = graphicdir + "/cloud.svg"
412# the `shape` values of a network (`_network_specs`) drawn as an icon by the GUI schema and the export
413# PDF when icons are on (as the default network shape otherwise) instead of a graphviz node shape
414network_shape_icons = {"cloud": cloud_icon_svg_file}
415
416# TrueType fonts embedded in the PDFs of "sre export" and "sre outline" (downloaded by "make fonts"
417# into fontdir, the fonts-dejavu packages in the fhs layout): {style: file} with the styles ''
418# (regular), 'B', 'I' and 'BI'. A missing style uses the regular file. If the regular file cannot
419# be loaded, the core Helvetica/Courier fonts are used and the text is reduced to Latin-1.
420pdf_font_files = {
421    '': fontdir + "/DejaVuSans.ttf",
422    'B': fontdir + "/DejaVuSans-Bold.ttf",
423    'I': fontdir + "/DejaVuSans-Oblique.ttf",
424    'BI': fontdir + "/DejaVuSans-BoldOblique.ttf",
425}
426pdf_mono_font_files = {
427    '': fontdir + "/DejaVuSansMono.ttf",
428    'B': fontdir + "/DejaVuSansMono-Bold.ttf",
429}
430
431exam_only_affix = ["_EXAM_", "_OLD_", "_DRAFT_", "_TESTS_"]
432
433exam_json_name = "exam.json"
434exam_json_keyword = "exam_json"
435exam_start_after = "start_after"
436exam_end_before = "end_before"
437exam_eval_interval = "eval_interval"
438exam_pre_start_date = "pre_start_date"
439exam_started_at = "started_at"
440exam_ended_at = "ended_at"
441exam_duration = "duration"
442exam_labs = "labs"
443exam_record_sessions = "record_sessions"
444
445
446def parse_lab_entry(entry) -> tuple[str, str | None]:
447    """Parse a labs entry: new [lab, flavor] list or legacy plain string.
448    Returns (lab_cli_arg, flavor_name_or_None)."""
449    if isinstance(entry, list):
450        return entry[0], entry[1]
451    return entry, None  # backward compat with old exam.json
452
453
454srelab_py_name = "srelab.py"
455shipped_lab_link_name = "sre"       # the shipped labs inside lab_dir: lab/sre (a symlink to shipped_lab_dir in the fhs layout)
456titles_file_name = "titles.json"
457# The options of titles.json: every key starting with titles_reserved_prefix is an option, never an entry
458titles_reserved_prefix = "@"
459titles_sort_key = "@sort"           # the order of the entries of the directory in the lab tree
460titles_sort_name = "name"           # by file name (the default)
461titles_sort_title = "title"         # by title, in the user's language
462titles_sort_manual = "manual"       # the order of the entries in titles.json
463titles_sort_modes = (titles_sort_name, titles_sort_title, titles_sort_manual)
464# The keys of the entries of `sre list --with-titles`
465lab_list_name_key = "name"
466lab_list_title_key = "title"
467lab_list_kind_key = "kind"
468lab_list_sort_key = "sort"          # the titles_sort_* mode the entry is placed among its siblings by
469lab_list_kind_dir = "dir"
470lab_list_kind_lab = "lab"
471data_json_name = "data.json"
472# Keys/markers used inside data.json by Data0.to_dict()/from_dict()
473data_json_ips6_key = "ips6"          # Data0.ips6 container (IPv6Interface values)
474data_json_nets6_key = "nets6"        # Data0.nets6 container (IPv6Network values)
475data_json_iface_marker = "__iface__" # standalone IPv4Interface / IPv6Interface field
476data_json_mac_marker = "__mac__"     # standalone netaddr.EUI field
477private_dir_name = ".private"
478files_dir_name = "files"
479private_mount_dir_name = "mnt"
480user_public_dir_name = "user_public_dir"
481shared_dir_name = "shared"
482records_dir_name = "records"
483recorder_idle_time_limit = 10  # seconds; passed to asciinema --idle-time-limit to compress idle gaps in .cast files
484recorder_config_dir = sre_pub_dir + "/asciinema"  # ASCIINEMA_CONFIG_HOME — avoids asciinema falling back to $HOME/.config (which the sre user cannot write)
485use_asciinema_for_records = True  # True: prefer asciinema (fall back to script if missing). False: always record with script(1).
486eval_in_progress_name = "eval_in_progress"
487auto_eval_log_name = "auto_eval.log"
488debug_project_marker_name = "debug_project"
489instructor_mode_marker_name = "instructor_mode"
490# Operations log (GUI "Log" tab): every `sre state` / `sre eval` of a debug project appends what it
491# executed, every `sre state` of an instructor-mode project too; lives in the public project dir,
492# never written for the other projects
493operations_log_name = "operations.log"
494operations_log_max_output_chars = 16384  # per command output written to operations.log
495operations_log_view_max_lines = 100000   # block cap of the GUI Log tab
496auto_eval_count_keyword = "auto_eval_count"
497info_json_name = "info.json"
498
499answer_dir_name = "answers"
500answer_file_name = "answers.json"
501cheat_file_name = "cheat.json"
502cheat_file_max_size = 1_000_000  # bytes: a bigger answers/cheat.json is refused (answers.consume_cheat_file)
503# decompressed bytes of an evaluation archive the web agent reads at most (archive_summary.read_archive: the
504# archive directories may be a site or NFS directory; a zstd bomb there must not exhaust the agent)
505archive_max_size = 64 << 20
506
507hostname_keyword = "hostname"
508login_keyword = "login"
509fullname_keyword = "fullname"
510email_keyword = "email"
511language_keyword = "language"
512group_keyword = "group"            # the student group of the owner (web front-end, when configured)
513
514# Languages selectable in the GUI language priority dialog
515available_language_in_interface = ['en', 'fr']
516language_display_names = {'en': 'English', 'fr': 'Français'}
517
518running_lab_name_keyword = "running_lab_name"
519eval_date_keyword = "eval_date"
520re_eval_date_keyword = "re_eval_date"
521
522sysreseval_answers_updated_at = 'answers_updated_at'
523sysreseval_exam_time_remaining = 'exam_time_remaining'
524sysreseval_exam_started_at = 'exam_started_at'
525sysreseval_exam_duration = 'exam_duration'
526sysreseval_exam_mode = 'exam_mode'
527
528initial_state_name = "initial"
529save_state_name = "save"
530restore_state_name = "restore"
531# States applied by the lab lifecycle itself (start / save / restore), never listed as user states
532lifecycle_state_names = (initial_state_name, save_state_name, restore_state_name)
533
534# --- save / restore of running projects (`sre save`, `sre restore`) ---
535save_file_suffix = ".sre"
536save_file_magic = b"SRESAVE1\n"
537save_format_version = 1
538save_stdio_arg = "-"                    # `sre restore -` reads the save file from stdin
539save_tmp_dir = sre_pub_dir + "/tmp"     # not /tmp (may be a small tmpfs): full-image saves can be large
540save_kathara_name = "kathara.tar"       # payload member: the Kathara save archive
541save_user_public_member = "user_public" # payload member: contents of the user public dir (shared/, volumes)
542# keys of the cleartext JSON header line
543save_meta_format_version = "format_version"
544save_meta_sre_version = "sre_version"
545save_meta_running_lab_name = "running_lab_name"
546save_meta_lab_name = "lab_name"
547save_meta_srelab_file = "srelab_file"
548save_meta_username = "username"
549save_meta_saved_at = "saved_at"
550save_meta_debug_project = "debug_project"
551save_meta_full_images = "full_images"
552save_meta_shared_path = "shared_path"
553save_meta_encrypted = "encrypted"
554save_meta_kdf_salt = "kdf_salt"
555save_meta_kdf_iterations = "kdf_iterations"
556save_kdf_iterations = 600_000
557save_cipher_chunk_size = 16 * 1024 * 1024
558
559self_grade_timestamp_dir = sre_pub_dir + "/last_self_grades"
560
561# --- re-evaluations queued for the web portal (`sre re-eval --update-web`, web_updates.py) ---
562# one <original archive name>.json per pending update (the record of archive_summary.archive_record),
563# served by the web agent of the host to the portal, which replaces the row of that archive
564web_updates_dir_name = "web-updates"
565web_updates_dir = sre_pub_dir + "/" + web_updates_dir_name
566web_updates_suffix = ".json"
567web_updates_max_age = 24 * 3600     # seconds: an update the portal never took (its evaluation is not there) is dropped
568web_updates_max_size = 8 << 20      # bytes: a bigger entry is refused
569archive_keyword = "archive"         # the file name of an archive in its record
570
571srelab_link_name = "srelab"
572
573graphviz_default_nodesep = 0.8
574graphviz_default_ranksep = 1.5
575graphviz_default_splines = "curved"
576graphviz_default_overlap = "prism"
577
578
579class SRE:
580    args = None
581    module_rvlab = None
582    eval_obj = None
583    username = None
584
585
586def datetime_to_string(dt) -> str:
587    return f"{dt:%Y%m%d%H%M%S}"
588
589
590def string_to_datetime(string) -> datetime:
591    return datetime.strptime(string, "%Y%m%d%H%M%S")
592
593
594def get_lab_name_from_cli_arg(lab_cli_arg: str, is_path: bool) -> str:
595    if is_path:
596        lab_cli_arg = os.path.abspath(lab_cli_arg)
597    return lab_cli_arg.replace("/", "@")
598
599
600def get_running_lab_name(lab_name: str, instance_start_date: datetime, username: str = None) -> str:
601    if username is None:
602        if SRE.username is None:
603            username = ""
604        else:
605            username = SRE.username
606    if exetests_separator in lab_name:
607        raise ValueError(f"lab_name must not contain '{exetests_separator}': {lab_name!r}")
608    if exetests_separator in username:
609        raise ValueError(f"username must not contain '{exetests_separator}': {username!r}")
610    return f"{datetime_to_string(instance_start_date)}@@@{lab_name}@@@{username}"
611
612
613running_lab_name_match_pattern = '^([0-9]+)@@@(.+)@@@(.+)$'
614
615
616def get_lab_name_from_running_lab_name(running_lab_name: str) -> str:
617    match = re.match(running_lab_name_match_pattern, running_lab_name)
618    if match:
619        return match.group(2)
620    else:
621        return 'ERROR-running_lab_name-ILLEGAL-FORMAT'
622
623
624def get_username_from_running_lab_name(running_lab_name: str) -> str:
625    match = re.match(running_lab_name_match_pattern, running_lab_name)
626    if match:
627        return match.group(3)
628    return ''
629
630
631def get_start_date_string_from_running_lab_name(running_lab_name: str) -> str:
632    """Instance start date (YYYYmmddHHMMSS digits) encoded in a running_lab_name,
633    or '' when the name does not follow get_running_lab_name().  The digits
634    sort chronologically, so callers compare/sort the string and only convert
635    it with string_to_datetime() for display."""
636    match = re.match(running_lab_name_match_pattern, running_lab_name)
637    return match.group(1) if match else ''
638
639
640def get_abbreviated_lab_name_from_running_lab_name(running_lab_name: str) -> str:
641    name = get_lab_name_from_running_lab_name(running_lab_name).replace("@", "/").rpartition("/")[2]
642    return name.removesuffix(".py")
643
644
645def get_current_srelab_file_from_running_lab_name(running_lab_name: str) -> str:
646    lab_name = get_lab_name_from_running_lab_name(running_lab_name)
647    pathstr = lab_name.replace("@", "/")
648    if not pathstr.startswith("/"):
649        abspath = (Path(lab_dir) / pathstr).resolve()
650    else:
651        abspath = Path(pathstr).resolve()
652    if abspath.name.endswith(".py"):
653        return str(abspath)
654    else:
655        return str(abspath / srelab_py_name)
656
657
658def get_srelab_dir(running_lab_name: str):
659    lab_name = get_lab_name_from_running_lab_name(running_lab_name)
660    pathstr = lab_name.replace("@", "/")
661    if not pathstr.startswith("/"):
662        abspath = (Path(lab_dir) / pathstr).resolve()
663    else:
664        abspath = Path(pathstr).resolve()
665    if abspath.name.endswith(".py"):
666        return None
667    else:
668        return str(abspath)
669
670
671def project_has_directory(running_lab_name=None) -> bool:
672    if running_lab_name.endswith(".py"):
673        return False
674    else:
675        return True
676
677
678def get_archive_name(running_lab_name: str, date: datetime) -> str:
679    return f"{datetime_to_string(date)}_{running_lab_name}.zst"
680
681
682# Matches the basename produced by get_archive_name(): group 1 is the eval
683# date (YYYYmmddHHMMSS), group 2 the running_lab_name (which may itself
684# contain '@', '_' and '.').  Use with re.fullmatch.
685archive_name_match_pattern = r'(\d{14})_(.+)\.zst'
686
687
688def get_date_from_archive_name(filename: str) -> datetime | None:
689    """Creation (eval) date encoded in an archive filename, or None when the
690    name does not follow get_archive_name() or the digits are not a date."""
691    match = re.fullmatch(archive_name_match_pattern, Path(filename).name)
692    if not match:
693        return None
694    try:
695        return string_to_datetime(match.group(1))
696    except ValueError:
697        return None
698
699
700def private_lab_dir(running_lab_name: str) -> str:
701    return f"{sre_projects_dir}/{running_lab_name}/{private_dir_name}"
702
703
704def auto_eval_log_filename(running_lab_name: str) -> str:
705    return f"{private_lab_dir(running_lab_name)}/{auto_eval_log_name}"
706
707
708def debug_project_marker_filename(running_lab_name: str) -> str:
709    return f"{private_lab_dir(running_lab_name)}/{debug_project_marker_name}"
710
711
712def instructor_mode_marker_filename(running_lab_name: str) -> str:
713    return f"{private_lab_dir(running_lab_name)}/{instructor_mode_marker_name}"
714
715
716def operations_log_filename(running_lab_name: str) -> str:
717    return f"{public_lab_dir(running_lab_name)}/{operations_log_name}"
718
719
720def link_to_user_public_dir(running_lab_name: str) -> str:
721    return f"{sre_projects_dir}/{running_lab_name}/{private_dir_name}/{user_public_dir_name}"
722
723
724def public_lab_dir(running_lab_name: str) -> str:
725    return f"{sre_projects_dir}/{running_lab_name}"
726
727
728def info_filename(running_lab_name: str) -> str:
729    return f"{sre_projects_dir}/{running_lab_name}/{info_json_name}"
730
731
732def data_filename(running_lab_name: str) -> str:
733    return f"{sre_projects_dir}/{running_lab_name}/{private_dir_name}/{data_json_name}"
734
735
736def private_mount_dir(running_lab_name: str) -> str:
737    return f"{sre_projects_dir}/{running_lab_name}/{private_dir_name}/{private_mount_dir_name}"
738
739
740def files_dir(running_lab_name: str) -> str:
741    return f"{sre_projects_dir}/{running_lab_name}/{private_dir_name}/{files_dir_name}"
742
743
744def records_dir(running_lab_name: str) -> str:
745    return f"{sre_projects_dir}/{running_lab_name}/{private_dir_name}/{records_dir_name}"
746
747
748def srelab_link_filename(running_lab_name: str) -> str:
749    return f"{private_lab_dir(running_lab_name)}/{srelab_link_name}"
750
751
752def answers_filename(running_lab_name: str) -> str:
753    return f"{sre_projects_dir}/{running_lab_name}/{answer_dir_name}/{answer_file_name}"
754
755
756def cheat_filename(running_lab_name: str) -> str:
757    return f"{sre_projects_dir}/{running_lab_name}/{answer_dir_name}/{cheat_file_name}"
758
759
760def answers_dir(running_lab_name: str) -> str:
761    return f"{sre_projects_dir}/{running_lab_name}/{answer_dir_name}"
762
763
764def self_grade_timestamp_file(lab_name: str, username: str | None = None) -> str:
765    """The cooldown file of the self-evaluations of a lab: one per lab and owner (``sre eval``
766    passes the owner of the project), so that the students of a shared host do not block each
767    other; the old per-lab file is used when *username* is not given."""
768    if username:
769        return f"{self_grade_timestamp_dir}/{lab_name}@@@{username}"
770    return f"{self_grade_timestamp_dir}/{lab_name}"
install_layout = 'checkout'
sre_user = 'sre'
sre_uid = 1100
sre_gid = 1100
docker_group = 'docker'
docker_gid = 988
admin_uids = []
admin_gids = [2000]
main_sre_dir = $PWD
sre_pub_dir = '/var/lib/sre'
sre_user_public_dir = '/home/sre'
lab_dir = '/Users/em/Orsay/Info/sre/lab'
shipped_lab_dir = '/Users/em/Orsay/Info/sre/lab'
lib_dir = '/Users/em/Orsay/Info/sre/lib'
graphicdir = '/Users/em/Orsay/Info/sre/graphics'
fontdir = '/Users/em/Orsay/Info/sre/graphics/fonts'
translations_dirs = ['/Users/em/Orsay/Info/sre/translations', '/Users/em/Orsay/Info/sre/translations']
locale_dirs = ['/Users/em/Orsay/Info/sre/locale', '/Users/em/Orsay/Info/sre/locale']
archive_dirs = ['/var/lib/sre/archives']
sre_wrapper = '/Users/em/Orsay/Info/sre/bin/sre-wrapper'
sysreseval_exe = '/Users/em/Orsay/Info/sre/bin/sysreseval'
sre_exe = '/Users/em/Orsay/Info/sre/sbin/sre'
docker_socket_path = '/var/run/docker.sock'
self_update_allowed = True
network_plugin_hint = 'Install it with `make network-plugin` in /Users/em/Orsay/Info/sre (see the installation guide).'
kathara_hint = 'install it again with `make venv`'
web_dir = '/Users/em/Orsay/Info/sre/web'
web_config_file = '/etc/sreweb/web.toml'
agent_config_file = '/etc/sreweb/agent.toml'
site_params_file = None
debug_mode = False
email_in_gecos_last_field: bool = True
max_docker_concurrency = 16
display_marks_in_auto_evaluations_by_default = False
use_numerical_marks_by_default = True
default_maximum_mark = 20
SELF_EVAL_SCOPE = 1
EXO_EVAL_SCOPE = 2
BOTH_EVAL_SCOPE = 3
grade_scopes = (1, 2, 3)
default_eval_interval_during_exams = 60
default_eval_interval_without_exam_mode = 60
default_save_record_interval_during_exams = 60
default_exam_duration = 90
default_inactivity_threshold_in_watch_command = 90
default_dashboard_refresh_interval_in_watch_command = 1
max_duration_between_exam_pre_start_and_start = 60
default_exec_shell = '/bin/bash'
update_github_repository = 'sysreseval/sysreseval'
update_github_api_url = 'https://api.github.com'
update_tarball_url = 'https://codeload.github.com/{repository}/tar.gz/{commit}'
update_default_branch = 'main'
update_http_timeout = 60
update_lab_subdirs = ['sre']
allow_privileged_machines = True
disable_volume_mount_on_root_partition = False
use_sudo_user_for_username = True
check_project_owner = True
execute_commands_on_host: Literal['shell', 'split', False] = 'shell'
authorized_src_dir = ['/Users/em/Orsay/Info/sre/lab', '/home']
terminal_cmd_prefix = ['/usr/bin/mate-terminal', '--']
terminal_title_opt = '--title'
sre_version = '0.16.1'
default_docker_image_version = '1.33'
sre_docker_namespace = 'sysreseval'
def sre_docker_image(image_name: str = 'base') -> str:
299def sre_docker_image(image_name: str = "base") -> str:
300    return f"{sre_docker_namespace}/{image_name}:{default_docker_image_version}"
default_docker_image = 'sysreseval/base:1.33'
update_state_file_name = '.sre-update.json'
update_lock_file_name = '.sre-update.lock'
update_tmp_prefix = '.sre-update-'
update_params_follow_new = ('sre_version', 'default_docker_image_version')
update_params_backup_suffix = '.bak.'
update_system_templates_dir = 'scripts/etc'
update_state_commit = 'commit'
update_state_ref = 'ref'
update_state_updated_at = 'updated_at'
update_state_previous_commit = 'previous_commit'
update_state_files = 'files'
update_state_params_upstream = 'params_upstream'
sre_projects_dir = '/var/lib/sre/projects'
sre_name_env_variable = 'SRE_LAB_NAME'
sre_host_ip_env_variable = 'SRE_HOST_IP'
sre_x11_display_env_variable = 'SRE_X11_DISPLAY'
sre_display_env_variable = 'SRE_DISPLAY'
sre_host_ip = '172.17.0.1'
exetests_path = '/Users/em/Orsay/Info/sre/lib/exetests.py'
exetests_machines_path = '/usr/local/sbin/exetests.py'
exetests_env_name = 'EXETESTS'
exetests_separator = '@@@'
default_timeout = 20
default_state_cmd_timeout = 120
exit_code_flavor_form_needed = 3
exit_code_flavor_not_allowed = 4
default_show_nat_network = True
default_host_network_color = 'deepskyblue'
default_host_network_name = 'Internet'
default_host_network_shape = 'hexagon'
default_host_network_exploded = False
default_host_network_edge_relative_length = 1.0
default_ipv6 = None
network_mode_hub = 'hub'
network_mode_switch = 'switch'
network_mode_managed = 'managed'
network_modes = ('hub', 'switch', 'managed')
default_network_mode = 'hub'
min_vlan_id = 1
max_vlan_id = 4094
switch_port_reference_prefix = '@'
switch_cmd_error_code = -2
switch_user_commands = ('help', 'showinfo', 'port/showinfo', 'port/print', 'port/allprint', 'port/setvlan', 'vlan/create', 'vlan/remove', 'vlan/addport', 'vlan/delport', 'vlan/print', 'vlan/allprint', 'hash/showinfo', 'hash/print', 'hash/find', 'fstp/showinfo', 'fstp/print', 'fstp/setfstp', 'fstp/setedge', 'fstp/bonus')
switch_console_exit_commands = ('exit', 'quit', 'logout')
switch_console_watch_interval = 1.0
default_machine_shape = 'box'
default_network_shape = 'ellipse'
pdf_schema_file = 'schema.pdf'
pdf_info_file = 'informations.pdf'
terminal_font_size = 12
terminal_color_scheme = 'black_on_white'
content_font_size = 12
content_style_sheet = 'th, td { padding: 2px 8px; }'
default_form_field_lines = 4
system_font_size = 10
instructor_text_color = '#b00020'
instructor_background_color = '#ffe3e3'
instructor_begin_mark = '\ue000'
instructor_args_end_mark = '\ue001'
instructor_end_mark = '\ue002'
thats_all_folks_svg = '/Users/em/Orsay/Info/sre/graphics/Thats_all_folks.svg'
sysreseval_logo_svg = '/Users/em/Orsay/Info/sre/graphics/sysreseval.svg'
machine_icon_svg_file = '/Users/em/Orsay/Info/sre/graphics/machine.svg'
machine_forbidden_icon_svg_file = '/Users/em/Orsay/Info/sre/graphics/machine-forbidden.svg'
switch_icon_svg_file = '/Users/em/Orsay/Info/sre/graphics/switch.svg'
cloud_icon_svg_file = '/Users/em/Orsay/Info/sre/graphics/cloud.svg'
network_shape_icons = {'cloud': '/Users/em/Orsay/Info/sre/graphics/cloud.svg'}
pdf_font_files = {'': '/Users/em/Orsay/Info/sre/graphics/fonts/DejaVuSans.ttf', 'B': '/Users/em/Orsay/Info/sre/graphics/fonts/DejaVuSans-Bold.ttf', 'I': '/Users/em/Orsay/Info/sre/graphics/fonts/DejaVuSans-Oblique.ttf', 'BI': '/Users/em/Orsay/Info/sre/graphics/fonts/DejaVuSans-BoldOblique.ttf'}
pdf_mono_font_files = {'': '/Users/em/Orsay/Info/sre/graphics/fonts/DejaVuSansMono.ttf', 'B': '/Users/em/Orsay/Info/sre/graphics/fonts/DejaVuSansMono-Bold.ttf'}
exam_only_affix = ['_EXAM_', '_OLD_', '_DRAFT_', '_TESTS_']
exam_json_name = 'exam.json'
exam_json_keyword = 'exam_json'
exam_start_after = 'start_after'
exam_end_before = 'end_before'
exam_eval_interval = 'eval_interval'
exam_pre_start_date = 'pre_start_date'
exam_started_at = 'started_at'
exam_ended_at = 'ended_at'
exam_duration = 'duration'
exam_labs = 'labs'
exam_record_sessions = 'record_sessions'
def parse_lab_entry(entry) -> tuple[str, str | None]:
447def parse_lab_entry(entry) -> tuple[str, str | None]:
448    """Parse a labs entry: new [lab, flavor] list or legacy plain string.
449    Returns (lab_cli_arg, flavor_name_or_None)."""
450    if isinstance(entry, list):
451        return entry[0], entry[1]
452    return entry, None  # backward compat with old exam.json

Parse a labs entry: new [lab, flavor] list or legacy plain string. Returns (lab_cli_arg, flavor_name_or_None).

srelab_py_name = 'srelab.py'
titles_file_name = 'titles.json'
titles_reserved_prefix = '@'
titles_sort_key = '@sort'
titles_sort_name = 'name'
titles_sort_title = 'title'
titles_sort_manual = 'manual'
titles_sort_modes = ('name', 'title', 'manual')
lab_list_name_key = 'name'
lab_list_title_key = 'title'
lab_list_kind_key = 'kind'
lab_list_sort_key = 'sort'
lab_list_kind_dir = 'dir'
lab_list_kind_lab = 'lab'
data_json_name = 'data.json'
data_json_ips6_key = 'ips6'
data_json_nets6_key = 'nets6'
data_json_iface_marker = '__iface__'
data_json_mac_marker = '__mac__'
private_dir_name = '.private'
files_dir_name = 'files'
private_mount_dir_name = 'mnt'
user_public_dir_name = 'user_public_dir'
shared_dir_name = 'shared'
records_dir_name = 'records'
recorder_idle_time_limit = 10
recorder_config_dir = '/var/lib/sre/asciinema'
use_asciinema_for_records = True
eval_in_progress_name = 'eval_in_progress'
auto_eval_log_name = 'auto_eval.log'
debug_project_marker_name = 'debug_project'
instructor_mode_marker_name = 'instructor_mode'
operations_log_name = 'operations.log'
operations_log_max_output_chars = 16384
operations_log_view_max_lines = 100000
auto_eval_count_keyword = 'auto_eval_count'
info_json_name = 'info.json'
answer_dir_name = 'answers'
answer_file_name = 'answers.json'
cheat_file_name = 'cheat.json'
cheat_file_max_size = 1000000
archive_max_size = 67108864
hostname_keyword = 'hostname'
login_keyword = 'login'
fullname_keyword = 'fullname'
email_keyword = 'email'
language_keyword = 'language'
group_keyword = 'group'
available_language_in_interface = ['en', 'fr']
language_display_names = {'en': 'English', 'fr': 'Français'}
running_lab_name_keyword = 'running_lab_name'
eval_date_keyword = 'eval_date'
re_eval_date_keyword = 're_eval_date'
sysreseval_answers_updated_at = 'answers_updated_at'
sysreseval_exam_time_remaining = 'exam_time_remaining'
sysreseval_exam_started_at = 'exam_started_at'
sysreseval_exam_duration = 'exam_duration'
sysreseval_exam_mode = 'exam_mode'
initial_state_name = 'initial'
save_state_name = 'save'
restore_state_name = 'restore'
lifecycle_state_names = ('initial', 'save', 'restore')
save_file_suffix = '.sre'
save_file_magic = b'SRESAVE1\n'
save_format_version = 1
save_stdio_arg = '-'
save_tmp_dir = '/var/lib/sre/tmp'
save_kathara_name = 'kathara.tar'
save_user_public_member = 'user_public'
save_meta_format_version = 'format_version'
save_meta_sre_version = 'sre_version'
save_meta_running_lab_name = 'running_lab_name'
save_meta_lab_name = 'lab_name'
save_meta_srelab_file = 'srelab_file'
save_meta_username = 'username'
save_meta_saved_at = 'saved_at'
save_meta_debug_project = 'debug_project'
save_meta_full_images = 'full_images'
save_meta_shared_path = 'shared_path'
save_meta_encrypted = 'encrypted'
save_meta_kdf_salt = 'kdf_salt'
save_meta_kdf_iterations = 'kdf_iterations'
save_kdf_iterations = 600000
save_cipher_chunk_size = 16777216
self_grade_timestamp_dir = '/var/lib/sre/last_self_grades'
web_updates_dir_name = 'web-updates'
web_updates_dir = '/var/lib/sre/web-updates'
web_updates_suffix = '.json'
web_updates_max_age = 86400
web_updates_max_size = 8388608
archive_keyword = 'archive'
graphviz_default_nodesep = 0.8
graphviz_default_ranksep = 1.5
graphviz_default_splines = 'curved'
graphviz_default_overlap = 'prism'
class SRE:
580class SRE:
581    args = None
582    module_rvlab = None
583    eval_obj = None
584    username = None
args = None
module_rvlab = None
eval_obj = None
username = None
def datetime_to_string(dt) -> str:
587def datetime_to_string(dt) -> str:
588    return f"{dt:%Y%m%d%H%M%S}"
def string_to_datetime(string) -> datetime.datetime:
591def string_to_datetime(string) -> datetime:
592    return datetime.strptime(string, "%Y%m%d%H%M%S")
def get_lab_name_from_cli_arg(lab_cli_arg: str, is_path: bool) -> str:
595def get_lab_name_from_cli_arg(lab_cli_arg: str, is_path: bool) -> str:
596    if is_path:
597        lab_cli_arg = os.path.abspath(lab_cli_arg)
598    return lab_cli_arg.replace("/", "@")
def get_running_lab_name( lab_name: str, instance_start_date: datetime.datetime, username: str = None) -> str:
601def get_running_lab_name(lab_name: str, instance_start_date: datetime, username: str = None) -> str:
602    if username is None:
603        if SRE.username is None:
604            username = ""
605        else:
606            username = SRE.username
607    if exetests_separator in lab_name:
608        raise ValueError(f"lab_name must not contain '{exetests_separator}': {lab_name!r}")
609    if exetests_separator in username:
610        raise ValueError(f"username must not contain '{exetests_separator}': {username!r}")
611    return f"{datetime_to_string(instance_start_date)}@@@{lab_name}@@@{username}"
running_lab_name_match_pattern = '^([0-9]+)@@@(.+)@@@(.+)$'
def get_lab_name_from_running_lab_name(running_lab_name: str) -> str:
617def get_lab_name_from_running_lab_name(running_lab_name: str) -> str:
618    match = re.match(running_lab_name_match_pattern, running_lab_name)
619    if match:
620        return match.group(2)
621    else:
622        return 'ERROR-running_lab_name-ILLEGAL-FORMAT'
def get_username_from_running_lab_name(running_lab_name: str) -> str:
625def get_username_from_running_lab_name(running_lab_name: str) -> str:
626    match = re.match(running_lab_name_match_pattern, running_lab_name)
627    if match:
628        return match.group(3)
629    return ''
def get_start_date_string_from_running_lab_name(running_lab_name: str) -> str:
632def get_start_date_string_from_running_lab_name(running_lab_name: str) -> str:
633    """Instance start date (YYYYmmddHHMMSS digits) encoded in a running_lab_name,
634    or '' when the name does not follow get_running_lab_name().  The digits
635    sort chronologically, so callers compare/sort the string and only convert
636    it with string_to_datetime() for display."""
637    match = re.match(running_lab_name_match_pattern, running_lab_name)
638    return match.group(1) if match else ''

Instance start date (YYYYmmddHHMMSS digits) encoded in a running_lab_name, or '' when the name does not follow get_running_lab_name(). The digits sort chronologically, so callers compare/sort the string and only convert it with string_to_datetime() for display.

def get_abbreviated_lab_name_from_running_lab_name(running_lab_name: str) -> str:
641def get_abbreviated_lab_name_from_running_lab_name(running_lab_name: str) -> str:
642    name = get_lab_name_from_running_lab_name(running_lab_name).replace("@", "/").rpartition("/")[2]
643    return name.removesuffix(".py")
def get_current_srelab_file_from_running_lab_name(running_lab_name: str) -> str:
646def get_current_srelab_file_from_running_lab_name(running_lab_name: str) -> str:
647    lab_name = get_lab_name_from_running_lab_name(running_lab_name)
648    pathstr = lab_name.replace("@", "/")
649    if not pathstr.startswith("/"):
650        abspath = (Path(lab_dir) / pathstr).resolve()
651    else:
652        abspath = Path(pathstr).resolve()
653    if abspath.name.endswith(".py"):
654        return str(abspath)
655    else:
656        return str(abspath / srelab_py_name)
def get_srelab_dir(running_lab_name: str):
659def get_srelab_dir(running_lab_name: str):
660    lab_name = get_lab_name_from_running_lab_name(running_lab_name)
661    pathstr = lab_name.replace("@", "/")
662    if not pathstr.startswith("/"):
663        abspath = (Path(lab_dir) / pathstr).resolve()
664    else:
665        abspath = Path(pathstr).resolve()
666    if abspath.name.endswith(".py"):
667        return None
668    else:
669        return str(abspath)
def project_has_directory(running_lab_name=None) -> bool:
672def project_has_directory(running_lab_name=None) -> bool:
673    if running_lab_name.endswith(".py"):
674        return False
675    else:
676        return True
def get_archive_name(running_lab_name: str, date: datetime.datetime) -> str:
679def get_archive_name(running_lab_name: str, date: datetime) -> str:
680    return f"{datetime_to_string(date)}_{running_lab_name}.zst"
archive_name_match_pattern = '(\\d{14})_(.+)\\.zst'
def get_date_from_archive_name(filename: str) -> datetime.datetime | None:
689def get_date_from_archive_name(filename: str) -> datetime | None:
690    """Creation (eval) date encoded in an archive filename, or None when the
691    name does not follow get_archive_name() or the digits are not a date."""
692    match = re.fullmatch(archive_name_match_pattern, Path(filename).name)
693    if not match:
694        return None
695    try:
696        return string_to_datetime(match.group(1))
697    except ValueError:
698        return None

Creation (eval) date encoded in an archive filename, or None when the name does not follow get_archive_name() or the digits are not a date.

def private_lab_dir(running_lab_name: str) -> str:
701def private_lab_dir(running_lab_name: str) -> str:
702    return f"{sre_projects_dir}/{running_lab_name}/{private_dir_name}"
def auto_eval_log_filename(running_lab_name: str) -> str:
705def auto_eval_log_filename(running_lab_name: str) -> str:
706    return f"{private_lab_dir(running_lab_name)}/{auto_eval_log_name}"
def debug_project_marker_filename(running_lab_name: str) -> str:
709def debug_project_marker_filename(running_lab_name: str) -> str:
710    return f"{private_lab_dir(running_lab_name)}/{debug_project_marker_name}"
def instructor_mode_marker_filename(running_lab_name: str) -> str:
713def instructor_mode_marker_filename(running_lab_name: str) -> str:
714    return f"{private_lab_dir(running_lab_name)}/{instructor_mode_marker_name}"
def operations_log_filename(running_lab_name: str) -> str:
717def operations_log_filename(running_lab_name: str) -> str:
718    return f"{public_lab_dir(running_lab_name)}/{operations_log_name}"
def public_lab_dir(running_lab_name: str) -> str:
725def public_lab_dir(running_lab_name: str) -> str:
726    return f"{sre_projects_dir}/{running_lab_name}"
def info_filename(running_lab_name: str) -> str:
729def info_filename(running_lab_name: str) -> str:
730    return f"{sre_projects_dir}/{running_lab_name}/{info_json_name}"
def data_filename(running_lab_name: str) -> str:
733def data_filename(running_lab_name: str) -> str:
734    return f"{sre_projects_dir}/{running_lab_name}/{private_dir_name}/{data_json_name}"
def private_mount_dir(running_lab_name: str) -> str:
737def private_mount_dir(running_lab_name: str) -> str:
738    return f"{sre_projects_dir}/{running_lab_name}/{private_dir_name}/{private_mount_dir_name}"
def files_dir(running_lab_name: str) -> str:
741def files_dir(running_lab_name: str) -> str:
742    return f"{sre_projects_dir}/{running_lab_name}/{private_dir_name}/{files_dir_name}"
def records_dir(running_lab_name: str) -> str:
745def records_dir(running_lab_name: str) -> str:
746    return f"{sre_projects_dir}/{running_lab_name}/{private_dir_name}/{records_dir_name}"
def answers_filename(running_lab_name: str) -> str:
753def answers_filename(running_lab_name: str) -> str:
754    return f"{sre_projects_dir}/{running_lab_name}/{answer_dir_name}/{answer_file_name}"
def cheat_filename(running_lab_name: str) -> str:
757def cheat_filename(running_lab_name: str) -> str:
758    return f"{sre_projects_dir}/{running_lab_name}/{answer_dir_name}/{cheat_file_name}"
def answers_dir(running_lab_name: str) -> str:
761def answers_dir(running_lab_name: str) -> str:
762    return f"{sre_projects_dir}/{running_lab_name}/{answer_dir_name}"
def self_grade_timestamp_file(lab_name: str, username: str | None = None) -> str:
765def self_grade_timestamp_file(lab_name: str, username: str | None = None) -> str:
766    """The cooldown file of the self-evaluations of a lab: one per lab and owner (``sre eval``
767    passes the owner of the project), so that the students of a shared host do not block each
768    other; the old per-lab file is used when *username* is not given."""
769    if username:
770        return f"{self_grade_timestamp_dir}/{lab_name}@@@{username}"
771    return f"{self_grade_timestamp_dir}/{lab_name}"

The cooldown file of the self-evaluations of a lab: one per lab and owner (sre eval passes the owner of the project), so that the students of a shared host do not block each other; the old per-lab file is used when username is not given.