Development and testing¶
How to change Nops: tests, docs, the flow of a change, releases, and Go conventions.
What needs which tests¶
| Area | Tests |
|---|---|
internal/meta |
Table-driven unit tests: valid and invalid values, unknown keys, defaults. |
internal/acl |
Table-driven unit tests: valid and invalid rules and selectors, the most specific rule, deny, and which binding rules match. |
internal/engine |
Unit tests for every transition, for crash recovery, and for CAS conflicts. They use a fake Nomad and an injected clock. Each invariant has an adversarial test in invariants_test.go. |
internal/nomadx |
Unit tests against an httptest stub, plus integration tests. |
internal/store |
Tests against real SQLite in t.TempDir(). |
internal/hooks |
Unit tests with a fake Nomad and real SQLite, plus integration tests. |
internal/redact |
Unit tests on hand-built and real plan diffs: no secret reaches the database or the HTML. |
internal/secret |
Unit tests on the format and the randomness of a secret. |
internal/metrics |
Every metric against fakes. |
internal/notify |
httptest receivers for every adapter and every failure. |
internal/gitwatch |
Tests against a local bare repository. |
internal/web |
httptest for every page and state, and the login against a fake OIDC provider. |
internal/cli |
httptest stubs of the API for every command, plus an integration test against the real server. |
cmd/nops |
Covered by the integration smoke test. |
| The docs | go test ./... checks the option and meta key tables against the code, and the test names and links in the pages. |
scripts/release.sh |
scripts/release_test.sh and shellcheck. |
scripts/dev.sh |
scripts/dev_test.sh and shellcheck. |
A behaviour that depends on Nomad itself gets an integration test.
Integration¶
Integration tests live in tests/integration/ behind the integration build
tag. They run against nomad agent -dev with ACLs, using
NOPS_TEST_NOMAD_ADDR and NOPS_TEST_NOMAD_TOKEN; without the address they
skip. The end-to-end tests (e2e_*_test.go) build the real binary and run it
with the documented Nomad token, so a
missing capability fails CI. Hooks use raw_exec jobs, so the agent must run
on the same host as the tests.
Nomad version¶
CI tests against NOMAD_VERSION in .github/workflows/ci.yml; the README
states the same version, and a test fails if they differ. To move to a new
Nomad, change NOMAD_VERSION and NOMAD_SHA256, update the README, and run
the integration tests.
Coverage¶
At least 80% on engine, store, meta and hooks. No target on web and
cmd.
Writing docs¶
The docs follow common open source practice:
- Structure: Diátaxis. Each page is a tutorial, a how-to guide, reference or explanation, and holds only that kind of content. The index groups pages the same way.
- Style: the Google developer documentation style guide. Vale checks it in CI (commands).
- Content: what a reader needs to use or change Nops. Leave out what the code already answers. State each fact on one page and link to it from the others.
- Commits, issues and pull requests follow Conventional Commits and the repository's templates, and say what changes and why.
Doc audit¶
Before a release, check the claims of the pages changed since the last tag against the code and its tests. Report each claim that doesn't hold as doc wrong, code wrong?, untested, or unclear. The maintainer decides the fixes: doc fixes in one pull request, each behaviour fix in its own with a failing test first.
When a piece of work is "done"¶
go build,go vet,staticcheck,go test -raceand Vale pass.- Changes to
engine,hooksornomadxpass the integration tests. - Every behaviour change has a test.
- The docs and
examples/match the change.
What to update for each change¶
| Change | Update |
|---|---|
| Meta key | internal/meta and meta-keys.md |
| Flag or environment variable | internal/config and configuration.md |
| Policy behaviour | policies.md |
| Hook contract | hooks.md and examples/ |
| State, transition, schema, recovery | deployment-lifecycle.md |
| Detection or apply | architecture.md |
| Dashboard page or login | dashboard.md |
| Logs or notifications | logs-and-notifications.md |
| Metric | metrics.md |
| Running on a cluster | running-nops.md |
| First steps | getting-started.md |
| Term | glossary.md |
| Invariant | philosophy.md |
| Doc page | nav in mkdocs.yml |
| Build or release | Releasing |
| Nomad version | ci.yml and the README |
Commands¶
go test -race -cover ./...
go tool staticcheck -tags integration ./...
vale sync && vale docs README.md CONTRIBUTING.md internal/web/templates
# the docs site, previewed on http://127.0.0.1:8000
pip install -r scripts/mkdocs/requirements.txt
mkdocs serve
# Nomad and Nops on this machine: the dashboard on http://127.0.0.1:8080,
# login admin/admin; Ctrl-C stops both
scripts/dev.sh
# integration: a dev agent with ACLs, in another terminal
printf 'acl {\n enabled = true\n}\n' > agent.hcl
nomad agent -dev -config=agent.hcl &
export NOPS_TEST_NOMAD_ADDR=http://127.0.0.1:4646
export NOPS_TEST_NOMAD_TOKEN=$(curl -s -X POST $NOPS_TEST_NOMAD_ADDR/v1/acl/bootstrap | jq -r .SecretID)
go test -tags integration -race -count=1 ./tests/integration/...
Workflow and CI¶
- Pick an open issue with no branch or pull request. Settle open design questions in the issue first.
- Branch from
mainastype/short-description. - Make the change with its tests and docs.
- Open a pull request titled
type(scope): subject. Fill in the template with What changes, Why, and Notes for open questions, and addCloses #N. - After review, push new commits. Don't amend or force-push.
- The maintainer squash-merges and writes the final commit message.
main requires a pull request with these checks green:
| Check | Runs |
|---|---|
test |
gofmt, go mod tidy, build, go vet, go test -race |
lint |
staticcheck, shellcheck, the script tests, goreleaser check, Vale, the docs site build |
integration |
The integration tests against a Nomad dev agent |
pr-title |
The pull request title is a Conventional Commit |
govulncheck, CodeQL, secret scanning, and Dependabot also run. Actions are
pinned to full commit SHAs.
Issues use the forms in .github/ISSUE_TEMPLATE/: a plain title, the type
(Bug, Feature or Task), and a label for each area it touches.
Releasing¶
A release is a signed vX.Y.Z tag on main. Before v1, a minor release
may break things and a patch never does.
- Make sure
mainis green and the doc audit is done. - For a minor or major release, move the image tag in
running-nops.md to the new
X.Yin a pull request first: the script refuses to tag without it. - Run
scripts/release.sh. It lists the commits since the last tag, suggests a version, then signs and pushes the tag.--dry-runonly shows the suggestion. - Ship risky changes as a release candidate first (
v0.2.0-rc.1). - Check the result:
docker run --rm ghcr.io/music-gang/nops:0.2.0 -version. - Never move or delete a tag: fix forward with a patch release.
The release workflow builds the binary and the image with goreleaser,
publishes them to the GitHub release and ghcr.io/music-gang/nops, and
writes the release notes from the commit subjects, with Upgrade notes on
top, made from the BREAKING CHANGE: footers. A final release also
publishes the docs site. Image tags for vX.Y.Z
are X.Y.Z, X.Y and latest. The image is distroless with the binary
only. The version comes from the tag, set at build time with
-ldflags -X github.com/music-gang/nops/internal/version.version=<tag>.
Bump by hand: the goreleaser version in release.yml and ci.yml, and the
Debian release of the base image.
Commit messages¶
Conventional Commits in the Angular style:
<type>(<scope>): <subject>
<body: what changed and why>
<footer: BREAKING CHANGE, issue references, Co-Authored-By>
- Types:
feat,fix,docs,style,refactor,perf,test,build,ci,chore,revert. - Subject: imperative, lowercase, no trailing period, 72 characters at most. It becomes a line of the release notes, so write it for someone running Nops.
- Body: wrapped at 72 characters, plain text. Say what changed and why.
- Breaking change:
!after the scope and aBREAKING CHANGE:footer that says what changed and what to do. It becomes an item of the release's Upgrade notes.
The dashboard's code¶
Pages are html/template with plain CSS and htmx, vendored
under /static. Every action works as a plain form without JavaScript. The
look follows GitHub's Primer: light and dark themes,
one item per line. Deployment states use one color each: pending amber,
running blue, completed green, failed red, and rejected or superseded grey.
Static files are linked with a content hash, so a browser never mixes old and
new versions.
Requests that change state refuse cross-origin requests
(http.CrossOriginProtection) on top of SameSite=Lax cookies.
Terms used in the code¶
The words of the glossary, plus:
| Term | Meaning |
|---|---|
Nops / nops |
Nops is the product; nops in code font is the binary, image, module, or a name in code. |
| store | internal/store. |
| engine | internal/engine. Its loops are detection (also called the reconciler) and apply. |
| runner | hooks.Runner: runs one hook run to the end. |
| sentinel error | An exported Err... value checked with errors.Is. |
| fake / stub | A fake is an in-memory stand-in; a stub is an httptest server with canned answers. |
| in flight | An issue with a branch or an open pull request. |
| decision log | archive/decisions.md: decisions up to v0.4.0, no longer updated. |
Go conventions¶
- English everywhere: code, comments, docs, commits.
gofmt,go vetandstaticcheck.- Short, singular package names. No
utilorcommon. context.Contextfirst in every function that does I/O.- Every state change goes through
Store.Transition. - Wrap errors with context:
fmt.Errorf("dispatch hook %s: %w", id, err). Export sentinels only when callers need them. - Consumers define small interfaces.
- No
init()with side effects, no global state; inject time. - Log with
log/slog. - Every flag has its
NOPS_*variable; every interval and timeout is configurable. - Prefer an established package to writing a new library.