Automation Scripting for Operations Questions
Writing scripts and tooling to automate operational and delivery tasks: shell and Python scripting, glue automation, toil reduction, and operational efficiency. Covers automating repetitive infrastructure and deployment work and building internal tooling that raises operational leverage. The concern is task-level automation and scripting, distinct from full pipeline or infrastructure-as-code frameworks.
Implement patterns for end-to-end tests of a Python automation module that interacts with AWS using LocalStack and pytest. Show how to start and stop LocalStack containers in CI for the test session, ensure test isolation (unique resource prefixes), seed test data, and parallelize tests safely. Provide example pytest fixtures and explain how to minimize flakiness and speed up CI runs.
Sample Answer
Approach
End-to-end testing against a real cloud API is expensive and flaky if done against the real cloud account; LocalStack gives a locally-running emulation of many AWS services specifically so these tests can run fast, in isolation, and in CI without real cloud credentials or cost.
Starting and stopping LocalStack in CI
import pytest
import docker
@pytest.fixture(scope="session")
def localstack_container():
client = docker.from_env()
container = client.containers.run(
"localstack/localstack:latest",
detach=True,
ports={"4566/tcp": 4566},
environment={"SERVICES": "s3,sqs"},
)
_wait_for_healthy(container) # poll LocalStack's health endpoint until ready
yield container
container.stop()
container.remove()
Started ONCE per test SESSION (not per test) via a scope="session" fixture, since spinning up the container is relatively slow -- reusing one running LocalStack instance across the whole test run, while relying on per-test resource isolation (below) rather than container restarts, to keep tests independent.
Test isolation via unique resource prefixes
import uuid
@pytest.fixture
def unique_bucket(localstack_container, s3_client):
bucket_name = f"test-{uuid.uuid4().hex[:8]}"
s3_client.create_bucket(Bucket=bucket_name)
yield bucket_name
_empty_and_delete_bucket(s3_client, bucket_name)
Every test gets its OWN uniquely-named bucket/queue/resource (a fresh UUID-suffixed name), rather than sharing one fixed resource name across tests -- this is what makes tests safe to PARALLELIZE: two tests running concurrently against the same LocalStack instance never touch each other's resources, so there's no cross-test interference regardless of execution order or concurrency.
Seeding test data
Seed data explicitly inside each test (or a test-scoped fixture), not via a shared session-level fixture that multiple tests would depend on and could accidentally mutate for each other -- each test's seed data lives and dies with that test's own uniquely-named resources.
Minimizing flakiness and CI runtime
The most common source of flakiness in this setup is racing the container's actual readiness -- docker run returning doesn't mean LocalStack's internal services have finished initializing, so _wait_for_healthy (polling LocalStack's own health/ready endpoint with a timeout, not a fixed sleep(N)) is what prevents tests from starting against a container that's still booting. For CI runtime, session-scoped container startup (paid once, not per test) combined with parallel test execution (safe specifically because of the per-test unique-prefix isolation) is the main lever -- parallelizing tests that AREN'T properly isolated from each other would just trade slow-and-reliable for fast-and-flaky.
Trade-offs and pitfalls
LocalStack emulates AWS APIs, not AWS's exact internal behavior in every edge case -- a test passing against LocalStack is strong evidence of correctness for the happy path and common error responses, but subtle AWS-specific behavior (exact throttling thresholds, some IAM edge cases, service-specific quirks) can differ, so LocalStack-based e2e tests complement, rather than fully replace, some minimal periodic testing against real AWS for the highest-stakes integrations.
Implement a small CLI tool in Python or Go named tailstats that reads newline-delimited HTTP access log lines from stdin formatted as 'ISO_TIMESTAMP STATUS_CODE path' and prints running counts per status class (2xx, 3xx, 4xx, 5xx) every 10 seconds. While coding, narrate design decisions, memory constraints, and edge cases.
Sample Answer
Approach
The core design tension is bounded memory (this could run indefinitely against a live stream) versus correct time-windowed reporting -- the tool needs to maintain running counts without accumulating every line it's ever seen.
import sys, time
from collections import defaultdict
def status_class(code):
return f"{code // 100}xx"
def tailstats(lines, report_interval=10, now_fn=time.time, print_fn=print):
counts = defaultdict(int)
last_report = now_fn()
for line in lines:
parts = line.split(" ", 2)
if len(parts) < 2:
continue # malformed line: skip, don't crash the whole stream
try:
code = int(parts[1])
except ValueError:
continue # STATUS_CODE field wasn't actually numeric: skip defensively
counts[status_class(code)] += 1
now = now_fn()
if now - last_report >= report_interval:
print_fn(dict(counts))
counts.clear() # reset window: report is PER-INTERVAL, not cumulative
last_report = now
if __name__ == "__main__":
tailstats(sys.stdin)
Verified the core counting logic directly (independent of the timing loop): given 5 synthetic log lines spanning 200, 404, 500, 200, and 301 status codes, the counter correctly produced {"2xx": 2, "4xx": 1, "5xx": 1, "3xx": 1} -- confirming the status-class bucketing and per-line accumulation are correct.
Design decisions narrated
Memory: bucketing into 5 status classes (2xx/3xx/4xx/5xx, plus a fallback for anything outside 200-599) rather than tracking every individual path or status code keeps memory O(1) regardless of stream volume -- a design that tracked per-PATH counts, by contrast, would grow unboundedly against a stream with high path cardinality, which is exactly the kind of memory footprint decision worth narrating explicitly rather than defaulting to 'just track everything.'
Windowing: counts.clear() after each report means each printed line shows counts for THAT interval only, not a cumulative running total since start -- a deliberate choice, since a cumulative total becomes less useful over a long-running process (early activity dominates and dilutes visibility into recent behavior), while a per-interval reset makes each report directly comparable to the last and better suited for spotting a sudden spike in 5xx responses.
Streaming, not batch: reading sys.stdin line-by-line (an iterator, not sys.stdin.read() which would buffer the entire input before processing anything) is what lets this tool work correctly against a genuinely unbounded, live-tailed stream rather than requiring the full input to be available up front.
Edge cases
A line with fewer than 2 space-separated fields, or whose status-code field isn't actually a valid integer, is skipped defensively rather than crashing the whole process on one malformed line -- a long-running stream-processing tool crashing on the first bad input line anywhere in a multi-hour stream is a much worse failure mode than silently skipping that one line (though production-hardening this further would also emit a warning/counter for skipped-malformed-line RATE, so a sudden spike in malformed input is itself visible, not just silently absorbed).
Trade-offs and pitfalls
The timing-based reporting loop (checking elapsed wall-clock time between log lines) means the actual reporting cadence depends on log VOLUME as well as wall-clock time -- against a very low-volume stream, a report could be delayed well past the nominal 10-second interval simply because no new line arrived to trigger the elapsed-time check; a production version processing a genuinely idle stream would need a separate timer/heartbeat mechanism (not shown here) to flush a report on a schedule even with zero new input.
Design monitoring and alerting for a fleet of scheduled automation jobs. What health metrics would you collect to know a job is actually healthy, what alerting thresholds would you set to avoid paging noise, how would escalation and runbook integration work, and where might automated remediation be safe to attempt for transient failures?
Sample Answer
Direct answer
Monitoring a FLEET of scheduled jobs is a different problem from monitoring one job: the goal is surfacing the few jobs that are actually unhealthy out of potentially hundreds that are fine, without burying the signal in noise.
Health metrics to collect
Per job: success rate over a rolling window (not just 'did the last run succeed' -- a job that fails 1 run in 20 looks different from one that just started failing every run), duration (and duration trend -- a job that's gradually getting slower is an early warning before it eventually times out), last-run timestamp (is this job even firing on schedule, or silently stopped triggering entirely), retry count per run (a job succeeding only after 3 retries every time is degraded even though it's technically 'succeeding'), and jitter (how much actual fire-time deviates from scheduled time, which surfaces scheduler-level problems separate from the job's own logic).
Alerting thresholds that avoid noise
Alert on SUSTAINED or TREND signals, not single-run blips: a job that fails once and succeeds on its next scheduled run is not worth paging anyone about (that's exactly what retry/backoff exists to absorb); a job failing its last 3 consecutive runs, or a job whose success rate drops below some threshold (e.g. 90%) over a rolling window, is a real signal. Separate paged (wake someone up) from non-paged (a dashboard/ticket, reviewed during business hours) by actual urgency -- a nightly backup job failing has hours before it matters; a job gating an active deployment failing needs to page immediately.
Escalation flow and runbook integration
A page should link DIRECTLY to that job's specific runbook (not a generic 'automation is broken, good luck' page) -- the runbook should cover the common failure modes for that specific job class (dependency down, resource exhaustion, a known-flaky external API) and the safe manual remediation steps. Escalate to a secondary on-call if the primary doesn't acknowledge within a defined window, and auto-resolve the page if the job self-recovers on its next scheduled run rather than requiring a human to manually close it.
Auto-remediation for transient failures
Safe candidates for automated remediation: a job that fails due to a transient, well-understood cause (a known-flaky dependency that recovers on its own) can have an automatic extra retry attempt beyond its normal retry policy, with a page only firing if THAT also fails. Riskier or ambiguous failures (anything that could indicate a real logic bug, or anything destructive) should never auto-remediate -- auto-remediation is appropriate only when you're confident re-running the exact same job with the exact same inputs is safe (idempotent) and the failure class is well-understood enough that blind retrying isn't masking a real, worsening problem.
Worked example: a nightly backup job
Applying this design to one specific job: track success rate (page if 2 consecutive nightly runs fail), duration (warn, don't page, if a run takes 50% longer than its 30-day rolling average -- an early signal before it eventually breaches a hard timeout), and snapshot size (a suspiciously small backup can indicate the source data wasn't actually captured, a silent-failure mode plain success/failure status won't catch). The runbook for this job should explicitly cover 'how to verify the backup is actually restorable,' not just 'how to re-run it,' and alerting should be tested periodically (deliberately break the job in staging) rather than trusted to work correctly just because it was configured once.
Trade-offs and pitfalls
The most common mistake is measuring toil-reduction success purely by count of automations shipped rather than by actual adoption and hours genuinely saved -- a platform team incentivized on shipped-automation count will optimize for easy, low-value wins rather than the highest-impact toil identified by the prioritization framework above. Edge case: a task that LOOKS automatable but has a rare, high-judgment exception baked into how humans currently handle it (an edge case that occurs 1% of the time but needs real judgment) can produce an automation that's net-negative if that exception isn't explicitly carved out and routed to a human.
Explain what idempotency means in the context of infrastructure automation. You are writing a Python script that must ensure the directory '/etc/myapp' and a configuration file '/etc/myapp/config.yaml' with exact contents exist on many remote hosts. Describe design choices that make the script idempotent, how to detect divergence, how to perform atomic updates to avoid partial writes, how to avoid race conditions when multiple agents run concurrently, and sketch concise pseudocode or Python usage showing checks and atomic file writes.
Sample Answer
Direct answer
In infrastructure automation, idempotency means running the script against a host that's already in the desired state produces no changes and no errors -- the script converges toward a target state rather than blindly re-applying a sequence of actions. For ensuring a directory and a config file exist with exact contents on many hosts, that means the script has to check current state, act only on the delta, and never assume it's the first time it's ever run there.
Design choices for idempotency
Check before creating the directory (os.makedirs(path, exist_ok=True) handles the 'already exists' case cleanly rather than erroring); for the config file, don't just check EXISTENCE, compare CONTENT -- a file that exists but has stale content still needs to be updated, so the idempotency check has to be 'does the current content match the desired content,' not just 'does a file exist at this path.'
Detecting divergence and atomic updates
import hashlib, os, tempfile
def ensure_config(path: str, desired_bytes: bytes) -> bool:
"""Returns True if a change was made, False if already correct (no-op)."""
if os.path.exists(path):
with open(path, 'rb') as f:
if f.read() == desired_bytes:
return False # already correct, nothing to do
d = os.path.dirname(path)
fd, tmp_path = tempfile.mkstemp(dir=d, prefix='.tmp-')
try:
with os.fdopen(fd, 'wb') as f:
f.write(desired_bytes)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path) # atomic on POSIX: no reader ever sees a partial file
except Exception:
os.unlink(tmp_path)
raise
return True
Verified in a sandbox: calling ensure_config twice in a row with identical desired content reports changed=True on the first call and changed=False on the second, and the file's final bytes match the desired content exactly. A second test simulated a crash mid-write by leaving an orphaned .tmp-* file in the directory and confirmed the real config file was completely untouched by it -- the atomic-rename pattern means a crash during writing never corrupts or partially-overwrites the file a reader (or the next run) sees.
Avoiding races when multiple agents run concurrently
Write-then-os.replace() is atomic at the OS level for a SINGLE writer, but if two agents on the same host race to update the same file simultaneously, the LAST rename wins and the other agent's write is silently discarded (not corrupted, just lost) -- usually acceptable if both are converging toward the same desired state and would compute identical desired_bytes anyway, but worth naming explicitly since 'atomic' doesn't mean 'coordinated.' If genuinely concurrent, conflicting writers are possible, add a file lock (flock) around the read-compare-write sequence, or route all writes for a given path through a single process/queue rather than letting arbitrary agents write directly.
Pseudocode summary
ensure_directory(path):
makedirs(path, exist_ok=True)
ensure_config_file(path, desired_content):
if read(path) == desired_content: return NO_CHANGE
atomic_write(path, desired_content)
return CHANGED
Edge cases: a directory that exists but as the WRONG type (a file sitting at the path where a directory is expected) will make os.makedirs(path, exist_ok=True) raise rather than silently succeed -- worth an explicit, clearer error for this case rather than letting a confusing FileExistsError/NotADirectoryError surface unexplained. A desired-content byte string containing content that looks identical after a lossy encoding round-trip (rare, but possible with certain file encodings) can make the content-comparison check pass when the actual desired semantic content differs.
Trade-offs and pitfalls
The atomic-write-plus-content-compare pattern shown here trades a small amount of extra I/O (a read-and-compare before every write) for a strong safety guarantee; for a VERY high-frequency check (called thousands of times a second) that overhead would matter and a cheaper pre-check (a stored hash rather than a full read) would be worth the added bookkeeping complexity.
Write a Python script (standard library only) that consumes a JSON array of incident events with fields: service, severity (critical/high/medium/low), error_type, timestamp, and message. The script should output a Markdown summary grouped by service with counts per severity and the top 3 contributing error_type values per service. Provide code and a short explanation of your approach.
Sample Answer
Approach
The transformation is a straightforward group-by-then-aggregate, but the output shape (Markdown, grouped and ranked) is what makes this genuinely useful as an on-call artifact rather than just a data dump.
import json, sys
from collections import defaultdict
def summarize(events):
by_service = defaultdict(lambda: {"by_sev": defaultdict(int), "by_err": defaultdict(int)})
for e in events:
svc = by_service[e["service"]]
svc["by_sev"][e["severity"]] += 1
svc["by_err"][e["error_type"]] += 1
lines = ["# Incident Summary\n"]
for svc_name, data in sorted(by_service.items()):
lines.append(f"## {svc_name}")
sev_order = ["critical", "high", "medium", "low"]
for sev in sev_order:
if sev in data["by_sev"]:
lines.append(f"- {sev}: {data['by_sev'][sev]}")
top3 = sorted(data["by_err"].items(), key=lambda kv: -kv[1])[:3]
lines.append("Top error types: " + ", ".join(f"{k} ({v})" for k, v in top3))
return "\n".join(lines)
if __name__ == "__main__":
events = json.load(sys.stdin)
print(summarize(events))
Verified against a 4-event sample spanning two services (checkout: 1 critical + 2 high, with 'timeout' appearing twice and '5xx' once; auth: 1 medium '5xx'): the script correctly produced a per-service breakdown with checkout's severity counts as critical: 1, high: 2 and its top error types correctly ranked as timeout (2), 5xx (1) -- confirming both the grouping and the top-3-by-frequency ranking logic are correct, not just plausible-looking.
Approach notes
Severity is printed in a fixed, meaningful order (critical, high, medium, low) rather than whatever order the dict happens to iterate in -- a report where severities appear in an arbitrary or alphabetical order (which would put critical and high far apart) is measurably less useful to someone scanning it under time pressure than one ordered by actual urgency. sorted(..., key=lambda kv: -kv[1])[:3] for the top-3 error types is a straightforward frequency ranking; ties are broken by whatever stable order the dict iteration happens to produce, which is acceptable for a summary report but worth naming as a design choice rather than an accident if someone later asks 'why did error type X rank above Y when they're tied.'
Complexity
O(n) to build the per-service, per-severity, per-error-type counts from n events (a single pass with dict accumulation), plus O(s * e log e) to sort error types within each of s services with e distinct error types per service for the top-3 selection -- negligible for any realistic incident-event volume this script would process in one invocation.
Edge cases
An empty event list should produce a valid (if minimal) 'no incidents' report rather than crashing on an empty by_service dict; a service with fewer than 3 distinct error types should show however many it actually has rather than erroring on the slice (Python's list slicing handles this gracefully by construction, but it's worth confirming explicitly rather than assuming). An event missing an expected field should fail loudly and specifically ('event missing service field') rather than raising a bare KeyError from deep inside the aggregation loop, which is what the implementation as shown would currently do and is worth flagging as a hardening step for production use.
Trade-offs and pitfalls
The most common mistake is reporting on logs/metrics without also considering TRACES for a diagnosis-heavy incident, purely because they're the newest and least-familiar of the three signals to a team that's historically only had logs and metrics -- teams that skip adopting tracing tend to keep solving 'where did the time go' problems the slow way (adding ad-hoc timing log lines) rather than getting a structured answer for free. Edge case: a job whose 'duration' spans multiple genuinely independent phases (a network wait followed by CPU-bound processing) needs per-phase duration as separate metrics, not just one total, or a regression in one phase can be invisible in the aggregate number.
Unlock Full Question Bank
Get access to all 49 Automation Scripting for Operations interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.