Compare commits

..
Author SHA1 Message Date
sneak f8e04d2598 Merge branch 'main' into TODO
check / check (push) Successful in 36s
2026-07-06 21:14:13 +02:00
sneak 58afa7394d Add standard Workflow section to TODO.md 2026-07-06 21:06:49 +02:00
sneak 7055f29005 Add TODO.md 2026-07-06 20:35:52 +02:00
28 changed files with 337 additions and 924 deletions
-1
View File
@@ -1,6 +1,5 @@
.git/ .git/
bin/ bin/
.lint-cache/
*.md *.md
LICENSE LICENSE
.editorconfig .editorconfig
+1 -1
View File
@@ -6,4 +6,4 @@ jobs:
steps: steps:
# actions/checkout v4.2.2, 2026-02-28 # actions/checkout v4.2.2, 2026-02-28
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild - run: docker build .
-1
View File
@@ -1,7 +1,6 @@
bin/ bin/
vendor/ vendor/
data/ data/
.lint-cache/
.env .env
*.exe *.exe
/dnswatcher /dnswatcher
+12 -14
View File
@@ -1,9 +1,5 @@
version: "2" version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run: run:
timeout: 5m timeout: 5m
modules-download-mode: readonly modules-download-mode: readonly
@@ -18,17 +14,19 @@ linters:
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings:
lll: linters-settings:
line-length: 88 lll:
funlen: line-length: 88
lines: 80 funlen:
statements: 50 lines: 80
cyclop: statements: 50
max-complexity: 15 cyclop:
dupl: max-complexity: 15
threshold: 100 dupl:
threshold: 100
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
+2 -2
View File
@@ -4,8 +4,8 @@ FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4
RUN apk add --no-cache git make gcc musl-dev binutils-gold RUN apk add --no-cache git make gcc musl-dev binutils-gold
# golangci-lint v2.12.2, 2026-08-07 # golangci-lint v2.10.1
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5 RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee
# goimports v0.42.0 # goimports v0.42.0
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0
+28 -24
View File
@@ -1,44 +1,48 @@
.PHONY: all bootstrap setup build lint fmt fmt-check test check clean hooks docker .PHONY: all build lint fmt fmt-check test check clean hooks docker
BINARY := dnswatcher BINARY := dnswatcher
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -X main.Version=$(VERSION) LDFLAGS := -X main.Version=$(VERSION)
# Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
# of README.md).
all: check build all: check build
bootstrap:
@script/bootstrap
setup:
@script/setup
build: build:
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/dnswatcher go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) ./cmd/dnswatcher
test:
@script/test
lint: lint:
@script/lint golangci-lint run --config .golangci.yml ./...
fmt: fmt:
@script/fmt gofmt -s -w .
goimports -w .
fmt-check: fmt-check:
@script/fmt-check @test -z "$$(gofmt -l .)" || (echo "gofmt: files not formatted:" && gofmt -l . && exit 1)
test:
go test -v -race -timeout 30s -cover ./...
# Check runs all validation without making changes
# Used by CI and Docker build - fails if anything is wrong
check: check:
@script/check @echo "==> Checking formatting..."
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
docker: @echo "==> Running linter..."
@script/docker golangci-lint run --config .golangci.yml ./...
@echo "==> Running tests..."
hooks: go test -v -race -timeout 30s ./...
@script/install-precommit @echo "==> Building..."
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/dnswatcher
@echo "==> All checks passed!"
clean: clean:
rm -rf bin/ rm -rf bin/
hooks:
@echo '#!/bin/sh' > .git/hooks/pre-commit
@echo 'make check' >> .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Pre-commit hook installed."
docker:
docker build .
+1 -50
View File
@@ -17,26 +17,6 @@ without requiring an external database.
--- ---
## No DNS mocking. Ever.
**DNS is never mocked in this project — not in tests, not anywhere else.**
No mock resolvers, no fake DNS servers, no stubbed lookups.
dnswatcher's entire purpose is correct behavior against the real DNS.
Tests exercise real iterative resolution against live nameservers by
design; a test suite that passes against a mock proves nothing about the
one thing this program exists to do.
When live tests are flaky, that is a robustness problem, and it gets
fixed with robustness: retries with backoff, querying multiple
independent nameservers, longer timeouts — or explicit opt-in gating
decided by the project owner. Never with mocks.
Contributions that introduce mocked, faked, or stubbed DNS will be
rejected.
---
## Features ## Features
### DNS Domain Monitoring (Apex Domains) ### DNS Domain Monitoring (Apex Domains)
@@ -372,35 +352,6 @@ tracks reachability:
--- ---
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call
them. We provide:
- `script/bootstrap` — install all dependencies (go, pinned
golangci-lint and goimports, `go mod download`)
- `script/setup` — make a fresh clone ready for development: bootstrap
plus the git pre-commit hook
- `script/projectname` — print the project name (used for the Docker
image tag)
- `script/test` — run the test suite (race detector, coverage)
- `script/lint` — run golangci-lint, with its cache and its lock file
isolated to this checkout (under the git-ignored `.lint-cache/`) so
concurrent checkouts on one host cannot share cache entries or
contend on a single lock
- `script/fmt` — format all code (gofmt -s, goimports)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via
`script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/precommit` — run by the git pre-commit hook; `go mod tidy`
guard, then `script/check`
- `script/install-precommit` — install the git pre-commit hook
## Building ## Building
```sh ```sh
@@ -408,7 +359,7 @@ make build # Build binary to bin/dnswatcher
make test # Run tests with race detector make test # Run tests with race detector
make lint # Run golangci-lint make lint # Run golangci-lint
make fmt # Format code make fmt # Format code
make check # Run all checks (test, lint, fmt-check) make check # Run all checks (format, lint, test, build)
make clean # Remove build artifacts make clean # Remove build artifacts
``` ```
+18 -238
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-07-06 last_modified: 2026-02-22
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -34,46 +34,10 @@ style conventions are in separate documents:
every file before committing. There are zero exceptions to this rule. every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets: - Every repo with software must have a root `Makefile` with these targets:
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes), `make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`), `make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile `make hooks` (installs pre-commit hook). A model Makefile is at
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`. `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.) - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single instead of invoking the underlying tools directly. The Makefile is the single
@@ -93,83 +57,11 @@ style conventions are in separate documents:
as a build step so the build fails if the branch is not green. For non-server as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build `make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development stage before the final image is assembled.
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs `docker build .`) on push. Since the runs `docker build .` on push. Since the Dockerfile already runs `make check`,
Dockerfile already runs `make check`, a successful build implies all checks a successful build implies all checks pass.
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -177,11 +69,9 @@ style conventions are in separate documents:
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown, Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`. HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local - Pre-commit hook: `make check` if local testing is possible, otherwise
testing is not possible in the repo, `script/precommit` may skip `script/test` `make lint && make fmt-check`. The Makefile should provide a `make hooks`
and run only `script/lint` and `script/fmt-check`. The hook is installed by target to install the pre-commit hook.
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard - All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
@@ -192,42 +82,6 @@ style conventions are in separate documents:
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the - `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile. Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes. - Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary - `make check` must not modify any files in the repo. Tests may use temporary
@@ -244,13 +98,6 @@ style conventions are in separate documents:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo. a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name. - Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`. - Never force-push to `main`.
@@ -274,76 +121,12 @@ style conventions are in separate documents:
- Dockerized web services listen on port 8080 by default, overridable with - Dockerized web services listen on port 8080 by default, overridable with
`PORT`. `PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections: - `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose, - **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example: category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives "µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime." git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block. - **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist? - **Rationale**: Why does this exist?
- **Design**: How is the program structured? - **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put - **TODO**: Update meticulously, even between commits. When planning, put
@@ -362,13 +145,13 @@ style conventions are in separate documents:
- Database migrations live in `internal/db/migrations/` and must be embedded in - Database migrations live in `internal/db/migrations/` and must be embedded in
the binary. the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations - `000_migration.sql` — contains ONLY the creation of the migrations tracking
tracking table itself. Nothing else. table itself. Nothing else.
- `001_schema.sql` — the full application schema. - `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.). - **Pre-1.0.0:** never add additional migration files (002, 003, etc.). There
There is no installed base to migrate. Edit `001_schema.sql` directly. is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change. - **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release. Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation - All repos should have an `.editorconfig` enforcing the project's indentation
settings. settings.
@@ -398,9 +181,6 @@ style conventions are in separate documents:
- `README.md`, `.git`, `.gitignore`, `.editorconfig` - `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo) - `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile` - `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore` - `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml` - `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`
-19
View File
@@ -25,25 +25,6 @@ confirm make check still passes.
# Completed Steps # Completed Steps
- 2026-08-09: `script/lint` now isolates golangci-lint's per-user global
state to the checkout (#121): `GOLANGCI_LINT_CACHE` and `TMPDIR` are
both pointed at the git-ignored, Docker-ignored `.lint-cache/`. The
cache fixes cross-contamination; `TMPDIR` is what moves the lock,
which lives at `$TMPDIR/golangci-lint.lock` and not in the cache
directory. Reproduced both failure modes on the unfixed script (10 of
12 concurrent runs void with `parallel golangci-lint is running`; 11
of 12 reporting another checkout's paths) and both are gone at 20-way
concurrency after the fix
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the
org-standard v2-schema config used across the org's repos
(owner-authorized; same file is being landed as canonical via prompts
PR #24), with settings under `linters.settings` so the
lll/funlen/cyclop/dupl thresholds apply; fixed the resulting
`goconst`, `dupl`, and `lll` findings; the informational `gomodguard`
deprecation warning under this config is accepted
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
- 2026-02-20: iterative DNS resolver implemented; tests made hermetic - 2026-02-20: iterative DNS resolver implemented; tests made hermetic
with mocked DNS (origin/feature/resolver, unmerged) with mocked DNS (origin/feature/resolver, unmerged)
- 2026-02-20: CI actions and go install refs pinned to commit SHAs; - 2026-02-20: CI actions and go install refs pinned to commit SHAs;
+5 -25
View File
@@ -17,33 +17,13 @@ func TestClassifyDNSName(t *testing.T) {
}{ }{
{name: "apex domain simple", input: "example.com", want: config.DNSNameTypeDomain}, {name: "apex domain simple", input: "example.com", want: config.DNSNameTypeDomain},
{name: "hostname simple", input: "www.example.com", want: config.DNSNameTypeHostname}, {name: "hostname simple", input: "www.example.com", want: config.DNSNameTypeHostname},
{ {name: "apex domain multi-part TLD", input: "example.co.uk", want: config.DNSNameTypeDomain},
name: "apex domain multi-part TLD", {name: "hostname multi-part TLD", input: "api.example.co.uk", want: config.DNSNameTypeHostname},
input: "example.co.uk",
want: config.DNSNameTypeDomain,
},
{
name: "hostname multi-part TLD",
input: "api.example.co.uk",
want: config.DNSNameTypeHostname,
},
{name: "public suffix itself", input: "co.uk", wantErr: true}, {name: "public suffix itself", input: "co.uk", wantErr: true},
{name: "empty string", input: "", wantErr: true}, {name: "empty string", input: "", wantErr: true},
{ {name: "deeply nested hostname", input: "a.b.c.example.com", want: config.DNSNameTypeHostname},
name: "deeply nested hostname", {name: "trailing dot stripped", input: "example.com.", want: config.DNSNameTypeDomain},
input: "a.b.c.example.com", {name: "uppercase normalized", input: "WWW.Example.COM", want: config.DNSNameTypeHostname},
want: config.DNSNameTypeHostname,
},
{
name: "trailing dot stripped",
input: "example.com.",
want: config.DNSNameTypeDomain,
},
{
name: "uppercase normalized",
input: "WWW.Example.COM",
want: config.DNSNameTypeHostname,
},
} }
for _, tt := range tests { for _, tt := range tests {
+138 -119
View File
@@ -25,20 +25,6 @@ const (
colorDefault = "#6c757d" colorDefault = "#6c757d"
) )
// Priority strings used across multiple tests.
const (
prioError = "error"
prioWarning = "warning"
prioSuccess = "success"
prioInfo = "info"
prioUnknown = "unknown"
prioDefault = "default"
prioUrgent = "urgent"
)
// testHost is the hostname used in request construction tests.
const testHost = "example.com"
// errSimulated is a static error for transport failures. // errSimulated is a static error for transport failures.
var errSimulated = errors.New("simulated transport failure") var errSimulated = errors.New("simulated transport failure")
@@ -115,13 +101,13 @@ func TestNtfyPriority(t *testing.T) {
input string input string
want string want string
}{ }{
{prioError, prioUrgent}, {"error", "urgent"},
{prioWarning, "high"}, {"warning", "high"},
{prioSuccess, prioDefault}, {"success", "default"},
{prioInfo, "low"}, {"info", "low"},
{"", prioDefault}, {"", "default"},
{prioUnknown, prioDefault}, {"unknown", "default"},
{"critical", prioDefault}, {"critical", "default"},
} }
for _, tc := range cases { for _, tc := range cases {
@@ -148,12 +134,12 @@ func TestSlackColor(t *testing.T) {
input string input string
want string want string
}{ }{
{prioError, colorError}, {"error", colorError},
{prioWarning, colorWarning}, {"warning", colorWarning},
{prioSuccess, colorSuccess}, {"success", colorSuccess},
{prioInfo, colorInfo}, {"info", colorInfo},
{"", colorDefault}, {"", colorDefault},
{prioUnknown, colorDefault}, {"unknown", colorDefault},
{"critical", colorDefault}, {"critical", colorDefault},
} }
@@ -179,7 +165,7 @@ func TestNewRequest(t *testing.T) {
target := &url.URL{ target := &url.URL{
Scheme: "https", Scheme: "https",
Host: testHost, Host: "example.com",
Path: "/webhook", Path: "/webhook",
} }
body := bytes.NewBufferString("hello") body := bytes.NewBufferString("hello")
@@ -201,9 +187,9 @@ func TestNewRequest(t *testing.T) {
) )
} }
if req.Host != testHost { if req.Host != "example.com" {
t.Errorf( t.Errorf(
"Host = %q, want %q", req.Host, testHost, "Host = %q, want %q", req.Host, "example.com",
) )
} }
@@ -231,7 +217,7 @@ func TestNewRequestPreservesContext(t *testing.T) {
ctxKey("k"), ctxKey("k"),
"v", "v",
) )
target := &url.URL{Scheme: "https", Host: testHost} target := &url.URL{Scheme: "https", Host: "example.com"}
req := notify.NewRequestForTest( req := notify.NewRequestForTest(
ctx, http.MethodGet, target, http.NoBody, ctx, http.MethodGet, target, http.NoBody,
@@ -303,10 +289,10 @@ func TestSendNtfyHeaders(t *testing.T) {
) )
} }
if captured.priority != prioUrgent { if captured.priority != "urgent" {
t.Errorf( t.Errorf(
"Priority header = %q, want %q", "Priority header = %q, want %q",
captured.priority, prioUrgent, captured.priority, "urgent",
) )
} }
@@ -325,10 +311,10 @@ func TestSendNtfyAllPriorities(t *testing.T) {
input string input string
want string want string
}{ }{
{prioError, prioUrgent}, {"error", "urgent"},
{prioWarning, "high"}, {"warning", "high"},
{prioSuccess, prioDefault}, {"success", "default"},
{prioInfo, "low"}, {"info", "low"},
} }
for _, tc := range priorities { for _, tc := range priorities {
@@ -370,69 +356,56 @@ func TestSendNtfyAllPriorities(t *testing.T) {
} }
} }
// assertSendStatusError verifies that send returns an error func TestSendNtfyClientError(t *testing.T) {
// wrapping wantErr when the server responds with status. t.Parallel()
func assertSendStatusError(
t *testing.T,
status int,
wantErr error,
send func(*notify.Service, *url.URL) error,
) {
t.Helper()
srv := httptest.NewServer( srv := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status) w.WriteHeader(http.StatusForbidden)
}), }),
) )
defer srv.Close() defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport) svc := notify.NewTestService(srv.Client().Transport)
target, _ := url.Parse(srv.URL) topicURL, _ := url.Parse(srv.URL)
err := send(svc, target) err := svc.SendNtfy(
context.Background(), topicURL, "t", "m", "info",
)
if err == nil { if err == nil {
t.Fatalf("expected error for %d response", status) t.Fatal("expected error for 403 response")
} }
if !errors.Is(err, wantErr) { if !errors.Is(err, notify.ErrNtfyFailed) {
t.Errorf("error = %v, want %v", err, wantErr) t.Errorf("error = %v, want ErrNtfyFailed", err)
} }
} }
func sendNtfyInfo(
svc *notify.Service, target *url.URL,
) error {
return svc.SendNtfy(
context.Background(), target, "t", "m", prioInfo,
)
}
func sendSlackInfo(
svc *notify.Service, target *url.URL,
) error {
return svc.SendSlack(
context.Background(), target, "t", "m", prioInfo,
)
}
func TestSendNtfyClientError(t *testing.T) {
t.Parallel()
assertSendStatusError(
t, http.StatusForbidden,
notify.ErrNtfyFailed, sendNtfyInfo,
)
}
func TestSendNtfyServerError(t *testing.T) { func TestSendNtfyServerError(t *testing.T) {
t.Parallel() t.Parallel()
assertSendStatusError( srv := httptest.NewServer(
t, http.StatusInternalServerError, http.HandlerFunc(
notify.ErrNtfyFailed, sendNtfyInfo, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}),
) )
defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport)
topicURL, _ := url.Parse(srv.URL)
err := svc.SendNtfy(
context.Background(), topicURL, "t", "m", "info",
)
if err == nil {
t.Fatal("expected error for 500 response")
}
if !errors.Is(err, notify.ErrNtfyFailed) {
t.Errorf("error = %v, want ErrNtfyFailed", err)
}
} }
func TestSendNtfySuccess(t *testing.T) { func TestSendNtfySuccess(t *testing.T) {
@@ -577,11 +550,11 @@ func TestSendSlackAllColors(t *testing.T) {
priority string priority string
want string want string
}{ }{
{prioError, colorError}, {"error", colorError},
{prioWarning, colorWarning}, {"warning", colorWarning},
{prioSuccess, colorSuccess}, {"success", colorSuccess},
{prioInfo, colorInfo}, {"info", colorInfo},
{prioUnknown, colorDefault}, {"unknown", colorDefault},
} }
for _, tc := range colors { for _, tc := range colors {
@@ -633,19 +606,53 @@ func TestSendSlackAllColors(t *testing.T) {
func TestSendSlackClientError(t *testing.T) { func TestSendSlackClientError(t *testing.T) {
t.Parallel() t.Parallel()
assertSendStatusError( srv := httptest.NewServer(
t, http.StatusBadRequest, http.HandlerFunc(
notify.ErrSlackFailed, sendSlackInfo, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}),
) )
defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport)
webhookURL, _ := url.Parse(srv.URL)
err := svc.SendSlack(
context.Background(), webhookURL, "t", "m", "info",
)
if err == nil {
t.Fatal("expected error for 400 response")
}
if !errors.Is(err, notify.ErrSlackFailed) {
t.Errorf("error = %v, want ErrSlackFailed", err)
}
} }
func TestSendSlackServerError(t *testing.T) { func TestSendSlackServerError(t *testing.T) {
t.Parallel() t.Parallel()
assertSendStatusError( srv := httptest.NewServer(
t, http.StatusBadGateway, http.HandlerFunc(
notify.ErrSlackFailed, sendSlackInfo, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}),
) )
defer srv.Close()
svc := notify.NewTestService(srv.Client().Transport)
webhookURL, _ := url.Parse(srv.URL)
err := svc.SendSlack(
context.Background(), webhookURL, "t", "m", "error",
)
if err == nil {
t.Fatal("expected error for 502 response")
}
if !errors.Is(err, notify.ErrSlackFailed) {
t.Errorf("error = %v, want ErrSlackFailed", err)
}
} }
func TestSendSlackNetworkError(t *testing.T) { func TestSendSlackNetworkError(t *testing.T) {
@@ -970,62 +977,74 @@ func TestSendNotificationMattermostOnly(t *testing.T) {
} }
} }
// assertSendNotificationTolerates verifies SendNotification func TestSendNotificationNtfyError(t *testing.T) {
// neither panics nor blocks when the endpoint configured by t.Parallel()
// setURL responds with status.
func assertSendNotificationTolerates(
t *testing.T,
status int,
priority string,
setURL func(*notify.Service, *url.URL),
) {
t.Helper()
srv := httptest.NewServer( srv := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status) w.WriteHeader(http.StatusInternalServerError)
}), }),
) )
defer srv.Close() defer srv.Close()
target, _ := url.Parse(srv.URL) ntfyURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport) svc := notify.NewTestService(http.DefaultTransport)
setURL(svc, target) svc.SetNtfyURL(ntfyURL)
// Should not panic or block.
svc.SendNotification( svc.SendNotification(
context.Background(), "t", "m", priority, context.Background(), "t", "m", "error",
) )
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
} }
func TestSendNotificationNtfyError(t *testing.T) {
t.Parallel()
assertSendNotificationTolerates(
t, http.StatusInternalServerError, prioError,
(*notify.Service).SetNtfyURL,
)
}
func TestSendNotificationSlackError(t *testing.T) { func TestSendNotificationSlackError(t *testing.T) {
t.Parallel() t.Parallel()
assertSendNotificationTolerates( srv := httptest.NewServer(
t, http.StatusForbidden, prioError, http.HandlerFunc(
(*notify.Service).SetSlackWebhookURL, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}),
) )
defer srv.Close()
slackURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport)
svc.SetSlackWebhookURL(slackURL)
svc.SendNotification(
context.Background(), "t", "m", "error",
)
time.Sleep(100 * time.Millisecond)
} }
func TestSendNotificationMattermostError(t *testing.T) { func TestSendNotificationMattermostError(t *testing.T) {
t.Parallel() t.Parallel()
assertSendNotificationTolerates( srv := httptest.NewServer(
t, http.StatusBadGateway, prioWarning, http.HandlerFunc(
(*notify.Service).SetMattermostWebhookURL, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}),
) )
defer srv.Close()
mmURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport)
svc.SetMattermostWebhookURL(mmURL)
svc.SendNotification(
context.Background(), "t", "m", "warning",
)
time.Sleep(100 * time.Millisecond)
} }
// ── SlackPayload JSON marshaling ────────────────────────── // ── SlackPayload JSON marshaling ──────────────────────────
+2 -2
View File
@@ -29,14 +29,14 @@ func TestAlertHistoryAddAndRecent(t *testing.T) {
Timestamp: now.Add(-2 * time.Minute), Timestamp: now.Add(-2 * time.Minute),
Title: "first", Title: "first",
Message: "msg1", Message: "msg1",
Priority: prioInfo, Priority: "info",
}) })
h.Add(notify.AlertEntry{ h.Add(notify.AlertEntry{
Timestamp: now.Add(-1 * time.Minute), Timestamp: now.Add(-1 * time.Minute),
Title: "second", Title: "second",
Message: "msg2", Message: "msg2",
Priority: prioWarning, Priority: "warning",
}) })
entries := h.Recent() entries := h.Recent()
+1 -1
View File
@@ -69,7 +69,7 @@ func (rc RetryConfig) backoff(attempt int) time.Duration {
lo := raw * (1 - jitterFraction) lo := raw * (1 - jitterFraction)
hi := raw * (1 + jitterFraction) hi := raw * (1 + jitterFraction)
jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter needs no crypto/rand jittered := lo + rand.Float64()*(hi-lo) //nolint:gosec // jitter does not need crypto/rand
return time.Duration(jittered) return time.Duration(jittered)
} }
+25 -37
View File
@@ -13,16 +13,6 @@ import (
const testHostname = "www.example.com" const testHostname = "www.example.com"
// Shared fixture values used across tests.
const (
testNS1 = "ns1.example.com."
testNS2 = "ns2.example.com."
testAltNS1 = "ns1.test.com."
testIPv4 = "93.184.216.34"
testIP = "1.2.3.4"
statusError = "error"
)
// populateState fills a State with representative test data across all categories. // populateState fills a State with representative test data across all categories.
func populateState(t *testing.T, s *state.State) { func populateState(t *testing.T, s *state.State) {
t.Helper() t.Helper()
@@ -30,7 +20,7 @@ func populateState(t *testing.T, s *state.State) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
s.SetDomainState("example.com", &state.DomainState{ s.SetDomainState("example.com", &state.DomainState{
Nameservers: []string{testNS1, testNS2}, Nameservers: []string{"ns1.example.com.", "ns2.example.com."},
LastChecked: now, LastChecked: now,
}) })
@@ -41,17 +31,17 @@ func populateState(t *testing.T, s *state.State) {
s.SetHostnameState(testHostname, &state.HostnameState{ s.SetHostnameState(testHostname, &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
testNS1: { "ns1.example.com.": {
Records: map[string][]string{ Records: map[string][]string{
"A": {testIPv4}, "A": {"93.184.216.34"},
"AAAA": {"2606:2800:220:1:248:1893:25c8:1946"}, "AAAA": {"2606:2800:220:1:248:1893:25c8:1946"},
}, },
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
}, },
testNS2: { "ns2.example.com.": {
Records: map[string][]string{ Records: map[string][]string{
"A": {testIPv4}, "A": {"93.184.216.34"},
}, },
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
@@ -162,13 +152,13 @@ func TestSaveLoadRoundTrip_Hostnames(t *testing.T) {
func verifyNS1Records(t *testing.T, hn *state.HostnameState) { func verifyNS1Records(t *testing.T, hn *state.HostnameState) {
t.Helper() t.Helper()
ns1, ok := hn.RecordsByNameserver[testNS1] ns1, ok := hn.RecordsByNameserver["ns1.example.com."]
if !ok { if !ok {
t.Fatal("missing nameserver ns1.example.com.") t.Fatal("missing nameserver ns1.example.com.")
} }
aRecords := ns1.Records["A"] aRecords := ns1.Records["A"]
if len(aRecords) != 1 || aRecords[0] != testIPv4 { if len(aRecords) != 1 || aRecords[0] != "93.184.216.34" {
t.Errorf("ns1 A records: got %v", aRecords) t.Errorf("ns1 A records: got %v", aRecords)
} }
@@ -223,8 +213,7 @@ func TestSaveLoadRoundTrip_Ports(t *testing.T) {
} }
} }
// TestSaveLoadRoundTrip_Certificates verifies certificate data // TestSaveLoadRoundTrip_Certificates verifies certificate data survives a save/load cycle.
// survives a save/load cycle.
func TestSaveLoadRoundTrip_Certificates(t *testing.T) { func TestSaveLoadRoundTrip_Certificates(t *testing.T) {
t.Parallel() t.Parallel()
@@ -664,7 +653,7 @@ func TestDomainState_GetSet(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
ds := &state.DomainState{ ds := &state.DomainState{
Nameservers: []string{testAltNS1}, Nameservers: []string{"ns1.test.com."},
LastChecked: now, LastChecked: now,
} }
@@ -675,7 +664,7 @@ func TestDomainState_GetSet(t *testing.T) {
t.Fatal("expected true for existing domain") t.Fatal("expected true for existing domain")
} }
if len(got.Nameservers) != 1 || got.Nameservers[0] != testAltNS1 { if len(got.Nameservers) != 1 || got.Nameservers[0] != "ns1.test.com." {
t.Errorf("nameservers: got %v", got.Nameservers) t.Errorf("nameservers: got %v", got.Nameservers)
} }
@@ -685,7 +674,7 @@ func TestDomainState_GetSet(t *testing.T) {
// Overwrite. // Overwrite.
ds2 := &state.DomainState{ ds2 := &state.DomainState{
Nameservers: []string{testAltNS1, "ns2.test.com."}, Nameservers: []string{"ns1.test.com.", "ns2.test.com."},
LastChecked: now.Add(time.Hour), LastChecked: now.Add(time.Hour),
} }
@@ -715,8 +704,8 @@ func TestHostnameState_GetSet(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
hs := &state.HostnameState{ hs := &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
testNS1: { "ns1.example.com.": {
Records: map[string][]string{"A": {testIP}}, Records: map[string][]string{"A": {"1.2.3.4"}},
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
}, },
@@ -731,7 +720,7 @@ func TestHostnameState_GetSet(t *testing.T) {
t.Fatal("expected true for existing hostname") t.Fatal("expected true for existing hostname")
} }
nsState, ok := got.RecordsByNameserver[testNS1] nsState, ok := got.RecordsByNameserver["ns1.example.com."]
if !ok { if !ok {
t.Fatal("missing nameserver entry") t.Fatal("missing nameserver entry")
} }
@@ -741,7 +730,7 @@ func TestHostnameState_GetSet(t *testing.T) {
} }
aRecords := nsState.Records["A"] aRecords := nsState.Records["A"]
if len(aRecords) != 1 || aRecords[0] != testIP { if len(aRecords) != 1 || aRecords[0] != "1.2.3.4" {
t.Errorf("A records: got %v", aRecords) t.Errorf("A records: got %v", aRecords)
} }
} }
@@ -880,7 +869,7 @@ func TestCertificateState_ErrorField(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
cs := &state.CertificateState{ cs := &state.CertificateState{
Status: statusError, Status: "error",
Error: "connection refused", Error: "connection refused",
LastChecked: now, LastChecked: now,
} }
@@ -904,8 +893,8 @@ func TestCertificateState_ErrorField(t *testing.T) {
t.Fatal("missing certificate after load") t.Fatal("missing certificate after load")
} }
if got.Status != statusError { if got.Status != "error" {
t.Errorf("status: got %q, want %q", got.Status, statusError) t.Errorf("status: got %q, want %q", got.Status, "error")
} }
if got.Error != "connection refused" { if got.Error != "connection refused" {
@@ -923,9 +912,9 @@ func TestHostnameState_ErrorField(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second) now := time.Now().UTC().Truncate(time.Second)
hs := &state.HostnameState{ hs := &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
testNS1: { "ns1.example.com.": {
Records: nil, Records: nil,
Status: statusError, Status: "error",
Error: "SERVFAIL", Error: "SERVFAIL",
LastChecked: now, LastChecked: now,
}, },
@@ -952,9 +941,9 @@ func TestHostnameState_ErrorField(t *testing.T) {
t.Fatal("missing hostname after load") t.Fatal("missing hostname after load")
} }
nsState := got.RecordsByNameserver[testNS1] nsState := got.RecordsByNameserver["ns1.example.com."]
if nsState.Status != statusError { if nsState.Status != "error" {
t.Errorf("status: got %q, want %q", nsState.Status, statusError) t.Errorf("status: got %q, want %q", nsState.Status, "error")
} }
if nsState.Error != "SERVFAIL" { if nsState.Error != "SERVFAIL" {
@@ -1073,8 +1062,7 @@ func TestConcurrentGetSet(t *testing.T) {
wg.Wait() wg.Wait()
} }
// runConcurrentOps performs a series of get/set/delete // runConcurrentOps performs a series of get/set/delete operations for concurrency testing.
// operations for concurrency testing.
func runConcurrentOps(s *state.State, key string, now time.Time) { func runConcurrentOps(s *state.State, key string, now time.Time) {
const iterations = 50 const iterations = 50
@@ -1097,7 +1085,7 @@ func runConcurrentOps(s *state.State, key string, now time.Time) {
s.SetHostnameState(key+".example.com", &state.HostnameState{ s.SetHostnameState(key+".example.com", &state.HostnameState{
RecordsByNameserver: map[string]*state.NameserverRecordState{ RecordsByNameserver: map[string]*state.NameserverRecordState{
"ns1.test.": { "ns1.test.": {
Records: map[string][]string{"A": {testIP}}, Records: map[string][]string{"A": {"1.2.3.4"}},
Status: "ok", Status: "ok",
LastChecked: now, LastChecked: now,
}, },
+7 -13
View File
@@ -26,12 +26,6 @@ const tlsPort = 443
// hoursPerDay converts days to hours for duration calculations. // hoursPerDay converts days to hours for duration calculations.
const hoursPerDay = 24 const hoursPerDay = 24
// Status values recorded for nameserver and certificate checks.
const (
statusOK = "ok"
statusError = "error"
)
// Params contains dependencies for Watcher. // Params contains dependencies for Watcher.
type Params struct { type Params struct {
fx.In fx.In
@@ -350,7 +344,7 @@ func buildHostnameState(
for ns, recs := range records { for ns, recs := range records {
hs.RecordsByNameserver[ns] = &state.NameserverRecordState{ hs.RecordsByNameserver[ns] = &state.NameserverRecordState{
Records: recs, Records: recs,
Status: statusOK, Status: "ok",
LastChecked: now, LastChecked: now,
} }
} }
@@ -408,7 +402,7 @@ func (w *Watcher) detectNSDisappearances(
current map[string]map[string][]string, current map[string]map[string][]string,
) { ) {
for ns, prevNS := range prev.RecordsByNameserver { for ns, prevNS := range prev.RecordsByNameserver {
if _, ok := current[ns]; ok || prevNS.Status != statusOK { if _, ok := current[ns]; ok || prevNS.Status != "ok" {
continue continue
} }
@@ -427,7 +421,7 @@ func (w *Watcher) detectNSDisappearances(
for ns := range current { for ns := range current {
prevNS, ok := prev.RecordsByNameserver[ns] prevNS, ok := prev.RecordsByNameserver[ns]
if !ok || prevNS.Status != statusError { if !ok || prevNS.Status != "error" {
continue continue
} }
@@ -711,7 +705,7 @@ func (w *Watcher) handleTLSError(
now time.Time, now time.Time,
err error, err error,
) { ) {
if hasPrev && !w.firstRun && prev.Status == statusOK { if hasPrev && !w.firstRun && prev.Status == "ok" {
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Host: %s\nIP: %s\nError: %s", "Host: %s\nIP: %s\nError: %s",
hostname, ip, err, hostname, ip, err,
@@ -727,7 +721,7 @@ func (w *Watcher) handleTLSError(
w.state.SetCertificateState( w.state.SetCertificateState(
certKey, &state.CertificateState{ certKey, &state.CertificateState{
Status: statusError, Status: "error",
Error: err.Error(), Error: err.Error(),
LastChecked: now, LastChecked: now,
}, },
@@ -754,7 +748,7 @@ func (w *Watcher) handleTLSSuccess(
Issuer: cert.Issuer, Issuer: cert.Issuer,
NotAfter: cert.NotAfter, NotAfter: cert.NotAfter,
SubjectAlternativeNames: cert.SubjectAlternativeNames, SubjectAlternativeNames: cert.SubjectAlternativeNames,
Status: statusOK, Status: "ok",
LastChecked: now, LastChecked: now,
}, },
) )
@@ -766,7 +760,7 @@ func (w *Watcher) detectTLSChanges(
prev *state.CertificateState, prev *state.CertificateState,
cert *tlscheck.CertificateInfo, cert *tlscheck.CertificateInfo,
) { ) {
if prev.Status == statusError { if prev.Status == "error" {
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Host: %s\nIP: %s\nTLS recovered", "Host: %s\nIP: %s\nTLS recovered",
hostname, ip, hostname, ip,
+97 -108
View File
@@ -18,17 +18,6 @@ import (
// errNotFound is returned when mock data is missing. // errNotFound is returned when mock data is missing.
var errNotFound = errors.New("not found") var errNotFound = errors.New("not found")
// Fixture values shared across tests.
const (
testDomain = "example.com"
testHost = "www.example.com"
testNS1 = "ns1.example.com."
testNS2 = "ns2.example.com."
testIPv4 = "93.184.216.34"
testIP = "1.2.3.4"
testIssuer = "DigiCert"
)
// --- Mock implementations --- // --- Mock implementations ---
type mockResolver struct { type mockResolver struct {
@@ -267,8 +256,8 @@ func TestFirstRunBaseline(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain} cfg.Domains = []string{"example.com"}
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{"www.example.com"}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
setupBaselineMocks(deps) setupBaselineMocks(deps)
@@ -280,37 +269,37 @@ func TestFirstRunBaseline(t *testing.T) {
} }
func setupBaselineMocks(deps *testDeps) { func setupBaselineMocks(deps *testDeps) {
deps.resolver.nsRecords[testDomain] = []string{ deps.resolver.nsRecords["example.com"] = []string{
testNS1, "ns1.example.com.",
testNS2, "ns2.example.com.",
} }
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
testNS1: {"A": {testIPv4}}, "ns1.example.com.": {"A": {"93.184.216.34"}},
testNS2: {"A": {testIPv4}}, "ns2.example.com.": {"A": {"93.184.216.34"}},
} }
deps.resolver.allRecords[testHost] = map[string]map[string][]string{ deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
testNS1: {"A": {testIPv4}}, "ns1.example.com.": {"A": {"93.184.216.34"}},
testNS2: {"A": {testIPv4}}, "ns2.example.com.": {"A": {"93.184.216.34"}},
} }
deps.resolver.ipAddresses[testHost] = []string{ deps.resolver.ipAddresses["www.example.com"] = []string{
testIPv4, "93.184.216.34",
} }
deps.portChecker.results["93.184.216.34:80"] = true deps.portChecker.results["93.184.216.34:80"] = true
deps.portChecker.results["93.184.216.34:443"] = true deps.portChecker.results["93.184.216.34:443"] = true
deps.tlsChecker.certs["93.184.216.34:www.example.com"] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs["93.184.216.34:www.example.com"] = &tlscheck.CertificateInfo{
CommonName: testHost, CommonName: "www.example.com",
Issuer: testIssuer, Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{ SubjectAlternativeNames: []string{
testHost, "www.example.com",
}, },
} }
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
CommonName: testDomain, CommonName: "example.com",
Issuer: testIssuer, Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{ SubjectAlternativeNames: []string{
testDomain, "example.com",
}, },
} }
} }
@@ -359,24 +348,24 @@ func TestDomainPortAndTLSChecks(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain} cfg.Domains = []string{"example.com"}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.nsRecords[testDomain] = []string{ deps.resolver.nsRecords["example.com"] = []string{
testNS1, "ns1.example.com.",
} }
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
testNS1: {"A": {testIPv4}}, "ns1.example.com.": {"A": {"93.184.216.34"}},
} }
deps.portChecker.results["93.184.216.34:80"] = true deps.portChecker.results["93.184.216.34:80"] = true
deps.portChecker.results["93.184.216.34:443"] = true deps.portChecker.results["93.184.216.34:443"] = true
deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs["93.184.216.34:example.com"] = &tlscheck.CertificateInfo{
CommonName: testDomain, CommonName: "example.com",
Issuer: testIssuer, Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{ SubjectAlternativeNames: []string{
testDomain, "example.com",
}, },
} }
@@ -417,17 +406,17 @@ func TestNSChangeDetection(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain} cfg.Domains = []string{"example.com"}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.nsRecords[testDomain] = []string{ deps.resolver.nsRecords["example.com"] = []string{
testNS1, "ns1.example.com.",
testNS2, "ns2.example.com.",
} }
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
testNS1: {"A": {testIP}}, "ns1.example.com.": {"A": {"1.2.3.4"}},
testNS2: {"A": {testIP}}, "ns2.example.com.": {"A": {"1.2.3.4"}},
} }
deps.portChecker.results["1.2.3.4:80"] = false deps.portChecker.results["1.2.3.4:80"] = false
deps.portChecker.results["1.2.3.4:443"] = false deps.portChecker.results["1.2.3.4:443"] = false
@@ -436,13 +425,13 @@ func TestNSChangeDetection(t *testing.T) {
w.RunOnce(ctx) w.RunOnce(ctx)
deps.resolver.mu.Lock() deps.resolver.mu.Lock()
deps.resolver.nsRecords[testDomain] = []string{ deps.resolver.nsRecords["example.com"] = []string{
testNS1, "ns1.example.com.",
"ns3.example.com.", "ns3.example.com.",
} }
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
testNS1: {"A": {testIP}}, "ns1.example.com.": {"A": {"1.2.3.4"}},
"ns3.example.com.": {"A": {testIP}}, "ns3.example.com.": {"A": {"1.2.3.4"}},
} }
deps.resolver.mu.Unlock() deps.resolver.mu.Unlock()
@@ -470,15 +459,15 @@ func TestRecordChangeDetection(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{"www.example.com"}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{ deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
testNS1: {"A": {testIPv4}}, "ns1.example.com.": {"A": {"93.184.216.34"}},
} }
deps.resolver.ipAddresses[testHost] = []string{ deps.resolver.ipAddresses["www.example.com"] = []string{
testIPv4, "93.184.216.34",
} }
deps.portChecker.results["93.184.216.34:80"] = false deps.portChecker.results["93.184.216.34:80"] = false
deps.portChecker.results["93.184.216.34:443"] = false deps.portChecker.results["93.184.216.34:443"] = false
@@ -487,10 +476,10 @@ func TestRecordChangeDetection(t *testing.T) {
w.RunOnce(ctx) w.RunOnce(ctx)
deps.resolver.mu.Lock() deps.resolver.mu.Lock()
deps.resolver.allRecords[testHost] = map[string]map[string][]string{ deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
testNS1: {"A": {"93.184.216.35"}}, "ns1.example.com.": {"A": {"93.184.216.35"}},
} }
deps.resolver.ipAddresses[testHost] = []string{ deps.resolver.ipAddresses["www.example.com"] = []string{
"93.184.216.35", "93.184.216.35",
} }
deps.resolver.mu.Unlock() deps.resolver.mu.Unlock()
@@ -512,24 +501,24 @@ func TestPortStateChange(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{"www.example.com"}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{ deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
testNS1: {"A": {testIP}}, "ns1.example.com.": {"A": {"1.2.3.4"}},
} }
deps.resolver.ipAddresses[testHost] = []string{ deps.resolver.ipAddresses["www.example.com"] = []string{
testIP, "1.2.3.4",
} }
deps.portChecker.results["1.2.3.4:80"] = true deps.portChecker.results["1.2.3.4:80"] = true
deps.portChecker.results["1.2.3.4:443"] = true deps.portChecker.results["1.2.3.4:443"] = true
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
CommonName: testHost, CommonName: "www.example.com",
Issuer: testIssuer, Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{ SubjectAlternativeNames: []string{
testHost, "www.example.com",
}, },
} }
@@ -552,24 +541,24 @@ func TestTLSExpiryWarning(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{"www.example.com"}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{ deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
testNS1: {"A": {testIP}}, "ns1.example.com.": {"A": {"1.2.3.4"}},
} }
deps.resolver.ipAddresses[testHost] = []string{ deps.resolver.ipAddresses["www.example.com"] = []string{
testIP, "1.2.3.4",
} }
deps.portChecker.results["1.2.3.4:80"] = true deps.portChecker.results["1.2.3.4:80"] = true
deps.portChecker.results["1.2.3.4:443"] = true deps.portChecker.results["1.2.3.4:443"] = true
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
CommonName: testHost, CommonName: "www.example.com",
Issuer: testIssuer, Issuer: "DigiCert",
NotAfter: time.Now().Add(3 * 24 * time.Hour), NotAfter: time.Now().Add(3 * 24 * time.Hour),
SubjectAlternativeNames: []string{ SubjectAlternativeNames: []string{
testHost, "www.example.com",
}, },
} }
@@ -603,25 +592,25 @@ func TestTLSExpiryWarningDedup(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{"www.example.com"}
cfg.TLSInterval = 24 * time.Hour cfg.TLSInterval = 24 * time.Hour
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{ deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
testNS1: {"A": {testIP}}, "ns1.example.com.": {"A": {"1.2.3.4"}},
} }
deps.resolver.ipAddresses[testHost] = []string{ deps.resolver.ipAddresses["www.example.com"] = []string{
testIP, "1.2.3.4",
} }
deps.portChecker.results["1.2.3.4:80"] = true deps.portChecker.results["1.2.3.4:80"] = true
deps.portChecker.results["1.2.3.4:443"] = true deps.portChecker.results["1.2.3.4:443"] = true
deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs["1.2.3.4:www.example.com"] = &tlscheck.CertificateInfo{
CommonName: testHost, CommonName: "www.example.com",
Issuer: testIssuer, Issuer: "DigiCert",
NotAfter: time.Now().Add(3 * 24 * time.Hour), NotAfter: time.Now().Add(3 * 24 * time.Hour),
SubjectAlternativeNames: []string{ SubjectAlternativeNames: []string{
testHost, "www.example.com",
}, },
} }
@@ -658,17 +647,17 @@ func TestGracefulShutdown(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain} cfg.Domains = []string{"example.com"}
cfg.DNSInterval = 100 * time.Millisecond cfg.DNSInterval = 100 * time.Millisecond
cfg.TLSInterval = 100 * time.Millisecond cfg.TLSInterval = 100 * time.Millisecond
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.nsRecords[testDomain] = []string{ deps.resolver.nsRecords["example.com"] = []string{
testNS1, "ns1.example.com.",
} }
deps.resolver.allRecords[testDomain] = map[string]map[string][]string{ deps.resolver.allRecords["example.com"] = map[string]map[string][]string{
testNS1: {"A": {testIP}}, "ns1.example.com.": {"A": {"1.2.3.4"}},
} }
deps.portChecker.results["1.2.3.4:80"] = false deps.portChecker.results["1.2.3.4:80"] = false
deps.portChecker.results["1.2.3.4:443"] = false deps.portChecker.results["1.2.3.4:443"] = false
@@ -698,13 +687,13 @@ func setupHostnameIP(
hostname, ip string, hostname, ip string,
) { ) {
deps.resolver.allRecords[hostname] = map[string]map[string][]string{ deps.resolver.allRecords[hostname] = map[string]map[string][]string{
testNS1: {"A": {ip}}, "ns1.example.com.": {"A": {ip}},
} }
deps.portChecker.results[ip+":80"] = true deps.portChecker.results[ip+":80"] = true
deps.portChecker.results[ip+":443"] = true deps.portChecker.results[ip+":443"] = true
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
CommonName: hostname, CommonName: hostname,
Issuer: testIssuer, Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{hostname}, SubjectAlternativeNames: []string{hostname},
} }
@@ -713,7 +702,7 @@ func setupHostnameIP(
func updateHostnameIP(deps *testDeps, hostname, ip string) { func updateHostnameIP(deps *testDeps, hostname, ip string) {
deps.resolver.mu.Lock() deps.resolver.mu.Lock()
deps.resolver.allRecords[hostname] = map[string]map[string][]string{ deps.resolver.allRecords[hostname] = map[string]map[string][]string{
testNS1: {"A": {ip}}, "ns1.example.com.": {"A": {ip}},
} }
deps.resolver.mu.Unlock() deps.resolver.mu.Unlock()
@@ -725,7 +714,7 @@ func updateHostnameIP(deps *testDeps, hostname, ip string) {
deps.tlsChecker.mu.Lock() deps.tlsChecker.mu.Lock()
deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{ deps.tlsChecker.certs[ip+":"+hostname] = &tlscheck.CertificateInfo{
CommonName: hostname, CommonName: hostname,
Issuer: testIssuer, Issuer: "DigiCert",
NotAfter: time.Now().Add(90 * 24 * time.Hour), NotAfter: time.Now().Add(90 * 24 * time.Hour),
SubjectAlternativeNames: []string{hostname}, SubjectAlternativeNames: []string{hostname},
} }
@@ -736,11 +725,11 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{"www.example.com"}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
setupHostnameIP(deps, testHost, "10.0.0.1") setupHostnameIP(deps, "www.example.com", "10.0.0.1")
ctx := t.Context() ctx := t.Context()
w.RunOnce(ctx) w.RunOnce(ctx)
@@ -751,7 +740,7 @@ func TestDNSRunsBeforePortAndTLSChecks(t *testing.T) {
} }
// DNS changes to a new IP; port and TLS must pick it up. // DNS changes to a new IP; port and TLS must pick it up.
updateHostnameIP(deps, testHost, "10.0.0.2") updateHostnameIP(deps, "www.example.com", "10.0.0.2")
w.RunOnce(ctx) w.RunOnce(ctx)
@@ -771,8 +760,8 @@ func TestSendTestNotification_Enabled(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain} cfg.Domains = []string{"example.com"}
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{"www.example.com"}
cfg.SendTestNotification = true cfg.SendTestNotification = true
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
@@ -797,8 +786,8 @@ func TestSendTestNotification_ViaRun(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain} cfg.Domains = []string{"example.com"}
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{"www.example.com"}
cfg.SendTestNotification = true cfg.SendTestNotification = true
cfg.DNSInterval = 24 * time.Hour cfg.DNSInterval = 24 * time.Hour
cfg.TLSInterval = 24 * time.Hour cfg.TLSInterval = 24 * time.Hour
@@ -844,8 +833,8 @@ func TestSendTestNotification_Disabled(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Domains = []string{testDomain} cfg.Domains = []string{"example.com"}
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{"www.example.com"}
cfg.SendTestNotification = false cfg.SendTestNotification = false
cfg.DNSInterval = 24 * time.Hour cfg.DNSInterval = 24 * time.Hour
cfg.TLSInterval = 24 * time.Hour cfg.TLSInterval = 24 * time.Hour
@@ -882,16 +871,16 @@ func TestNSFailureAndRecovery(t *testing.T) {
t.Parallel() t.Parallel()
cfg := defaultTestConfig(t) cfg := defaultTestConfig(t)
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{"www.example.com"}
w, deps := newTestWatcher(t, cfg) w, deps := newTestWatcher(t, cfg)
deps.resolver.allRecords[testHost] = map[string]map[string][]string{ deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
testNS1: {"A": {testIP}}, "ns1.example.com.": {"A": {"1.2.3.4"}},
testNS2: {"A": {testIP}}, "ns2.example.com.": {"A": {"1.2.3.4"}},
} }
deps.resolver.ipAddresses[testHost] = []string{ deps.resolver.ipAddresses["www.example.com"] = []string{
testIP, "1.2.3.4",
} }
deps.portChecker.results["1.2.3.4:80"] = false deps.portChecker.results["1.2.3.4:80"] = false
deps.portChecker.results["1.2.3.4:443"] = false deps.portChecker.results["1.2.3.4:443"] = false
@@ -901,8 +890,8 @@ func TestNSFailureAndRecovery(t *testing.T) {
w.RunOnce(ctx) w.RunOnce(ctx)
deps.resolver.mu.Lock() deps.resolver.mu.Lock()
deps.resolver.allRecords[testHost] = map[string]map[string][]string{ deps.resolver.allRecords["www.example.com"] = map[string]map[string][]string{
testNS1: {"A": {testIP}}, "ns1.example.com.": {"A": {"1.2.3.4"}},
} }
deps.resolver.mu.Unlock() deps.resolver.mu.Unlock()
-82
View File
@@ -1,82 +0,0 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present.
# golangci-lint and goimports are installed via `go install` at the same
# pinned commits the Dockerfile uses (never "latest").
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-08-07 (same pins as the Dockerfile)
# golangci-lint v2.12.2
GOLANGCI_LINT_REF="github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5"
# goimports v0.42.0
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
PKGMGR=""
SUDO=""
APT_UPDATED=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt)
if [ -z "$APT_UPDATED" ]; then
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
APT_UPDATED=1
fi
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
main() {
cd "$ROOT"
if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi
if missing go; then pkg_install go golang go go; fi
# Lint/format tools, pinned via go install (installs into
# "$(go env GOPATH)/bin"; ensure that is on your PATH).
if missing golangci-lint; then go install "$GOLANGCI_LINT_REF"; fi
if missing goimports; then go install "$GOIMPORTS_REF"; fi
go mod download
echo "bootstrap complete"
}
main "$@"
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs make check, so
# a successful build implies all checks pass.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
}
main "$@"
-14
View File
@@ -1,14 +0,0 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
gofmt -s -w .
goimports -w .
}
main "$@"
-18
View File
@@ -1,18 +0,0 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
files="$(gofmt -l .)"
if [ -n "$files" ]; then
echo "gofmt: files not formatted:" >&2
echo "$files" >&2
exit 1
fi
}
main "$@"
-16
View File
@@ -1,16 +0,0 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook"
chmod +x "$hook"
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"
-41
View File
@@ -1,41 +0,0 @@
#!/bin/sh
# script/lint: run the linter.
#
# golangci-lint keeps two pieces of per-user global state, and both of
# them break when several checkouts on one host lint concurrently:
#
# 1. Its analysis cache (GOLANGCI_LINT_CACHE, default
# ~/.cache/golangci-lint). Entries are keyed by content, not by
# checkout, so a hit written by another checkout is replayed
# verbatim - including that checkout's file paths. The run then
# reports findings for files it never linted.
#
# 2. Its "one runner at a time" lock, which does NOT live in the
# cache directory: golangci-lint locks
# $(os.TempDir())/golangci-lint.lock, i.e.
# "$TMPDIR"/golangci-lint.lock (pkg/commands/run.go,
# acquireFileLock). It waits 5s, then aborts with "parallel
# golangci-lint is running" - a non-result that looks like a lint
# failure. Setting GOLANGCI_LINT_CACHE alone does not move it.
#
# So both are pinned under the checkout root. The cache is never shared,
# and TMPDIR makes the lock file per-checkout, which keeps the lock
# doing its actual job (serialising runs that share one cache) at the
# right scope. .lint-cache/ is git-ignored and Docker-ignored, and
# caching still works: it persists across runs in this checkout.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
GOLANGCI_LINT_CACHE="$ROOT/.lint-cache/cache"
TMPDIR="$ROOT/.lint-cache/tmp"
export GOLANGCI_LINT_CACHE TMPDIR
mkdir -p "$GOLANGCI_LINT_CACHE" "$TMPDIR"
golangci-lint run --config .golangci.yml ./...
}
main "$@"
-21
View File
@@ -1,21 +0,0 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all. Go extra:
# go mod tidy must be a no-op before the checks run.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
go mod tidy
if ! git diff --exit-code -- go.mod go.sum; then
echo "precommit: go mod tidy changed go.mod/go.sum;" \
"stage the changes and retry" >&2
exit 1
fi
"$SCRIPT_DIR/check"
}
main "$@"
-12
View File
@@ -1,12 +0,0 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "dnswatcher"
}
main "$@"
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"
-12
View File
@@ -1,12 +0,0 @@
#!/bin/sh
# script/test: run the test suite.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
go test -v -race -timeout 30s -cover ./...
}
main "$@"