Ops scripting
The small scripts operations engineers write every week: parse access logs and .env files, compute p95 latency, uptime and SLO burn rates, match cron schedules, compare semantic versions, plan deploy waves and rolling batches, bin-pack containers, and clean up rotated logs.
These problems are original practice. They are not questions from a named employer or a live hiring loop.
1.Count status classes
Write
count_status_classes(lines). A valid line looks like 1.2.3.4 - -[06/Nov/2026:10:00:00 +0000]"GET /a HTTP/1.1"200 512: three space-separated fields, a[bracketed]time, a"quoted"request, a 3-digit status from 100 to 599, and a byte count of digits or -. Return a dict from class ("2xx","5xx", ...) to count, only for classes seen. Any other line counts under"malformed".2.Top client IPs with ties
Write
top_ips(lines, n). The client IP is the first whitespace-separated field of each log line; skip blank lines. Return [[ip, count], ...] for the n busiest IPs, sorted by count descending, then IP as a plain string ascending. If other IPs tie with the count in n-th place, include them too, so the result can be longer than n. n is at least 1.3.Slow endpoints by p95
Write
slow_endpoints(samples, threshold_ms). samples is a list of[path, latency_ms]with int latencies. For each path take the nearest-rank p95: sort its latencies and pick the k-th smallest, k =ceil(0.95 * count), 1-based, so 20 samples give the 19th. Return [[path, p95], ...] for paths whose p95 is strictly abovethreshold_ms, sorted by p95 descending, then path ascending.4.Parse a duration
Write
parse_duration(text)for strings like"1h30m15s". Units are d, h, m, s, in that order; each unit is used at most once and must follow a non-negative integer. Values are not capped, so"90m"is 5400. Return the total seconds as an int, orNoneif the string is empty or breaks any rule (unknown unit, repeat, wrong order, number with no unit like"90", unit with no number, spaces).5.Format seconds as a duration
Write
format_duration(seconds)for a non-negative int. Break it into days, hours, minutes and seconds (1d is 86400 s) and return them joined with no spaces in that order, like"1h30m15s". Leave out every unit whose count is 0, so 3601 is"1h1s". Zero seconds is"0s".6.Sort semantic versions
Write
sort_versions(versions)for strings like 1.4.10 or 2.0.0-rc.1. Sort ascending: compare major, minor, patch as ints. At equal core, a pre-release (text after the first -) sorts before the plain release. Pre-releases compare dot-separated parts in turn: all-digit parts numerically and before text parts, text parts by string order; if all shared parts tie, fewer parts is lower. Keep duplicates.7.Layered config overrides
Write
resolve_config(defaults, file_cfg, env, prefix). Defaults hold int, bool or str. Applyfile_cfgover defaults, then env; keys not in defaults are ignored in all layers. Key k reads env var prefix +k.upper()(APP_LOG_LEVEL forlog_level). Env values are str: convert to the default's type (int viaint(), bool from true/false in any case, str as is). If it fails, keep the lower layer's value.8.Expand ${VAR} placeholders
Write
expand_vars(text, env). Replace each ${NAME}withenv[NAME], or an empty string if NAME is missing. ${NAME:-fallback}uses fallback (the text up to the next }) when NAME is missing or empty. NAME is a letter or _ followed by letters, digits or _. Inserted values are not expanded again. Leave anything else unchanged, including $NAME without braces and an unclosed ${.9.Parse a .env file
Write
parse_dotenv(text). Per line: trim it; skip blank lines and lines starting with #; drop a leading"export ". Split on the first =, trim key and value, and skip lines with no = or an empty key. If the value starts and ends with the same quote (' or "), strip them and keep the inside as is. Otherwise cut an inline comment at the first # that follows whitespace, then trim. Later keys win.10.Filter IPs by CIDR
Write
ips_in_cidr(ips, cidr)returning the strings from ips whose address falls inside the network cidr, in input order, keeping duplicates. The network and broadcast addresses count as inside. cidr may have host bits set ("10.0.0.5/24"means 10.0.0.0/24). Skip strings that are not valid IP addresses. An IPv6 address is never inside an IPv4 network. The ipaddress module is allowed.11.Merge firewall port ranges
Write
merge_port_ranges(rules)where each rule is a port like"443"or an inclusive range like"8000-8080". Skip rules not in one of those two digit forms, outside 1-65535, or with start > end. Merge ranges that overlap or touch (80-90 and 91-95 become 80-95). Return strings sorted by start:"443"for a single port,"a-b"for a range.12.Deployment waves
Write
deploy_waves(deps), where deps maps each service to the list of services it needs running first. A service with no dependencies is in wave 1; otherwise its wave is 1 + the highest wave among its dependencies. Dependencies that are not keys of deps are external and ignored. Return a list of waves, each a sorted list of names, orNoneif there is a dependency cycle (including self).13.Rolling update batches
Write
rolling_batches(instances, max_unavailable)that splits the instance names, keeping their order, into update batches.max_unavailableis an int, or a percent string like"25%"meaning floor(len(instances)* 25 / 100). The batch size is that number, but at least 1. Every batch has that size except possibly the last. Return a list of lists; no instances gives [].14.Quorum health check
Write
service_health(checks). checks is a time-ordered list of[service, instance, status]; only the latest status per (service, instance) counts, and any status other than exactly"up"(case-sensitive) means down. A service is"up"when more than half of its distinct instances are up (2 of 4 is not enough), else"down". Return a dict mapping every service seen to"up"or"down".15.Uptime from outage windows
Write
uptime_percent(start, end, outages)for a window from start to end (int seconds, end > start). Each outage is[s, e], down from s up to e. Outages may overlap (count shared time once), extend past the window (count only the part inside), or be empty or reversed (e <= s, ignore). Return 100 *up_time/ window length, rounded to 4 decimals.16.Error budget burn rate
Write
burn_rate_alert(slo, long_window, short_window, threshold). slo is a target like 0.999, so the error budget is 1 - slo. Each window is[bad, total]request counts. Burn rate = (bad / total) / budget, rounded to 4 decimals; a window with total 0 has rate 0.0. Return{"long": rate, "short": rate, "page": bool}, where page is true only when both rounded rates are >= threshold.17.Detect flapping alerts
Write
flapping_alerts(events, window, min_changes). events is a time-sorted list of[ts, alert, state], ts in int seconds. A change is an event whose state differs from that alert's previous event (an alert's first event and repeats are not changes). An alert flaps if at leastmin_changesof its changes fit in window seconds (last ts - first ts <= window). Return flapping names sorted.18.Deduplicate alerts in a window
Write
dedupe_alerts(alerts, window). alerts is a time-sorted list of[ts, key]with ts in int seconds. Keep an alert unless an alert with the same key was kept less than window seconds before it (ts -kept_ts< window); exactly window seconds later is kept. Compare against the last kept alert for that key, not the last suppressed one. Return the kept alerts as[ts, key]lists in input order.19.Match a cron expression
Write
cron_matches(expr, minute, hour, day). expr has three space-separated fields: minute (0-59), hour (0-23), day of month (1-31). Each field is a comma list of items: *, N, A-B (inclusive), */S, or A-B/S. A step counts from the start of its range: */15 is 0,15,30,45 for minutes, */2 is 1,3,5... for days, and 1-10/4 is 1,5,9. ReturnTrueif all three fields match the given values.20.Next cron run today
Write
next_run(expr, now). expr is"MINUTE HOUR"; each field is a comma list of *, N, A-B (inclusive), */S or A-B/S, with steps counting from the start of the range (minutes 0-59, hours 0-23). now is"HH:MM". Return the first matching time strictly after now on the same day, as"HH:MM"with two-digit parts, orNoneif no matching time is left today.21.Disk usage rollup
Write
disk_rollup(files, depth). files is a list of[path, size_bytes]with absolute paths like /var/log/app/x.log. Group each file by its first depth directory names: with depth 2 that file goes under"/var/log". A file with fewer directories uses all it has ("/a.txt"goes under"/"). Ignore empty parts from repeated slashes. Return a dict from prefix to total bytes.22.Log rotation cleanup
Write
rotate_logs(names, keep). A rotated file is named PREFIX.YYYY-MM-DD, like app.log.2026-11-03, where PREFIX is everything before the last dot. For each prefix keep the keep newest dates and delete the rest. Names not ending in .YYYY-MM-DD with digits (like app.log) are never deleted. keep may be 0. Return the names to delete, sorted ascending.23.Parse memory quantities
Write
parse_memory(text)for Kubernetes memory strings: a number (digits, optional .digits) then an optional suffix. k, M, G, T mean 1000^1 to 1000^4; Ki, Mi, Gi, Ti mean 1024^1 to 1024^4; no suffix means bytes. Suffixes are case-sensitive, so"500m"and"1gi"are invalid. Return the bytes as an int, dropping any fraction, orNonefor an invalid string.24.Bin-pack containers onto nodes
Write
bin_pack(containers, capacity). containers is a list of[name, cpu]; cpu and capacity are CPU strings ("250m"is 250 millicores,"1.5"is 1500). Sort by millicores descending, ties by name ascending; put each on the first node, in creation order, with room left (exact fill is fine), else open a node. Return nodes as lists of names in placement order, orNoneif a container exceeds capacity.25.Detect config drift
Write
config_drift(desired, actual)for nested dicts. Return{"missing": [...], "extra": [...], "changed": [...]}, each a sorted list of dotted paths like"db.pool.size": in desired only, in actual only, or in both with different values. Recurse only when both sides are dicts; a dict versus a non-dict is changed. A missing or extra dict is one entry at its own path. Lists compare whole.26.Certificate expiry check
Write
cert_expiry(certs, today, days). certs is a list of[host, not_after]with dates as"YYYY-MM-DD"; today uses the same format."expired"holds hosts whosenot_afteris before today;"expiring"holds hosts dated from today through today + days, both ends inclusive. Skip certs whose date is not a real calendar date. Return{"expired": [...], "expiring": [...]}, each ordered by date, then host.27.Tail matching lines
Write
tail_matching(lines, pattern, n), like grep piped to tail -n. pattern is a Python regular expression, matched anywhere in the line, case-sensitive. Return the last n matching lines as[line_number, line]pairs, with line numbers starting at 1, in file order. If fewer than n lines match, return them all; n = 0 returns [].28.Group multi-line stack traces
Write
group_stack_traces(lines)to rebuild log entries. A line that starts with a space or tab, or starts with"Caused by:", continues the previous entry; any other line starts a new entry. Skip lines that are empty or only whitespace. A continuation line with nothing before it starts an entry. Return the entries in order, each as its lines joined by a newline character.