1 Commits
Author SHA1 Message Date
sneak d61d8e2c69 docker: a plain docker build . stamps the git version (closes #210)
check / check (push) Successful in 1m40s
A plain `docker build .`, which is how upaas builds, stamped `dev`:
`.dockerignore` left out `.git` and the builder declared
`ARG VERSION=dev`. `.dockerignore` now sends `.git` and lists no tracked
file, which git in the build would count as deleted and mark `-dirty`.
`ARG VERSION` has no default. The Makefile takes `VERSION` from the
environment with `?=`, so a build arg still wins (`script/docker` keeps
passing one); otherwise `git describe` runs in the builder. A new
`make version` prints the version, and the builder fails when the
context carries `.git` and it comes out empty, `dev` or `unknown`.

Model: opus-5-5
2026-10-02 01:15:55 +00:00
11 changed files with 35 additions and 176 deletions
+4 -7
View File
@@ -1,9 +1,6 @@
# .git is sent, without its config: the builder stage derives the version it # .git is sent: the builder stage derives the version it stamps into the
# stamps into the binary from it, and `git describe` does not need the config, # binary from it. No tracked file may be listed here: git in the build
# which can hold a credential (a password in the remote URL, a CI token). No # would see it as deleted and mark the version -dirty, and an excluded .md
# tracked file may be listed here: git in the build would see it as deleted # would silently drop out of the prettier check in Dockerfile.fmt.
# and mark the version -dirty, and an excluded .md would silently drop out of
# the prettier check in Dockerfile.fmt.
.git/config
bin/ bin/
node_modules/ node_modules/
+7 -8
View File
@@ -37,17 +37,16 @@ COPY . .
RUN make test RUN make test
# Version stamped into the binary: the VERSION build arg when one is # Version stamped into the binary: the VERSION build arg when one is
# given and not empty (script/docker passes one), otherwise what # given (script/docker passes one), otherwise what `git describe` says of
# `git describe` says of the .git in the build context, so a plain # the .git in the build context, so a plain `docker build .` of a clone
# `docker build .` of a clone stamps its tag or short commit. The build # stamps its tag or short commit. The build arg reaches make through the
# arg reaches make through the environment. # environment.
ARG VERSION ARG VERSION
# A context that carries .git, as a directory or as a file, must yield a # A context that carries .git must yield a real version: one that is
# real version: one that is empty, `dev` or `unknown` cannot be traced # empty, `dev` or `unknown` cannot be traced back to a commit.
# back to a commit.
RUN version="$(make version)"; \ RUN version="$(make version)"; \
if [ -e .git ]; then \ if [ -d .git ]; then \
case "$version" in \ case "$version" in \
"" | dev | unknown) \ "" | dev | unknown) \
echo "version is \"$version\" although the build context carries .git" >&2; \ echo "version is \"$version\" although the build context carries .git" >&2; \
+2 -5
View File
@@ -3,11 +3,8 @@
BINARY := dnswatcher BINARY := dnswatcher
# VERSION given on the command line (`make build VERSION=...`) or in the # VERSION given on the command line (`make build VERSION=...`) or in the
# environment, which is how the Dockerfile's VERSION build arg arrives, # environment, which is how the Dockerfile's VERSION build arg arrives,
# wins over what `git describe` says of this checkout. An empty one counts # wins over what `git describe` says of this checkout.
# as not given; `override` is what replaces an empty command-line value. VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
ifeq ($(VERSION),)
override VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
endif
LDFLAGS := -X main.Version=$(VERSION) LDFLAGS := -X main.Version=$(VERSION)
# Standard targets are thin shims; the implementations live in script/ # Standard targets are thin shims; the implementations live in script/
+8 -17
View File
@@ -407,13 +407,6 @@ it watches. Instead, it performs full iterative resolution:
4. **Authoritative query**: Queries all discovered authoritative nameservers 4. **Authoritative query**: Queries all discovered authoritative nameservers
directly for the requested records. directly for the requested records.
In steps 2 and 3 the servers are asked one at a time in a random order, chosen
anew each time, so no one root server gets every first query. A server that does
not reply, refuses the query, or gives an error reply such as SERVFAIL or a
referral that leads no closer to the name is passed over for the next one. When
a referral names a zone's nameservers without their addresses, the addresses of
all of them are looked up, so that each can be asked.
This approach ensures: This approach ensures:
- Independence from any upstream resolver's cache or filtering. - Independence from any upstream resolver's cache or filtering.
@@ -587,19 +580,17 @@ make clean # Remove build artifacts
`make build` sets the version with `-ldflags "-X main.Version=..."`, taking it `make build` sets the version with `-ldflags "-X main.Version=..."`, taking it
from `VERSION` when given on the command line (`make build VERSION=1.2.3`) or in from `VERSION` when given on the command line (`make build VERSION=1.2.3`) or in
the environment, otherwise from `git describe --tags --always --dirty`, and the environment, otherwise from `git describe --tags --always --dirty`, and
`dev` without git metadata. An empty `VERSION` counts as not given. The version `dev` without git metadata. The version appears in the startup log and in the
appears in the startup log and in the health check response. health check response.
The image takes it the same way, from the `.git` the build context carries, so a The image takes it the same way, from the `.git` the build context carries, so a
plain `docker build .` of a clone stamps the commit it was built from; a clone plain `docker build .` of a clone stamps the commit it was built from; a clone
without tags, such as a shallow one, stamps the short commit. `.dockerignore` without tags, such as a shallow one, stamps the short commit. A
sends `.git` without `.git/config`, which `git describe` does not need and which `--build-arg VERSION=...` takes precedence; `make docker` passes the version
can hold a credential. A non-empty `--build-arg VERSION=...` takes precedence; `git describe` gives on the host. The build fails when the context carries
`make docker` passes the version `git describe` gives on the host. The build `.git` and the version comes out empty, `dev` or `unknown`. `.dockerignore` must
fails when the context carries `.git`, as a directory or as a file, and the list no tracked file: git in the build would see it as deleted and mark the
version comes out empty, `dev` or `unknown`. `.dockerignore` must list no version `-dirty`.
tracked file: git in the build would see it as deleted and mark the version
`-dirty`.
--- ---
+1 -2
View File
@@ -21,8 +21,6 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
- 2026-10-02: a plain `docker build .` of a clone stamps its tag or short - 2026-10-02: a plain `docker build .` of a clone stamps its tag or short
commit, not `dev`: the build context now carries `.git` (closes #210). commit, not `dev`: the build context now carries `.git` (closes #210).
- 2026-10-02: the resolver tries root servers, and every other server list it
walks, in a random order each time, not always from the top (closes #138).
- 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any - 2026-10-02: a name listed more than once in `DNSWATCHER_TARGETS`, in any
letter case or with a trailing dot, is watched once (closes #207). letter case or with a trailing dot, is watched once (closes #207).
- 2026-10-01: README checked against the code and corrected: metrics, CORS, - 2026-10-01: README checked against the code and corrected: metrics, CORS,
@@ -135,4 +133,5 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149
- 1.0 readiness: run it with a real config and read the logs: - 1.0 readiness: run it with a real config and read the logs:
https://git.eeqj.de/sneak/dnswatcher/issues/66 https://git.eeqj.de/sneak/dnswatcher/issues/66
- fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138
- review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144 - review toward 1.0: https://git.eeqj.de/sneak/dnswatcher/issues/144
+5 -5
View File
@@ -9,11 +9,11 @@
// //
// 1. Bounded concurrency. Tests run in parallel and the build hosts // 1. Bounded concurrency. Tests run in parallel and the build hosts
// have many cores, so without a limit every test starts its own // have many cores, so without a limit every test starts its own
// iterative resolution at the same instant and they all send their // iterative resolution at the same instant and they all hit the
// first queries to the root servers within a few milliseconds of // first root server within a few milliseconds of each other. Root
// each other. Root servers rate-limit that, which shows up as a // servers rate-limit that, which shows up as a different arbitrary
// different arbitrary subset of tests failing on each run. Run caps // subset of tests failing on each run. Run caps how many live
// how many live operations are in flight at once in one test binary. // operations are in flight at once in one test binary.
// //
// 2. Retry with exponential backoff. Each live operation gets several // 2. Retry with exponential backoff. Each live operation gets several
// attempts with its own timeout. An attempt is retried when it // attempts with its own timeout. An attempt is retried when it
-21
View File
@@ -36,24 +36,3 @@ func (r *Resolver) QueryEachNS(
) (map[string]*NameserverResponse, error) { ) (map[string]*NameserverResponse, error) {
return r.queryEachNS(ctx, nameservers, hostname) return r.queryEachNS(ctx, nameservers, hostname)
} }
// ResolveNSIPs exports resolveNSIPs for testing.
func (r *Resolver) ResolveNSIPs(
ctx context.Context,
nsNames []string,
) []string {
return r.resolveNSIPs(ctx, nsNames)
}
// RootServerList exports rootServerList for testing.
func RootServerList() []string {
return rootServerList()
}
// Shuffled exports shuffled for testing.
func Shuffled(
servers []string,
shuffle func(n int, swap func(i, j int)),
) []string {
return shuffled(servers, shuffle)
}
+8 -26
View File
@@ -4,9 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math/rand/v2"
"net" "net"
"slices"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -261,25 +259,9 @@ func (r *Resolver) followDelegation(
return nil, ErrNoNameservers return nil, ErrNoNameservers
} }
// shuffled returns a copy of servers in the order shuffle puts them // queryServers asks servers, the servers of zone, about name until one
// in. The resolver passes rand.Shuffle, so each time it walks a list of // gives a usable reply. A server that times out, refuses or gives a
// servers it starts at a random one, and no one server gets every // reply that is not usable is passed over for the next.
// first query.
func shuffled(
servers []string,
shuffle func(n int, swap func(i, j int)),
) []string {
order := slices.Clone(servers)
shuffle(len(order), func(i, j int) {
order[i], order[j] = order[j], order[i]
})
return order
}
// queryServers asks servers, the servers of zone, about name in a random
// order until one gives a usable reply. A server that times out, refuses
// or gives a reply that is not usable is passed over for the next.
func (r *Resolver) queryServers( func (r *Resolver) queryServers(
ctx context.Context, ctx context.Context,
servers []string, servers []string,
@@ -289,7 +271,7 @@ func (r *Resolver) queryServers(
) (*dns.Msg, error) { ) (*dns.Msg, error) {
var lastErr error var lastErr error
for _, ip := range shuffled(servers, rand.Shuffle) { for _, ip := range servers {
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
} }
@@ -358,10 +340,6 @@ func nsSetFrom(resp *dns.Msg, domain string) []string {
return extractNSSet(resp.Answer) return extractNSSet(resp.Answer)
} }
// resolveNSIPs returns the addresses of every nameserver in nsNames
// whose name resolves, for a referral that carries none. The walk can
// then go on to the zone's other nameservers when one gives no usable
// reply.
func (r *Resolver) resolveNSIPs( func (r *Resolver) resolveNSIPs(
ctx context.Context, ctx context.Context,
nsNames []string, nsNames []string,
@@ -373,6 +351,10 @@ func (r *Resolver) resolveNSIPs(
if err == nil { if err == nil {
ips = append(ips, resolved...) ips = append(ips, resolved...)
} }
if len(ips) > 0 {
break
}
} }
return ips return ips
-29
View File
@@ -1,8 +1,6 @@
package resolver_test package resolver_test
import ( import (
"math/rand/v2"
"slices"
"testing" "testing"
"github.com/miekg/dns" "github.com/miekg/dns"
@@ -237,30 +235,3 @@ func TestExtractRecordValue_LetterCase(t *testing.T) {
}) })
} }
} }
// TestShuffled shuffles the root servers with many seeds. Every order
// must hold each root server once, so each is tried before a
// resolution fails; each root server must come first for some seed, so
// no one root server gets every first query; and the list passed in
// must be left as it was.
func TestShuffled(t *testing.T) {
t.Parallel()
const seeds = 1000
roots := resolver.RootServerList()
before := slices.Clone(roots)
first := make(map[string]bool)
for seed := range uint64(seeds) {
rng := rand.New(rand.NewPCG(seed, 0)) //nolint:gosec // seeded on purpose
order := resolver.Shuffled(roots, rng.Shuffle)
assert.ElementsMatch(t, roots, order)
first[order[0]] = true
}
assert.Len(t, first, len(roots))
assert.Equal(t, before, roots)
}
-34
View File
@@ -383,37 +383,3 @@ func liveResolveIPsAllowingEmpty(
return out return out
} }
// liveResolveNSIPs looks up the addresses of the nameservers named
// names, retrying until there are at least atLeast of them: a name
// whose lookup got no reply is left out of the result, not an error.
func liveResolveNSIPs(
t *testing.T,
r *resolver.Resolver,
names []string,
atLeast int,
) []string {
t.Helper()
var out []string
livednstest.Retry(
t,
"ResolveNSIPs("+strings.Join(names, ", ")+")",
func(ctx context.Context) error {
ips := r.ResolveNSIPs(ctx, names)
if len(ips) < atLeast {
return fmt.Errorf(
"%w: %d addresses, expected at least %d",
livednstest.ErrNoAnswer, len(ips), atLeast,
)
}
out = ips
return nil
},
)
return out
}
-22
View File
@@ -139,28 +139,6 @@ func TestFindAuthoritativeNameservers_CloudflareDomain(
} }
} }
// TestResolveNSIPs_EveryNameserver looks up the addresses of two of
// google.com's nameservers together, as the walk does when a referral
// names a zone's nameservers without their addresses, and compares them
// with each looked up alone. Together they must give the addresses of
// both, not only of the first that resolves, so that when one gives no
// usable reply the walk goes on to the other.
func TestResolveNSIPs_EveryNameserver(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
names := []string{"ns3.google.com.", "ns4.google.com."}
want := make([]string, 0, len(names))
for _, name := range names {
want = append(want, liveResolveNSIPs(t, r, []string{name}, 1)...)
}
got := liveResolveNSIPs(t, r, names, len(want))
assert.ElementsMatch(t, want, got)
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// QueryNameserver tests // QueryNameserver tests
// ---------------------------------------------------------------- // ----------------------------------------------------------------