Practical infra coding

Write a config linter that exits non-zero on errors

mediumCLI tools Must-do

Problem statement

Every service in a repo ships a small JSON config, and a CI step should reject bad ones before they reach a deploy. Write lint_config.py FILE [FILE ...] [--strict] that checks each file and prints one line per problem: FILE: LEVEL KEY: message.

Errors

  • The file is missing, is not valid UTF-8, or is not valid JSON (report the line and column).
  • The top level is not an object.
  • name, port or env is missing.
  • name is not lowercase letters, digits and -, starting with a letter (max 63 characters).
  • port is not an integer from 1 to 65535. Booleans do not count as integers.
  • env is not one of dev, staging, prod.
  • replicas (optional, default 1) is not an integer >= 1.
  • healthcheck (optional) is not an object, or its path does not start with /.

Warnings

  • env is prod with replicas below 2.
  • Any key not listed above (usually a typo).

Report every problem in a file, not just the first. Print FILE: ok for a clean file and a summary line at the end. Exit 1 if there were any errors (or any warnings with --strict), otherwise 0.

good.json

JSON
{
"name": "payments-api",
"port": 8443,
"env": "prod",
"replicas": 3,
"healthcheck": {"path": "/healthz", "interval_s": 10}
}

bad.json

JSON
{
"name": "Payments_API",
"port": true,
"env": "production",
"replicas": 0,
"healthcheck": {"path": "healthz"},
"replica": 2
}

broken.json

JSON
{
"name": "search",
"port": 9200,
}

Examples

Example 1

Input: python lint_config.py good.json bad.json broken.json missing.json; echo "exit: $?"

Output: good.json: ok bad.json: ERROR name: must be lowercase letters, digits and '-', got 'Payments_API' bad.json: ERROR port: must be an integer 1-65535, got true bad.json: ERROR env: must be one of dev, prod, staging, got 'production' bad.json: ERROR replicas: must be an integer >= 1, got 0 bad.json: ERROR healthcheck.path: must start with '/', got 'healthz' bad.json: WARN replica: unknown key (typo?) broken.json: ERROR <file>: invalid JSON at line 3 col 15: Illegal trailing comma before end of object missing.json: ERROR <file>: file not found 7 error(s), 1 warning(s) in 4 file(s) exit: 1

Explanation: "port": true would pass an isinstance(port, int) check, because bool is a subclass of int in Python. The trailing comma after 9200 in broken.json is caught by the JSON parser, which points at the comma itself.

Example 2

Input: python lint_config.py good.json; echo "exit: $?"

Output: good.json: ok 0 error(s), 0 warning(s) in 1 file(s) exit: 0

Hints

Approach

Optimal

Separate the three concerns: reading a file, checking a parsed config, and reporting.

  1. lint_file turns every way reading can fail into a normal problem entry: missing file, bad encoding, and JSONDecodeError with its lineno/colno. A linter that crashes with a traceback on the one file that is actually broken is useless in CI.
  2. lint takes a parsed value and returns a list of (level, key, message) tuples. It checks the root type first, then required keys, then each field only if it is present, so a missing port gives one clear error, not two. Every rule appends and continues, so one run reports everything wrong with a file.
  3. Type traps. is_int rejects booleans. The messages print the offending value with json.dumps or repr, so true, "8080" and 8080 are distinguishable in the output. replicas uses a default of 1, so it is optional but still validated if given. The prod warning only runs when replicas itself is valid.
  4. Unknown keys are the set difference between the file's keys and the known set, sorted for a stable order. They are warnings because they usually mean a typo (replica for replicas) that silently leaves the real setting at its default.
  5. Exit code. 1 if any error, or any warning under --strict; otherwise 0. This is what lets the CI step fail the build.

Because lint is a pure function over a dict, it is easy to unit-test each rule without touching the filesystem.

ComplexityTime O(total size of the files)Space O(size of one file)
Python
import argparse
import json
import re
import sys
NAME_RE = re.compile(r"^[a-z][a-z0-9-]{0,62}$")
ENVS = {"dev", "staging", "prod"}
KNOWN = {"name", "port", "env", "replicas", "healthcheck"}
def is_int(v):
return isinstance(v, int) and not isinstance(v, bool) # True is an int in Python
def lint(cfg):
# Returns a list of (level, key, message).
out = []
if not isinstance(cfg, dict):
return [("ERROR", "<root>", "top level must be an object")]
for key in ("name", "port", "env"):
if key not in cfg:
out.append(("ERROR", key, "required key is missing"))
name = cfg.get("name")
if name is not None and not (isinstance(name, str) and NAME_RE.match(name)):
out.append(("ERROR", "name", f"must be lowercase letters, digits and '-', got {name!r}"))
port = cfg.get("port")
if port is not None and not (is_int(port) and 1 <= port <= 65535):
out.append(("ERROR", "port", f"must be an integer 1-65535, got {json.dumps(port)}"))
env = cfg.get("env")
if env is not None and env not in ENVS:
out.append(("ERROR", "env", f"must be one of {', '.join(sorted(ENVS))}, got {env!r}"))
replicas = cfg.get("replicas", 1)
if not (is_int(replicas) and replicas >= 1):
out.append(("ERROR", "replicas", f"must be an integer >= 1, got {json.dumps(replicas)}"))
elif env == "prod" and replicas < 2:
out.append(("WARN", "replicas", "prod with a single replica has no redundancy"))
hc = cfg.get("healthcheck")
if hc is not None:
if not isinstance(hc, dict):
out.append(("ERROR", "healthcheck", "must be an object"))
elif not str(hc.get("path", "")).startswith("/"):
out.append(("ERROR", "healthcheck.path", f"must start with '/', got {hc.get('path')!r}"))
for key in sorted(set(cfg) - KNOWN):
out.append(("WARN", key, "unknown key (typo?)"))
return out
def lint_file(path):
try:
with open(path, encoding="utf-8") as f:
cfg = json.load(f)
except FileNotFoundError:
return [("ERROR", "<file>", "file not found")]
except json.JSONDecodeError as e:
return [("ERROR", "<file>", f"invalid JSON at line {e.lineno} col {e.colno}: {e.msg}")]
except UnicodeDecodeError:
return [("ERROR", "<file>", "not valid UTF-8")]
return lint(cfg)
def main(argv=None):
p = argparse.ArgumentParser(prog="lint_config.py")
p.add_argument("files", nargs="+")
p.add_argument("--strict", action="store_true", help="treat warnings as errors")
args = p.parse_args(argv)
errors = warnings = 0
for path in args.files:
problems = lint_file(path)
for level, key, msg in problems:
print(f"{path}: {level:<5} {key}: {msg}")
errors += sum(1 for p in problems if p[0] == "ERROR")
warnings += sum(1 for p in problems if p[0] == "WARN")
if not problems:
print(f"{path}: ok")
print(f"{errors} error(s), {warnings} warning(s) in {len(args.files)} file(s)")
return 1 if errors or (args.strict and warnings) else 0
if __name__ == "__main__":
sys.exit(main())

Follow-up questions

  • Suggest the closest known key for an unknown one (replica -> did you mean replicas?) using difflib.get_close_matches.
  • Add a --format json output so a code review bot can annotate the exact lines.
  • Check rules across files, for example that no two services use the same port.

Frequently asked questions

Config validation in CI is one of the cheapest ways to prevent outages: a wrong port or a typo'd key is caught at review time instead of at deploy time. Interviewers use a linter task to see whether you report all problems clearly and whether the exit code is right, since that is what the pipeline acts on.

In a real repo, a JSON Schema plus a validator library is a good choice. It is not in the standard library, though, and interviews usually want to see the checks written out. The structure here (collect problems, then decide the exit code) is the same one a schema-based tool uses.

Usually not by default, or teams start ignoring the linter. --strict gives repos that want zero warnings a way to opt in, and makes it easy to tighten rules gradually.