Build-style mock tasks that reproduce the shape of a real network-automation tool — gNMI telemetry, Ansible variable precedence, a Nornir-style deploy pipeline — with the network mocked out so the logic stands alone.
These are not pure-algorithm puzzles. Each one asks you to assemble a small pipeline that looks like a real tool: ingest structured data, transform it, compare, and act. There is no network — the streams, inventories, and devices are mocked so the interviewer can watch how you structure the flow, name the stages, and keep each function pure and testable.
The common thread: parse into a normalized shape, then compute over that shape. Flatten nested notifications before you rate them; resolve variables before you render; build an intended config before you diff. Keep the transform steps small and side-effect-free, and push the one impure action (applying a patch) to the very edge.
Problem statement. A pygnmi-style client hands you nested notifications; all of the pain is in
parsing them and computing a rate from the counters. There is no network here: the mock MOCK_STREAM
returns data in exactly the form of pygnmi's client.subscribe()/get(). Only the standard
library is allowed; target time is 15 minutes.
A pygnmi-style notification looks like:
{"timestamp": <ns>, "prefix": "interfaces",
"update": [{"path": "interface[name=eth0]/state/counters/in-octets",
"val": 1000}, ...]}
flatten(notification) -> dict[str, int] — join prefix + path into
one full path and return {full_path: val}. For example
flatten(NOTIF0) yields
{"interfaces/interface[name=eth0]/state/counters/in-octets": 1000, ...}.bps(samples) -> dict[str, float] — samples is a list of
(timestamp_ns, flat_dict) ordered by time. For every path present in both the
first and the last sample, return the rate in bits per second:
(last - first) * 8 / dt_seconds. Skip a path that appears only once. Empty input returns
{}.Follow-up. 64-bit counters do not wrap in practice, but old 32-bit SNMP counters do — how do you tell a
wrap apart from a device restart? And why is a subscribe STREAM (on-change / sample) better than
polling with a periodic get?
flatten is the ingest stage: it turns one nested notification
into a flat {path: value} map. Concatenate the prefix with each update's path (guarding the
empty-prefix case) and read the val. Keep it pure — no rate math here.bps only needs the first and last samples: a rate is a
difference over an elapsed time, so the middle points do not matter for an average rate. Convert nanoseconds
to seconds once.dt, means there is
no rate to compute — return {}. Iterate the first sample's paths and only emit those also present
in the last, which naturally drops paths seen once.flatten over the stream to build the
samples list, then feed it to bps — two small stages, each independently testable."""Drill 13 (solution): flatten pygnmi notifications and compute bit-rates."""
from __future__ import annotations
MOCK_STREAM = [
{
"timestamp": 0,
"prefix": "interfaces",
"update": [
{"path": "interface[name=eth0]/state/counters/in-octets", "val": 1000},
{"path": "interface[name=eth0]/state/counters/out-octets", "val": 5000},
],
},
{
"timestamp": 1_000_000_000,
"prefix": "interfaces",
"update": [
{"path": "interface[name=eth0]/state/counters/in-octets", "val": 2000},
{"path": "interface[name=eth0]/state/counters/out-octets", "val": 5500},
],
},
]
def flatten(notification: dict) -> dict[str, int]:
prefix = notification.get("prefix", "")
out: dict[str, int] = {}
for upd in notification.get("update", []):
path = upd["path"]
full = f"{prefix}/{path}" if prefix else path
out[full] = upd["val"]
return out
def bps(samples: list[tuple[int, dict]]) -> dict[str, float]:
if len(samples) < 2:
return {}
ts_first, first = samples[0]
ts_last, last = samples[-1]
dt = (ts_last - ts_first) / 1e9
if dt <= 0:
return {}
out: dict[str, float] = {}
for path, start in first.items():
if path in last:
out[path] = (last[path] - start) * 8 / dt
return out
Can you assemble a two-stage telemetry pipeline — normalize a vendor's nested shape, then compute over the
normalized shape — rather than solve one clever algorithm? The signal is in keeping flatten pure,
handling the unit conversion (ns → s) and the degenerate inputs cleanly, and recognizing that an average rate
needs only the endpoints. The follow-up checks whether you understand counter semantics (wrap vs. restart) and
why streaming telemetry beats polling.
Problem statement. The classic source of Ansible bugs is variable precedence.
There is no Jinja and no network here: the inventory is a plain dict (the mock INVENTORY). Standard
library only; target time is 12 minutes.
The inventory shape:
{"defaults": {...},
"groups": {"<g>": {"vars": {...}}, ...},
"hosts": {"<h>": {"vars": {...}, "groups": ["g1","g2"]}, ...}}
resolve_vars(inventory, host) -> dict — merge variables in ascending priority:
defaults < group_vars (in the host's group order) < host_vars. A later group overrides an
earlier one; host_vars override everything. An unknown host raises KeyError.render(template, variables) -> str — replace placeholders of the form {{ name }}
(inner whitespace does not matter) with values from variables. A missing variable raises
KeyError(name).Follow-up. Where do extra-vars (-e) sit in this chain in real Ansible? (At the very top —
they override everything.) And why is group_vars/all dangerous?
resolve_vars is just dict.update applied in the
right order: start from a copy of defaults, apply each group's vars in the host's listed group
order, then apply host vars last. Because later updates win, this is the precedence ladder — no
special-casing needed.KeyError(host) if absent,
before doing any merging. Copy defaults so you never mutate the inventory.\{\{\s*(\w+)\s*\}\} captures the name
and tolerates surrounding whitespace. Use re.sub with a callback so a missing key raises
KeyError at the exact placeholder, and coerce values with str() so ints render."""Drill 14 (solution): ansible-like variable precedence and rendering."""
from __future__ import annotations
import re
_PLACEHOLDER = re.compile(r"\{\{\s*(\w+)\s*\}\}")
INVENTORY = {
"defaults": {"ntp": "10.0.0.1", "mtu": 1500, "domain": "corp.net"},
"groups": {
"emea": {"vars": {"ntp": "10.1.0.1"}},
"edge": {"vars": {"mtu": 9000}},
},
"hosts": {
"r1": {"vars": {"hostname": "r1"}, "groups": ["emea", "edge"]},
"r2": {"vars": {"hostname": "r2"}, "groups": ["emea"]},
},
}
def resolve_vars(inventory: dict, host: str) -> dict:
hosts = inventory["hosts"]
if host not in hosts:
raise KeyError(host)
result = dict(inventory.get("defaults", {}))
host_entry = hosts[host]
for group in host_entry.get("groups", []):
group_vars = inventory.get("groups", {}).get(group, {}).get("vars", {})
result.update(group_vars)
result.update(host_entry.get("vars", {}))
return result
def render(template: str, variables: dict) -> str:
def repl(match: re.Match) -> str:
name = match.group(1)
if name not in variables:
raise KeyError(name)
return str(variables[name])
return _PLACEHOLDER.sub(repl, template)
Do you understand precedence as an ordered fold rather than a pile of if-statements? The elegant
answer is that "last write wins" and applying updates in priority order gives you Ansible's whole precedence
model for free. They also watch for the details that bite in production: not mutating the shared inventory,
raising KeyError at the right place, whitespace-tolerant placeholders, and stringifying non-string
values. This is pipeline assembly (resolve → render), not a standalone algorithm.
Problem statement. A full network-as-code cycle with no network: source-of-truth →
generate the intended config from a template → compare against running → patch → deploy to a mock device.
MockDevice plays the role of the connection (napalm/netmiko). Standard library only; target time is
15 minutes.
MockDevice exposes:
.get_config() -> str # current running config (line by line)
.apply(patch: list[str]) # "no X" removes line X, otherwise adds it
Implement:
generate_config(sot, host, template) -> str — take the host's vars from sot[host]
and substitute them into the {{ name }} template.config_diff(running, intended) -> dict{"added":[...], "removed":[...]} — added
is the lines in intended not in running (in intended order); removed is
the lines in running not in intended (in running order). Ignore blank lines.make_patch(diff) -> list[str] — first remove the excess: "no " + line for each
removed line, then add each added line as-is.deploy(device, sot, host, template) -> dict{"changed": bool, "patch": [...]} — build
intended, pull running, compute diff, patch, apply. changed == (patch is non-empty). A repeated
deploy returns changed=False, patch=[].Follow-up. How does this differ from napalm ... commit with atomic rollback? Why is
idempotency a mandatory property? And where is the dry-run in this design?
device.apply mutates state. Keeping the impure step at the very end is what
makes the pipeline testable and gives you a natural dry-run (stop before apply).generate_config is the same {{ name }}
substitution as the Ansible drill — resolve the host's vars from the SoT and template against them._lines helper strips whitespace and drops
blanks so both sides compare apples to apples. Build sets for O(1) membership, but emit added/
removed in the original list order for readable, deterministic patches.no commands first. Because the diff of an
already-converged device is empty, the patch is empty and deploy reports
changed=False — idempotency falls out of the design."""Drill 15 (solution): nornir-like SoT -> render -> diff -> patch -> deploy."""
from __future__ import annotations
import re
_PLACEHOLDER = re.compile(r"\{\{\s*(\w+)\s*\}\}")
SOT = {"r1": {"hostname": "r1", "mtu": 9000}}
TEMPLATE = "hostname {{ hostname }}\nmtu {{ mtu }}\nservice timestamps"
class MockDevice:
def __init__(self, running: str = ""):
self._lines = [ln.strip() for ln in running.split("\n") if ln.strip()]
def get_config(self) -> str:
return "\n".join(self._lines)
def apply(self, patch: list[str]) -> None:
for cmd in patch:
if cmd.startswith("no "):
target = cmd[3:]
self._lines = [ln for ln in self._lines if ln != target]
elif cmd not in self._lines:
self._lines.append(cmd)
def generate_config(sot: dict, host: str, template: str) -> str:
variables = sot[host]
def repl(match: re.Match) -> str:
name = match.group(1)
if name not in variables:
raise KeyError(name)
return str(variables[name])
return _PLACEHOLDER.sub(repl, template)
def _lines(text: str) -> list[str]:
return [ln.strip() for ln in text.split("\n") if ln.strip()]
def config_diff(running: str, intended: str) -> dict:
run = _lines(running)
want = _lines(intended)
run_set, want_set = set(run), set(want)
added = [ln for ln in want if ln not in run_set]
removed = [ln for ln in run if ln not in want_set]
return {"added": added, "removed": removed}
def make_patch(diff: dict) -> list[str]:
patch = [f"no {ln}" for ln in diff["removed"]]
patch.extend(diff["added"])
return patch
def deploy(device: MockDevice, sot: dict, host: str, template: str) -> dict:
intended = generate_config(sot, host, template)
running = device.get_config()
patch = make_patch(config_diff(running, intended))
device.apply(patch)
return {"changed": bool(patch), "patch": patch}
This is the flagship "assemble a pipeline" task: can you wire five named stages (SoT → generate → diff →
patch → deploy) into a clean flow where each stage is a small function and the only side effect lives at the
boundary? They want to see idempotency emerge from the design (a converged device diffs to nothing), a
deterministic remove-then-add patch order, and normalized line comparison. The follow-up separates people who
can also reason about the operational envelope — atomic commit/rollback, why idempotency is non-negotiable in a
config loop, and where a dry-run naturally fits (compute the patch, skip apply).