Code Review and Working with Existing Codebases Questions
Reviewing others' code and navigating unfamiliar systems: giving and receiving actionable review feedback, spotting correctness and design issues, and reading and understanding large or legacy codebases before changing them. Covers collaborative coding norms, incremental change in shared repositories, and verifying changes against existing behavior. The team-facing side of day-to-day engineering.
You are reviewing an Ansible playbook intended to be idempotent. Identify problems in this snippet and propose changes to make it idempotent and testable.
- hosts: web
tasks:
- name: install nginx
command: apt-get install -y nginx
- name: create conf
copy:
content: "server { listen 80; }"
dest: /etc/nginx/sites-enabled/default
- name: restart nginx
service:
name: nginx
state: restarted
What would you change and why? How would you test the playbook in CI?
Sample Answer
Direct answer
None of the three tasks here is idempotent, meaning running the playbook a second time against a server that's already correctly configured should report no changes, but this one still reports changes, or worse, causes them, every single time. Each task needs to move from an imperative shell command to a declarative module that checks the current state before acting, and the fix should be validated by actually running the playbook twice and confirming the second run reports zero changes, not just by reading the code and assuming it's fine.
Structured elaboration
Task 1: command: apt-get install -y nginx. A raw shell command has no idea whether nginx is already installed; it just runs apt-get install every single time. That might be a no-op at the package-manager level, but Ansible itself has no way to know that and will always report this task as "changed," which defeats the entire point of using a configuration-management tool.
Task 2: the copy task for the config file. This one is closer to idempotent already, since Ansible's copy module compares the destination file's content against what's being written and only reports a change when the content actually differs. It's still incomplete though: no explicit file permissions or owner are set, and a config change should trigger a service reload, not happen silently with no connection to the next task.
Task 3: service: state: restarted. This always restarts the service on every single run, whether or not anything actually changed. It's the least idempotent line in the whole playbook: running this playbook nightly, for example on a schedule, would bounce nginx nightly for no reason at all.
The fix. Use Ansible's notify/handler pattern: the config-file task notifies a handler, and the handler, which reloads or restarts the service, only runs when that specific task actually reported a change. A no-op run then touches the service zero times.
Worked example
A corrected version of the playbook:
- hosts: web
become: true
tasks:
- name: install nginx
apt:
name: nginx
state: present
- name: place nginx site config
copy:
content: "server { listen 80; }"
dest: /etc/nginx/sites-available/default
owner: root
group: root
mode: '0644'
notify: reload nginx
handlers:
- name: reload nginx
service:
name: nginx
state: reloaded
Why each change matters: the apt module is declarative, it checks the package's actual state first, so a rerun is cheap and honest about whether anything changed. notify plus a handler means the service only restarts, specifically via reloaded, which is less disruptive than a full restarted, exactly when the configuration actually changed, not on every run regardless of state.
How to test this in CI (continuous integration). Use Molecule, a testing framework built specifically for Ansible roles, to spin up an ephemeral container and converge (run) the playbook against it, then converge a SECOND time and assert the second run reports zero changed tasks, that's a direct, mechanical test of "is this actually idempotent," rather than trusting it by inspection. Add a verify step, using a tool like Testinfra, asserting the real end state: nginx is installed, the config file has the expected content, and the service is running. Wire this into the CI pipeline so a role that regresses on idempotency fails the build automatically, instead of being caught by a human rerunning it by hand much later.
Trade-offs and pitfalls
reloaded is gentler than restarted, but not every application supports a clean reload; some genuinely need a full restart to pick up certain kinds of configuration changes, so this substitution has to match how the real service actually behaves, not be applied blindly to every service task. Testing idempotency by running the playbook twice in CI adds real time to every pipeline run, a fair cost for something this cheap to verify and this easy to silently break without anyone noticing.
You are reviewing a short Bash script authored by an operations engineer. Identify bugs, anti-patterns, portability and safety concerns, and propose fixes. The script:
#!/bin/bash
DIR=$1
if [ ! -d $DIR ]; then
mkdir $DIR
fi
for file in $(ls $DIR); do
sudo echo 'Processing' $file
rm -rf $DIR/$file
done
What would you comment on in the PR, and how would you rewrite or patch this script to be safer and idempotent?
Sample Answer
Direct answer
The two most serious problems are unquoted variables, which break on filenames containing spaces, and using ls in a for loop, which word-splits its output the same way. On top of that, sudo echo doesn't do what it looks like it does, and the script has no error handling and isn't safely idempotent (running it twice wouldn't produce the same safe result as running it once). I'd rewrite it to quote everything, use find instead of ls for the loop, and add explicit error handling.
Structured elaboration
- Word-splitting from unquoted variables.
$DIRand$fileare used unquoted throughout. The shell splits an unquoted variable's value on whitespace before using it, so a filename likemy file.txtbecomes two separate words,myandfile.txt, wherever it's used unquoted. lsin aforloop.for file in $(ls $DIR)runsls, then word-splits its output the same way as above, and additionally can misbehave on filenames containing newlines or glob characters. Iterating a directory's contents should usefind(or a glob directly), neverls's text output.sudo echodoes nothing useful.sudoelevates the single command that follows it, hereecho, not any of the commands after it in the loop. Therm -rftwo lines later still runs as the invoking user, with no elevated permissions at all, so this line is misleading: it looks like it's setting up privileged execution and it isn't.- No error handling. There's no
set -e(exit on any command failing) and no check thatmkdiror the loop itself succeeded, so a failure partway through is silent. - Destructive with no safety.
rm -rf $DIR/$fileruns unconditionally, with no dry-run option and no check that$DIRis actually the directory intended, which is dangerous if$DIRis ever accidentally empty or wrong.
Worked example
The original script's word-splitting bug, demonstrated on a real directory containing three files, one of which has a space in its name (report.txt, my file.txt, data.csv):
#!/bin/bash
DIR=$1
if [ ! -d $DIR ]; then
mkdir $DIR
fi
for file in $(ls $DIR); do
echo 'Processing' $file
done
Run as ./orig.sh testdir, output:
Processing data.csv
Processing my
Processing file.txt
Processing report.txt
my file.txt was silently split into two separate "files," my and file.txt, neither of which actually exists on disk; the original script's rm -rf $DIR/$file line would then try to remove two nonexistent paths instead of the one real file, silently failing to do what it was clearly meant to do.
The rewritten, safe version:
#!/usr/bin/env bash
set -euo pipefail
DIR="${1:-}"
if [[ -z "$DIR" ]]; then
echo "Usage: $0 <directory>" >&2
exit 2
fi
mkdir -p -- "$DIR"
while IFS= read -r -d '' file; do
echo "Processing: $file"
rm -rf -- "$file"
done < <(find "$DIR" -mindepth 1 -maxdepth 1 -type f -print0)
find ... -print0 prints each filename terminated by a NUL byte (\0) instead of a newline, since NUL is the one character that can never appear in a filename. read -r -d '' then reads up to that NUL byte instead of splitting on whitespace or newlines, so a name containing spaces, tabs, or even a literal newline comes through as one intact string. IFS= clears the input field separator for the read, which stops it from trimming leading or trailing whitespace off each name. Together the three pieces replace ls's whitespace/newline-based splitting with a delimiter no filename can ever contain.
Run on the same directory, output:
Processing: testdir/my file.txt
Processing: testdir/data.csv
Processing: testdir/report.txt
Every filename, including the one with a space, is now handled as a single, correct unit. Running the script a second time on the now-empty directory produces no output and exits 0, since mkdir -p is idempotent and there are no more files to process.
Complexity
This is O(n) in the number of directory entries, one find traversal plus one removal per entry; there's no algorithmic complexity concern here, the risk was entirely in correctness and safety, not performance.
Edge cases
- An empty directory:
find -mindepth 1returns nothing, the loop body never runs, no error. Both the buggy and fixed versions handle this the same, trivially. - A subdirectory inside
$DIR: the fixed version above adds-type fspecifically so it only processes files, not subdirectories, because the original script's intent (process and remove files) was ambiguous about whether subdirectories should also be recursively wiped; that's worth confirming with the script's author rather than guessing. $DIRpointing somewhere unintended (an empty variable, a typo): the fixed version exits early with a usage message if the argument is missing, rather than silently operating on the wrong path or failing confusingly deep inside the loop.
Trade-offs and pitfalls
set -euo pipefail is safer by default but has a real gotcha: a command that's expected to sometimes return nonzero (a grep that finds no match, for example) will abort the whole script unless it's explicitly handled, which surprises people the first time they hit it. Given this script's line calls rm -rf in a loop, I'd also want a --dry-run flag added before trusting it against anything that matters, printing what would be removed without removing it, since a subtle bug in a real deletion script is far more costly than the same bug in a read-only one.
Technical coding: Given the following Python function used in a deployment script, write pytest unit tests that cover normal behavior and edge cases. Mock external API calls.
import requests
def get_latest_image(repo):
r = requests.get(f'https://registry.example/api/{repo}/latest')
r.raise_for_status()
return r.json()['tag']
Provide at least three tests and explain why you chose them.
Sample Answer
Direct answer
I'd write at least four tests, covering the success path, an HTTP error response, a malformed JSON body missing the expected key, and a network-level failure like a timeout, all with requests.get mocked so no test makes a real network call. Each test targets a distinct way this function can fail in production, not just variations on the happy path.
Structured elaboration
Approach. Mock requests.get so the function's own logic, not the network, is what's under test. For each test, build a fake response object with just enough behavior to drive the code path being tested (raise_for_status either does nothing or raises, json() returns a controlled payload), then assert on get_latest_image's return value or on the exception it raises.
Why these specific tests.
- Success proves the normal path works and the correct value is extracted from a realistic JSON payload.
- HTTP error (a 4xx or 5xx status) proves the function surfaces the failure via
raise_for_status()rather than silently returning something wrong. - Missing key in the response body proves that if the API's response shape doesn't match what the code expects, the caller gets a clear exception rather than a confusing downstream error somewhere else.
- Network-level failure (a timeout, a connection error) proves the function doesn't swallow or mask an infrastructure problem, which matters specifically because this function is used in a deployment script where a caller needs to know the difference between "the deploy image genuinely doesn't exist" and "we couldn't reach the registry at all."
Worked example
# deploy_utils.py
import requests
def get_latest_image(repo):
r = requests.get(f'https://registry.example/api/{repo}/latest')
r.raise_for_status()
return r.json()['tag']
# test_deploy_utils.py
from unittest.mock import Mock, patch
import pytest
import requests
from deploy_utils import get_latest_image
def make_response(json_data=None, raise_error=None):
resp = Mock()
resp.raise_for_status = Mock(side_effect=raise_error) if raise_error else Mock()
resp.json = Mock(return_value=json_data or {})
return resp
def test_get_latest_image_returns_tag_on_success():
resp = make_response(json_data={'tag': 'v1.2.3'})
with patch('deploy_utils.requests.get', return_value=resp) as mock_get:
result = get_latest_image('myapp')
assert result == 'v1.2.3'
mock_get.assert_called_once_with('https://registry.example/api/myapp/latest')
def test_get_latest_image_raises_on_http_error():
resp = make_response(raise_error=requests.exceptions.HTTPError('404 Client Error'))
with patch('deploy_utils.requests.get', return_value=resp):
with pytest.raises(requests.exceptions.HTTPError):
get_latest_image('missing-repo')
def test_get_latest_image_raises_keyerror_on_malformed_body():
resp = make_response(json_data={'digest': 'sha256:abc'})
with patch('deploy_utils.requests.get', return_value=resp):
with pytest.raises(KeyError):
get_latest_image('myapp')
def test_get_latest_image_propagates_network_timeout():
with patch('deploy_utils.requests.get', side_effect=requests.exceptions.Timeout):
with pytest.raises(requests.exceptions.Timeout):
get_latest_image('myapp')
Actually run with pytest, output:
test_deploy_utils.py::test_get_latest_image_returns_tag_on_success PASSED
test_deploy_utils.py::test_get_latest_image_raises_on_http_error PASSED
test_deploy_utils.py::test_get_latest_image_raises_keyerror_on_malformed_body PASSED
test_deploy_utils.py::test_get_latest_image_propagates_network_timeout PASSED
4 passed
Complexity
This is straightforward, constant-time mocked I/O per test, no algorithmic complexity to speak of; the interesting design decision is which failure modes are worth a dedicated test, not runtime cost.
Edge cases
- A 500 server error takes the exact same code path as a 404, since both raise via
raise_for_status(); one test covering "any HTTP error" is representative, a second status-specific test adds little. - A response that's valid JSON but not a dict at all (a bare list, for example) would raise
TypeErrorrather thanKeyErrorwhen['tag']is applied; worth a fifth test if this API's contract is genuinely uncertain. - Real production code often uses a
requests.Sessionwith a configured retry adapter rather than a barerequests.get; mocking at therequests.getlevel, as done here, doesn't exercise that retry behavior at all, which would need a different test approach.
Trade-offs and pitfalls
Mocking at the requests.get level is fast and has zero network flakiness, but it also means these tests can't catch a real integration problem, like the registry's actual response shape changing; a smaller number of separate integration tests against a real or realistic staging registry are worth having alongside these, not instead of them. A common pitfall is mocking so aggressively that the test asserts almost nothing about get_latest_image's own logic, for example forgetting to assert on the exact URL called, which would let a bug in the f-string (a wrong path, a typo) slip through unnoticed.
You find a change in a PR that appears to add API keys and database passwords as plain text constants in a repository. As the reviewer, what steps do you take immediately and what long-term code-review policies and automated checks would you recommend to prevent credentials from being committed? Include remediation for secrets already committed to history.
Sample Answer
Direct answer
My immediate steps are to block the merge, notify whoever owns those credentials so they can be rotated right away, and treat every exposed key or password as compromised regardless of whether the PR has merged yet. Long term, I'd push for two things together: automated secret scanning that blocks a push before it ever lands, and a real secrets-manager so hardcoding a credential as a source constant stops being something a developer would even reach for.
Structured elaboration
Immediate steps.
- Block the PR with a clear comment: which lines, why they can't merge as-is.
- Notify the owner of each credential (the team responsible for that database, that API) so rotation starts immediately, in parallel with fixing the code, not after.
- Treat the credentials as compromised the moment they were pushed, even to a non-default branch, since they were already visible in the hosting platform's systems and any CI run's logs.
Remediation if already committed to shared history. Rotate every exposed credential first; that's the step that actually stops them being usable. Separately, decide whether history needs rewriting: if the branch is shared or already merged, a tool like git filter-repo can remove the secret from history, but that requires a force-push and everyone with a clone re-syncing, which is real coordination overhead, not a quick fix.
Long-term prevention policy.
- Automated secret scanning wired into CI on every PR, and ideally as a server-side push-protection check that rejects the push outright before the secret ever lands in the repository, which is strictly better than catching it in review after the fact.
- A pre-commit hook (the
pre-commitframework, running something likedetect-secretsorgitleaks) catches most cases even before a push happens. - A managed secrets store (a cloud provider's secrets manager, or a tool like Vault) so credentials are injected at runtime rather than living in source at all; combined with short-lived, role-based credentials instead of long-lived static keys where the platform supports it.
- A written policy in the contribution guidelines stating plainly that credentials never go in source, plus periodic scans of existing history to catch anything that predates the automated checks.
Worked example
A reviewer notices DB_PASSWORD = "hunter2" hardcoded in settings.py, already merged two releases ago. Immediate step: rotate the database password right away, since it's been live and exposed in history for two full release cycles, not just caught in an open PR. Then: add settings.py's pattern to the secret-scanner's ruleset so this specific style of hardcoded constant is caught automatically going forward, replace it with a read from the team's secrets manager, and separately schedule the history rewrite (or accept the rotated-but-not-rewritten trade-off, documented as a deliberate decision) since this secret already reached the default branch and every existing clone.
Trade-offs and pitfalls
Push-protection style scanning that blocks a push outright is the strongest prevention, but it will occasionally false-positive on something that looks like a secret and isn't, which needs a fast override path or it trains developers to route around the check entirely. Migrating fully to a secrets manager and short-lived credentials is a real engineering investment, not a one-line config change, so a reasonable interim step is "no new hardcoded secrets, enforced by CI" while the migration for already-existing ones happens on its own timeline. The most common pitfall in the immediate response is fixing the code (removing the line from the diff) while forgetting that the review only ever showed you the PR, not everywhere else that credential might already be exposed.
Behavioral: Tell me about a time when you found a critical bug or security issue in infrastructure code during a code review. Use the STAR format: describe the Situation, the Task you had, the Actions you took as reviewer and with the team, and the Results (including any follow-up changes to process or automation).
Sample Answer
Direct answer
I'll walk through a real example from reviewing infrastructure-as-code: catching a security group change that would have opened a database to unrestricted inbound access, and how that turned into both an immediate fix and a lasting change to how the team reviews that class of change.
Structured elaboration
Situation. I was reviewing a routine-looking Terraform PR meant to let a new internal reporting service reach a database. Task. As the reviewer, my job was to catch anything that changed the actual security posture of that database, not just check that the Terraform plan applied cleanly. Action. I noticed the PR's security group rule used 0.0.0.0/0 for the inbound CIDR range (CIDR, Classless Inter-Domain Routing, is the notation for writing a whole block of IP addresses as one value; 0.0.0.0/0 specifically means "every possible IP address"), instead of the reporting service's specific subnet, almost certainly copy-pasted from an example rather than deliberately chosen. Result covers what happened next, both the immediate fix and the longer-term process change, below.
Worked example
I marked the PR as blocking with a specific comment explaining the exposure: this rule would allow any host on the internet to attempt a connection to the database's port, not just the internal reporting service the PR was supposedly scoping access to. I proposed the concrete fix, scoping the rule to the reporting service's actual subnet CIDR instead, and pushed a one-line diff to make it easy for the author to just take. Given the severity, I also flagged it in the team's on-call channel rather than waiting for an asynchronous PR reply, since an already-merged version of a similar mistake elsewhere in the account was worth checking for immediately, not after the PR conversation finished. The PR was updated and merged with the corrected, scoped rule within the hour. Separately, I proposed and helped add an automated policy check (using Open Policy Agent, a policy-as-code tool that can evaluate Terraform plans against written rules) to the CI pipeline that specifically rejects any security group rule opening a sensitive port to 0.0.0.0/0 without an explicit, reviewed exception, so this exact mistake can't reach production again without a deliberate override.
Trade-offs and pitfalls
The judgment call in a story like this is deciding how loudly to escalate: raising it in a live channel instead of just a PR comment was the right call given the actual exposure, but that same urgency would be the wrong tone for a much lower-severity finding, and using it there would just train the team to tune out urgent-sounding messages. A pitfall to watch for when telling this kind of story is stopping at "I found the bug and it got fixed," without the automation follow-up: the more convincing version of this story is one where the process change means the same category of mistake gets caught automatically next time, not just this one instance.
Unlock Full Question Bank
Get access to all 31 Code Review and Working with Existing Codebases interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.