Compare commits
1 Commits
next
...
fix/99-ser
| Author | SHA1 | Date | |
|---|---|---|---|
| 02b63a4e65 |
26
Dockerfile
26
Dockerfile
@@ -1,27 +1,13 @@
|
|||||||
# Lint stage - fast feedback on lint issues, before the build starts.
|
|
||||||
# The linter is invoked directly rather than through `make lint`: that
|
|
||||||
# target shells out to `docker build -f Dockerfile.lint`, and there is
|
|
||||||
# no docker daemon inside a docker build.
|
|
||||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10
|
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN make fmt-check
|
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# golang 1.25-alpine, 2026-02-28
|
# golang 1.25-alpine, 2026-02-28
|
||||||
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
|
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache git make gcc musl-dev binutils-gold
|
RUN apk add --no-cache git make gcc musl-dev binutils-gold
|
||||||
|
|
||||||
# Force BuildKit to run the lint stage before proceeding
|
# golangci-lint v2.12.2, 2026-08-07
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5
|
||||||
|
# goimports v0.42.0
|
||||||
|
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
@@ -29,8 +15,8 @@ RUN go mod download
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Run the tests - build fails if any test fails
|
# Run all checks - build fails if any check fails
|
||||||
RUN make test
|
RUN make check
|
||||||
|
|
||||||
# Build the binary
|
# Build the binary
|
||||||
RUN make build
|
RUN make build
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# Lint-only image: used by script/lint. golangci-lint is never run on
|
|
||||||
# the host — the repo is COPYed into the build context and the linter
|
|
||||||
# runs as a build step, so a successful build IS a clean lint. This
|
|
||||||
# also works where the docker daemon is remote and bind mounts are
|
|
||||||
# impossible.
|
|
||||||
#
|
|
||||||
# `golangci-lint config verify` is deliberately NOT run here: it
|
|
||||||
# fetches its JSON schema over a live, unpinned HTTPS call, which would
|
|
||||||
# make linting network-dependent and defeat hash-pinning. The cost of
|
|
||||||
# that: unknown top-level keys in .golangci.yml are silently ignored,
|
|
||||||
# so a mistyped or wrong-schema key lints clean while applying nothing.
|
|
||||||
#
|
|
||||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10
|
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
|
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
|
|
||||||
# Dependencies first, so this stage stays cached across lint runs.
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Everything below is invalidated on every run by the
|
|
||||||
# --no-cache-filter=lint that script/lint passes: caching is explicitly
|
|
||||||
# waived for linting, and a cached build lints nothing.
|
|
||||||
FROM deps AS lint
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
RUN golangci-lint run --config .golangci.yml ./...
|
|
||||||
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 sneak
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
49
README.md
49
README.md
@@ -1,6 +1,6 @@
|
|||||||
# dnswatcher
|
# dnswatcher
|
||||||
|
|
||||||
dnswatcher is an MIT-licensed, pre-1.0 Go daemon by [@sneak](https://sneak.berlin) that monitors DNS records, TCP port availability, and TLS certificates, delivering real-time change notifications via Slack, Mattermost, and ntfy webhooks.
|
dnswatcher is a pre-1.0 Go daemon by [@sneak](https://sneak.berlin) that monitors DNS records, TCP port availability, and TLS certificates, delivering real-time change notifications via Slack, Mattermost, and ntfy webhooks.
|
||||||
|
|
||||||
> ⚠️ Pre-1.0 software. APIs, configuration, and behavior may change without notice.
|
> ⚠️ Pre-1.0 software. APIs, configuration, and behavior may change without notice.
|
||||||
|
|
||||||
@@ -182,6 +182,27 @@ dnswatcher exposes a lightweight HTTP API for operational visibility:
|
|||||||
| `GET /api/v1/status` | Current monitoring state |
|
| `GET /api/v1/status` | Current monitoring state |
|
||||||
| `GET /metrics` | Prometheus metrics (optional) |
|
| `GET /metrics` | Prometheus metrics (optional) |
|
||||||
|
|
||||||
|
#### Server timeouts
|
||||||
|
|
||||||
|
The HTTP server sets all four socket-level timeouts. These are compile-time
|
||||||
|
constants in `internal/server/server.go`, not configurable via environment
|
||||||
|
variables.
|
||||||
|
|
||||||
|
| Timeout | Value | Purpose |
|
||||||
|
|---------------------|-------|-----------------------------------------------|
|
||||||
|
| `ReadHeaderTimeout` | 10s | Bounds the request header read (slowloris) |
|
||||||
|
| `ReadTimeout` | 15s | Bounds the whole request read, headers + body |
|
||||||
|
| `WriteTimeout` | 75s | Bounds handler execution plus response flush |
|
||||||
|
| `IdleTimeout` | 120s | Reaps idle keep-alive connections |
|
||||||
|
|
||||||
|
These are distinct from the 60s per-request handler budget applied by
|
||||||
|
`chimw.Timeout` in `internal/server/routes.go`, which cancels the request
|
||||||
|
context but does not touch the socket. `WriteTimeout` is deliberately
|
||||||
|
larger than that budget: the write deadline is armed once request headers
|
||||||
|
are read, so a smaller value would sever the connection before a handler
|
||||||
|
using its full budget could respond. `IdleTimeout` exceeds common
|
||||||
|
Prometheus scrape intervals so the scraper reuses its connection.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
@@ -380,25 +401,14 @@ standard: normalized scripts in `script/` are the entrypoints for the
|
|||||||
development workflow, and the Makefile targets are thin shims that call
|
development workflow, and the Makefile targets are thin shims that call
|
||||||
them. We provide:
|
them. We provide:
|
||||||
|
|
||||||
- `script/bootstrap` — install all dependencies (go, pinned goimports,
|
- `script/bootstrap` — install all dependencies (go, pinned
|
||||||
`go mod download`). It does not install golangci-lint: see
|
golangci-lint and goimports, `go mod download`)
|
||||||
`script/lint` below.
|
|
||||||
- `script/setup` — make a fresh clone ready for development: bootstrap
|
- `script/setup` — make a fresh clone ready for development: bootstrap
|
||||||
plus the git pre-commit hook
|
plus the git pre-commit hook
|
||||||
- `script/projectname` — print the project name (used for the Docker
|
- `script/projectname` — print the project name (used for the Docker
|
||||||
image tag)
|
image tag)
|
||||||
- `script/test` — run the test suite (race detector, coverage). Caching
|
- `script/test` — run the test suite (race detector, coverage)
|
||||||
is waived for testing, exactly as it is for linting: `-count=1`
|
- `script/lint` — run golangci-lint
|
||||||
forces every invocation to execute, because the suite queries live
|
|
||||||
DNS and a cached pass queries nothing. Failures are rerun with `-v`
|
|
||||||
automatically, and the build fails even if that rerun passes.
|
|
||||||
- `script/lint` — run golangci-lint, always inside Docker: it builds
|
|
||||||
`Dockerfile.lint`, which COPYs the repo into the digest-pinned
|
|
||||||
`golangci-lint` image and lints as a build step, so a successful
|
|
||||||
build is a clean lint. The linter is never installed or run on the
|
|
||||||
host, and Docker is the only prerequisite. Caching is waived for
|
|
||||||
linting: the lint stage is forced to execute on every run with
|
|
||||||
`--no-cache-filter`, because a cached build lints nothing.
|
|
||||||
- `script/fmt` — format all code (gofmt -s, goimports)
|
- `script/fmt` — format all code (gofmt -s, goimports)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `script/check` — run test, lint, and fmt-check
|
||||||
@@ -414,7 +424,7 @@ them. We provide:
|
|||||||
```sh
|
```sh
|
||||||
make build # Build binary to bin/dnswatcher
|
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 in Docker (requires docker)
|
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 (test, lint, fmt-check)
|
||||||
make clean # Remove build artifacts
|
make clean # Remove build artifacts
|
||||||
@@ -486,9 +496,8 @@ Viper for configuration.
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
dnswatcher is released under the MIT License, Copyright (c) 2026
|
License has not yet been chosen for this project. Pending decision by the
|
||||||
[@sneak](https://sneak.berlin). See the [`LICENSE`](./LICENSE) file in the
|
author (MIT, GPL, or WTFPL).
|
||||||
repository root for the full text.
|
|
||||||
|
|
||||||
## Author
|
## Author
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-08-07
|
last_modified: 2026-07-06
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
This document covers repository structure, tooling, and workflow standards. Code
|
||||||
@@ -189,13 +189,8 @@ style conventions are in separate documents:
|
|||||||
module under test to verify it compiles/parses. There is no excuse for
|
module under test to verify it compiles/parses. There is no excuse for
|
||||||
`make test` to be a no-op.
|
`make test` to be a no-op.
|
||||||
|
|
||||||
- `make test` must complete in under 60 seconds. That is the hard cap, and a
|
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
|
||||||
suite that exceeds it fails. Under 20 seconds is the target. A suite between
|
Makefile.
|
||||||
20 and 60 seconds is still green, but the overage must be filed as an
|
|
||||||
improvement bug against that repo. Add a 90-second timeout to the test
|
|
||||||
invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately
|
|
||||||
sits above the hard cap so that it catches a genuinely hung test rather than a
|
|
||||||
merely slow one.
|
|
||||||
|
|
||||||
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
||||||
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
||||||
@@ -214,9 +209,9 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@go test -timeout 90s -race -cover ./... || \
|
@go test -timeout 30s -race -cover ./... || \
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
go test -timeout 90s -race -v ./...; exit 1; }
|
go test -timeout 30s -race -v ./...; exit 1; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Python example:
|
Python example:
|
||||||
@@ -265,10 +260,7 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||||
manually by the user. Fetch from
|
manually by the user. Fetch from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
|
||||||
canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed
|
|
||||||
commit-pinned via
|
|
||||||
`go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.
|
|
||||||
|
|
||||||
- When pinning images or packages by hash, add a comment above the reference
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
with the version and date (YYYY-MM-DD).
|
with the version and date (YYYY-MM-DD).
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ real servers ensures the resolver works correctly in production.
|
|||||||
|
|
||||||
- Tests hit real DNS infrastructure and require network access
|
- Tests hit real DNS infrastructure and require network access
|
||||||
- Test duration depends on network conditions; timeout tuning keeps
|
- Test duration depends on network conditions; timeout tuning keeps
|
||||||
the suite within the 60-second target
|
the suite within the 30-second target
|
||||||
- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms)
|
- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms)
|
||||||
plus processing margin
|
plus processing margin
|
||||||
- Root server fan-out is limited to reduce parallel query load
|
- Root server fan-out is limited to reduce parallel query load
|
||||||
@@ -31,7 +31,4 @@ real servers ensures the resolver works correctly in production.
|
|||||||
exists for unit-testing other packages that consume the resolver)
|
exists for unit-testing other packages that consume the resolver)
|
||||||
- **Do not add `-short` flags** to skip slow tests
|
- **Do not add `-short` flags** to skip slow tests
|
||||||
- **Do not increase `-timeout`** to hide hanging queries
|
- **Do not increase `-timeout`** to hide hanging queries
|
||||||
- **Do not remove `-count=1` from `script/test`** — Go's test cache
|
|
||||||
replays a previous run's output without querying anything, so a
|
|
||||||
cached pass is not evidence that live resolution works
|
|
||||||
- **Do not modify linter configuration** to suppress findings
|
- **Do not modify linter configuration** to suppress findings
|
||||||
|
|||||||
85
TODO.md
85
TODO.md
@@ -18,80 +18,21 @@ iterative resolver implementation with hermetic mocked tests.
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Add the README sections required by policy (Description, Getting Started,
|
Policy scaffold commit: add LICENSE, REPO_POLICIES.md, .editorconfig,
|
||||||
Rationale, Design, TODO, License, Author) if any are still missing.
|
.dockerignore, and .gitea/workflows/check.yml, and add the missing
|
||||||
|
fmt-check, docker, and hooks targets to the Makefile. One commit, then
|
||||||
|
confirm make check still passes.
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-08-10: comment-only corrections to `script/bootstrap`,
|
- 2026-08-09: `http.Server` now sets all four socket-level timeouts
|
||||||
`script/cibuild`, and `Dockerfile.lint`. The `goimports` pin in
|
(`ReadTimeout` 15s, `ReadHeaderTimeout` 10s, `WriteTimeout` 75s,
|
||||||
`script/bootstrap` was justified by a claim that `script/fmt-check`
|
`IdleTimeout` 120s) as named constants in `internal/server/server.go`,
|
||||||
runs it on the host; it does not (it runs `gofmt -l .` only), so the
|
closing the slowloris / unreaped-keep-alive exposure required by
|
||||||
header now credits `script/fmt` alone. `script/cibuild` still claimed
|
`REPO_POLICIES.md` before 1.0; `WriteTimeout` is deliberately greater
|
||||||
the `Dockerfile` runs `make check`, which stopped being true when
|
than the 60s `chimw.Timeout` handler budget so that budget stays
|
||||||
linting moved to its own stage; it now describes the lint stage
|
reachable, and tests in `internal/server` pin both the non-zero
|
||||||
(`make fmt-check` plus `golangci-lint`) and the builder stage
|
values and that relationship (#99)
|
||||||
(`make test`, `make build`). The `docker`-missing warning in
|
|
||||||
`script/bootstrap` reads as one sentence instead of three fragments
|
|
||||||
each re-prefixed with `bootstrap:`. `Dockerfile.lint` now records the
|
|
||||||
residual risk of omitting `golangci-lint config verify`: unknown
|
|
||||||
top-level keys in `.golangci.yml` are silently ignored, so a mistyped
|
|
||||||
key lints clean while applying nothing. No behaviour changed
|
|
||||||
- 2026-08-10: MIT `LICENSE` added at the repository root, closing the
|
|
||||||
last gap in `REPO_POLICIES.md`'s required-minimum file list and
|
|
||||||
removing the all-rights-reserved default that would otherwise have
|
|
||||||
shipped with a 1.0 tag. The licence choice is the standing org policy
|
|
||||||
(any public repo lacking a licence gets MIT; a private repo with no
|
|
||||||
licence is already all-rights-reserved), and this repo is public. The
|
|
||||||
file holds the canonical MIT text byte-for-byte with only the
|
|
||||||
copyright line filled in (`Copyright (c) 2026 sneak`); no clauses were
|
|
||||||
added, removed, or reflowed. `README.md`'s first line now names the
|
|
||||||
licence, as the Description requirement demands, and the License
|
|
||||||
section states MIT and points at the file instead of saying the choice
|
|
||||||
is pending. `make fmt` covers only Go sources (`gofmt -s`,
|
|
||||||
`goimports`), so it cannot reflow `LICENSE`
|
|
||||||
- 2026-08-10: the policy scaffold (`REPO_POLICIES.md`, `.editorconfig`,
|
|
||||||
`.dockerignore`, `.gitea/workflows/check.yml`, and the `fmt-check`,
|
|
||||||
`docker`, and hooks Makefile targets) is present; it landed piecemeal
|
|
||||||
across the scripts-to-rule-them-all and policy commits rather than as
|
|
||||||
the single commit this file once planned
|
|
||||||
- 2026-08-10: Go's test cache disabled for `script/test` via `-count=1`,
|
|
||||||
so every invocation actually executes. A cached pass replays an
|
|
||||||
earlier run's output without querying DNS at all, which in this repo
|
|
||||||
means the suite's entire premise goes unexercised while the run
|
|
||||||
reports green in under a second. The conditional verbose rerun that
|
|
||||||
`REPO_POLICIES.md` mandates was added at the same time (the primary
|
|
||||||
run had been unconditionally `-v`): quiet first, `-v` only on
|
|
||||||
failure, `-count=1` on both, and exit 1 forced regardless of the
|
|
||||||
rerun's result so a flake passing the second time cannot turn the
|
|
||||||
build green. `-timeout 90s` left alone as the deliberate backstop
|
|
||||||
above the 60s hard cap. Uncached suite runs ~4s, well inside the 20s
|
|
||||||
target
|
|
||||||
- 2026-08-10: live-DNS test flakiness addressed by robustness rather
|
|
||||||
than gating, per the owner's ruling on #93: new
|
|
||||||
`internal/resolver/livedns_test.go` adds a package-wide concurrency
|
|
||||||
gate (so parallel tests stop bursting at the first root server),
|
|
||||||
retry with exponential backoff on transport failures only, and
|
|
||||||
quorum instead of unanimity for multi-nameserver assertions. Quorum
|
|
||||||
tolerates silence only: every per-nameserver status must be in a
|
|
||||||
closed allowlist (`ok`/`timeout`/`error`, or
|
|
||||||
`nxdomain`/`timeout`/`error`), so a wrong answer from a minority —
|
|
||||||
`nodata` today, any status added later — fails the test instead of
|
|
||||||
sliding through under the majority. The
|
|
||||||
`make test` cap moved to the new org-wide 60s hard cap / 20s target
|
|
||||||
with a 90s `-timeout` backstop; `REPO_POLICIES.md` re-vendored
|
|
||||||
byte-identical from `sneak/prompts`. No mocks, no `-short`, no build
|
|
||||||
tags, no skips, and no change to production resolver behaviour
|
|
||||||
- 2026-08-10: all linting moved into Docker: new root `Dockerfile.lint`
|
|
||||||
on the digest-pinned `golangci/golangci-lint:v2.12.2` image,
|
|
||||||
`script/lint` reduced to a thin wrapper that builds it with
|
|
||||||
`--no-cache-filter=lint` so the linter actually executes every run,
|
|
||||||
golangci-lint install dropped from `script/bootstrap` (goimports
|
|
||||||
stays, `script/fmt` needs it on the host), and the root `Dockerfile`
|
|
||||||
given its own lint stage so its build no longer recurses through
|
|
||||||
`make check` into `script/lint`. `golangci-lint config verify` is
|
|
||||||
deliberately omitted: it fetches its schema over an unpinned live
|
|
||||||
HTTPS call
|
|
||||||
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
|
- 2026-08-07: golangci-lint bumped to v2.12.2 (commit-pinned installs
|
||||||
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the
|
in `Dockerfile` and `script/bootstrap`); `.golangci.yml` set to the
|
||||||
org-standard v2-schema config used across the org's repos
|
org-standard v2-schema config used across the org's repos
|
||||||
@@ -120,6 +61,8 @@ Rationale, Design, TODO, License, Author) if any are still missing.
|
|||||||
|
|
||||||
Compliance:
|
Compliance:
|
||||||
|
|
||||||
|
- Add README sections required by policy (Description, Getting Started,
|
||||||
|
Rationale, Design, TODO, License, Author) if any are missing
|
||||||
- Pin Dockerfile base images by sha256 and ensure the Docker build runs
|
- Pin Dockerfile base images by sha256 and ensure the Docker build runs
|
||||||
make check
|
make check
|
||||||
|
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
package resolver_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/resolver"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tests for the live-DNS harness in livedns_test.go itself. These
|
|
||||||
// exercise pure logic and the retry/concurrency plumbing; they
|
|
||||||
// perform no DNS resolution of any kind, so they neither mock DNS
|
|
||||||
// nor depend on it.
|
|
||||||
|
|
||||||
// Names for the synthetic status maps below. Nothing is ever queried
|
|
||||||
// at them: they are map keys handed to the package's pure counting
|
|
||||||
// helpers, not a stand-in for a nameserver.
|
|
||||||
const (
|
|
||||||
nsExample1 = "ns1.example."
|
|
||||||
nsExample2 = "ns2.example."
|
|
||||||
nsExample3 = "ns3.example."
|
|
||||||
nsExample4 = "ns4.example."
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLiveQuorumIsStrictMajority(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := map[int]int{
|
|
||||||
0: 1,
|
|
||||||
1: 1,
|
|
||||||
2: 2,
|
|
||||||
3: 2,
|
|
||||||
4: 3,
|
|
||||||
5: 3,
|
|
||||||
13: 7,
|
|
||||||
}
|
|
||||||
|
|
||||||
for total, want := range cases {
|
|
||||||
assert.Equal(
|
|
||||||
t, want, liveQuorum(total),
|
|
||||||
"liveQuorum(%d)", total,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStatusCountingIgnoresSilentNameservers(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
results := map[string]*resolver.NameserverResponse{
|
|
||||||
nsExample1: {
|
|
||||||
Nameserver: nsExample1,
|
|
||||||
Status: resolver.StatusOK,
|
|
||||||
},
|
|
||||||
nsExample2: {
|
|
||||||
Nameserver: nsExample2,
|
|
||||||
Status: resolver.StatusOK,
|
|
||||||
},
|
|
||||||
nsExample3: {
|
|
||||||
Nameserver: nsExample3,
|
|
||||||
Status: resolver.StatusTimeout,
|
|
||||||
},
|
|
||||||
nsExample4: {
|
|
||||||
Nameserver: nsExample4,
|
|
||||||
Status: resolver.StatusError,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, 2, countStatus(results, resolver.StatusOK),
|
|
||||||
)
|
|
||||||
assert.Equal(
|
|
||||||
t, 0, countStatus(results, resolver.StatusNXDomain),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Two of four answered, which is short of the quorum of
|
|
||||||
// three: this is the state that triggers a retry rather
|
|
||||||
// than an assertion failure.
|
|
||||||
assert.Equal(t, 2, answeredCount(results))
|
|
||||||
assert.Less(t, answeredCount(results), liveQuorum(len(results)))
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
"ns1.example.=ok ns2.example.=ok "+
|
|
||||||
"ns3.example.=timeout ns4.example.=error",
|
|
||||||
describeStatuses(results),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRetryLiveRecoversFromTransientFailure(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const wantAttempts = 2
|
|
||||||
|
|
||||||
attempts := 0
|
|
||||||
|
|
||||||
retryLive(t, "transient", func(_ context.Context) error {
|
|
||||||
attempts++
|
|
||||||
|
|
||||||
if attempts < wantAttempts {
|
|
||||||
return errLiveNoAnswer
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
assert.Equal(t, wantAttempts, attempts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRetryLiveGivesEachAttemptADeadline(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
retryLive(t, "deadline", func(ctx context.Context) error {
|
|
||||||
deadline, ok := ctx.Deadline()
|
|
||||||
assert.True(t, ok, "attempt should carry a deadline")
|
|
||||||
|
|
||||||
remaining := time.Until(deadline)
|
|
||||||
|
|
||||||
assert.LessOrEqual(t, remaining, liveAttemptTimeout)
|
|
||||||
|
|
||||||
// Lower bound too: without one this passes for a
|
|
||||||
// deadline far shorter than intended, which would
|
|
||||||
// silently turn every live attempt into an instant
|
|
||||||
// timeout.
|
|
||||||
assert.Greater(t, remaining, liveAttemptTimeout/2)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnsanctionedStatusesRejectsWrongAnswers is the regression test
|
|
||||||
// for the defect this allowlist exists to prevent: a minority of
|
|
||||||
// nameservers answering WRONGLY while quorum keeps the suite green.
|
|
||||||
// nodata is the case that motivated it — it is a wrong answer, not
|
|
||||||
// silence, and it was previously banned by neither test.
|
|
||||||
func TestUnsanctionedStatusesRejectsWrongAnswers(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
// Four nameservers, three OK and one answering nodata: a
|
|
||||||
// quorum of three is satisfied and no NXDOMAIN is present, so
|
|
||||||
// the old blocklist assertions both passed on this input.
|
|
||||||
results := map[string]*resolver.NameserverResponse{
|
|
||||||
nsExample1: {
|
|
||||||
Nameserver: nsExample1,
|
|
||||||
Status: resolver.StatusOK,
|
|
||||||
},
|
|
||||||
nsExample2: {
|
|
||||||
Nameserver: nsExample2,
|
|
||||||
Status: resolver.StatusOK,
|
|
||||||
},
|
|
||||||
nsExample3: {
|
|
||||||
Nameserver: nsExample3,
|
|
||||||
Status: resolver.StatusOK,
|
|
||||||
},
|
|
||||||
nsExample4: {
|
|
||||||
Nameserver: nsExample4,
|
|
||||||
Status: resolver.StatusNoData,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.GreaterOrEqual(
|
|
||||||
t,
|
|
||||||
countStatus(results, resolver.StatusOK),
|
|
||||||
liveQuorum(len(results)),
|
|
||||||
)
|
|
||||||
assert.Zero(t, countStatus(results, resolver.StatusNXDomain))
|
|
||||||
|
|
||||||
// nodata is an ANSWER, so it never triggers a retry: nothing
|
|
||||||
// but the allowlist stands between it and a false green.
|
|
||||||
assert.Equal(t, len(results), answeredCount(results))
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
[]string{nsExample4 + "=nodata"},
|
|
||||||
unsanctionedStatuses(
|
|
||||||
results,
|
|
||||||
resolver.StatusOK,
|
|
||||||
resolver.StatusTimeout,
|
|
||||||
resolver.StatusError,
|
|
||||||
),
|
|
||||||
"nodata must be reported as an unsanctioned status",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnsanctionedStatusesToleratesSilenceOnly(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
results := map[string]*resolver.NameserverResponse{
|
|
||||||
nsExample1: {
|
|
||||||
Nameserver: nsExample1,
|
|
||||||
Status: resolver.StatusNXDomain,
|
|
||||||
},
|
|
||||||
nsExample2: {
|
|
||||||
Nameserver: nsExample2,
|
|
||||||
Status: resolver.StatusTimeout,
|
|
||||||
},
|
|
||||||
nsExample3: {
|
|
||||||
Nameserver: nsExample3,
|
|
||||||
Status: resolver.StatusError,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
allowed := []string{
|
|
||||||
resolver.StatusNXDomain,
|
|
||||||
resolver.StatusTimeout,
|
|
||||||
resolver.StatusError,
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Empty(
|
|
||||||
t,
|
|
||||||
unsanctionedStatuses(results, allowed...),
|
|
||||||
"timeout and error are non-answers and are tolerated",
|
|
||||||
)
|
|
||||||
|
|
||||||
// The same silent nameservers do not count towards a quorum.
|
|
||||||
assert.Equal(t, 1, answeredCount(results))
|
|
||||||
|
|
||||||
// An unknown status is treated as silence by answeredCount —
|
|
||||||
// so it retries and fails loudly — and is unsanctioned by the
|
|
||||||
// allowlist rather than quietly permitted.
|
|
||||||
const laterStatus = "some-status-added-later"
|
|
||||||
|
|
||||||
results[nsExample4] = &resolver.NameserverResponse{
|
|
||||||
Nameserver: nsExample4,
|
|
||||||
Status: laterStatus,
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, 1, answeredCount(results))
|
|
||||||
assert.Equal(
|
|
||||||
t,
|
|
||||||
[]string{nsExample4 + "=" + laterStatus},
|
|
||||||
unsanctionedStatuses(results, allowed...),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunLiveBoundsConcurrency(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const workers = 24
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
wg sync.WaitGroup
|
|
||||||
inFlight int
|
|
||||||
maxSeen int
|
|
||||||
)
|
|
||||||
|
|
||||||
wg.Add(workers)
|
|
||||||
|
|
||||||
for range workers {
|
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
_ = runLive(func(_ context.Context) error {
|
|
||||||
mu.Lock()
|
|
||||||
inFlight++
|
|
||||||
|
|
||||||
if inFlight > maxSeen {
|
|
||||||
maxSeen = inFlight
|
|
||||||
}
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
time.Sleep(time.Millisecond)
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
inFlight--
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
assert.Positive(t, maxSeen)
|
|
||||||
assert.LessOrEqual(
|
|
||||||
t, maxSeen, liveConcurrency,
|
|
||||||
"live queries must stay under the package-wide gate",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
package resolver_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"slices"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/resolver"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
// Live DNS test support
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// Every test in this package resolves against the real, live DNS —
|
|
||||||
// see TESTING.md. Nothing here mocks, fakes, stubs, records or
|
|
||||||
// replays DNS, and nothing here skips or gates a test: the helpers
|
|
||||||
// below only change *how* the live queries are issued, so that a
|
|
||||||
// single dropped UDP packet or one slow authoritative server does
|
|
||||||
// not turn a correct resolver into a red build.
|
|
||||||
//
|
|
||||||
// Three mechanisms, all test-side:
|
|
||||||
//
|
|
||||||
// 1. Bounded concurrency. The package's tests are parallel and the
|
|
||||||
// build hosts have many cores, so without a limit every test
|
|
||||||
// starts its own iterative resolution at the same instant and
|
|
||||||
// they all hit the first root server in rootServerList() within
|
|
||||||
// a few milliseconds of each other. Root servers rate-limit
|
|
||||||
// that, which shows up as a different arbitrary subset of tests
|
|
||||||
// failing on each run. liveGate caps how many resolutions are
|
|
||||||
// in flight at once.
|
|
||||||
//
|
|
||||||
// 2. Retry with exponential backoff. Each live operation gets
|
|
||||||
// several attempts with its own timeout. The retry predicate is
|
|
||||||
// strictly transport-level — "did a nameserver answer at all" —
|
|
||||||
// never the assertion the test is making. A resolver that
|
|
||||||
// answers incorrectly still fails on the first attempt.
|
|
||||||
//
|
|
||||||
// 3. Quorum. Where an assertion spans several independent
|
|
||||||
// nameservers, a strict majority answering as expected is
|
|
||||||
// enough; a server that fails to answer is tolerated, while a
|
|
||||||
// server that answers *wrongly* still fails the test.
|
|
||||||
//
|
|
||||||
// The tolerance in (3) is expressed as an ALLOWLIST of sanctioned
|
|
||||||
// statuses, never as a blocklist of known-bad ones. A blocklist bans
|
|
||||||
// the one wrong answer its author thought of and silently admits
|
|
||||||
// every other status, including any added to the resolver later; an
|
|
||||||
// allowlist fails on anything nobody explicitly sanctioned. Silence
|
|
||||||
// (timeout, error) is the only thing quorum exists to tolerate. A
|
|
||||||
// *wrong answer* — nxdomain for a name that exists, ok for one that
|
|
||||||
// does not, nodata for either — is never tolerated at any count.
|
|
||||||
|
|
||||||
const (
|
|
||||||
// liveAttempts is how many times a live DNS operation is
|
|
||||||
// attempted before the test fails.
|
|
||||||
liveAttempts = 3
|
|
||||||
|
|
||||||
// liveAttemptTimeout bounds one attempt. Worst case for an
|
|
||||||
// operation is liveAttempts * liveAttemptTimeout plus the
|
|
||||||
// backoff — about 26 seconds, well inside the 90-second
|
|
||||||
// `go test -timeout` backstop even when several operations
|
|
||||||
// exhaust their attempts.
|
|
||||||
liveAttemptTimeout = 8 * time.Second
|
|
||||||
|
|
||||||
// liveBackoffBase is the delay after the first failed
|
|
||||||
// attempt; it is multiplied by liveBackoffFactor each time.
|
|
||||||
liveBackoffBase = 500 * time.Millisecond
|
|
||||||
|
|
||||||
// liveBackoffFactor is the exponential backoff multiplier.
|
|
||||||
liveBackoffFactor = 2
|
|
||||||
|
|
||||||
// liveConcurrency caps how many live resolutions may be in
|
|
||||||
// flight across the whole package at once.
|
|
||||||
liveConcurrency = 6
|
|
||||||
|
|
||||||
// minNameservers is the smallest nameserver count a
|
|
||||||
// well-run zone is expected to publish.
|
|
||||||
minNameservers = 2
|
|
||||||
)
|
|
||||||
|
|
||||||
// liveGate bounds concurrent live resolutions package-wide. It has
|
|
||||||
// to be package scoped: the whole point is that it is shared by
|
|
||||||
// every parallel test in the package.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // package-wide live query rate limit
|
|
||||||
var liveGate = make(chan struct{}, liveConcurrency)
|
|
||||||
|
|
||||||
var (
|
|
||||||
// errLiveNoAnswer reports that a live operation produced no
|
|
||||||
// usable answer, which is retried rather than asserted on.
|
|
||||||
errLiveNoAnswer = errors.New("no answer from live DNS")
|
|
||||||
|
|
||||||
// errLiveNoQuorum reports that too few of a domain's
|
|
||||||
// nameservers answered for a quorum assertion to be made.
|
|
||||||
errLiveNoQuorum = errors.New("no nameserver quorum")
|
|
||||||
)
|
|
||||||
|
|
||||||
// runLive executes one attempt of a live operation, holding a slot
|
|
||||||
// in liveGate for its duration and bounding it with its own
|
|
||||||
// timeout.
|
|
||||||
func runLive(op func(ctx context.Context) error) error {
|
|
||||||
liveGate <- struct{}{}
|
|
||||||
defer func() { <-liveGate }()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(
|
|
||||||
context.Background(), liveAttemptTimeout,
|
|
||||||
)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
return op(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// retryLive runs op until it reports success, retrying transport
|
|
||||||
// failures with exponential backoff, and fails the test if every
|
|
||||||
// attempt fails. op returns an error only for a failure to obtain
|
|
||||||
// an answer — never for an answer the test disagrees with, which
|
|
||||||
// belongs in an assertion so that it fails immediately. op stores
|
|
||||||
// whatever it obtained where its caller can find it.
|
|
||||||
func retryLive(
|
|
||||||
t *testing.T,
|
|
||||||
what string,
|
|
||||||
op func(ctx context.Context) error,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var last error
|
|
||||||
|
|
||||||
backoff := liveBackoffBase
|
|
||||||
|
|
||||||
for attempt := range liveAttempts {
|
|
||||||
if attempt > 0 {
|
|
||||||
t.Logf(
|
|
||||||
"%s: attempt %d of %d failed (%v), "+
|
|
||||||
"retrying in %s",
|
|
||||||
what, attempt, liveAttempts, last, backoff,
|
|
||||||
)
|
|
||||||
time.Sleep(backoff)
|
|
||||||
|
|
||||||
backoff *= liveBackoffFactor
|
|
||||||
}
|
|
||||||
|
|
||||||
last = runLive(op)
|
|
||||||
if last == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Fatalf(
|
|
||||||
"%s: no answer after %d live attempts: %v",
|
|
||||||
what, liveAttempts, last,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// liveQuorum is how many of total nameservers must agree for a
|
|
||||||
// multi-nameserver assertion to hold: a strict majority.
|
|
||||||
func liveQuorum(total int) int {
|
|
||||||
if total < 1 {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return total/2 + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// countStatus counts the responses carrying the given status.
|
|
||||||
func countStatus(
|
|
||||||
results map[string]*resolver.NameserverResponse,
|
|
||||||
status string,
|
|
||||||
) int {
|
|
||||||
n := 0
|
|
||||||
|
|
||||||
for _, resp := range results {
|
|
||||||
if resp.Status == status {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// liveAnswerStatuses is the closed set of statuses that count as a
|
|
||||||
// nameserver having ANSWERED at all, whether or not the test agrees
|
|
||||||
// with the answer. It is deliberately an allowlist: a status added
|
|
||||||
// to the resolver later is treated as silence, so it can only ever
|
|
||||||
// cause a retry and then a loud failure, never a quiet pass.
|
|
||||||
func liveAnswerStatuses() []string {
|
|
||||||
return []string{
|
|
||||||
resolver.StatusOK,
|
|
||||||
resolver.StatusNXDomain,
|
|
||||||
resolver.StatusNoData,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// answeredCount counts the nameservers that produced an answer of
|
|
||||||
// any kind, as opposed to failing or timing out.
|
|
||||||
func answeredCount(
|
|
||||||
results map[string]*resolver.NameserverResponse,
|
|
||||||
) int {
|
|
||||||
answers := liveAnswerStatuses()
|
|
||||||
|
|
||||||
n := 0
|
|
||||||
|
|
||||||
for _, resp := range results {
|
|
||||||
if slices.Contains(answers, resp.Status) {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// unsanctionedStatuses returns "nameserver=status" for every result
|
|
||||||
// whose status the caller did not explicitly sanction, sorted for a
|
|
||||||
// stable failure message. Callers pass the full closed set they will
|
|
||||||
// accept — the expected answer plus whichever non-answers (timeout,
|
|
||||||
// error) quorum is allowed to tolerate — so that any status outside
|
|
||||||
// it fails the test by name.
|
|
||||||
func unsanctionedStatuses(
|
|
||||||
results map[string]*resolver.NameserverResponse,
|
|
||||||
allowed ...string,
|
|
||||||
) []string {
|
|
||||||
offenders := make([]string, 0, len(results))
|
|
||||||
|
|
||||||
for ns, resp := range results {
|
|
||||||
if slices.Contains(allowed, resp.Status) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
offenders = append(
|
|
||||||
offenders, fmt.Sprintf("%s=%s", ns, resp.Status),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Strings(offenders)
|
|
||||||
|
|
||||||
return offenders
|
|
||||||
}
|
|
||||||
|
|
||||||
// describeStatuses renders per-nameserver statuses for use in
|
|
||||||
// assertion failure messages.
|
|
||||||
func describeStatuses(
|
|
||||||
results map[string]*resolver.NameserverResponse,
|
|
||||||
) string {
|
|
||||||
parts := make([]string, 0, len(results))
|
|
||||||
for ns, resp := range results {
|
|
||||||
parts = append(
|
|
||||||
parts, fmt.Sprintf("%s=%s", ns, resp.Status),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
sort.Strings(parts)
|
|
||||||
|
|
||||||
return strings.Join(parts, " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
// Live operation wrappers
|
|
||||||
// ----------------------------------------------------------------
|
|
||||||
|
|
||||||
// liveFindAuthoritative resolves a domain's authoritative
|
|
||||||
// nameservers, retrying until the delegation chain can be walked.
|
|
||||||
func liveFindAuthoritative(
|
|
||||||
t *testing.T,
|
|
||||||
r *resolver.Resolver,
|
|
||||||
domain string,
|
|
||||||
) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var out []string
|
|
||||||
|
|
||||||
retryLive(
|
|
||||||
t,
|
|
||||||
"FindAuthoritativeNameservers("+domain+")",
|
|
||||||
func(ctx context.Context) error {
|
|
||||||
ns, err := r.FindAuthoritativeNameservers(ctx, domain)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(ns) == 0 {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: %s has no nameservers",
|
|
||||||
errLiveNoAnswer, domain,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
out = ns
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// liveLookupNS is liveFindAuthoritative through the LookupNS entry
|
|
||||||
// point, so that both entry points stay independently exercised.
|
|
||||||
func liveLookupNS(
|
|
||||||
t *testing.T,
|
|
||||||
r *resolver.Resolver,
|
|
||||||
domain string,
|
|
||||||
) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var out []string
|
|
||||||
|
|
||||||
retryLive(
|
|
||||||
t,
|
|
||||||
"LookupNS("+domain+")",
|
|
||||||
func(ctx context.Context) error {
|
|
||||||
ns, err := r.LookupNS(ctx, domain)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(ns) == 0 {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: %s has no nameservers",
|
|
||||||
errLiveNoAnswer, domain,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
out = ns
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// liveQueryNameserver queries one nameserver, retrying while that
|
|
||||||
// nameserver fails to answer. NXDOMAIN and NODATA are answers and
|
|
||||||
// are returned to the caller to assert on.
|
|
||||||
func liveQueryNameserver(
|
|
||||||
t *testing.T,
|
|
||||||
r *resolver.Resolver,
|
|
||||||
nameserver string,
|
|
||||||
hostname string,
|
|
||||||
) *resolver.NameserverResponse {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
what := fmt.Sprintf(
|
|
||||||
"QueryNameserver(%s, %s)", nameserver, hostname,
|
|
||||||
)
|
|
||||||
|
|
||||||
var out *resolver.NameserverResponse
|
|
||||||
|
|
||||||
retryLive(
|
|
||||||
t,
|
|
||||||
what,
|
|
||||||
func(ctx context.Context) error {
|
|
||||||
resp, err := r.QueryNameserver(
|
|
||||||
ctx, nameserver, hostname,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.Status == resolver.StatusTimeout ||
|
|
||||||
resp.Status == resolver.StatusError {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: %s returned %s: %s",
|
|
||||||
errLiveNoAnswer, nameserver,
|
|
||||||
resp.Status, resp.Error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
out = resp
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// liveQueryAllNameservers queries every authoritative nameserver
|
|
||||||
// for a hostname, retrying until a quorum of them has answered.
|
|
||||||
// Individual nameservers that stay silent are left in the result
|
|
||||||
// for the caller to account for.
|
|
||||||
func liveQueryAllNameservers(
|
|
||||||
t *testing.T,
|
|
||||||
r *resolver.Resolver,
|
|
||||||
hostname string,
|
|
||||||
) map[string]*resolver.NameserverResponse {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var out map[string]*resolver.NameserverResponse
|
|
||||||
|
|
||||||
retryLive(
|
|
||||||
t,
|
|
||||||
"QueryAllNameservers("+hostname+")",
|
|
||||||
func(ctx context.Context) error {
|
|
||||||
results, err := r.QueryAllNameservers(ctx, hostname)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results) == 0 {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: no nameservers queried for %s",
|
|
||||||
errLiveNoAnswer, hostname,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
answered := answeredCount(results)
|
|
||||||
if answered < liveQuorum(len(results)) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: %d of %d answered: %s",
|
|
||||||
errLiveNoQuorum, answered,
|
|
||||||
len(results), describeStatuses(results),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
out = results
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// liveResolveIPs resolves a hostname that is expected to have
|
|
||||||
// addresses, retrying until at least one is returned.
|
|
||||||
func liveResolveIPs(
|
|
||||||
t *testing.T,
|
|
||||||
r *resolver.Resolver,
|
|
||||||
hostname string,
|
|
||||||
) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var out []string
|
|
||||||
|
|
||||||
retryLive(
|
|
||||||
t,
|
|
||||||
"ResolveIPAddresses("+hostname+")",
|
|
||||||
func(ctx context.Context) error {
|
|
||||||
ips, err := r.ResolveIPAddresses(ctx, hostname)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(ips) == 0 {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"%w: no addresses for %s",
|
|
||||||
errLiveNoAnswer, hostname,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
out = ips
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// liveResolveIPsAllowingEmpty resolves a hostname that may legitimately
|
|
||||||
// have no addresses, so the empty result is returned rather than
|
|
||||||
// retried. Used for names that must not exist; the corresponding
|
|
||||||
// QueryAllNameservers test is what proves the nameservers actively
|
|
||||||
// said NXDOMAIN rather than merely staying silent.
|
|
||||||
func liveResolveIPsAllowingEmpty(
|
|
||||||
t *testing.T,
|
|
||||||
r *resolver.Resolver,
|
|
||||||
hostname string,
|
|
||||||
) []string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var out []string
|
|
||||||
|
|
||||||
retryLive(
|
|
||||||
t,
|
|
||||||
"ResolveIPAddresses("+hostname+")",
|
|
||||||
func(ctx context.Context) error {
|
|
||||||
ips, err := r.ResolveIPAddresses(ctx, hostname)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
out = ips
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -32,17 +32,32 @@ func newTestResolver(t *testing.T) *resolver.Resolver {
|
|||||||
return resolver.NewFromLogger(log)
|
return resolver.NewFromLogger(log)
|
||||||
}
|
}
|
||||||
|
|
||||||
// findOneNSForDomain picks one authoritative nameserver to aim a
|
func testContext(t *testing.T) context.Context {
|
||||||
// test at. Live-DNS retry, concurrency and quorum handling live in
|
t.Helper()
|
||||||
// livedns_test.go.
|
|
||||||
|
ctx, cancel := context.WithTimeout(
|
||||||
|
context.Background(), 60*time.Second,
|
||||||
|
)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
func findOneNSForDomain(
|
func findOneNSForDomain(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
r *resolver.Resolver,
|
r *resolver.Resolver,
|
||||||
|
ctx context.Context, //nolint:revive // test helper
|
||||||
domain string,
|
domain string,
|
||||||
) string {
|
) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return liveFindAuthoritative(t, r, domain)[0]
|
nameservers, err := r.FindAuthoritativeNameservers(
|
||||||
|
ctx, domain,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, nameservers)
|
||||||
|
|
||||||
|
return nameservers[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
@@ -55,7 +70,13 @@ func TestFindAuthoritativeNameservers_ValidDomain(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
nameservers := liveFindAuthoritative(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
|
|
||||||
|
nameservers, err := r.FindAuthoritativeNameservers(
|
||||||
|
ctx, "google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, nameservers)
|
||||||
|
|
||||||
hasGoogleNS := false
|
hasGoogleNS := false
|
||||||
|
|
||||||
@@ -78,9 +99,13 @@ func TestFindAuthoritativeNameservers_Subdomain(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
nameservers := liveFindAuthoritative(t, r, "www.google.com")
|
ctx := testContext(t)
|
||||||
|
|
||||||
assert.NotEmpty(t, nameservers)
|
nameservers, err := r.FindAuthoritativeNameservers(
|
||||||
|
ctx, "www.google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, nameservers)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFindAuthoritativeNameservers_ReturnsSorted(
|
func TestFindAuthoritativeNameservers_ReturnsSorted(
|
||||||
@@ -89,7 +114,12 @@ func TestFindAuthoritativeNameservers_ReturnsSorted(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
nameservers := liveFindAuthoritative(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
|
|
||||||
|
nameservers, err := r.FindAuthoritativeNameservers(
|
||||||
|
ctx, "google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.True(
|
assert.True(
|
||||||
t,
|
t,
|
||||||
@@ -104,8 +134,17 @@ func TestFindAuthoritativeNameservers_Deterministic(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
first := liveFindAuthoritative(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
second := liveFindAuthoritative(t, r, "google.com")
|
|
||||||
|
first, err := r.FindAuthoritativeNameservers(
|
||||||
|
ctx, "google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
second, err := r.FindAuthoritativeNameservers(
|
||||||
|
ctx, "google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, first, second)
|
assert.Equal(t, first, second)
|
||||||
}
|
}
|
||||||
@@ -116,8 +155,17 @@ func TestFindAuthoritativeNameservers_TrailingDot(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ns1 := liveFindAuthoritative(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
ns2 := liveFindAuthoritative(t, r, "google.com.")
|
|
||||||
|
ns1, err := r.FindAuthoritativeNameservers(
|
||||||
|
ctx, "google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ns2, err := r.FindAuthoritativeNameservers(
|
||||||
|
ctx, "google.com.",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, ns1, ns2)
|
assert.Equal(t, ns1, ns2)
|
||||||
}
|
}
|
||||||
@@ -128,7 +176,13 @@ func TestFindAuthoritativeNameservers_CloudflareDomain(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
nameservers := liveFindAuthoritative(t, r, "cloudflare.com")
|
ctx := testContext(t)
|
||||||
|
|
||||||
|
nameservers, err := r.FindAuthoritativeNameservers(
|
||||||
|
ctx, "cloudflare.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, nameservers)
|
||||||
|
|
||||||
for _, ns := range nameservers {
|
for _, ns := range nameservers {
|
||||||
assert.True(t, strings.HasSuffix(ns, "."),
|
assert.True(t, strings.HasSuffix(ns, "."),
|
||||||
@@ -145,9 +199,13 @@ func TestQueryNameserver_BasicA(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ns := findOneNSForDomain(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
resp := liveQueryNameserver(t, r, ns, "www.google.com")
|
ns := findOneNSForDomain(t, r, ctx, "google.com")
|
||||||
|
|
||||||
|
resp, err := r.QueryNameserver(
|
||||||
|
ctx, ns, "www.google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
require.NotNil(t, resp)
|
require.NotNil(t, resp)
|
||||||
|
|
||||||
assert.Equal(t, resolver.StatusOK, resp.Status)
|
assert.Equal(t, resolver.StatusOK, resp.Status)
|
||||||
@@ -164,8 +222,13 @@ func TestQueryNameserver_AAAA(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ns := findOneNSForDomain(t, r, "cloudflare.com")
|
ctx := testContext(t)
|
||||||
resp := liveQueryNameserver(t, r, ns, "cloudflare.com")
|
ns := findOneNSForDomain(t, r, ctx, "cloudflare.com")
|
||||||
|
|
||||||
|
resp, err := r.QueryNameserver(
|
||||||
|
ctx, ns, "cloudflare.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
aaaaRecords := resp.Records["AAAA"]
|
aaaaRecords := resp.Records["AAAA"]
|
||||||
require.NotEmpty(t, aaaaRecords,
|
require.NotEmpty(t, aaaaRecords,
|
||||||
@@ -184,8 +247,13 @@ func TestQueryNameserver_MX(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ns := findOneNSForDomain(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
resp := liveQueryNameserver(t, r, ns, "google.com")
|
ns := findOneNSForDomain(t, r, ctx, "google.com")
|
||||||
|
|
||||||
|
resp, err := r.QueryNameserver(
|
||||||
|
ctx, ns, "google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
mxRecords := resp.Records["MX"]
|
mxRecords := resp.Records["MX"]
|
||||||
require.NotEmpty(t, mxRecords,
|
require.NotEmpty(t, mxRecords,
|
||||||
@@ -197,8 +265,13 @@ func TestQueryNameserver_TXT(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ns := findOneNSForDomain(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
resp := liveQueryNameserver(t, r, ns, "google.com")
|
ns := findOneNSForDomain(t, r, ctx, "google.com")
|
||||||
|
|
||||||
|
resp, err := r.QueryNameserver(
|
||||||
|
ctx, ns, "google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
txtRecords := resp.Records["TXT"]
|
txtRecords := resp.Records["TXT"]
|
||||||
require.NotEmpty(t, txtRecords,
|
require.NotEmpty(t, txtRecords,
|
||||||
@@ -224,10 +297,14 @@ func TestQueryNameserver_NXDomain(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ns := findOneNSForDomain(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
resp := liveQueryNameserver(
|
ns := findOneNSForDomain(t, r, ctx, "google.com")
|
||||||
t, r, ns, "this-surely-does-not-exist-xyz.google.com",
|
|
||||||
|
resp, err := r.QueryNameserver(
|
||||||
|
ctx, ns,
|
||||||
|
"this-surely-does-not-exist-xyz.google.com",
|
||||||
)
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, resolver.StatusNXDomain, resp.Status)
|
assert.Equal(t, resolver.StatusNXDomain, resp.Status)
|
||||||
}
|
}
|
||||||
@@ -236,8 +313,13 @@ func TestQueryNameserver_RecordsSorted(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ns := findOneNSForDomain(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
resp := liveQueryNameserver(t, r, ns, "google.com")
|
ns := findOneNSForDomain(t, r, ctx, "google.com")
|
||||||
|
|
||||||
|
resp, err := r.QueryNameserver(
|
||||||
|
ctx, ns, "google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
for recordType, values := range resp.Records {
|
for recordType, values := range resp.Records {
|
||||||
assert.True(
|
assert.True(
|
||||||
@@ -254,8 +336,13 @@ func TestQueryNameserver_ResponseIncludesNameserver(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ns := findOneNSForDomain(t, r, "cloudflare.com")
|
ctx := testContext(t)
|
||||||
resp := liveQueryNameserver(t, r, ns, "cloudflare.com")
|
ns := findOneNSForDomain(t, r, ctx, "cloudflare.com")
|
||||||
|
|
||||||
|
resp, err := r.QueryNameserver(
|
||||||
|
ctx, ns, "cloudflare.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, ns, resp.Nameserver)
|
assert.Equal(t, ns, resp.Nameserver)
|
||||||
}
|
}
|
||||||
@@ -266,10 +353,14 @@ func TestQueryNameserver_EmptyRecordsOnNXDomain(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ns := findOneNSForDomain(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
resp := liveQueryNameserver(
|
ns := findOneNSForDomain(t, r, ctx, "google.com")
|
||||||
t, r, ns, "this-surely-does-not-exist-xyz.google.com",
|
|
||||||
|
resp, err := r.QueryNameserver(
|
||||||
|
ctx, ns,
|
||||||
|
"this-surely-does-not-exist-xyz.google.com",
|
||||||
)
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
totalRecords := 0
|
totalRecords := 0
|
||||||
for _, values := range resp.Records {
|
for _, values := range resp.Records {
|
||||||
@@ -283,9 +374,18 @@ func TestQueryNameserver_TrailingDotHandling(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ns := findOneNSForDomain(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
resp1 := liveQueryNameserver(t, r, ns, "google.com")
|
ns := findOneNSForDomain(t, r, ctx, "google.com")
|
||||||
resp2 := liveQueryNameserver(t, r, ns, "google.com.")
|
|
||||||
|
resp1, err := r.QueryNameserver(
|
||||||
|
ctx, ns, "google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp2, err := r.QueryNameserver(
|
||||||
|
ctx, ns, "google.com.",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, resp1.Status, resp2.Status)
|
assert.Equal(t, resp1.Status, resp2.Status)
|
||||||
}
|
}
|
||||||
@@ -298,9 +398,15 @@ func TestQueryAllNameservers_ReturnsAllNS(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
results := liveQueryAllNameservers(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
|
|
||||||
assert.GreaterOrEqual(t, len(results), minNameservers)
|
results, err := r.QueryAllNameservers(
|
||||||
|
ctx, "google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, results)
|
||||||
|
|
||||||
|
assert.GreaterOrEqual(t, len(results), 2)
|
||||||
|
|
||||||
for ns, resp := range results {
|
for ns, resp := range results {
|
||||||
assert.Equal(t, ns, resp.Nameserver)
|
assert.Equal(t, ns, resp.Nameserver)
|
||||||
@@ -311,36 +417,19 @@ func TestQueryAllNameservers_AllReturnOK(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
results := liveQueryAllNameservers(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
|
|
||||||
// A quorum, not unanimity: one authoritative server being
|
results, err := r.QueryAllNameservers(
|
||||||
// slow or rate-limiting us is a property of the live
|
ctx, "google.com",
|
||||||
// internet, not a resolver defect.
|
|
||||||
assert.GreaterOrEqual(
|
|
||||||
t,
|
|
||||||
countStatus(results, resolver.StatusOK),
|
|
||||||
liveQuorum(len(results)),
|
|
||||||
"a quorum of nameservers should answer OK: %s",
|
|
||||||
describeStatuses(results),
|
|
||||||
)
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Quorum tolerates SILENCE only. Every individual result must
|
for ns, resp := range results {
|
||||||
// be either the expected answer or a non-answer: ok, timeout
|
assert.Equal(
|
||||||
// or error, and nothing else. Stated as a closed allowlist so
|
t, resolver.StatusOK, resp.Status,
|
||||||
// that a wrong answer no one thought to ban — nxdomain and
|
"NS %s should return OK", ns,
|
||||||
// nodata today, any status added later — fails here rather
|
)
|
||||||
// than sliding through under the quorum.
|
}
|
||||||
assert.Empty(
|
|
||||||
t,
|
|
||||||
unsanctionedStatuses(
|
|
||||||
results,
|
|
||||||
resolver.StatusOK,
|
|
||||||
resolver.StatusTimeout,
|
|
||||||
resolver.StatusError,
|
|
||||||
),
|
|
||||||
"every nameserver must answer OK or not answer at all: %s",
|
|
||||||
describeStatuses(results),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryAllNameservers_NXDomainFromAllNS(
|
func TestQueryAllNameservers_NXDomainFromAllNS(
|
||||||
@@ -349,34 +438,20 @@ func TestQueryAllNameservers_NXDomainFromAllNS(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
results := liveQueryAllNameservers(
|
ctx := testContext(t)
|
||||||
t, r, "this-surely-does-not-exist-xyz.google.com",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert.GreaterOrEqual(
|
results, err := r.QueryAllNameservers(
|
||||||
t,
|
ctx,
|
||||||
countStatus(results, resolver.StatusNXDomain),
|
"this-surely-does-not-exist-xyz.google.com",
|
||||||
liveQuorum(len(results)),
|
|
||||||
"a quorum of nameservers should report NXDOMAIN: %s",
|
|
||||||
describeStatuses(results),
|
|
||||||
)
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Silence is tolerated; any actual answer other than NXDOMAIN
|
for ns, resp := range results {
|
||||||
// is not. Closed allowlist for the same reason as above: a
|
assert.Equal(
|
||||||
// server answering `ok` or `nodata` for a name that must not
|
t, resolver.StatusNXDomain, resp.Status,
|
||||||
// exist is a wrong answer, not a slow one.
|
"NS %s should return nxdomain", ns,
|
||||||
assert.Empty(
|
)
|
||||||
t,
|
}
|
||||||
unsanctionedStatuses(
|
|
||||||
results,
|
|
||||||
resolver.StatusNXDomain,
|
|
||||||
resolver.StatusTimeout,
|
|
||||||
resolver.StatusError,
|
|
||||||
),
|
|
||||||
"every nameserver must report NXDOMAIN or not answer "+
|
|
||||||
"at all: %s",
|
|
||||||
describeStatuses(results),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
@@ -387,7 +462,11 @@ func TestLookupNS_ValidDomain(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
nameservers := liveLookupNS(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
|
|
||||||
|
nameservers, err := r.LookupNS(ctx, "google.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, nameservers)
|
||||||
|
|
||||||
for _, ns := range nameservers {
|
for _, ns := range nameservers {
|
||||||
assert.True(t, strings.HasSuffix(ns, "."),
|
assert.True(t, strings.HasSuffix(ns, "."),
|
||||||
@@ -400,7 +479,10 @@ func TestLookupNS_Sorted(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
nameservers := liveLookupNS(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
|
|
||||||
|
nameservers, err := r.LookupNS(ctx, "google.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.True(t, sort.StringsAreSorted(nameservers))
|
assert.True(t, sort.StringsAreSorted(nameservers))
|
||||||
}
|
}
|
||||||
@@ -409,8 +491,15 @@ func TestLookupNS_MatchesFindAuthoritative(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
fromLookup := liveLookupNS(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
fromFind := liveFindAuthoritative(t, r, "google.com")
|
|
||||||
|
fromLookup, err := r.LookupNS(ctx, "google.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fromFind, err := r.FindAuthoritativeNameservers(
|
||||||
|
ctx, "google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, fromFind, fromLookup)
|
assert.Equal(t, fromFind, fromLookup)
|
||||||
}
|
}
|
||||||
@@ -423,7 +512,11 @@ func TestResolveIPAddresses_ReturnsIPs(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ips := liveResolveIPs(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
|
|
||||||
|
ips, err := r.ResolveIPAddresses(ctx, "google.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, ips)
|
||||||
|
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
parsed := net.ParseIP(ip)
|
parsed := net.ParseIP(ip)
|
||||||
@@ -437,7 +530,10 @@ func TestResolveIPAddresses_Deduplicated(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ips := liveResolveIPs(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
|
|
||||||
|
ips, err := r.ResolveIPAddresses(ctx, "google.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
|
||||||
@@ -451,7 +547,10 @@ func TestResolveIPAddresses_Sorted(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ips := liveResolveIPs(t, r, "google.com")
|
ctx := testContext(t)
|
||||||
|
|
||||||
|
ips, err := r.ResolveIPAddresses(ctx, "google.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.True(t, sort.StringsAreSorted(ips))
|
assert.True(t, sort.StringsAreSorted(ips))
|
||||||
}
|
}
|
||||||
@@ -462,10 +561,13 @@ func TestResolveIPAddresses_NXDomainReturnsEmpty(
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ips := liveResolveIPsAllowingEmpty(
|
ctx := testContext(t)
|
||||||
t, r, "this-surely-does-not-exist-xyz.google.com",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
ips, err := r.ResolveIPAddresses(
|
||||||
|
ctx,
|
||||||
|
"this-surely-does-not-exist-xyz.google.com",
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
assert.Empty(t, ips)
|
assert.Empty(t, ips)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,9 +575,11 @@ func TestResolveIPAddresses_CloudflareDomain(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
r := newTestResolver(t)
|
r := newTestResolver(t)
|
||||||
ips := liveResolveIPs(t, r, "cloudflare.com")
|
ctx := testContext(t)
|
||||||
|
|
||||||
assert.NotEmpty(t, ips)
|
ips, err := r.ResolveIPAddresses(ctx, "cloudflare.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, ips)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|||||||
19
internal/server/export_test.go
Normal file
19
internal/server/export_test.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewHTTPServer exports newHTTPServer for testing.
|
||||||
|
func NewHTTPServer(
|
||||||
|
listenAddr string,
|
||||||
|
handler http.Handler,
|
||||||
|
) *http.Server {
|
||||||
|
return newHTTPServer(listenAddr, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestTimeout exports the handler execution budget applied by
|
||||||
|
// chimw.Timeout in SetupRoutes, so tests can assert the relationship
|
||||||
|
// between it and the server's WriteTimeout.
|
||||||
|
const RequestTimeout time.Duration = requestTimeout
|
||||||
@@ -33,8 +33,52 @@ type Params struct {
|
|||||||
// shutdownTimeout is how long to wait for graceful shutdown.
|
// shutdownTimeout is how long to wait for graceful shutdown.
|
||||||
const shutdownTimeout = 30 * time.Second
|
const shutdownTimeout = 30 * time.Second
|
||||||
|
|
||||||
// readHeaderTimeout is the max duration for reading request headers.
|
// Socket-level timeouts for the HTTP server.
|
||||||
const readHeaderTimeout = 10 * time.Second
|
//
|
||||||
|
// These bound time spent on the connection itself and are a distinct
|
||||||
|
// control from the per-request handler budget enforced by
|
||||||
|
// chimw.Timeout(requestTimeout) in routes.go: that one cancels the
|
||||||
|
// request context after requestTimeout but never touches the socket,
|
||||||
|
// so without the values below a peer can hold a connection open
|
||||||
|
// forever (slowloris, unreaped keep-alives).
|
||||||
|
//
|
||||||
|
// The one hard constraint between the two controls is
|
||||||
|
// writeTimeout > requestTimeout. net/http arms the write deadline
|
||||||
|
// once the request headers have been read, so on a plaintext
|
||||||
|
// connection it covers handler execution AND the response flush. If
|
||||||
|
// writeTimeout were <= requestTimeout the server would sever the
|
||||||
|
// connection before a handler that legitimately consumed its full
|
||||||
|
// budget could emit anything, making the 60s budget unreachable in
|
||||||
|
// practice. The margin between them is the response-flush allowance.
|
||||||
|
//
|
||||||
|
// The only clients of this service are browsers loading the dashboard
|
||||||
|
// and a Prometheus scraper; the values are sized for those.
|
||||||
|
const (
|
||||||
|
// readHeaderTimeout is the max duration for reading request
|
||||||
|
// headers.
|
||||||
|
readHeaderTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// readTimeout bounds reading the entire request, headers plus
|
||||||
|
// body. Every route here is a GET with no body, so this only
|
||||||
|
// ever needs to cover headers; the extra 5s over
|
||||||
|
// readHeaderTimeout is slack, not a real allowance, and keeps a
|
||||||
|
// body dribbled one byte at a time from holding the read side
|
||||||
|
// open indefinitely.
|
||||||
|
readTimeout = 15 * time.Second
|
||||||
|
|
||||||
|
// writeTimeout must exceed the requestTimeout handler budget
|
||||||
|
// (60s) per the note above. The 15s difference is the allowance
|
||||||
|
// for flushing a completed response to a slow client.
|
||||||
|
writeTimeout = 75 * time.Second
|
||||||
|
|
||||||
|
// idleTimeout reaps keep-alive connections between requests. It
|
||||||
|
// is deliberately longer than the common Prometheus scrape
|
||||||
|
// intervals (15s/30s/60s) so the scraper reuses its connection
|
||||||
|
// rather than reconnecting every cycle, while a browser tab
|
||||||
|
// left open on the dashboard stops occupying a connection
|
||||||
|
// within two minutes of going quiet.
|
||||||
|
idleTimeout = 120 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
// Server is the HTTP server.
|
// Server is the HTTP server.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -76,16 +120,29 @@ func New(
|
|||||||
return srv, nil
|
return srv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newHTTPServer builds the listening http.Server with every
|
||||||
|
// socket-level timeout set. All four are set deliberately: a zero
|
||||||
|
// value in net/http means "no limit", not "some default".
|
||||||
|
func newHTTPServer(
|
||||||
|
listenAddr string,
|
||||||
|
handler http.Handler,
|
||||||
|
) *http.Server {
|
||||||
|
return &http.Server{
|
||||||
|
Addr: listenAddr,
|
||||||
|
Handler: handler,
|
||||||
|
ReadTimeout: readTimeout,
|
||||||
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
|
WriteTimeout: writeTimeout,
|
||||||
|
IdleTimeout: idleTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Run starts the HTTP server.
|
// Run starts the HTTP server.
|
||||||
func (s *Server) Run() {
|
func (s *Server) Run() {
|
||||||
s.SetupRoutes()
|
s.SetupRoutes()
|
||||||
|
|
||||||
listenAddr := fmt.Sprintf(":%d", s.port)
|
listenAddr := fmt.Sprintf(":%d", s.port)
|
||||||
s.httpServer = &http.Server{
|
s.httpServer = newHTTPServer(listenAddr, s)
|
||||||
Addr: listenAddr,
|
|
||||||
Handler: s,
|
|
||||||
ReadHeaderTimeout: readHeaderTimeout,
|
|
||||||
}
|
|
||||||
|
|
||||||
s.log.Info("http server starting", "addr", listenAddr)
|
s.log.Info("http server starting", "addr", listenAddr)
|
||||||
|
|
||||||
|
|||||||
112
internal/server/server_test.go
Normal file
112
internal/server/server_test.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/dnswatcher/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
// noopHandler stands in for the router; newHTTPServer only stores it.
|
||||||
|
func noopHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPServerTimeoutsAreSet asserts that every socket-level
|
||||||
|
// timeout is configured. A zero value in net/http means "no limit",
|
||||||
|
// so a refactor that silently drops one of these reintroduces the
|
||||||
|
// slowloris / unreaped-keep-alive exposure this guards against.
|
||||||
|
//
|
||||||
|
// The assertions are on the configured field values only; nothing
|
||||||
|
// here measures elapsed time, so the test cannot flake on timing.
|
||||||
|
func TestHTTPServerTimeoutsAreSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := server.NewHTTPServer(":8080", noopHandler())
|
||||||
|
|
||||||
|
if srv.ReadTimeout <= 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"ReadTimeout must be non-zero, got %v",
|
||||||
|
srv.ReadTimeout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.ReadHeaderTimeout <= 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"ReadHeaderTimeout must be non-zero, got %v",
|
||||||
|
srv.ReadHeaderTimeout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.WriteTimeout <= 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"WriteTimeout must be non-zero, got %v",
|
||||||
|
srv.WriteTimeout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.IdleTimeout <= 0 {
|
||||||
|
t.Errorf(
|
||||||
|
"IdleTimeout must be non-zero, got %v",
|
||||||
|
srv.IdleTimeout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteTimeoutExceedsHandlerBudget pins the one relationship the
|
||||||
|
// values must satisfy. net/http arms the write deadline once request
|
||||||
|
// headers are read, so it covers handler execution plus the response
|
||||||
|
// flush. If WriteTimeout were not greater than the chimw.Timeout
|
||||||
|
// handler budget, the connection would be severed before a handler
|
||||||
|
// that used its full budget could respond, making that budget
|
||||||
|
// unreachable.
|
||||||
|
func TestWriteTimeoutExceedsHandlerBudget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := server.NewHTTPServer(":8080", noopHandler())
|
||||||
|
|
||||||
|
if srv.WriteTimeout <= server.RequestTimeout {
|
||||||
|
t.Errorf(
|
||||||
|
"WriteTimeout (%v) must exceed handler budget (%v)",
|
||||||
|
srv.WriteTimeout,
|
||||||
|
server.RequestTimeout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadTimeoutCoversHeaderTimeout asserts the read deadline for
|
||||||
|
// the whole request is at least as long as the header-only deadline;
|
||||||
|
// a smaller ReadTimeout would make ReadHeaderTimeout unreachable.
|
||||||
|
func TestReadTimeoutCoversHeaderTimeout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := server.NewHTTPServer(":8080", noopHandler())
|
||||||
|
|
||||||
|
if srv.ReadTimeout < srv.ReadHeaderTimeout {
|
||||||
|
t.Errorf(
|
||||||
|
"ReadTimeout (%v) must be >= ReadHeaderTimeout (%v)",
|
||||||
|
srv.ReadTimeout,
|
||||||
|
srv.ReadHeaderTimeout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTTPServerAddrAndHandler covers the rest of the constructor so
|
||||||
|
// a future edit cannot drop the listen address or the handler.
|
||||||
|
func TestHTTPServerAddrAndHandler(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := server.NewHTTPServer(":9999", noopHandler())
|
||||||
|
|
||||||
|
if srv.Addr != ":9999" {
|
||||||
|
t.Errorf("Addr = %q, want %q", srv.Addr, ":9999")
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.Handler == nil {
|
||||||
|
t.Error("Handler must not be nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,17 +3,15 @@
|
|||||||
# this repo. Idempotent: every install is guarded by a check so already
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
# or apk (detected in that order); assumes nothing is present.
|
# or apk (detected in that order); assumes nothing is present.
|
||||||
# goimports is installed via `go install` at a pinned commit (never
|
# golangci-lint and goimports are installed via `go install` at the same
|
||||||
# "latest") because script/fmt runs it on the host; script/fmt-check
|
# pinned commits the Dockerfile uses (never "latest").
|
||||||
# does not (it runs gofmt only).
|
|
||||||
# The linter is NOT installed here: golangci-lint runs via docker only
|
|
||||||
# (script/lint), pinned by image digest, so its only prerequisite is a
|
|
||||||
# working docker.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
# Pinned version, 2026-08-07 (same pin as the Dockerfile)
|
# 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 v0.42.0
|
||||||
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
|
GOIMPORTS_REF="golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0"
|
||||||
|
|
||||||
@@ -71,17 +69,11 @@ main() {
|
|||||||
if missing make; then pkg_install gnumake make make make; fi
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
if missing go; then pkg_install go golang go go; fi
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
|
||||||
# Format tools, pinned via go install (installs into
|
# Lint/format tools, pinned via go install (installs into
|
||||||
# "$(go env GOPATH)/bin"; ensure that is on your PATH).
|
# "$(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
|
if missing goimports; then go install "$GOIMPORTS_REF"; fi
|
||||||
|
|
||||||
# Linting runs via docker only (script/lint). Warn, don't fail:
|
|
||||||
# everything except `make lint` works without it.
|
|
||||||
if missing docker; then
|
|
||||||
echo "bootstrap: WARNING: docker not found; install it to" \
|
|
||||||
"run make lint and make docker." >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
echo "bootstrap complete"
|
echo "bootstrap complete"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/cibuild: run the CI build. The Dockerfile's lint stage runs
|
# script/cibuild: run the CI build. The Dockerfile runs make check, so
|
||||||
# make fmt-check and golangci-lint; its builder stage runs make test
|
# a successful build implies all checks pass.
|
||||||
# and make build. A successful build implies all of those passed.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|||||||
20
script/lint
20
script/lint
@@ -1,28 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/lint: run the linter. golangci-lint is never installed or run
|
# script/lint: run the linter.
|
||||||
# on the host: it runs via docker only, one way, everywhere. This
|
|
||||||
# builds Dockerfile.lint, which COPYs the repo into the digest-pinned
|
|
||||||
# golangci-lint image and lints as a build step, so a successful build
|
|
||||||
# means a clean lint.
|
|
||||||
#
|
|
||||||
# --no-cache-filter=lint forces the lint stage (source copy + linter
|
|
||||||
# run) to execute on every invocation. Without it an unchanged tree
|
|
||||||
# returns success in well under a second having linted nothing. The
|
|
||||||
# deps stage (base image + go mod download) stays cached, and no global
|
|
||||||
# cache invalidation is performed. --progress=plain keeps the linter's
|
|
||||||
# own output visible.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build \
|
golangci-lint run --config .golangci.yml ./...
|
||||||
--progress=plain \
|
|
||||||
--no-cache-filter=lint \
|
|
||||||
--target lint \
|
|
||||||
-f Dockerfile.lint \
|
|
||||||
.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
25
script/test
25
script/test
@@ -1,35 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/test: run the test suite.
|
# script/test: run the test suite.
|
||||||
#
|
|
||||||
# -count=1 disables Go's test cache, and is load-bearing here. This
|
|
||||||
# suite queries live DNS on every run by policy (TESTING.md); a cached
|
|
||||||
# result is a replay of an earlier run's output with no query made at
|
|
||||||
# all. On an unchanged tree the whole suite would return success in
|
|
||||||
# under a second having resolved nothing, which makes the repeated-run
|
|
||||||
# green that is used as evidence for flakiness fixes worthless. Do not
|
|
||||||
# remove it.
|
|
||||||
#
|
|
||||||
# Conditional verbose rerun per REPO_POLICIES.md: run quiet first so
|
|
||||||
# CI and docker build logs stay readable, and rerun with -v only on
|
|
||||||
# failure. The rerun also carries -count=1 (a cached replay of the
|
|
||||||
# failure would show nothing new), and the exit status is forced to 1
|
|
||||||
# no matter how the rerun ends: the first failure already proved the
|
|
||||||
# suite broken, so a flaky test that passes the second time must not
|
|
||||||
# turn the build green.
|
|
||||||
#
|
|
||||||
# -timeout 90s is a deliberate backstop above the 60s hard cap on
|
|
||||||
# suite duration. Do not lower it.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
go test -count=1 -race -timeout 90s -cover ./... || {
|
go test -v -race -timeout 30s -cover ./...
|
||||||
echo "--- Rerunning with -v for details ---" >&2
|
|
||||||
go test -count=1 -race -timeout 90s -v ./... || true
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user