| name | osdc-tooling-and-quality |
|---|---|
| description | OSDC tools (tofu, just, mise, crane, uv), automation hierarchy, unit tests, code style and linting rules (13 linters, indentation), "Don't Do" list, and quality gates. Applies to the OSDC project (`osdc/`). Load when writing code, running linters, adding scripts/tests, or debugging and trying to understand issues. |
The knowledge base for this project (upstream repos, reference docs) is at ~/meta/actions-knowledge-base.
This project uses OpenTofu (tofu), NOT Terraform. Running terraform commands will corrupt the state file and can destroy infrastructure. There is no recovery.
- NEVER run
terraform init,terraform plan,terraform apply, or anyterraformsubcommand - ALWAYS use
tofuorjustrecipes (which calltofuinternally) - Directories are named
terraform/but the tool istofu - In
mise.toml, the entry isopentofu, neverterraform
Versions are pinned in osdc/mise.toml (mise auto-installs on first run):
- OpenTofu (
tofu) 1.7: Infrastructure as code. See warning above — never useterraform. - just: Command runner. Recipes in
osdc/justfile. Single entry point for all operations. - mise: Tool version manager. Config in
osdc/mise.toml. Auto-installs tools on first run. - Python 3.12, kubectl 1.34, helm 3.14, awscli 2, packer 1.10 — pinned.
- kube-linter v0.7.6 — pinned via the aqua backend (
aqua:stackrox/kube-linter); rule names and severities shift across versions, so a bump requires re-runningjust lintagainst the full tree. - crane, uv, ruff — pinned to
latest. - uv: Python package manager. Always use
uv, never pip/conda/poetry.
actionlint is also installed via mise.toml but is not wired into just lint (currently unused by the lint pipeline). GitHub Actions workflow YAML in .github/workflows/ is therefore not linted as part of just lint — only ad-hoc invocation of actionlint covers it.
Required order of preference for new automation:
- just recipes — use existing just recipes for all tasks; check justfile first before creating anything
- Python scripts — for new automation requiring logic/complexity
- Bash scripts — ONLY when Python is unsuitable OR trivial (< 20 lines)
DO NOT create bash scripts if a Python solution is reasonable. Python provides better error handling, testability, and maintainability.
ALWAYS use uv for Python dependencies (uv pip install, uv venv, uv run). NEVER use pip, conda, poetry, or other package managers.
Every new Python script with testable logic MUST have co-located unit tests. When adding or modifying functionality in ANY script, you MUST check for existing tests and update them to cover the changes. When adding new functionality, add corresponding tests. No code change is complete without verifying its test coverage.
- Co-located tests: Test files live next to the script they test (e.g.,
scripts/python/foo.py->scripts/python/test_foo.py). Test discovery injust testisfind-driven (justfiletestrecipe) — it walks the tree fortest_*.pyand prunes.scratch,.venv,tests/e2e, andbase|modules/*/tests/smoke. (Thetestpathslist inpyproject.tomlis only consulted when invoking barepytest, and it notably does NOT includetests/smoke/even thoughjust testdoes.) - Pure-logic extraction: Separate testable logic (data transforms, validation, comparisons) from side effects (subprocess calls, kubectl, helm). Test the pure logic; mock the side effects only when necessary.
- Run before declaring done:
just testmust pass clean. No skipped tests, no xfails for new code. - Coverage gate (95% per file):
just testruns pytest with-n auto --cov, then enforces a per-file coverage threshold of 95% (hard-coded in thetestrecipe).pyproject.tomlalso sets[tool.coverage.report] fail_under = 95. Ifjust testfails on otherwise-passing tests, check the per-file coverage report — any file under 95% will block. - Coverage expectation: Test happy paths, edge cases (empty inputs, missing keys), and any cross-module interaction scenarios (e.g., multi-module namespace sharing).
- Test directory exclusions:
just testdiscoverstest_*.pyfiles everywhere EXCEPT:tests/e2e/(live-cluster end-to-end tests — run viajust test-compactor,just test-janitor)base/<component>/tests/smoke/andmodules/<module>/tests/smoke/(live-cluster smoke tests — run viajust smoke <cluster>)- The project-root
tests/smoke/IS included — it holds unit tests for the smoke helpers package. If you put unit tests under a module'stests/smoke/, they will NOT run injust test.
- Other live-cluster test recipes (also outside
just test):just integration-test <cluster>,just load-test <cluster>,just workload-test <cluster>— each drives its own Python entrypoint underintegration-tests/*/scripts/python/. - Coverage exclusion for smoke helpers: Directories matching
*/tests/smokeor*/tests/smoke/*are excluded from the--cov=flags (justfiletestrecipe). Their unit tests run, but the 95% per-file gate doesn't apply to them — full coverage is provided by the live-cluster smoke runs. - Coverage
omit:pyproject.toml[tool.coverage.run] omit = ["test_*.py", "conftest.py"]— test files themselves don't count toward coverage of the modules under test.
Before declaring any code change complete, you MUST run both of these and they MUST pass clean:
just lint # All 13 linters must pass with zero errors
just test # All unit tests must passIf either fails, fix the issues before finishing. Do not defer lint or test failures — they block CI and break other contributors.
The just test, just lint, and just smoke recipes detect the AGENT_ENVIRONMENT=true env var (set by default in the launcher). When enabled:
- On success: output is suppressed to just
OK (<time>)— no verbose logs, no per-linter/test detail. - On failure: full output is returned as usual — failures, stack traces, linter errors, everything needed to diagnose the problem.
This keeps agent context clean. A passing just lint returns one line instead of hundreds. Agents should leverage this: run the recipe, check for OK — if you get it, move on. No need to capture output to temp files or parse results on success.
ALWAYS capture full output to a temp file AND tail the end in one command. This pattern applies to just lint, just test, and just smoke — every time, regardless of expected outcome:
just lint 2>&1 | tee /tmp/lint-output.txt | tail -n 10 ; wc -l /tmp/lint-output.txt
just test 2>&1 | tee /tmp/test-output.txt | tail -n 10 ; wc -l /tmp/test-output.txt
just smoke 2>&1 | tee /tmp/smoke-output.txt | tail -n 10 ; wc -l /tmp/smoke-output.txtWhy this pattern:
- The
tail -n 10shows you the result immediately (successOKor failure summary) without flooding context. - The
wc -ltells you how much output was captured. - The full output is saved to
/tmp/— if something fails, do NOT re-run the command. Instead, investigate the temp file:
# Read the full file
cat /tmp/lint-output.txt
# Read specific sections
head -n 50 /tmp/lint-output.txt
tail -n 30 /tmp/test-output.txt
# Search for specific errors
grep -n "ERROR\|FAILED\|error" /tmp/lint-output.txt
# Extract a specific linter's output
awk '/^━━━ shfmt ━━━$/,/^━━━/' /tmp/lint-output.txtNEVER re-run just lint, just test, or just smoke just to see what failed. The temp file has everything — read it, grep it, slice it. Re-running wastes time and tokens.
Lint: the last lines show which linters failed (FAILED linters: shfmt, ruff check). Use awk to extract the specific linter section from the temp file.
Tests: the last lines show which directories failed (FAILED test dirs: ...). For deeper investigation, re-run just that directory:
cd <failed-dir> && uv run pytest --tb=short -vIf you know which files changed, run the specific linter directly. Run from the project root (osdc/):
# Python
uv run ruff check --config ruff.toml path/to/file.py
uv run ruff format --check --config ruff.toml path/to/file.py
# Shell
shellcheck path/to/script.sh
shfmt -d -i 2 -ci -bn path/to/script.sh
# Python tests (specific directory)
cd path/to/test/dir && uv run pytest --tb=short -q
# Terraform
tofu fmt -check -recursive modules/my-module/terraform/
# YAML
uv run yamllint -c .yamllint.yaml path/to/file.yamljust lint runs 13 linters. just lint-fix auto-fixes what it can — in this order: tofu fmt, shfmt, ruff (check --fix then format), taplo fmt. All must pass — CI blocks on any failure.
| Language | Indent | Tool |
|---|---|---|
| Python | 4 spaces | ruff |
| Shell (.sh) | 2 spaces | shfmt (-i 2 -ci -bn) |
| YAML | 2 spaces (sequences indented) | yamllint |
| HCL/Terraform | 2 spaces | tofu fmt |
| TOML | 2 spaces | taplo |
| Dockerfile | 4 spaces | .editorconfig |
| JSON / jsonnet | 2 spaces | .editorconfig |
| justfile | 4 spaces | .editorconfig |
| Makefile | tab | .editorconfig |
- Line length: 120 (not 80/88)
- Target: Python 3.12
- Imports must be sorted (isort rules enabled).
known-first-party = ["osdc"] - Selected rule families (
ruff.toml[lint] select):E,F,W,I,UP,B,SIM,RUF,S,T20,PIE,C4,PT,RSE,RET,TCH,ARG,ERA - Globally ignored:
E501(line length is the formatter's job),S108(/tmppaths),S603/S607(subprocess calls),T201(print()allowed),RET504(assignment-before-return),ARG001(unused function arguments) - Test-file ignores (
**/test_*.py):S101(asserts),S106(hardcoded passwords),ARG(unused args),PT009/PT019/PT027(unittest-style patterns),ERA001(commented-out code in test comments) - Tests directories (
**/tests/**): same as test files plusTCH - No commented-out code (
ERA001) — except in test files - Use comprehensions over
map()/filter()(C4) - Don't use
assertoutside test files (S101)
- 2-space indent, case bodies indented (
-ci), binary ops (&&/||) start the next line (-bn) - Always quote variables (shellcheck
SC2086) enable=allis set in.shellcheckrc, with these noisy checks disabled:SC2250(prefer${var}over$var),SC2292(prefer[[ ]]over[ ]),SC2310(function in if/&& disablesset -e),SC2312(consider invoking separately for exit code)source-path=SCRIPTDIRso sourced files resolve relative to the script
- 2-space indent, sequences indented too
- Truthy values: only
true,false,yes,no(noton/off/True/False) - Max line length: 200 (warning only)
- No trailing whitespace, newline at EOF, max 2 consecutive blank lines
- Scope: yamllint runs against
base/kubernetes/,base/node-compactor/kubernetes/,base/helm/, everymodules/<module>/kubernetes/, everymodules/<module>/helm/, everymodules/<module>/defs/(relevant for runner / nodepool definition YAMLs), andclusters.yaml
- Manifests validated against official schemas in strict mode (
-strict -summary -ignore-missing-schemas); CRDs without published schemas are tolerated via-ignore-missing-schemasand a CRDs-catalog schema URL - Most safety/security checks are active by default. Explicitly excluded in
.kube-linter.yaml: resource-requirements checks (unset-cpu-requirements,unset-memory-requirements— Karpenter handles scheduling),no-liveness-probe,no-read-only-root-fs,run-as-non-root,privileged-container,privilege-escalation-container,host-ipc/host-network/host-pid,sensitive-host-mounts,liveness-port,drop-net-raw-capability,dangling-service,latest-tag,pdb-min-available,pdb-unhealthy-pod-eviction-policy
- AWS plugin enabled, pinned to version 0.38.0 (
.tflint.hcl); catches invalid instance types and deprecated resources call_module_type = "none"so tflint runs without atofu init(module sources don't need to resolve)- Canonical formatting enforced by
tofu fmt
All new terraform resources MUST use for_each keyed by a stable identifier (CIDR, AZ, name, or composite like "${bucket}-${az}"), with one narrow exception:
# ALLOWED — gating an entire resource on a feature flag
count = var.should_this_exist ? 1 : 0Otherwise: always for_each.
Why: count indexes resources by list position. Removing or reordering an element shifts every subsequent index, which terraform interprets as destroy+recreate at the shifted indexes — even though the underlying real-world resource hasn't changed. for_each keys resources by stable identifiers, so removing one entry only destroys that one resource. This matters acutely for immutable or in-use AWS resources (subnets, NAT GWs, EIPs, route tables, CIDR reservations) where a spurious destroy+recreate can drop traffic, leak allocations, or block apply.
Existing count-based resources are out of scope to refactor — leave them as-is unless you're already touching them for another reason.
- Standard rules, but apt/pip version pinning not required (
DL3008/DL3013ignored)
- Scans
base/andmodules/for HIGH/CRITICAL IaC issues - Known exceptions in
.trivyignore(public EKS API, privileged DaemonSets, etc.)
- LF line endings, trailing newline required, no trailing whitespace
- NEVER run
terraform— usetofuorjustrecipes (terraform will corrupt state) - Don't run state-changing CLI commands directly (apply, delete, install, destroy) — use
justrecipes - Don't create bash scripts without considering Python first
- Don't use pip/conda/poetry — use
uvfor Python packages - Don't install packages or run setup scripts without checking first
- Don't update ANY versions (tools, deps, images) without explicit approval
- Don't create documentation files unless explicitly requested
- Don't experiment with the cluster — read-only investigation is fine, but don't change anything
- Don't mix unrelated files or technologies in the same directory