Link¶
The otto.link package models connectivity between lab hosts as one
Link type regardless of where it came from: implicit SSH/telnet hop
edges and declared lab.json routes. otto.link.derive resolves those
edges at lab-load time. A link is a topology edge — the route that
exists; the live, host-resident tunnels built over it are
otto.tunnel’s concern (see the tunnel guide
and API reference).
Impairment builds on that same edge model: otto.link.params is the typed
parameter set and its unit/merge rules, otto.link.placement resolves
where one direction’s netem lands (endpoint or in-path middlebox),
otto.link.impairer is the pluggable LinkImpairer registry
(otto.link.netem the first-party NetEm registrant), otto.link.manage
is the merge-read-modify-verify orchestration behind otto link
impair/repair/list, and otto.link.sentinel tags the detached
--expire timer processes. See the link guide for
CLI usage, the in-path model, and the Python API.
The link subsystem: the unified Link edge model and its derivations.
Model, static derivation, and id computation for the static link layer
(implicit hop edges, declared routes). Live tunnel creation/discovery lives
in otto.tunnel.
- otto.link.canonical_key(params: ImpairmentParams) tuple[int | None, ...]¶
Spelling-independent identity for params — compare MEANING, not text.
tc canonicalizes on display, so the exact string otto applies is NOT the string it reads back:
1.5mbit->``1500Kbit``,10mbps->``80Mbit`` (bytes to bits, times 8),>=1sin%gseconds. A post-apply verify (and any equality that must survive a kernel round-trip) therefore has to canonicalize both sides first, or it false-fails on documented inputs. Times collapse to integer microseconds, percents to integer milli-percent, rate to integer bits-per-second via the_RATE_UNITS_BPSmultiplier map;roundscrubs float dust (700 * 0.001 == 0.7000000000000001) — final-review Critical 2026-07-10.Whole-millisecond delay/jitter values are tick-aligned (netem quantizes to 64ns psched ticks, and 1ms = 1,000,000ns divides evenly by 64) and round-trip through this key exactly. Sub-millisecond values do NOT:
0.7msis the QUANTIZATION example, not a round-trip-stable one — on real hardware it reads back as699us, a genuine ~1us kernel-tick delta rather than a spelling reformat (observed live, 2026-07-10). This key still records that delta exactly (it must — it is the ONE place meaning is captured);equivalent()is where the tick-quantization tolerance for the delay/jitter slots lives.
- otto.link.equivalent(a: ImpairmentParams, b: ImpairmentParams) bool¶
Return
Truewhen a and b mean the same impairment (seecanonical_key()).The delay/jitter (TIME) slots of the canonical key compare with a small tolerance instead of exact equality; every other slot (percent, rate) still compares exactly. WHY: netem quantizes delay/jitter to 64ns kernel (psched) ticks and tc’s display is microsecond-truncated, so a value like
700uscan read back as699uson real hardware — a genuine kernel-tick artifact, observed live 2026-07-10, not a spelling reformat (canonical_key()already handles those). The tolerance ismax(2us, 0.5% of the larger value): the 2us floor is 2x the observed ~1us quantization delta, and the 0.5% relative band covers tc’s%gdisplay rounding at second scale (see the second-scale cases inTestTickQuantizationTolerance).This cannot mask a real mismatch: an impairment that genuinely failed to apply (or applied as a different value) reads back as
None(field missing), the PRIOR value (merge never took), or a different field entirely — never a same-field sub-percent delta on the value that WAS actually applied.Noneis only equivalent toNone.
Runtime Link model — the unified edge object across all provenances.
One type regardless of where the link came from, so the CLI, topology derivation, and monitor GUI all speak the same object (foundation spec §6).
- class otto.link.model.Provenance(value)¶
Bases:
EnumWhere a link came from.
-
IMPLICIT =
'implicit'¶ Derived from a host’s
hopchain (the ssh/telnet management path).
-
DECLARED =
'declared'¶ Declared in
lab.json’slinkssection (a data-plane route).
-
DYNAMIC =
'dynamic'¶ Realized by otto tunnels; reserved for topology overlays.
-
IMPLICIT =
-
class otto.link.model.LinkEndpoint(host: str, interface: str | None =
None, ip: str ='', port: int | None =None)¶ Bases:
objectOne end of a link: a host, optionally pinned to a named interface.
- interface : str | None¶
Netdev name (a key in the host’s
interfacesmap);None= the managementip/ the host’s sole interface.
- otto.link.model.make_link_id(a: LinkEndpoint, b: LinkEndpoint, protocol: str) str¶
Deterministic id for the route
a <-> bover protocol.STABILITY CONTRACT — changing this algorithm invalidates every recorded static route id across otto versions:
endpoints are sorted by
(host, interface or "")so a<->b == b<->a;a
Noneinterface falls back to''in the canonical string;protocol is lowercased, so a route declared
"udp"and one declared"UDP"reconcile to the same id;ports and ips are excluded — the id names the route, not any one observation of it;
format:
"lnk-"+ first 12 hex chars of sha256 over"{lo.host}|{lo.interface or ''}|{hi.host}|{hi.interface or ''}|{protocol.lower()}".
- otto.link.model.make_static_link_id(a: LinkEndpoint, b: LinkEndpoint, name: str | None) str¶
Readable handle for a static link — the declared
nameora--b.No hash: static links (implicit hop edges, declared routes) are described connectivity, not otto tunnels, so they never wear the
lnk-<hex>form. Endpoints are sorted soa<->bandb<->ayield the same handle.
-
class otto.link.model.Link(a: LinkEndpoint, b: LinkEndpoint, protocol: str =
'tcp', provenance: Provenance =Provenance.DECLARED, id: str ='', name: str | None =None, impair: str | None =None)¶ Bases:
objectAn edge between two endpoints, from any provenance.
- a : LinkEndpoint¶
- b : LinkEndpoint¶
- provenance : Provenance¶
Pure derivations of the static link layer (implicit hop edges + declared links).
No I/O and no live host access: callers hand in host dicts / host objects,
these functions hand back Link objects. That keeps
every rule here unit-testable without a lab.
- class otto.link.derive.HostAddressing(ip: str, interfaces: dict[str, str] = <factory>)¶
Bases:
objectThe minimal addressing view of a host a link endpoint needs.
- otto.link.derive.addressing_from_dict(host_data: dict[str, Any]) tuple[str, HostAddressing]¶
(host_id, HostAddressing)from a raw lab.json host dict.Applies the interface string-shorthand (a bare string value is the ip), mirroring
InterfaceSpec’s coercion — this reads raw dicts so cross-lab (dangling) endpoints resolve without constructing hosts.
- otto.link.derive.resolve_declared_links(link_data: list[dict[str, Any]], hosts: Mapping[str, HostAddressing], *, source: str, loaded_ids: Collection[str]) list[Link]¶
Validate + resolve raw
linksentries into DECLAREDLinkobjects.source names the origin (a file path or “lab.json”) for error messages. loaded_ids is the id set of the requested lab’s hosts;
link_dataspans ALL lab files (so cross-lab dangling endpoints resolve), so an entry whose both endpoints lie outside loaded_ids belongs to an unrelated lab and is skipped — its errors (unknown host, bad interface, or even a structurally malformed record) must not break this lab’s load, symmetric with the cross-lab host-record containment inJsonFileLabRepository. An entry touching the lab (≥1 endpoint in loaded_ids) is resolved and fails loud, naming the entry’s original file index and source.
- otto.link.derive.implicit_links(hosts: Mapping[str, Any]) list[Link]¶
IMPLICIT edges from
hopchains, rooted at the built-inlocalhost.Duck-typed on purpose (reads
id/ip/hop/term): callers passlab.hosts, tests pass stand-ins. A host with ahopedges to its hop host; a hop-less host edges tolocal(the “you are here” root — the monitor’s reachability cascade needs the full chain back to local). Protocol = the child’s management term (ssh/telnet).
Typed impairment parameters: unit parsing, merge, and coupling rules.
Spec §3.1/§3.3 (docs/superpowers/specs/2026-07-10-link-impairment-design.md): bare time = milliseconds, bare percent = percent, rate REQUIRES an explicit tc unit; re-impair merges per-param last-one-wins and an explicit zero clears just that param.
- otto.link.params.parse_time_ms(text: str, *, option: str) float¶
Parse a time value in milliseconds; a bare number means ms (spec §3.1).
- otto.link.params.parse_percent(text: str, *, option: str) float¶
Parse a percentage; a bare number means percent (spec §3.1).
- otto.link.params.parse_rate(text: str) str¶
Parse a rate; an explicit tc unit is REQUIRED. Bare
"0"clears (§3.3).
-
class otto.link.params.ImpairmentParams(delay_ms: float | None =
None, jitter_ms: float | None =None, loss_pct: float | None =None, corrupt_pct: float | None =None, duplicate_pct: float | None =None, reorder_pct: float | None =None, rate: str | None =None)¶ Bases:
objectOne impairment parameter set.
None= not set/absent.- merged_over(base: ImpairmentParams) ImpairmentParams¶
Per-param last-one-wins over base; explicit zeros clear (spec §3.3).
-
class otto.link.params.Selector(port: int, proto: str | None =
None)¶ Bases:
objectOne port-scoped impairment selector: a service port, EITHER side.
Matches traffic whose SOURCE OR DESTINATION port is
port— otto never needs to know which endpoint is the server.protonarrows to one L4 protocol;None= both tcp and udp.Selector(5201)andSelector(5201, "tcp")are DISTINCT keys; the former’s filters simply match a superset of the latter’s traffic (spec 2026-07-11 §1).
- otto.link.params.canonical_key(params: ImpairmentParams) tuple[int | None, ...]¶
Spelling-independent identity for params — compare MEANING, not text.
tc canonicalizes on display, so the exact string otto applies is NOT the string it reads back:
1.5mbit->``1500Kbit``,10mbps->``80Mbit`` (bytes to bits, times 8),>=1sin%gseconds. A post-apply verify (and any equality that must survive a kernel round-trip) therefore has to canonicalize both sides first, or it false-fails on documented inputs. Times collapse to integer microseconds, percents to integer milli-percent, rate to integer bits-per-second via the_RATE_UNITS_BPSmultiplier map;roundscrubs float dust (700 * 0.001 == 0.7000000000000001) — final-review Critical 2026-07-10.Whole-millisecond delay/jitter values are tick-aligned (netem quantizes to 64ns psched ticks, and 1ms = 1,000,000ns divides evenly by 64) and round-trip through this key exactly. Sub-millisecond values do NOT:
0.7msis the QUANTIZATION example, not a round-trip-stable one — on real hardware it reads back as699us, a genuine ~1us kernel-tick delta rather than a spelling reformat (observed live, 2026-07-10). This key still records that delta exactly (it must — it is the ONE place meaning is captured);equivalent()is where the tick-quantization tolerance for the delay/jitter slots lives.
- otto.link.params.equivalent(a: ImpairmentParams, b: ImpairmentParams) bool¶
Return
Truewhen a and b mean the same impairment (seecanonical_key()).The delay/jitter (TIME) slots of the canonical key compare with a small tolerance instead of exact equality; every other slot (percent, rate) still compares exactly. WHY: netem quantizes delay/jitter to 64ns kernel (psched) ticks and tc’s display is microsecond-truncated, so a value like
700uscan read back as699uson real hardware — a genuine kernel-tick artifact, observed live 2026-07-10, not a spelling reformat (canonical_key()already handles those). The tolerance ismax(2us, 0.5% of the larger value): the 2us floor is 2x the observed ~1us quantization delta, and the 0.5% relative band covers tc’s%gdisplay rounding at second scale (see the second-scale cases inTestTickQuantizationTolerance).This cannot mask a real mismatch: an impairment that genuinely failed to apply (or applied as a different value) reads back as
None(field missing), the PRIOR value (merge never took), or a different field entirely — never a same-field sub-percent delta on the value that WAS actually applied.Noneis only equivalent toNone.
Pluggable link impairers: the LinkImpairer contract + IMPAIRERS registry.
Mirrors the transfer-backend registry (otto.host.transfer.registry): custom
impairers register from init modules under a name; a host’s impairer pin /
valid_impairers menu select one per placement host (spec §5). NetEm is the
only first-party registrant (otto.link.netem).
-
otto.link.impairer.FIRST_SELECTOR_BAND =
4¶ prio bands 1-3 keep kernel-default priomap semantics; selectors start at band 4.
-
otto.link.impairer.MAX_SELECTORS =
8¶ Per-netdev selector cap (bands 4..11 inside the fixed 11-band prio root).
- class otto.link.impairer.ScopedState(kind: ~typing.Literal['clean', 'whole', 'scoped', 'foreign'], whole: ~otto.link.params.ImpairmentParams | None = None, selectors: dict[~otto.link.params.Selector, tuple[int, ~otto.link.params.ImpairmentParams]] = <factory>)¶
Bases:
objectDiscriminated read-back of one placement netdev’s impairment shape.
Exactly one of four kinds (spec §1):
clean(no otto state — including kernel-default root qdiscs),whole(today’s root netem,wholeset),scoped(selectorsmaps eachSelectorto its(band, params)), orforeign(a root qdisc otto did not generate: reported bylist, loudly refused on mutate).- whole : ImpairmentParams | None¶
- classmethod clean() ScopedState¶
No otto impairment state on the netdev.
- classmethod whole_link(params: ImpairmentParams) ScopedState¶
Today’s whole-link root netem.
- classmethod from_selectors(selectors: dict[Selector, tuple[int, ImpairmentParams]]) ScopedState¶
Create a port-scoped tree: selector -> (band, params).
- classmethod foreign() ScopedState¶
Report a root qdisc otto did not generate — never mutated, only reported.
- class otto.link.impairer.LinkImpairer¶
Bases:
objectBuilds the shell commands that apply/read/clear one placement’s impairment.
Stateless: implementations build command strings and parse output; the orchestration layer (
otto.link.manage) runs them on hosts.-
host_families : ClassVar[frozenset[str]] =
frozenset({})¶ Host families this impairer serves (e.g.
frozenset({"unix"})).
-
supports_selectors : ClassVar[bool] =
False¶ Whether this impairer implements the optional port-scoped surface (the
scoped_*builders +parse_scoped()). Defaults off so third-party impairers are unaffected; a--portrequest routed to a non-supporting impairer is a loud capability error in orchestration.
- apply_command(netdev: str, params: ImpairmentParams) str¶
Shell command applying params to netdev (idempotent replace).
- read_command(netdev: str) str¶
Shell command whose output
parse_read()understands.
- parse_read(output: str) ImpairmentParams | None¶
Parse
read_command()output;None= no impairment present.
- scoped_root_command(netdev: str) str¶
Command creating the scoped classful root on netdev (idempotent).
- scoped_band_command(netdev: str, band: int, params: ImpairmentParams) str¶
Command applying params as band band’s per-selector leaf (idempotent).
- scoped_filter_commands(netdev: str, band: int, selector: Selector) list[str]¶
Commands steering selector’s traffic into band band.
- scoped_clear_selector_commands(netdev: str, band: int, selector: Selector) list[str]¶
Commands removing selector’s filters and its band leaf (root kept).
- scoped_read_commands(netdev: str) list[str]¶
Return the two read commands whose outputs
parse_scoped()understands.
- parse_scoped(qdisc_output: str, filter_output: str) ScopedState¶
Parse
scoped_read_commands()outputs into aScopedState.
-
host_families : ClassVar[frozenset[str]] =
-
otto.link.impairer.register_impairer(name: str, cls: type[LinkImpairer], *, overwrite: bool =
False) None¶ Make a custom impairer available to lab data under name.
Call from an init module listed in
.otto/settings.toml. The impairer must declare a non-emptyLinkImpairer.host_families; otherwise it could never validate against any host and is rejected here.
- otto.link.impairer.build_impairer(name: str) type[LinkImpairer]¶
Return the impairer class registered under name (rich unknown-name error).
NetEm — the first-party LinkImpairer (tc qdisc netem on unix hosts).
argv builders ALWAYS emit explicit units (spec §3.1) — tc’s bare-number
semantics vary by parameter and iproute2 version. The parser reads
tc qdisc show dev X back into ImpairmentParams
(kernel qdisc config is the only state — spec §6) and tolerates both modern
and old iproute2 formatting (50ms vs 50.0ms).
- otto.link.netem.netem_args(params: ImpairmentParams) str¶
Render params as netem qdisc arguments with explicit units.
- otto.link.netem.parse_qdisc_show(output: str) ImpairmentParams | None¶
Parse
tc qdisc show dev Xoutput;None= no root netem qdisc.
- otto.link.netem.parse_scoped_outputs(qdisc_output: str, filter_output: str) ScopedState¶
Parse the two
NetEmImpairer.scoped_read_commands()outputs.Only trees otto generated parse as
scoped; kernel-default roots (handle0:/noqueue) areclean; a root netem iswhole(the byte-identical v1 read-back); everything else isforeign. An otherwise-ours root with zero leaves and zero filters isclean— a timer race that empties the tree must not wedge exclusivity.
- class otto.link.netem.NetEmImpairer¶
Bases:
LinkImpairertc/netem on a unix host’s interface.
-
host_families : ClassVar[frozenset[str]] =
frozenset({'unix'})¶ Host families this impairer serves (e.g.
frozenset({"unix"})).
-
supports_selectors : ClassVar[bool] =
True¶ Whether this impairer implements the optional port-scoped surface (the
scoped_*builders +parse_scoped()). Defaults off so third-party impairers are unaffected; a--portrequest routed to a non-supporting impairer is a loud capability error in orchestration.
- apply_command(netdev: str, params: ImpairmentParams) str¶
Idempotent
tc qdisc replacecommand applying params to netdev.
- read_command(netdev: str) str¶
tc qdisc showcommand; output is understood byparse_qdisc_show().
- parse_read(output: str) ImpairmentParams | None¶
Parse
read_command()output viaparse_qdisc_show().
- scoped_root_command(netdev: str) str¶
Idempotent 11-band prio root; bands 1-3 keep kernel-default semantics.
- scoped_band_command(netdev: str, band: int, params: ImpairmentParams) str¶
Idempotent netem leaf for band. classid/handle minors are HEX.
- scoped_filter_commands(netdev: str, band: int, selector: Selector) list[str]¶
u32 filters steering selector into band band, fixed pref-slot order.
- scoped_clear_selector_commands(netdev: str, band: int, selector: Selector) list[str]¶
Delete selector’s filters (by pref) then its band’s netem leaf.
- scoped_read_commands(netdev: str) list[str]¶
Qdisc + filter reads for
parse_scoped().The filter read is guarded (
2>/dev/null || true) belt-and-braces: captured live on the veggies bed, iproute2 6.1.0, 2026-07-11,tc filter show ... parent 1:on a netdev with no1:parent (every clean or whole-link netdev) does NOT error — it exits 0 with empty stdout. The guard is kept anyway for older/other iproute2 builds where this call is documented to fail; either way the read path must treat empty/absent output as ‘no filters’, not a host error.
- parse_scoped(qdisc_output: str, filter_output: str) ScopedState¶
Parse
scoped_read_commands()outputs viaparse_scoped_outputs().
-
host_families : ClassVar[frozenset[str]] =
Impairment placements: where one direction’s qdisc lands (spec §4/§9).
A placement is (host, netdev, direction). Two resolvers map a link to
placements — endpoint mode and in-path (middlebox) mode — plus the two
mandatory refusals: never a management interface, never a link with the local
host as an endpoint.
- class otto.link.placement.FlowDirection(value)¶
Bases:
EnumOne direction of a link’s traffic, in endpoint order.
-
A_TO_B =
'a->b'¶
-
B_TO_A =
'b->a'¶
-
A_TO_B =
- class otto.link.placement.Placement(host_id: str, netdev: str, direction: FlowDirection)¶
Bases:
objectWhere one direction’s impairment lands: a netdev on a host.
- direction : FlowDirection¶
- otto.link.placement.parse_ip_addr(output: str) dict[str, list[IPv4Interface]]¶
Parse
ip -o addr showoutput into netdev -> addressed interfaces.
- otto.link.placement.ensure_not_local_link(link: Link) None¶
Refuse any link with the local host as an endpoint OR its middlebox (spec §9).
The local host’s connectivity to the bed IS otto’s management path, in EVERY mode — as an endpoint, or (
link.impair) as the in-path middlebox that would service the impairment on otto’s own machine.
- otto.link.placement.ensure_not_mgmt(placement: Placement, addr_table: dict[str, list[IPv4Interface]], mgmt_ip: str) None¶
Refuse a placement on the netdev carrying the host’s management ip (§9).
Only a POSITIVE match refuses: a mgmt ip invisible in the table (e.g. a NAT-fronted host) cannot be on the placement netdev.
- otto.link.placement.ensure_not_hop_transit(placement: Placement, addr_table: dict[str, list[IPv4Interface]], dependent_mgmt_ips: Collection[tuple[str, str]]) None¶
Refuse a placement whose netdev carries another host’s hop/management transit (§9).
Where
ensure_not_mgmt()protects the placement host’s OWN management interface, this protects a MIDDLEMAN’s forwarding interface. If some lab host reaches otto only by hopping THROUGH the placement host, degrading the netdev that carries that host’s management subnet severs otto → that host — a self-lockout one indirection out. dependent_mgmt_ips are the(host_id, mgmt_ip)pairs whose hop chain includes this placement’s host; a positive subnet match on the placement netdev refuses.
- otto.link.placement.endpoint_placements(link: Link, directions: Collection[FlowDirection]) list[Placement]¶
Endpoint mode: each direction lands on its ORIGIN endpoint’s interface.
- otto.link.placement.inpath_placements(link: Link, impair_host_id: str, addr_table: dict[str, list[IPv4Interface]], directions: Collection[FlowDirection]) list[Placement]¶
In-path mode: each direction lands on the middlebox netdev FACING it.
The facing netdev is the one toward the direction’s target endpoint, auto-resolved by subnet match (spec §4).
Impair/repair/list orchestration — kernel qdisc state is the ONLY state.
Reads go through host.exec (no privilege needed); mutations through
host.run(cmd, sudo=host.current_user != "root"). Every host call is
wrapped in asyncio.wait_for(..., _IMPAIR_HOST_TIMEOUT) and a down host is
a loud, host-named RuntimeError — never a skip (spec §9, dev-VM rule).
These four functions — impair_link(), repair_link(),
repair_all(), read_link_states() — plus find_link() ARE the
public API (spec’s single-API constraint): the CLI, the future GUI topology
overlay, and any direct importer call exactly these. Nothing here prints or
knows about exit codes/colors.
-
class otto.link.manage.AppliedPlacement(placement: Placement, params: ImpairmentParams, selector: Selector | None =
None)¶ Bases:
objectOne placement’s post-verify impairment state.
- params : ImpairmentParams¶
The merged params actually verified present after the mutation.
- class otto.link.manage.ImpairReport(link_id: str, applied: list[~otto.link.manage.AppliedPlacement] = <factory>)¶
Bases:
objectOutcome of
impair_link(): every placement actually mutated.- applied : list[AppliedPlacement]¶
- class otto.link.manage.RepairReport(link_id: str, cleared: list[~otto.link.placement.Placement] = <factory>, timers_cancelled: int = 0)¶
Bases:
objectOutcome of
repair_link(): what got cleared, and how many timers died.
- class otto.link.manage.DirectionState(whole: ~otto.link.params.ImpairmentParams | None = None, scoped: dict[~otto.link.params.Selector, ~otto.link.params.ImpairmentParams] = <factory>, foreign: bool = False)¶
Bases:
objectOne direction’s full impairment shape (the
list/GUI read feed).At most one of
whole/scoped/foreignis populated (whole-link and port-scoped are exclusive per netdev in v1; a foreign tree is opaque). All three empty = clean.- whole : ImpairmentParams | None¶
- scoped : dict[Selector, ImpairmentParams]¶
- class otto.link.manage.LinkState(link: ~otto.link.model.Link, by_direction: dict[~otto.link.placement.FlowDirection, DirectionState | None] = <factory>, impairable: bool = True, unreachable: bool = False)¶
Bases:
objectOne link’s current impairment state, direction by direction (for
list).- by_direction : dict[FlowDirection, DirectionState | None]¶
Per-direction shape;
None= that direction’s host couldn’t be read.
- otto.link.manage.find_link(lab: Any, ident: str) Link¶
Resolve ident (a link id or its
name) againstlab.static_links().Raises a
ValueErrorlisting every known id when ident matches neither, so a typo’d CLI argument gets a usable hint.
-
async otto.link.manage.impair_link(lab: Lab, ident: str, params: ImpairmentParams, *, from_host: str | None =
None, expire: int | None =None, selector: Selector | None =None) ImpairReport¶ Impair link ident with params (merge-read-modify-replace, verified).
params merges over each placement’s CURRENTLY-applied state (
merged_over()) — a bare re-impair layers onto what’s already there, an explicit zero clears just that one param. Every placement’s existing expire-timer is cancelled before the mutation runs; a fresh one is launched after a successful verify when expire is given.--from(from_host) narrows endpoint mode to the direction originating at that host; omitted, both directions are impaired. In-path links (link.impairset) ignore endpoint selection and always place on the middlebox’s facing interfaces.selector (
--port) routes the mutation through the port-scoped path instead: params merges over just THAT selector’s currently-applied state (not the whole netdev’s), landing in its own prio band (assigned on first use, kept across re-impairs, capped atMAX_SELECTORSper netdev) with its own pair of u32 filters. Whole-link and port-scoped impairment are exclusive per netdev (spec §1): a bare impair against scoped state, or a scoped impair against whole-link state, is a loudValueErrortelling the operator to repair first. A host whose impairer doesn’t declaresupports_selectorsis also a loud capability error — never a silent fallback to whole-link. Expire-timers follow the same split: a bare impair only ever cancels/launches v1 whole-link timers; a scoped impair only ever cancels/launches its OWN selector’s v2 timer, leaving every other selector’s timer (and any v1 timer, which scoped state can’t have anyway) untouched.No half-impairments: if any placement fails mid-way (mutation doesn’t verify, host unreachable, etc.), every placement touched in this call — INCLUDING the one whose own mutation just failed — is restored to its PRIOR state before the error propagates.
-
async otto.link.manage.repair_link(lab: Lab, ident: str, *, selector: Selector | None =
None) RepairReport¶ Clear link ident’s impairment state and cancel its timers.
Bare (
selector=None): clears EVERYTHING per placement that has any otto state — whole-link or the entire scoped tree, each a single root delete — and cancels every v1 and v2 timer. With selector: clears just that selector (deleting the root when it is the last one) and cancels only its own v2 timer; a selector that isn’t present clears nothing.Every clear is verified by a post-clear re-read: a clear that silently didn’t take is a loud, host-named failure, never reported as
cleared.
- async otto.link.manage.repair_all(lab: Lab) tuple[list[RepairReport], list[str]]¶
Repair every static link in lab; never raises.
A link that structurally can’t be impaired (
ValueError— no named interface, local-link, mgmt refusal, …) is silently skipped: it was never impaired in the first place. A link whose repair fails for a live reason (RuntimeError— host down, command failed) is collected into failures instead of aborting the rest.
- async otto.link.manage.read_link_states(lab: Lab) list[LinkState]¶
Read the current impairment state of every static link.
This is the
list/GUI-overlay feed.Reads only (
exec, no sudo); never raises per-link, so one bad host can’t hide the rest of the fleet’s state from a caller likeotto link listor a topology overlay.
otto-impair argv sentinel + expire-timer discovery (spec §7; v2 spec 2026-07-11 §1).
Wire formats, percent-encoded segments, framing via otto.host.daemon:
v1 (whole-link timers):
otto-impair:v1:<link-id>:<netdev>v2 (per-selector timers):
otto-impair:v2:<link-id>:<netdev>:<port>:<proto-or-empty>
v1 stays parseable forever so repair cancels timers launched by older otto.
The timer process’s argv IS the state — discoverable via ps, unambiguously
otto’s, owner-agnostic.
-
otto.link.sentinel.IMPAIR_PS_COMMAND : str =
"ps -eo pid= -eo etime= -eo args= 2>/dev/null | \\grep -a ' otto-impair:' || true"¶ The per-host expire-timer scan. Built by
otto.host.daemon.ps_scan_command()— see it for the procps portability story; bytes pinned byTestWireGolden.
- class otto.link.sentinel.ImpairTimer(pid: int, link_id: str, netdev: str, selector: Selector | None)¶
Bases:
objectOne live expire-timer seen in a ps scan.
- otto.link.sentinel.encode_impair_sentinel(link_id: str, netdev: str) str¶
v1 sentinel token tagging one placement’s WHOLE-LINK expire timer.
Whole-link timers deliberately stay on v1 — the whole-link path is byte-identical to pre-selector otto (spec 2026-07-11 hard constraint).
- otto.link.sentinel.encode_impair_sentinel_v2(link_id: str, netdev: str, selector: Selector) str¶
v2 sentinel token tagging one selector’s expire timer on one placement.
- otto.link.sentinel.parse_impair_sentinel(token: str) tuple[str, str, Selector | None] | None¶
Decode a v1 OR v2 token to
(link_id, netdev, selector);Noneif not ours.v1 tokens decode with
selector=None. Unknown versions and malformed v2 payloads (non-numeric/out-of-range port, unknown proto) parse toNone, never an error — the framing stability contract.
- otto.link.sentinel.parse_impair_ps(output: str) list[ImpairTimer]¶
Reconstruct live timers from
IMPAIR_PS_COMMANDoutput (v1 AND v2).
Pydantic boundary specs for a lab.json links entry.
Structural validation only: endpoint references (host ids, interface keys)
are resolved against the loaded host set at lab-load time
(otto.link.derive.resolve_declared_links()), where the hosts are known.
-
class otto.models.link.LinkEndpointSpec(*, host: str, interface: str | None =
None)¶ Bases:
OttoModelOne end of a declared link: a host id plus (optionally) a named interface.
interface(a key in the host’sinterfacesmap, i.e. a netdev name) is required only when the host defines more than one interface; with one or none, otto assumes the sole interface / the managementip.
-
class otto.models.link.LinkSpec(*, endpoints: Annotated[list[LinkEndpointSpec], MinLen(min_length=2), MaxLen(max_length=2)], protocol: str =
'tcp', name: str | None =None, impair: str | None =None, management: str | None =None)¶ Bases:
OttoModelBoundary spec for one
linksentry inlab.json.protocolis informational for declared links (what the route carries); it becomes functional for dynamic links (sub-project #2).managementis reserved for sub-project #5: accepted and carried, not yet consumed.- endpoints : list[LinkEndpointSpec]¶
- impair : str | None¶
In-path middlebox host id servicing this link’s impairment (sub-project #3, spec §10). Reference-validated at lab load (
otto.link.derive.resolve_declared_links()).managementremains reserved for #5.