Lab Authoring Guide¶
A lab is a Python module that defines three classes: Data, NetScheme, and Grade, each inheriting from the corresponding *0 base class in lib_sre.py. An optional Flavor class can be defined to parameterize the lab at start time.
A lab is usually a single .py file.
Directory labs
SRE also supports directory labs — a subdirectory containing a srelab.py
plus per-state file trees (initial/, state1/, …) that are copied into the containers at the matching state
transition. This is the Kathara layout, but in practice it’s rarely needed: most setup is better expressed in code, since Data-derived values
(random IPs, secrets, etc.) can then be injected into the generated files.
The directory form is mainly useful when you need to ship payload that can’t reasonably be generated at runtime — a pre-built apt package,
a large data file, or anything else you don’t want to download from the Internet on every lab start.
/opt/sre/lab/s4/tp_ssh/
├── srelab.py ← the module
├── initial/ ← files injected at startup
│ ├── router/ ← files for the machine named "router"
│ │ └── etc/network/interfaces
│ └── all/ ← files copied to every machine
│ └── etc/motd
└── state1/ ← files injected when "sre state <lab> state1" is called
└── router/
└── etc/...
The authoring workflow on the CLI¶
While iterating on a srelab.py, you typically alternate between editing the module and exercising it with a handful of sre subcommands. Full reference lives in CLI reference; this section just names the commands you’ll reach for as an author and explains the one option that exists for authors only.
sre check [-p] <lab> [<state>]¶
Static validation, no containers deployed. Imports the module, runs Data.compute_pre_generate() → Data.generate() → data.compute_post_generate(), instantiates NetScheme, applies initial() (op registration only), then calls Grade.grade() once. Catches import errors, missing fields, bad topology, and exceptions raised at registration time. With a trailing <state> argument, the named @sre_state method is also exercised. Run this first after every non-trivial edit — it’s much faster than starting a real lab.
sre start --debug-project [-p] <lab> [--xauth-file <path>]¶
The author counterpart to sre start. Privileged only and incompatible with --user. Deploys the lab exactly like a normal start, then drops a .private/debug_project marker that the rest of the runtime keys on. Effect:
Topology: every machine is shown in the GUI’s Machines tab — even those declared
hidden=Trueorallow_connection=False. Connect buttons are exposed for all of them so you can step inside infrastructure machines (DNS, monitors, helpers) to inspect their state.Restrictions lifted: user-mode guards on
sre stateandsre connect(which normally refuse to touch hidden machines or applyuser_allowed=Falsestates) are bypassed.Grading: when you
sre evala debug project, marks are always shown and every grade scope is surfaced —no_mark_on_self_grade,hide_potential_penalty_grades_in_self_gradeand the periodic-eval gating are all ignored.-pis implicit:--debug-projectalways treats the lab argument as a filesystem path, so you can point it directly at the.pyyou’re editing (e.g.~/labs/draft.py) without copying it into/opt/sre/lab/first.
The marker survives container restarts and is cleared when the project is sre stopped or sre wiped.
--xauth-file <path> is the other authoring-time option on sre start. By default, machines marked x11_host=True pick up the host’s X magic cookie from the SRE_XAUTH_COOKIE environment variable (the sre-wrapper sets this for the GUI flow). When you start a project by hand from a shell where that variable isn’t set — typical for --debug-project — pass --xauth-file ~/.Xauthority to source the cookie from a file instead. Privileged only.
sre connect <running_lab> <device> and sre exec <running_lab> <device> <command…>¶
sre connect opens an interactive shell inside <device> (using the machine’s configured shell). sre exec runs a one-shot command and returns its stdout/stderr/exit code — useful for scripting checks against a running lab without holding a TTY. sre exec is privileged only; both honor the debug-project marker, so they can target hidden machines when the project was started with --debug-project.
sre connect --exec <argument…> is a hybrid: launches the machine’s shell, runs the command, exits. Unlike sre exec it goes through the shell launcher and is available to students.
sre state <running_lab> <state_name>¶
Applies a non-initial @sre_state method against the live containers. The main use case during authoring is exercising the instructor’s “fully-configured” state (often called final) so you can run sre eval against a known-good configuration and verify the grading code reports the expected full marks. In a non-debug project, students can only apply states declared with @sre_state(user_allowed=True) and only when the module sets allow_user_states = True — --debug-project lifts both guards.
sre eval <running_lab>¶
Runs an evaluation against the live containers — equivalent to pressing the Start evaluation button in the GUI. The evaluation also refreshes the project’s view of the lab module, so any edits to informations, questions, or grade rubric take effect on the next display without needing to restart the lab.
sre wipe¶
Stops every running Kathara lab and removes everything under /var/lib/sre/projects/. The reset button between iterations: when a lab is misbehaving and sre stop <running_lab> isn’t enough (orphaned bridges, broken Kathara state, leftover marker files), sre wipe brings the host back to a clean slate. It does not touch exam.json or archive directories.
A typical edit-test cycle:
sre check -p ~/labs/draft.py final # validate module + the 'final' state
sre start --debug-project -p ~/labs/draft.py # deploy, with debug visibility
sre connect <running_lab> router # poke around
sre state <running_lab> final # apply the instructor's known-good state
sre eval <running_lab> # confirm grade == max
sre wipe # reset before the next edit
Identifying a running lab and where its files live¶
Every running project lives in its own subdirectory of /var/lib/sre/projects/. The directory name encodes the launch timestamp, the lab name, and the student username:
/var/lib/sre/projects/{YYYYmmddHHMMSS}@@@{lab_name}@@@{username}/
e.g. 20260606143215@@@s4@ssh@@@alice. List the directory to see what’s currently running:
ls /var/lib/sre/projects/
Partial names are accepted. Every subcommand that takes a <running_lab> argument resolves it by substring match against the directory listing —
you only need to type enough of the name to uniquely identify one project. If your substring matches several running labs, sre prints the candidates and
exits so you can disambiguate; if it matches none, you get no running lab matches '...'. In practice the lab name itself (or even a fragment of it) is usually enough:
sre connect ssh router # works if only one ssh project is running
sre connect 20260606 router # disambiguate by timestamp prefix
sre connect alice router # disambiguate by username
Inside the project directory you’ll find:
Path (relative to the project dir) |
Contents |
|---|---|
|
Public metadata read by the GUI: lab title, |
|
The topology diagram rendered with graphviz. |
|
Student answers to |
|
Optional instructor-provided cheat answers keyed by state name (used by |
|
Bind-mounted into every container as |
|
Mode |
|
The serialized |
|
Symlink to the lab’s |
|
Target of |
|
Asciinema recordings of terminal sessions, if |
|
PID lock created during |
|
Marker dropped by |
|
One line per student-triggered self-eval; drives the |
Evaluation archives are not stored under the project directory — they go to /var/lib/sre/archives/ (plus any extra archive_dirs declared at module level), named {YYYYmmddHHMMSS}_{running_lab_name}.zst.
The four classes at a glance¶
A lab module defines up to four classes, each with a narrow role:
Data— a@dataclassthat holds every per-instance parameter of the lab (IP addresses, secrets, ports, randomized values).Data.generate()is called once atsre start; the resulting instance is serialized to.private/data.jsonand reloaded on every later operation.Flavor(optional) — a@dataclassthat lets a singlesrelab.pyproduce different variants of the lab at start time (e.g. random vs. fixed addresses, easy vs. hard). The GUI rendersflavor_formas a form; the chosenFlavoris then passed toData.generate(flavor).NetScheme— declares the network topology (_machine_specs,_network_specs,_topology) and the imperative configuration of each state. Theinitialstate runs atsre start; additional@sre_statemethods can be applied later withsre state <lab> <name>.Grade— registers what to evaluate and how to score it. It does not run anything itself: SRE drives a multi-steprun_tests()loop that callsGrade.grade()repeatedly, executing the commands registered by each call between passes. See The grading lifecycle.
The flow at sre start is: Data.generate(flavor) → NetScheme(data).initial() (which lays out interfaces and writes files). The flow at sre eval is: reload data.json into a fresh Data, rebuild NetScheme, then run Grade.run_tests() against the live containers.
Data class¶
Data is a dataclass that holds all lab-specific parameters (IP addresses, random secrets, etc.). It inherits from Data0.
from dataclasses import dataclass
from ipaddress import IPv4Interface
from SRE.lib_sre import Data0
from ips import random_ipv4networks, random_ipv4s
@dataclass(slots=True)
class Data(Data0):
secret: str = ''
vlan_id: int = 0
@classmethod
def generate(cls, flavor=None):
data = cls(secret="changeme", vlan_id=42)
# data.nets and data.ips are injected automatically by Data0.__post_init__
data.nets.lan, data.nets.mgmt = random_ipv4networks([24, 24], from_private_network=True)
ip = random_ipv4s(data.nets.lan, 1)[0]
data.ips.router = IPv4Interface(f'{ip}/{data.nets.lan.prefixlen}')
return data
Key points:
data.ipsholdsIPv4Interfacevalues (address + prefix length);data.netsholdsIPv4Networkvalues;data.macsholdsnetaddr.EUI(MAC address) values.All three containers are injected automatically into every
Data0subclass instance by__post_init__— no declaration needed.The containers enforce their types: assigning a plain string to
data.ipsraisesTypeError; always wrap with the correct type before assigning.data.ipsrequiresIPv4Interface(not bareIPv4Address) — always assign with a prefix, e.g.IPv4Interface('10.0.0.1/24').Data.generate(flavor)is called once atsre start; the result is saved todata.json.Serialization handles
IPv4Interface,IPv4Network,EUI, and nestedData0subclasses transparently.
IP helper functions (from /opt/sre/lib/ips.py)¶
from ips import random_ipv4networks, random_ipv4s
# Returns a list of n disjoint networks with the given prefix lengths:
nets = random_ipv4networks([24, 28, 24], from_private_network=True)
# Returns n distinct random IPv4Address objects within a network:
hosts = random_ipv4s(nets[0], 3, exclude_nets=[nets[1]])
# Wrap as IPv4Interface before assigning to data.ips:
data.ips.host = IPv4Interface(f'{hosts[0]}/{nets[0].prefixlen}')
compute_pre_generate and compute_post_generate¶
Two optional lifecycle hooks let a Data subclass derive auxiliary values without storing them in data.json. Override either as needed; the defaults are no-ops.
Hook |
Signature |
Receiver |
Purpose |
|---|---|---|---|
|
|
class |
Set class-level attributes derived from |
|
|
instance |
Set instance-level attributes derived from the dataclass fields after generation or reload. |
When they run:
At
sre start:Data.compute_pre_generate(flavor)→Data.generate(flavor)→data.compute_post_generate().At
sre eval,sre state,sre connect, etc.: afterdata.jsonis reloaded into a freshDatainstance viafrom_json/from_dict/unpack, both hooks run again — pre-generate first (with the persistedflavor), then post-generate.sre checkexercises the same sequence as a sanity check.
Because the hooks run on every reload, they should be deterministic and side-effect-free: no random number generation, no IP allocation, no file I/O. Anything that must be randomized once and then persisted belongs in generate().
Typical use case — derive the machine list and per-machine spec from a Flavor:
@dataclass(slots=True)
class Data(Data0):
secret: str = ''
@classmethod
def compute_pre_generate(cls, flavor=None):
if flavor is None:
flavor = Flavor()
match flavor.network_size:
case "small":
r_max, m_max = 2, 2
case "medium":
r_max, m_max = 3, 4
case _:
r_max, m_max = 4, 7
cls.routers = [f"r{i}" for i in range(1, r_max + 1)]
cls.non_routers = [f"m{i}" for i in range(0, m_max + 1)]
cls.machine_specs = {'gw': {'bridged': True}}
@classmethod
def generate(cls, flavor=None):
data = cls(secret=random_password(16))
# cls.routers / cls.non_routers are already populated by compute_pre_generate
data.nets.lan = random_ipv4networks([24], from_private_network=True)[0]
return data
NetScheme.build() and Grade.grade() can then read self.data.routers / self.data.non_routers directly, even on reload, without those fields needing to live in data.json.
Flavor class¶
A Flavor is an optional dataclass that parameterizes a lab at start time. If defined, the GUI presents a form
to the student before starting.
A typical use case — see lab/sre/static_routing.py — is to switch a lab between two modes: when students work on it autonomously or during an exam, randomized IP addresses are preferable so each student has their own topology;
in a supervised classroom, the instructor may want every student to share the same values, so a single explanation at the board applies to everyone. A Flavor lets the lab expose both modes from the same srelab.py and pick one at start time.
from dataclasses import dataclass
from typing import Tuple
from SRE.lib_sre import Flavor0
@dataclass(slots=True)
class Flavor(Flavor0):
nb: int = 0
flavor_form = """
Number of clients: @@{nb:[0-9]+}@@
Mode: @@{mode:>easy|hard}@@
"""
def allowed_by_user(self) -> Tuple[bool, str]:
"""Return (True, '') if the student is allowed this flavor."""
if self.nb <= 5:
return True, ""
return False, "Maximum 5 clients allowed."
# Named presets (accessible as Flavor.easy, Flavor.hard, etc.)
Flavor.easy = Flavor(nb=1)
Flavor.hard = Flavor(nb=5)
Module-level control:
flavor_form_at_startup = True # show the flavor form when the student opens the lab
Flavor API:
Method |
Description |
|---|---|
|
Serialize/deserialize field values |
|
Build a |
|
Returns |
In Data.generate(flavor), check if flavor is not None before using flavor fields.
NetScheme class¶
NetScheme declares the lab’s network topology and exposes the imperative API used by every state method. Topology is declared at class level via three dicts; per-state actions live in methods decorated with @sre_state (covered in the next section).
from SRE.lib_sre import NetScheme0, sre_state, make_tr
tr = make_tr('en')
class NetScheme(NetScheme0):
# Declare machines: keys are machine names, values are Machine kwargs
_machine_specs = {
'router': {'color': 'green'},
'client': {},
'hidden': {'hidden': True, 'allow_connection': False},
}
# Declare network display options
_network_specs = {
'lan': {'color': 'yellow'},
'mgmt': {'color': 'gray'},
}
# Declare topology: net_name → list of machines (or dict with explicit interface numbers)
_topology = {
'lan': ['router', 'client'], # auto interface numbering
'mgmt': {'router': 0, 'hidden': 1}, # explicit interface numbers
}
def __init__(self, data, running_lab_name):
super().__init__(data=data, running_lab_name=running_lab_name)
# Markdown text for the Informations tab (supports make_tr for i18n)
self.informations = tr(
"## Lab description\nConfigure routing.",
fr="## Description\nConfigurez le routage.",
)
Declarations and accessors¶
Attribute / Method |
Description |
|---|---|
|
Class-level dict: |
|
Class-level dict: |
|
Class-level dict: |
|
The |
|
Markdown text (or |
|
Iterator over all |
|
Iterator over machine name strings |
|
Iterator over all |
Machine constructor parameters¶
Parameter |
Default |
Description |
|---|---|---|
|
|
Docker image |
|
|
Enable host-network bridging (required for |
|
|
Forward X11 to the host: inject |
|
|
Hidden from the Machines tab |
|
|
Show Connect button in the GUI |
|
|
Shell launched by |
|
|
Shell Kathara uses to execute startup |
|
|
Run the container in privileged mode. Refused at startup unless |
|
|
Node color in the SVG diagram |
|
|
Commands run at container start |
|
|
Kernel parameters |
|
|
Environment variables |
|
|
Port mappings, e.g. |
|
|
ulimit settings |
|
|
Volume mounts |
Port wildcards: "80XX:80/tcp" allocates the first free port in 8000–8099.
State methods¶
A state is a method of NetScheme decorated with @sre_state. The body of the method does not execute commands directly — instead, it registers operations (run a shell command, write a file, copy a file from the host, …) which SRE then applies in order against the running containers.
The initial state is always applied at sre start and is where the lab configures interfaces, launches services, and writes per-machine files derived from Data. Additional states are applied on demand with sre state <running_lab> <state_name>. Their main use case is the instructor’s workflow: a state that brings the project into a fully-configured, all-questions-answered shape, so the grading code can be exercised end-to-end without going through the student workflow. Students see and can apply a state only if the module-level attribute allow_user_states = True and that state was decorated with @sre_state(user_allowed=True).
@sre_state(user_allowed=False)
def initial(self):
"""Always applied at sre start."""
d = self.data
self.cmd('router', f'ip addr add {d.ips.router} dev eth0')
self.cmd('router', 'sysctl -w net.ipv4.ip_forward=1')
self.file('client', '/etc/resolv.conf', f'nameserver {d.ips.router.ip}\n')
@sre_state(user_allowed=True, description=tr("Final config", fr="Configuration finale"))
def final(self):
"""Optional additional state; students can apply it via 'sre state'."""
self.cmd('router', 'iptables -A FORWARD -j ACCEPT')
@sre_state decorator¶
Parameter |
Description |
|---|---|
|
If |
|
Human-readable label shown in the GUI (supports |
Per-machine operations¶
These all register an op against a single machine and accept a step= parameter (see Multi-step state setup below).
Method |
Description |
|---|---|
|
Run a shell command inside |
|
Create or overwrite a file inside |
|
Append to a file (creates it if missing). |
|
Same as |
File transfer between host and container¶
Method |
Description |
|---|---|
|
Copy a file from the host into |
|
Copy a file from |
Host-side operations¶
Method |
Description |
|---|---|
|
Run a shell command on the host (not inside any container). Refused if |
|
Invoke a Python callable on the host at this step, with no arguments. Useful when the next steps need values computed in Python. |
Multi-step state setup — the step parameter¶
Every state-method operation accepts step=N (default 1). When SRE applies a state it groups all registered ops by step and runs them in ascending order: every step-1 op finishes before any step-2 op starts. This matters when later ops depend on earlier ones being in place — for example writing a config file at step 1 and only restarting the service at step 2:
@sre_state(user_allowed=False)
def initial(self):
self.file('dns', '/etc/unbound/unbound.conf', conf, step=1)
self.cmd('dns', 'systemctl restart unbound', step=2)
Inside a single step the per-machine op order is preserved, and ops on different machines run in parallel. host_cmd / host_callback for step N run after all container ops of step N finish.
Network config helpers (from /opt/sre/lib/net_config.py)¶
from net_config import set_net_config_entry, set_sysctl, NetConfigEntry, SysctlConfig
# In initial():
set_net_config_entry(net_scheme=self, machine_name='router', nc_entry=[
([data.ips.router_lan], [(IPv4Network('0.0.0.0/0'), data.ips.gw)]),
])
set_sysctl(net_scheme=self, machine_name='router', sysctl_config={'ipv4.ip_forward': 1})
NetConfig is a list of interface entries; each entry is ([addresses], [(dest_network, gateway), ...]) or 'dhcp' or None.
Persistent equivalents — these write to /etc/network/interfaces and /etc/sysctl.d/99-sre.conf so the config survives a container restart:
set_persistent_net_config_entry(net_scheme, machine_name, nc_entry)set_persistent_sysctl(net_scheme, machine_name, sysctl_config)
State helpers (from /opt/sre/lib/state_helpers.py)¶
Convenience functions called inside @sre_state methods:
from state_helpers import (set_unbound_server, set_basic_unbound_server,
change_password, create_user,
hosts_file_content, create_hosts_file)
Function |
Description |
|---|---|
|
Drop a permissive Unbound DNS config into |
|
Same as above; explicit name when you want to make the “basic / permissive” intent obvious. |
|
Set a user’s password via |
|
Create a user with |
|
Return |
|
Write |
Grade class¶
Grade declares what the evaluator checks and how results are scored. It does not execute anything itself: every self.test(...) call merely registers a command. SRE then drives a multi-step loop (run_tests()) that calls Grade.grade() repeatedly, executing registered commands between passes and feeding their real results back into the next call.
from SRE.lib_sre import Grade0
class Grade(Grade0):
def grade(self):
"""Register tests, questions, and grade elements.
Called multiple times per evaluation — must be side-effect-free."""
super().grade()
# Register a test: run a shell command in a container
result, code = self.test('router', 'ping -c1 -W1 8.8.8.8', timeout=5)
# Register a question (student's text answer)
answer = self.question_text(
title='Default gateway',
description='What is the default gateway for the client?',
default_answer='',
)
# Register a grade element and set its score
self.add_grade_element('Connectivity', max_grade=4)
if code == 0:
self.set_grade('Connectivity', 4)
elif '64 bytes' in result:
self.set_grade('Connectivity', 2)
self.add_grade_element('Gateway answer', max_grade=2)
if str(self.data.ips.router.ip) in answer:
self.set_grade('Gateway answer', 2)
The grading lifecycle¶
Grade.grade() is not called once. It is called repeatedly by run_tests(), with the registered commands actually executed between calls. The loop is:
self.stepstarts at 0,self.max_stepat 1.load_answers()reads the student’sanswers.json.reset_before_grade()clears questions, grade rubrics, and section counters.grade()is called.self.test(machine, cmd, step=N)registerscmdunder(machine, N)and returns(default_value, default_code)(i.e. placeholders) the first time it is seen.If a call uses
step=Klarger thanself.max_step,max_stepis bumped toK, extending the loop.self.add_grade_element/self.set_grade/self.question_*calls all register entries; they don’t read any container state directly.
self.stepis incremented toN. All commands registered at stepNare bundled per machine into oneEXETESTS@@@cmd1@@@cmd2@@@…env var and run inside each container in parallel (16-workerThreadPoolExecutor, via/usr/local/sbin/exetests.py). Host-sidetest_host()commands for stepNrun in parallel on the host.Results are stored back into
self._tests[(machine, N)]. The loop returns to step 2. On this pass, the registration calls for stepNfind the entry already populated and return the real(stdout, exit_code); the code paths gated on those results now execute.When
self.step > self.max_step, the loop exits. The archive (zstd-compressed msgpack with all tests, answers, errors, and grade list) is written toparams.archives_dirand to every directory in the module-levelarchive_dirs.
This has two consequences for grade():
It must be side-effect-free. It is run several times — at least twice (one registration pass, one result pass), more if you use multi-step tests. Never call
subprocess, write to disk, or mutate global state inside it. All work goes throughself.test/self.question_*.self.test()returns placeholders the first time it is reached. Code that branches on the return value (e.g.if code == 0:) executes both with the placeholder (no-op) and with the real result. Make sure the placeholder branch is harmless — it will execute, but itsadd_grade_elementcalls are wiped byreset_before_grade()before the next pass, so only the final pass’s calls end up in the archive.
A worked two-step example: configure step 1 to perform a setup action, then read its effect at step 2.
def grade(self):
super().grade()
self.test('router', 'systemctl restart bird', step=1)
out, code = self.test('router', 'birdc show route', step=2)
self.add_grade_element('OSPF routes', max_grade=5)
if 'OSPF' in out:
self.set_grade('OSPF routes', 5)
On the first call, both self.test() invocations register; both return placeholders. SRE executes the step-1 command (systemctl restart bird). On the second call, the step-1 test returns its real result; the step-2 test registers and returns its placeholder. SRE executes the step-2 command. On the third call, both tests return real results; out contains the routing table, the rubric is populated, and the loop exits.
Grade parts¶
A grade part is a named group that bundles related rubric items together. Parts are purely presentational: they affect how grades are displayed in the GUI’s Evaluations tab and in the PDFs produced by sre outline — each part is rendered as a labeled block with a subtotal row summing its elements. The overall total, the archive contents, and the marks returned by mark_exo_eval() / mark_self_eval() are unchanged.
Register a part with self.add_grade_part(title, description='') and pass the returned object as grade_part= to every element that belongs to it:
def grade(self):
super().grade()
part1 = self.add_grade_part("part1", tr("Client DNS dig"))
self.add_grade_element(title="dig_host1_a", max_grade=1, grade=int(host1_ok),
grade_part=part1, description=tr("dig — A de host1"))
self.add_grade_element(title="dig_host2_a", max_grade=1, grade=int(host2_ok),
grade_part=part1, description=tr("dig — A de host2"))
part2 = self.add_grade_part("part2", tr("Serveur DNS cache Unbound"))
self.add_grade_element(title="unbound_running", max_grade=2, grade=int(unbound_ok),
grade_part=part2, description=tr("unbound actif"))
# ...
Elements registered without grade_part= are shown ungrouped (above or between the parts, in registration order). Parts are rendered in the order they were registered with add_grade_part(). See lab/sre/dns1.py for a real-world example with three parts.
Self-eval vs instructor-eval scope¶
Every add_grade_element call takes a scope= bitmask that decides which audiences see the element. Three values are defined in params:
Value |
Visible in |
|---|---|
|
Student self-eval only — |
|
Instructor view only — |
|
Both audiences |
The total mark reflects only the elements in the active scope: mark_self_eval() sums elements with scope & SELF_EVAL_SCOPE, mark_exo_eval() sums elements with scope & EXO_EVAL_SCOPE. The same grade() body can therefore produce a different total for a student self-eval than for the instructor’s archive.
A common pattern is to register a detailed rubric for the instructor and a coarse summary for the student — enough feedback to know something is broken in a given part, without disclosing which individual check failed:
from SRE import params
def grade(self):
super().grade()
part1 = self.add_grade_part("part1", tr("DNS resolution"))
host1_ok = ... # bool computed from self.test(...) results
host2_ok = ...
ptr_ok = ...
# Detailed rubric — only the instructor (and sre outline / sre sheet) sees these
self.add_grade_element('dig host1 A', max_grade=1, grade=int(host1_ok),
grade_part=part1, scope=params.EXO_EVAL_SCOPE)
self.add_grade_element('dig host2 A', max_grade=1, grade=int(host2_ok),
grade_part=part1, scope=params.EXO_EVAL_SCOPE)
self.add_grade_element('dig host1 PTR', max_grade=1, grade=int(ptr_ok),
grade_part=part1, scope=params.EXO_EVAL_SCOPE)
# Summary — what the student sees during self-eval
all_ok = host1_ok and host2_ok and ptr_ok
self.add_grade_element('part1_global', max_grade=1, grade=int(all_ok),
scope=params.SELF_EVAL_SCOPE,
description='Part 1 (combined)')
During the student’s self-eval, only the SELF_EVAL_SCOPE element appears: one row per part with a pass/fail score (no need for grade parts there).
During the instructor’s evaluation, only the three EXO_EVAL_SCOPE rows appear. scope and grade_part compose freely — elements in the same part can have different scopes and will be subtotalled accordingly for each audience.
Cheat answers¶
cheat_answers on question_text / question_form maps a state name (e.g. 'final') to an answer value. When the student has applied that state, the cheat answer is used instead of the student’s actual input. Useful when the instructor’s final state should produce a fully-passing evaluation.
Letter grades vs numeric marks¶
By default, marks are numeric, scaled to params.default_maximum_mark, and rounded to one decimal. Set self._use_numerical_marks = False (per instance, inside grade() or earlier) to switch to letter grades on the total: A+ ≥ 18/20, A ≥ 16, B ≥ 14, C ≥ 12, D ≥ 10, else F.
Per-element letter conversion (OK / MEH / FAIL) is also available via GradeElement.to_grade_letter() — full marks → OK, partial → MEH, zero → FAIL.
Overriding mark_exo_eval() to adjust the overall mark¶
The overall mark stored in the archive (and surfaced by sre outline, sre sheet, and sre cat) is produced by Grade.mark_exo_eval(). The default implementation is:
def mark_exo_eval(self):
return self._compute_mark(self._total_grade_exo_eval, self._total_max_exo_eval)
i.e. ceil(10 · maximum_mark · total_grade / total_max) / 10 in numerical mode, or the A+/A/B/C/D/F letter in letter mode. _total_grade_exo_eval / _total_max_exo_eval are the sums over every add_grade_element that contributes to the instructor’s view (i.e. excluding self-eval-only rubrics). mark_self_eval() is the counterpart used during student self-evaluations.
Override mark_exo_eval() (and/or mark_self_eval()) on the Grade subclass when you want a non-default scale — typically because the maximum of the rubric does not match the intended denominator. Common reasons:
Fixed denominator. The exam was designed against an absolute target (e.g. 39 points). The rubric may sum to more, but you want the mark expressed against that fixed value. Replace
_total_max_exo_evalwith the constant, and cap at_maximum_markso bonuses don’t push above the cap:import math class Grade(Grade0): def mark_exo_eval(self): if not self._grade_list or self._total_max_exo_eval == 0: return None return min(self._maximum_mark, math.ceil(10 * self._maximum_mark * self._total_grade_exo_eval / 39) / 10)
Bonus / penalty handling. When penalty rubrics are registered with
max_grade=0(so they only subtract), they don’t enlarge the denominator — but if you want them to not subtract below zero either, clamptotal_gradebefore scaling.
mark_exo_eval() is called once at the end of run_tests(), after every pass of grade() and after compute_total(). By that time self._grade_list, self._total_grade_exo_eval / _total_max_exo_eval, self._maximum_mark, and self._use_numerical_marks are all final, so the override is free to read them. Return None to signal “no mark” (shown as blank), a float for a numeric mark, or a string for a letter grade.
Grade methods¶
Method |
Description |
|---|---|
|
Register a command in a container; returns |
|
Same as |
|
Register a free-text question; returns the student’s answer (or |
|
Register a form question with inline |
|
Display-only block (no input shown to the student). |
|
Register a named group of grade elements and return the resulting |
|
Add a graded rubric item. |
|
Set the score of a previously registered element. |
|
Increment the section counter at |
|
Same formatting as |
|
Record an error/warning in the archive. Only registered when |
|
Instance attributes available inside |
|
Number of student-triggered self-evaluations performed on this project before the current run (read from |
Grading Library Reference¶
DHCP helpers (from /opt/sre/lib/dhcp.py)¶
from dhcp import DhcpParameters, DhcpSubnet, set_dhcp_server, get_dhcp_server, check_running_dhcp_server
Data classes:
Class |
Purpose |
|---|---|
|
One |
|
Full server config: |
Functions used in NetScheme (state setup):
set_dhcp_server(net_scheme, machine, dhcp_params, step=1) — writes /etc/default/isc-dhcp-server and /etc/dhcp/dhcpd.conf from a DhcpParameters instance, then enables and restarts isc-dhcp-server.
Functions used in Grade (evaluation):
get_dhcp_server(grade, machine, step=1) → (DhcpParameters | None, int) — reads and parses the DHCP server configuration from the running container. Returns the parsed parameters and the number of parse errors. Returns (None, 1) if /etc/default/isc-dhcp-server is absent.
check_running_dhcp_server(grade, machine) → (bool, list[str]) — checks whether isc-dhcp-server is currently active. Returns (running, interfaces) where interfaces is the list of interface names dhcpd is bound to (e.g. ["eth0"]), or ["*"] if it listens on all interfaces. Interfaces are read from the live process command line, not from the config file.
TLS helpers (from /opt/sre/lib/tls.py)¶
from tls import eval_rsa_private_key, set_rsa_private_key, eval_self_signed_certificate, eval_certificate
Functions used in NetScheme (state setup):
set_rsa_private_key(net_scheme, machine_name, key_file, password, bits=4096, cipher='AES-256-CBC') — generates an encrypted RSA private key inside the container using openssl genrsa. File paths and the password are shell-quoted automatically.
Functions used in Grade (evaluation):
eval_rsa_private_key(grade, machine_name, key_file, password=None, bits=4096, cipher='AES-256-CBC', step=1) → bool — verifies that key_file is an RSA private key with the expected size and PEM cipher. Returns True if all checks pass.
eval_self_signed_certificate(grade, machine_name, key_file, cert_file, password, cn=None, bits=4096, cipher='AES-256-CBC', step=1) → dict — checks that cert_file is a valid self-signed certificate whose public key matches key_file. Returns a dict with keys subject, issuer, common_name, not_before, not_after, serial, fingerprint.
eval_certificate(grade, machine_name, key_file, cert_file, ca_file, step=1) → dict — checks that cert_file is a valid certificate signed by ca_file and whose public key matches key_file. Returns the same dict as eval_self_signed_certificate.
TCP port helpers (from /opt/sre/lib/grade_helpers.py)¶
from grade_helpers import eval_tcp_server
eval_tcp_server(grade, machine_name, port, step=1) → bool — checks whether a process is listening on port (TCP) inside the container. Returns True if the port is bound.
OSPF helpers (from /opt/sre/lib/frr.py)¶
from frr import get_ospf_interfaces
get_ospf_interfaces(grade, machine_name, step=1) → dict — runs vtysh -c "show ip ospf interface" inside the container and parses the output. Returns a dict keyed by interface name, each value containing: area, cost, state (DR/BDR/DROther/…), dr, bdr, neighbor_count, adj_neighbor_count.
Standard machine wiring (from /opt/sre/lib/std.py)¶
machine_config(net_scheme, machine_name, config) — concise helper for wiring a machine inside NetScheme.build() (or initial()). config is a (interfaces, sysctls) tuple:
interfaces: list of(network_name, address, routes)entries, whereroutesis a list of(dest_network, gateway)pairs.sysctls: dict of kernel parameter names to values.
Compared to calling set_net_config and set_sysctl directly, machine_config resolves network names through the NetScheme topology automatically.
Miscellaneous generators (from /opt/sre/lib/utils.py)¶
from utils import random_password, random_sentence
Function |
Description |
|---|---|
|
Returns a random alphanumeric password of the given length |
|
Returns a random sequence of lowercase words, space-separated, approximately |
Network inspection helpers (from /opt/sre/lib/net_config.py)¶
These functions are used inside Grade.grade() to read live container state.
from net_config import (get_ip_addresses, get_routes, get_sysctl_conf,
get_net_config_entry, get_persistent_net_config_entry,
eval_net_config, get_ip_forward, get_sys_parameter_bool,
get_sys_parameter)
Function |
Returns |
Description |
|---|---|---|
|
|
Run |
|
|
Run |
|
|
Read |
|
|
Read |
|
|
Read an arbitrary |
|
|
Same as |
|
|
Run |
|
|
Parse |
eval_net_config(grade, expected, machine_name=None, current=None, step=1) — compare a live or provided NetConfigEntry against an expected one. Returns an attribute-accessible dict with the following keys:
Key |
Description |
|---|---|
|
Number of IP addresses that match |
|
Number of IP addresses in expected |
|
|
|
|
|
Number of matching non-default static routes |
|
Number of non-default routes in expected |
|
Non-default routes present in current but not in expected |
|
Number of positions where both expected and current are |
|
Count of |
|
Count of |
If current is None, get_net_config_entry(grade, machine_name, step) is called automatically.
Ping helper (from /opt/sre/lib/ping.py)¶
from ping import eval_ping
eval_ping(grade, src, dest, step=1, net_config=None) → bool — run ping -c 1 -w 1 dest from src machine; return True if "bytes from" appears in the output.
src and dest can each be:
An
IPv4Addressor valid IPv4 string — used directly;srcis resolved by reverse-lookup innet_config"machine_name"— machine name looked up innet_config;dest→ first interface IP"machine_name:N"or"machine_name:ethN"— resolves to interface index N of that machine
If net_config is not provided, grade.net_scheme.net_config is used. Raises ValueError on resolution failure.
SSH helpers (from /opt/sre/lib/ssh.py)¶
Used in @sre_state methods (state setup):
from ssh import (add_ssh_monitor_agent, create_ssh_key_on_host,
remove_ssh_password_authentication_on_sshd,
set_forward_ssh_agent_in_ssh_config, copy_ssh_pub_key_on_machine)
Function |
Description |
|---|---|
|
Deploy and start an SSH monitor daemon on |
|
Generate an SSH key pair on the host ( |
|
Set |
|
Write |
|
Copy a public key file (host path) into |
Used in Grade.grade() (evaluation):
from ssh import (check_ssh_key, eval_ssh_public_key_in_authorized_keys,
eval_ssh_agent_exists, eval_ssh_agent_with_loaded_key,
eval_ssh_possible_with_password_authentification,
eval_ssh_connection_with_password, eval_ssh_connection_with_key,
eval_ssh_connection_with_ssh_agent, eval_synchronized_file)
Function |
Returns |
Description |
|---|---|---|
|
|
Verify that a key pair on |
|
|
Check that a public key appears in |
|
|
Return |
|
|
Check that a specific private key (on the host) is loaded in a running agent belonging to |
|
|
Probe |
|
|
Read |
|
|
Read |
|
|
Read |
|
|
Return |
Packet capture helpers (from /opt/sre/lib/pcap_gen.py)¶
Used in @sre_state methods (state setup):
from pcap_gen import generate_pcap_tcp_example, setup_tcp_client_server
generate_pcap_tcp_example(net_scheme, src_machine, dst_machine, dst_ip, dst_interface, output_file, dst_port_min=2000, dst_port_max=2999, payload_size=10, step=1) → dict
Generate a synthetic TCP traffic capture for pcap analysis exercises. Uses 3 consecutive steps starting at step. The pcap file is written to output_file inside dst_machine (must be under /shared/ for host-side analysis). Returns a dict with the captured port numbers and one TCP frame chosen from each direction:
Key |
Description |
|---|---|
|
TCP port numbers used |
|
1-based frame number (src → dst direction) |
|
TCP window field of that frame |
|
Absolute sequence number |
|
Absolute acknowledgment number |
|
Same fields for the dst → src direction |
The dict is populated at step+2 via a host callback after the capture finishes.
setup_tcp_client_server(net_scheme, src_machine, dst_machine, src_ip, dst_ip, secret, dst_port_min=3000, dst_port_max=3999, interval=3, step=1) → dict
Deploy a persistent background TCP client/server pair for generating live traffic throughout the lab. Uses 2 steps. The server runs on dst_machine, the client on src_machine; both survive for the lifetime of the lab. Returns {'server_port': int, 'client_port': int}.
Used in Grade.grade() (evaluation):
from pcap_gen import check_zero_window_probe, get_frame_info
check_zero_window_probe(grade, file, max_length, packet_number) → bool
Return True if the packet at 1-based packet_number in the pcap file (relative to the project shared directory) is a valid TCP Zero Window Probe. Checks: file within max_length KiB; target packet has ≤ 1 byte payload; a prior packet in the reverse direction advertised window=0; target SEQ equals SND.UNA or SND.UNA-1.
get_frame_info(grade, filename, max_length, frame_number) → dict | None
Return all parsed fields for a single frame from a pcap file (relative to the project shared directory). Returns None if the file is missing, too large, not a valid pcap, or frame_number does not exist. The returned dict always contains frame_number, frame_link_type, captured_length, original_length, timestamp_sec, timestamp_usec; plus protocol-specific fields for Ethernet, Linux cooked capture, ARP, IPv4, IPv6, ICMP, TCP, and UDP headers.
Module-level Attributes¶
Declare these at the top level of srelab.py to customize behavior. They are read with getattr(module, name, default), so any attribute may simply be omitted to keep the default.
Lab identity and presentation¶
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
filename without |
Lab title shown in the GUI tab header and in exported reports. Pass a |
|
|
|
Language code used to resolve |
Student self-evaluation¶
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
If |
|
|
|
Cooldown in seconds between two student self-evaluations (only enforced for |
|
|
|
During a student-triggered evaluation, hide numeric grades and show only the |
|
|
|
During a student-triggered evaluation, drop grade-list entries whose |
Automatic evaluation¶
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
Auto-eval interval in seconds when no exam is configured. |
|
|
|
If |
|
|
|
If |
|
|
|
If |
|
|
|
Upper bound used when normalizing the lab’s total grade |
Archives and recordings¶
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
Extra directories to write evaluation archives ( |
|
|
|
Container paths whose contents are copied into each evaluation archive (under the |
|
|
|
Override whether terminal sessions opened from the GUI are recorded (asciinema). |
|
|
|
Extra directories to write session-record archives ( |
|
|
|
Minimum delay in seconds between two automatic session-record saves for the same project during an exam. Set to |
State machine and flavors¶
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
If |
|
|
|
If |
Filesystem and export¶
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
Create |
|
|
|
If |
Schema rendering¶
These attributes tweak the graphviz topology diagram drawn for the GUI’s Schema tab and embedded in the PDF produced by sre export.
Attribute |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
If |
|
|
|
Label shown on the host-network vertex |
|
|
|
Fill color of the host-network vertex (graphviz color name or |
|
|
|
If |
|
|
|
Relative length (graphviz |
|
|
|
Graphviz |
|
|
|
Graphviz |
Complete Minimal Example¶
# /opt/sre/lab/example/srelab.py
from dataclasses import dataclass
from ipaddress import IPv4Interface, IPv4Network
from typing import Dict
from SRE.lib_sre import Data0, NetScheme0, Grade0, sre_state, make_tr
from ips import random_ipv4networks, random_ipv4s
from net_config import set_net_config_entry, NetConfigEntry, get_net_config_from_topology, set_ip_forward
from state_helpers import set_nat_gateway
tr = make_tr('en')
title = tr("Example: Router", fr="Exemple : Routeur")
allow_self_grade = True
delay_between_self_grade = 60
eval_interval_without_exam_mode = 120
@dataclass(slots=True)
class Data(Data0):
@classmethod
def generate(cls, flavor=None):
d = cls()
d.nets.lan1, d.nets.lan2 = random_ipv4networks([24, 24], from_private_network=True)
ips1 = random_ipv4s(d.nets.lan1, 2)
ips2 = random_ipv4s(d.nets.lan2, 2)
d.ips.router_lan1 = IPv4Interface(f'{ips1[0]}/{d.nets.lan1.prefixlen}')
d.ips.client1 = IPv4Interface(f'{ips1[1]}/{d.nets.lan1.prefixlen}')
d.ips.router_lan2 = IPv4Interface(f'{ips2[0]}/{d.nets.lan2.prefixlen}')
d.ips.client2 = IPv4Interface(f'{ips2[1]}/{d.nets.lan2.prefixlen}')
return d
class NetScheme(NetScheme0):
_machine_specs = {
'router': {'color': 'green'},
'client1': {},
'client2': {},
}
_network_specs = {
'lan1': {'color': 'yellow'},
'lan2': {'color': 'cyan'},
}
_topology = {
'lan1': ['router', 'client1'],
'lan2': ['router', 'client2'],
}
def __init__(self, data, running_lab_name):
super().__init__(data=data, running_lab_name=running_lab_name)
self.informations = tr(
"## Router Lab\nConfigure routing between lan1 and lan2.",
fr="## TP Routage\nConfigurez le routage entre lan1 et lan2.",
)
d = data
self.net_config = get_net_config_from_topology(net_scheme=self, gateway="router")
# self.net_config: Dict[str, NetConfigEntry] = {
# 'router': [([d.ips.router_lan1], [(d.nets.lan2, d.ips.router_lan2)]),
# ([d.ips.router_lan2], [])],
# 'client1': [([d.ips.client1], [(IPv4Network('0.0.0.0/0'), d.ips.router_lan1)])],
# 'client2': [([d.ips.client2], [(IPv4Network('0.0.0.0/0'), d.ips.router_lan2)])],
# }
@sre_state(user_allowed=False)
def initial(self):
for machine, config in self.net_config.items():
set_net_config_entry(net_scheme=self, machine_name=machine, nc_entry=config)
self.cmd('router', 'sysctl -w net.ipv4.ip_forward=1')
for m in self.get_visible_machine_names():
set_ip_forward(net_scheme=self, machine_name=m, ip_forward=(m == "router"))
set_nat_gateway(net_scheme=self, machine="router")
class Grade(Grade0):
def grade(self):
super().grade()
d = self.data
# Test cross-network reachability
result, code = self.test('client1', f'ping -c1 -W2 {d.ips.router_lan2.ip}', timeout=5)
self.add_grade_element('Cross-network ping', max_grade=10)
if code == 0:
self.set_grade('Cross-network ping', 10)
# Ask a question
answer = self.question_text(
title=tr('Routing protocol', fr='Protocole de routage'),
description=tr('Which mechanism routes packets between the two LANs?',
fr='Quel mécanisme route les paquets entre les deux LANs ?'),
)
self.add_grade_element('Routing answer', max_grade=5)
if 'ip_forward' in answer or 'forward' in answer.lower():
self.set_grade('Routing answer', 5)