4 Commits
Author SHA1 Message Date
clawbot 98a8e69b4b watcher: notify NS query failure and recovery (closes #104)
check / check (push) Successful in 1m5s
LookupAllRecords now returns each nameserver's response, so the
watcher saves its status: ok when it answered, NXDOMAIN and no records
included, and error with the reason when it timed out, answered
SERVFAIL or REFUSED, or could not be reached. A nameserver that starts
failing sends NS Failure and one that answers again sends NS Recovery.
A failing nameserver is left out of the record change and
inconsistency comparisons. The resolver used to report REFUSED and
network errors as an answer with no records; they are now errors. A
lookup cut short by its context now returns an error instead of a
failure of the nameserver it was querying.

Model: opus-5-5
2026-10-01 20:04:57 +00:00
clawbot bea9a3b2f2 docker: report the real version in the image (closes #109)
check / check (push) Successful in 1m17s
The Dockerfile builder stage takes ARG VERSION (default `dev`) and passes
it to `make build` on the command line, which overrides the Makefile's
`git describe` default. script/docker computes the version from
`git describe` on the host and passes it as --build-arg VERSION, because
.dockerignore leaves .git out of the build context and `git describe`
inside the build only ever produced `dev`. A build that passes no
argument, such as script/cibuild, still reports `dev`.

`logger.Identify`, which logs `starting` with the version, was never
called; `main` now calls it first, so the version is in the startup log.

Model: opus-5-5
2026-10-01 22:01:43 +02:00
clawbot e93c2664b8 notify: release held deliveries so shutdown tests fail, not hang (closes #176)
check / check (push) Successful in 1m5s
Two shutdown tests hold a delivery inside the test server's handler and
release it from a timer. The deferred timer stop ran before the server
was closed, so a drain that returned early left the handler blocked and
the server's close waited on it until the package timed out. Each test
now defers a release, guarded so the timer and the defer can both call
it, ahead of closing the server. The watchdog comment no longer names a
30-second timeout the test script does not use.

Model: opus-5-5
2026-10-01 21:58:58 +02:00
clawbot bde047f2a3 script/install-precommit: work where .git is a file (closes #129)
check / check (push) Successful in 1m9s
The script wrote the hook to .git/hooks, which fails when .git is a
file rather than a directory, as in a clone made with
--separate-git-dir. It now asks git for the repository's own git
directory with `git rev-parse --git-common-dir`, creates its hooks
directory if missing, and writes the hook there. In an ordinary clone
that is .git/hooks, so nothing moves. Before writing anything it stops
with an error when its top directory is not the top of the checkout
git finds, so a copy inside another repository cannot replace that
repository's hook. git's core.hooksPath setting is not followed; where
it is in force, git does not run the installed hook, as before.

Model: opus-5-5
2026-10-01 21:45:57 +02:00
17 changed files with 643 additions and 112 deletions
+5 -2
View File
@@ -34,8 +34,11 @@ COPY . .
# Run the tests - build fails if any test fails # Run the tests - build fails if any test fails
RUN make test RUN make test
# Build the binary # Build the binary. .dockerignore leaves out .git, so `git describe` in
RUN make build # the Makefile cannot find the version here: script/docker passes it as
# --build-arg VERSION, and a build that passes none reports `dev`.
ARG VERSION=dev
RUN make build VERSION="${VERSION}"
# Runtime stage # Runtime stage
# alpine 3.21, 2026-02-28 # alpine 3.21, 2026-02-28
+2
View File
@@ -1,6 +1,8 @@
.PHONY: all bootstrap setup build lint fmt fmt-check test check clean hooks docker .PHONY: all bootstrap setup build lint fmt fmt-check test check clean hooks docker
BINARY := dnswatcher BINARY := dnswatcher
# `make build VERSION=...` overrides this; the Dockerfile does so, as the
# image has no .git to describe.
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
LDFLAGS := -X main.Version=$(VERSION) LDFLAGS := -X main.Version=$(VERSION)
+29 -14
View File
@@ -71,18 +71,25 @@ rejected.
did on the previous check (additions, removals, value changes). did on the previous check (additions, removals, value changes).
- **NS query failure**: A nameserver that previously responded - **NS query failure**: A nameserver that previously responded
becomes unreachable (timeout, SERVFAIL, REFUSED, network error). becomes unreachable (timeout, SERVFAIL, REFUSED, network error).
This is distinct from "responded with no records." This is distinct from "responded with no records": a nameserver
that answers NXDOMAIN or with no records has responded. The alert
is sent once, on the check where it starts failing. A failing
nameserver gives no records, so it is not reported as a record
change or compared for inconsistency. A nameserver that is already
failing on the first check that sees it is recorded silently.
- **NS recovery**: A previously-unreachable nameserver starts - **NS recovery**: A previously-unreachable nameserver starts
responding again. responding again. Its records are not compared with those from
before it failed, so a change made while it was failing is not
reported as a record change.
- **Inconsistency detected**: Two nameservers return different record - **Inconsistency detected**: Two nameservers return different record
sets for the same hostname and did not already differ on the previous sets for the same hostname and did not already differ on the previous
check. Every pair of nameservers is compared. The alert is sent once check. Every pair of nameservers is compared. The alert is sent once
for each such pair, on the check where they start to disagree, and not for each such pair, on the check where they start to disagree, and not
again while they keep disagreeing, including after a restart. A again while they keep disagreeing, including after a restart. A
nameserver that was not in the previous check (newly added, or back nameserver that was not in the previous check (newly added, or back
after dropping out) and answers differently is reported on the check after dropping out), or failed on it, and answers differently is
where it appears. If a pair agrees again and later disagrees, the reported on the check where it answers. If a pair agrees again and
alert is sent again. later disagrees, the alert is sent again.
### TCP Port Monitoring ### TCP Port Monitoring
@@ -441,10 +448,14 @@ not as a merged view, to enable inconsistency detection.
The `status` field for each per-nameserver entry and certificate entry The `status` field for each per-nameserver entry and certificate entry
tracks reachability: tracks reachability:
| Status | Meaning | | Status | Meaning |
|-------------|-------------------------------------------------| |-------------|------------------------------------------------------------|
| `ok` | Query succeeded, records are current | | `ok` | Query succeeded, records are current |
| `error` | Query failed (timeout, SERVFAIL, network error) | | `error` | Query failed (timeout, SERVFAIL, REFUSED, network error) |
A nameserver that answers NXDOMAIN or with no records has status `ok` and
empty `records`. A nameserver whose query failed has status `error`, empty
`records`, and the reason in `error`.
--- ---
@@ -480,7 +491,8 @@ them. We provide:
- `script/check` — run test, lint, and fmt-check - `script/check` — run test, lint, and fmt-check
- `script/docker` — build the Docker image tagged via `script/projectname`, with - `script/docker` — build the Docker image tagged via `script/projectname`, with
`--no-cache-filter=lint,builder` so the lint stage and the builder stage, `--no-cache-filter=lint,builder` so the lint stage and the builder stage,
which runs the tests, run on every invocation which runs the tests, run on every invocation, and with the version from
`git describe` passed as `--build-arg VERSION`
- `script/cibuild` — CI entrypoint: `docker build` with - `script/cibuild` — CI entrypoint: `docker build` with
`--no-cache-filter=lint,builder`, so the lint stage and the builder stage, `--no-cache-filter=lint,builder`, so the lint stage and the builder stage,
which runs the tests, run on every invocation, because a cached build lints which runs the tests, run on every invocation, because a cached build lints
@@ -502,11 +514,14 @@ make clean # Remove build artifacts
### Build-Time Variables ### Build-Time Variables
Version is injected via `-ldflags`: `make build` sets the version with `-ldflags "-X main.Version=..."`, taking
it from `git describe --tags --always --dirty`, or from `VERSION` when given
on the command line (`make build VERSION=1.2.3`). The version appears in the
startup log and in the health check response.
```sh The Docker image has no `.git`, so the `Dockerfile` takes the version as
go build -ldflags "-X main.Version=$(git describe --tags --always)" ./cmd/dnswatcher `--build-arg VERSION`. `make docker` passes it; a plain `docker build`
``` passes none, and that image reports `dev`.
--- ---
+9 -8
View File
@@ -15,11 +15,18 @@ on the 1.0 milestone: https://git.eeqj.de/sneak/dnswatcher/milestone/7
# Next Step # Next Step
NS failure and NS recovery notifications: nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105
https://git.eeqj.de/sneak/dnswatcher/issues/104
# Completed Steps # Completed Steps
- 2026-10-01: a nameserver that does not answer is saved as `error` with the
reason, and NS failure and NS recovery are notified (closes #104).
- 2026-10-01: the image built by `make docker` reports the `git describe`
version, not `dev`, and the startup log now shows it (closes #109).
- 2026-10-01: two notify shutdown tests always release the delivery they hold,
so a drain that returns early fails them instead of hanging (closes #176).
- 2026-10-01: `script/install-precommit` asks git for the repository's git
directory, so `make hooks` also works where `.git` is a file (closes #129).
- 2026-10-01: `TODO.md` brought up to date: open issues listed by URL, every - 2026-10-01: `TODO.md` brought up to date: open issues listed by URL, every
Completed Steps entry cut to at most two lines (closes #146). Completed Steps entry cut to at most two lines (closes #146).
- 2026-10-01: wildcard CORS now applies only to the public routes, not to - 2026-10-01: wildcard CORS now applies only to the public routes, not to
@@ -86,14 +93,12 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104
# Future Steps # Future Steps
- nameserver IP address changes: https://git.eeqj.de/sneak/dnswatcher/issues/105
- `DNSWATCHER_SENTRY_DSN` does nothing: - `DNSWATCHER_SENTRY_DSN` does nothing:
https://git.eeqj.de/sneak/dnswatcher/issues/107 https://git.eeqj.de/sneak/dnswatcher/issues/107
- invalid DNS or TLS interval silently replaced by the default: - invalid DNS or TLS interval silently replaced by the default:
https://git.eeqj.de/sneak/dnswatcher/issues/177 https://git.eeqj.de/sneak/dnswatcher/issues/177
- rate limit on `/metrics` Basic Auth: - rate limit on `/metrics` Basic Auth:
https://git.eeqj.de/sneak/dnswatcher/issues/101 https://git.eeqj.de/sneak/dnswatcher/issues/101
- images report version `dev`: https://git.eeqj.de/sneak/dnswatcher/issues/109
- trial run of the finished image: - trial run of the finished image:
https://git.eeqj.de/sneak/dnswatcher/issues/149 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:
@@ -101,12 +106,8 @@ https://git.eeqj.de/sneak/dnswatcher/issues/104
- `goimports` in `make fmt-check`, Markdown formatting: - `goimports` in `make fmt-check`, Markdown formatting:
https://git.eeqj.de/sneak/dnswatcher/issues/119 https://git.eeqj.de/sneak/dnswatcher/issues/119
- final state save at shutdown: https://git.eeqj.de/sneak/dnswatcher/issues/114 - final state save at shutdown: https://git.eeqj.de/sneak/dnswatcher/issues/114
- `internal/notify` shutdown tests hang when a drain returns early:
https://git.eeqj.de/sneak/dnswatcher/issues/176
- README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108 - README accuracy sweep: https://git.eeqj.de/sneak/dnswatcher/issues/108
- README sections required by policy: - README sections required by policy:
https://git.eeqj.de/sneak/dnswatcher/issues/173 https://git.eeqj.de/sneak/dnswatcher/issues/173
- `script/install-precommit` in a linked worktree:
https://git.eeqj.de/sneak/dnswatcher/issues/129
- fixed root server order: https://git.eeqj.de/sneak/dnswatcher/issues/138 - 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
+1
View File
@@ -63,6 +63,7 @@ func main() {
return n return n
}, },
), ),
fx.Invoke(func(l *logger.Logger) { l.Identify() }),
fx.Invoke(func(*server.Server, *watcher.Watcher) {}), fx.Invoke(func(*server.Server, *watcher.Watcher) {}),
).Run() ).Run()
} }
+14 -7
View File
@@ -143,6 +143,12 @@ func TestDrainWaitsForInFlightDelivery(t *testing.T) {
srv := blockingNtfyServer(entered, release, &served) srv := blockingNtfyServer(entered, release, &served)
defer srv.Close() defer srv.Close()
// srv.Close waits for the handler, so release it however the
// test ends; otherwise a drain that returns early hangs the
// package instead of failing this test.
releaseHandler := sync.OnceFunc(func() { close(release) })
defer releaseHandler()
topicURL, _ := url.Parse(srv.URL) topicURL, _ := url.Parse(srv.URL)
svc := notify.NewTestService(http.DefaultTransport) svc := notify.NewTestService(http.DefaultTransport)
@@ -167,9 +173,7 @@ func TestDrainWaitsForInFlightDelivery(t *testing.T) {
// delay alone. // delay alone.
start := time.Now() start := time.Now()
timer := time.AfterFunc(inFlightHold, func() { timer := time.AfterFunc(inFlightHold, releaseHandler)
close(release)
})
defer timer.Stop() defer timer.Stop()
ctx, cancel := context.WithTimeout( ctx, cancel := context.WithTimeout(
@@ -268,7 +272,7 @@ func TestDrainBoundedByContextDeadline(t *testing.T) {
// all never returns here (the delivery is parked in a backoff // all never returns here (the delivery is parked in a backoff
// that never fires), so an unbounded drain must fail this // that never fires), so an unbounded drain must fail this
// test promptly instead of hanging the package until the test // test promptly instead of hanging the package until the test
// binary's 30s timeout. // binary's -timeout.
returned := make(chan struct{}) returned := make(chan struct{})
go func() { go func() {
@@ -447,6 +451,11 @@ func TestNewRegistersDrainingStopHook(t *testing.T) {
srv := blockingNtfyServer(entered, release, &served) srv := blockingNtfyServer(entered, release, &served)
defer srv.Close() defer srv.Close()
// As in TestDrainWaitsForInFlightDelivery: release the handler
// however the test ends, before srv.Close waits for it.
releaseHandler := sync.OnceFunc(func() { close(release) })
defer releaseHandler()
lifecycle := &recordingLifecycle{} lifecycle := &recordingLifecycle{}
svc := newNotifyService(t, lifecycle, srv.URL) svc := newNotifyService(t, lifecycle, srv.URL)
@@ -472,9 +481,7 @@ func TestNewRegistersDrainingStopHook(t *testing.T) {
t.Fatal("delivery never reached the endpoint") t.Fatal("delivery never reached the endpoint")
} }
timer := time.AfterFunc(inFlightHold, func() { timer := time.AfterFunc(inFlightHold, releaseHandler)
close(release)
})
defer timer.Stop() defer timer.Stop()
ctx, cancel := context.WithTimeout( ctx, cancel := context.WithTimeout(
+14 -1
View File
@@ -1,8 +1,21 @@
package resolver package resolver
import "github.com/miekg/dns" import (
"context"
"github.com/miekg/dns"
)
// ExtractRecordValue exports extractRecordValue for testing. // ExtractRecordValue exports extractRecordValue for testing.
func ExtractRecordValue(rr dns.RR) string { func ExtractRecordValue(rr dns.RR) string {
return extractRecordValue(rr) return extractRecordValue(rr)
} }
// QueryEachNS exports queryEachNS for testing.
func (r *Resolver) QueryEachNS(
ctx context.Context,
nameservers []string,
hostname string,
) (map[string]*NameserverResponse, error) {
return r.queryEachNS(ctx, nameservers, hostname)
}
+22 -14
View File
@@ -504,7 +504,9 @@ func (r *Resolver) queryAllTypes(
type queryState struct { type queryState struct {
gotNXDomain bool gotNXDomain bool
gotSERVFAIL bool gotSERVFAIL bool
gotRefused bool
gotTimeout bool gotTimeout bool
netErr error
hasRecords bool hasRecords bool
} }
@@ -542,8 +544,13 @@ func (r *Resolver) querySingleType(
) { ) {
msg, err := r.queryDNS(ctx, nsIP, hostname, qtype) msg, err := r.queryDNS(ctx, nsIP, hostname, qtype)
if err != nil { if err != nil {
if isTimeout(err) { switch {
case isTimeout(err):
state.gotTimeout = true state.gotTimeout = true
case errors.Is(err, ErrRefused):
state.gotRefused = true
default:
state.netErr = err
} }
return return
@@ -603,6 +610,12 @@ func classifyResponse(resp *NameserverResponse, state queryState) {
case state.gotSERVFAIL && !state.hasRecords: case state.gotSERVFAIL && !state.hasRecords:
resp.Status = StatusError resp.Status = StatusError
resp.Error = "server returned SERVFAIL" resp.Error = "server returned SERVFAIL"
case state.gotRefused && !state.hasRecords:
resp.Status = StatusError
resp.Error = "server returned REFUSED"
case state.netErr != nil && !state.hasRecords:
resp.Status = StatusError
resp.Error = "network error: " + state.netErr.Error()
case !state.hasRecords && !state.gotNXDomain: case !state.hasRecords && !state.gotNXDomain:
resp.Status = StatusNoData resp.Status = StatusNoData
} }
@@ -682,11 +695,14 @@ func (r *Resolver) queryEachNS(
results := make(map[string]*NameserverResponse) results := make(map[string]*NameserverResponse)
for _, ns := range nameservers { for _, ns := range nameservers {
resp, err := r.QueryNameserver(ctx, ns, hostname)
// A query the context cut short says nothing about the
// nameserver, so it must not be returned as its failure.
if checkCtx(ctx) != nil { if checkCtx(ctx) != nil {
return nil, ErrContextCanceled return nil, ErrContextCanceled
} }
resp, err := r.QueryNameserver(ctx, ns, hostname)
if err != nil { if err != nil {
results[ns] = &NameserverResponse{ results[ns] = &NameserverResponse{
Nameserver: ns, Nameserver: ns,
@@ -714,21 +730,13 @@ func (r *Resolver) LookupNS(
// LookupAllRecords performs iterative resolution to find all DNS // LookupAllRecords performs iterative resolution to find all DNS
// records for the given hostname, keyed by authoritative nameserver. // records for the given hostname, keyed by authoritative nameserver.
// Each nameserver's response carries its status and error with its
// records.
func (r *Resolver) LookupAllRecords( func (r *Resolver) LookupAllRecords(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
) (map[string]map[string][]string, error) { ) (map[string]*NameserverResponse, error) {
results, err := r.QueryAllNameservers(ctx, hostname) return r.QueryAllNameservers(ctx, hostname)
if err != nil {
return nil, err
}
out := make(map[string]map[string][]string, len(results))
for ns, resp := range results {
out[ns] = resp.Records
}
return out, nil
} }
// ResolveIPAddresses resolves a hostname to all IPv4 and IPv6 // ResolveIPAddresses resolves a hostname to all IPv4 and IPv6
+65 -1
View File
@@ -2,6 +2,7 @@ package resolver_test
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"net" "net"
"os" "os"
@@ -13,6 +14,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/dnswatcher/internal/livednstest"
"sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/resolver"
) )
@@ -231,6 +233,45 @@ func TestQueryNameserver_NXDomain(t *testing.T) {
assert.Equal(t, resolver.StatusNXDomain, resp.Status) assert.Equal(t, resolver.StatusNXDomain, resp.Status)
} }
// TestQueryNameserver_Refused asks a google.com nameserver about
// cloudflare.com, a zone it does not serve, which it refuses. Refusing
// is a failure to answer, not an answer with no records.
func TestQueryNameserver_Refused(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ns := findOneNSForDomain(t, r, "google.com")
var resp *resolver.NameserverResponse
livednstest.Retry(
t,
"QueryNameserver("+ns+", cloudflare.com)",
func(ctx context.Context) error {
var err error
resp, err = r.QueryNameserver(ctx, ns, "cloudflare.com")
if err != nil {
return err
}
// A timeout or a network error is no reply at all.
if resp.Status == resolver.StatusTimeout ||
strings.HasPrefix(resp.Error, "network error") {
return fmt.Errorf(
"%w: %s: %s",
livednstest.ErrNoAnswer, ns, resp.Error,
)
}
return nil
},
)
assert.Equal(t, resolver.StatusError, resp.Status)
assert.Equal(t, "server returned REFUSED", resp.Error)
}
func TestQueryNameserver_RecordsSorted(t *testing.T) { func TestQueryNameserver_RecordsSorted(t *testing.T) {
t.Parallel() t.Parallel()
@@ -518,6 +559,29 @@ func TestQueryAllNameservers_ContextCanceled(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
} }
// TestQueryEachNS_CanceledDuringQuery cancels the context while a
// nameserver is being queried, as shutdown does. A lookup cut short
// says nothing about the nameserver, so it must return an error, not a
// failed response for it.
func TestQueryEachNS_CanceledDuringQuery(t *testing.T) {
t.Parallel()
r := newTestResolver(t)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
// Finding the nameserver's address alone starts at the root
// servers and takes several round trips, so a cancel a few
// milliseconds in lands during the query.
time.AfterFunc(5*time.Millisecond, cancel)
results, err := r.QueryEachNS(
ctx, []string{"ns1.google.com."}, "google.com",
)
require.ErrorIs(t, err, resolver.ErrContextCanceled)
assert.Nil(t, results)
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Timeout tests // Timeout tests
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -530,7 +594,7 @@ func TestQueryNameserverIP_Timeout(t *testing.T) {
// Nothing answers at 192.0.2.1, a documentation address. The // Nothing answers at 192.0.2.1, a documentation address. The
// resolver tries each query twice, and the first try gives up // resolver tries each query twice, and the first try gives up
// after two seconds. A deadline that ends during the first try // after two seconds. A deadline that ends during the first try
// makes the status vary from run to run between nodata and // makes the status vary from run to run between error and
// timeout, so the deadline must outlast the first try. // timeout, so the deadline must outlast the first try.
ctx, cancel := context.WithTimeout( ctx, cancel := context.WithTimeout(
context.Background(), 3*time.Second, context.Background(), 3*time.Second,
+11 -4
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
) )
@@ -33,8 +34,7 @@ func NewForTest(
// NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing. // NewlyDisagreeingPairs exports newlyDisagreeingPairs for testing.
func NewlyDisagreeingPairs( func NewlyDisagreeingPairs(
prev *state.HostnameState, prev, current *state.HostnameState,
current map[string]map[string][]string,
) [][2]string { ) [][2]string {
return newlyDisagreeingPairs(prev, current) return newlyDisagreeingPairs(prev, current)
} }
@@ -43,8 +43,15 @@ func NewlyDisagreeingPairs(
func (w *Watcher) DetectHostnameChanges( func (w *Watcher) DetectHostnameChanges(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
prev *state.HostnameState, prev, current *state.HostnameState,
current map[string]map[string][]string,
) { ) {
w.detectHostnameChanges(ctx, hostname, prev, current) w.detectHostnameChanges(ctx, hostname, prev, current)
} }
// BuildHostnameState exports buildHostnameState for testing.
func BuildHostnameState(
results map[string]*resolver.NameserverResponse,
now time.Time,
) *state.HostnameState {
return buildHostnameState(results, now)
}
+7 -4
View File
@@ -105,7 +105,9 @@ func TestNewlyDisagreeingPairs(t *testing.T) {
prev := hostnameState(tt.loaded) prev := hostnameState(tt.loaded)
for i, current := range tt.checks { for i, records := range tt.checks {
current := hostnameState(records)
got := watcher.NewlyDisagreeingPairs(prev, current) got := watcher.NewlyDisagreeingPairs(prev, current)
if !slices.Equal(got, tt.want[i]) { if !slices.Equal(got, tt.want[i]) {
t.Errorf( t.Errorf(
@@ -114,7 +116,7 @@ func TestNewlyDisagreeingPairs(t *testing.T) {
) )
} }
prev = hostnameState(current) prev = current
} }
}) })
} }
@@ -162,8 +164,9 @@ func TestInconsistencyAlert(t *testing.T) {
prev := hostnameState(tt.loaded) prev := hostnameState(tt.loaded)
for range 3 { for range 3 {
w.DetectHostnameChanges(t.Context(), host, prev, disagree) current := hostnameState(disagree)
prev = hostnameState(disagree) w.DetectHostnameChanges(t.Context(), host, prev, current)
prev = current
} }
got := 0 got := 0
+3 -2
View File
@@ -5,6 +5,7 @@ import (
"context" "context"
"sneak.berlin/go/dnswatcher/internal/portcheck" "sneak.berlin/go/dnswatcher/internal/portcheck"
"sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/tlscheck" "sneak.berlin/go/dnswatcher/internal/tlscheck"
) )
@@ -17,11 +18,11 @@ type DNSResolver interface {
) ([]string, error) ) ([]string, error)
// LookupAllRecords queries all record types for a hostname, // LookupAllRecords queries all record types for a hostname,
// returning results keyed by nameserver then record type. // returning each nameserver's response keyed by nameserver.
LookupAllRecords( LookupAllRecords(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
) (map[string]map[string][]string, error) ) (map[string]*resolver.NameserverResponse, error)
// ResolveIPAddresses resolves a hostname to all IP addresses. // ResolveIPAddresses resolves a hostname to all IP addresses.
ResolveIPAddresses( ResolveIPAddresses(
+333
View File
@@ -0,0 +1,333 @@
package watcher_test
import (
"context"
"fmt"
"log/slog"
"strings"
"testing"
"time"
"sneak.berlin/go/dnswatcher/internal/livednstest"
"sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/watcher"
)
// answered is what a check saves for a nameserver that answered with
// these records.
func answered(records map[string][]string) *state.NameserverRecordState {
return &state.NameserverRecordState{Records: records, Status: "ok"}
}
// failed is what a check saves for a nameserver that did not answer.
func failed() *state.NameserverRecordState {
return &state.NameserverRecordState{
Records: map[string][]string{},
Status: "error",
Error: "all queries timed out",
}
}
// saved builds the hostname state a check saves.
func saved(
byNameserver map[string]*state.NameserverRecordState,
) *state.HostnameState {
return &state.HostnameState{RecordsByNameserver: byNameserver}
}
// alertCounts counts the hostname alerts sent, by kind.
type alertCounts struct {
failures, recoveries, recordChanges, inconsistencies int
}
// countAlerts runs the hostname change detection from the state loaded
// at startup through each check in turn, and counts the alerts sent.
func countAlerts(
t *testing.T,
loaded *state.HostnameState,
checks []*state.HostnameState,
) alertCounts {
t.Helper()
// The hostname change detection uses only the notifier.
notifier := &mockNotifier{}
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
prev := loaded
for _, current := range checks {
w.DetectHostnameChanges(t.Context(), host, prev, current)
prev = current
}
var got alertCounts
for _, n := range notifier.getNotifications() {
kind, _, _ := strings.Cut(n.Title, ":")
switch kind {
case "NS Failure":
got.failures++
case "NS Recovery":
got.recoveries++
case "Record Change":
got.recordChanges++
case "Inconsistency":
got.inconsistencies++
}
}
return got
}
func TestNSFailureAndRecoveryAlerts(t *testing.T) {
t.Parallel()
records := map[string][]string{"A": {ip1}}
bothAnswer := saved(map[string]*state.NameserverRecordState{
nsA: answered(records), nsB: answered(records),
})
bFails := saved(map[string]*state.NameserverRecordState{
nsA: answered(records), nsB: failed(),
})
onlyA := saved(map[string]*state.NameserverRecordState{
nsA: answered(records),
})
bAnswersNoRecords := saved(map[string]*state.NameserverRecordState{
nsA: answered(records), nsB: answered(map[string][]string{}),
})
bAnswersDifferently := saved(map[string]*state.NameserverRecordState{
nsA: answered(records), nsB: answered(map[string][]string{"A": {ip2}}),
})
// Each case starts from the state loaded at startup and runs the
// checks in order.
tests := []struct {
name string
loaded *state.HostnameState
checks []*state.HostnameState
want alertCounts
}{
{
"failure lasting several checks alerts once",
bothAnswer, []*state.HostnameState{bFails, bFails, bFails},
alertCounts{failures: 1},
},
{
"recovery alerts once",
bFails, []*state.HostnameState{bothAnswer, bothAnswer},
alertCounts{recoveries: 1},
},
{
"failing again after recovering alerts again",
bothAnswer, []*state.HostnameState{bFails, bothAnswer, bFails},
alertCounts{failures: 2, recoveries: 1},
},
{
"nameserver failing when first seen does not alert",
onlyA, []*state.HostnameState{bFails, bFails},
alertCounts{},
},
{
"answer with no records is a record change, not a failure",
bothAnswer, []*state.HostnameState{bAnswersNoRecords},
alertCounts{recordChanges: 1, inconsistencies: 1},
},
{
"recovered nameserver that answers differently disagrees",
bFails, []*state.HostnameState{bAnswersDifferently},
alertCounts{recoveries: 1, inconsistencies: 1},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := countAlerts(t, tt.loaded, tt.checks)
if got != tt.want {
t.Errorf("sent %+v, want %+v", got, tt.want)
}
})
}
}
func TestNSFailureAlertNamesHostnameNameserverAndReason(t *testing.T) {
t.Parallel()
records := map[string][]string{"A": {ip1}}
notifier := &mockNotifier{}
w := watcher.NewForTest(nil, nil, nil, nil, nil, notifier)
w.DetectHostnameChanges(
t.Context(), host,
saved(map[string]*state.NameserverRecordState{nsA: answered(records)}),
saved(map[string]*state.NameserverRecordState{nsA: failed()}),
)
notifications := notifier.getNotifications()
if len(notifications) != 1 {
t.Fatalf("sent %v, want one NS Failure", notifications)
}
msg := notifications[0].Message
if !strings.Contains(msg, host) || !strings.Contains(msg, nsA) ||
!strings.Contains(msg, failed().Error) {
t.Errorf(
"message %q does not name %s, %s and the reason",
msg, host, nsA,
)
}
}
// TestNameserverThatNeverAnswers asks a nameserver address where
// nothing answers, 192.0.2.1, and checks what the watcher saves for it.
// The deadline outlasts the resolver's first two-second try, as in the
// resolver's timeout test.
func TestNameserverThatNeverAnswers(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
t.Cleanup(cancel)
res := resolver.NewFromLogger(slog.Default())
resp, err := res.QueryNameserverIP(ctx, nsA, "192.0.2.1", host)
if err != nil {
t.Fatal(err)
}
hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{nsA: resp}, time.Now(),
)
got := hs.RecordsByNameserver[nsA]
if got.Status != "error" || got.Error == "" {
t.Errorf(
"saved status %q, error %q; want status error with a reason",
got.Status, got.Error,
)
}
}
// TestNameserverThatAnswersNXDOMAIN asks a real nameserver about a name
// that does not exist and checks what the watcher saves for it: NXDOMAIN
// is an answer, so the nameserver is saved as ok with no error.
func TestNameserverThatAnswersNXDOMAIN(t *testing.T) {
t.Parallel()
res := resolver.NewFromLogger(slog.Default())
name := "this-surely-does-not-exist-xyz." + testDomain
var (
ns string
resp *resolver.NameserverResponse
)
livednstest.Retry(t, "QueryNameserver("+name+")", func(ctx context.Context) error {
nameservers, err := res.LookupNS(ctx, testDomain)
if err != nil {
return err
}
ns = nameservers[0]
resp, err = res.QueryNameserver(ctx, ns, name)
if err != nil {
return err
}
// A timeout or a failure is no answer to check.
if resp.Status == resolver.StatusTimeout ||
resp.Status == resolver.StatusError {
return fmt.Errorf(
"%w: %s: %s", livednstest.ErrNoAnswer, ns, resp.Error,
)
}
return nil
})
if resp.Status != resolver.StatusNXDomain {
t.Fatalf("%s answered %q for %s, want NXDOMAIN", ns, resp.Status, name)
}
hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{ns: resp}, time.Now(),
)
got := hs.RecordsByNameserver[ns]
if got.Status != "ok" || got.Error != "" {
t.Errorf(
"saved status %q, error %q; want status ok with no error",
got.Status, got.Error,
)
}
}
// TestNameserverThatRefuses asks a google.com nameserver about
// cloudflare.com, a zone it does not serve, which it refuses, and checks
// what the watcher saves for it: REFUSED is no answer, so the nameserver
// is saved as error with the reason.
func TestNameserverThatRefuses(t *testing.T) {
t.Parallel()
const reason = "server returned REFUSED"
res := resolver.NewFromLogger(slog.Default())
var (
ns string
resp *resolver.NameserverResponse
)
livednstest.Retry(
t,
"QueryNameserver(cloudflare.com)",
func(ctx context.Context) error {
nameservers, err := res.LookupNS(ctx, testDomain)
if err != nil {
return err
}
ns = nameservers[0]
resp, err = res.QueryNameserver(ctx, ns, "cloudflare.com")
if err != nil {
return err
}
// A timeout or a network error is no reply at all.
if resp.Status == resolver.StatusTimeout ||
strings.HasPrefix(resp.Error, "network error") {
return fmt.Errorf(
"%w: %s: %s", livednstest.ErrNoAnswer, ns, resp.Error,
)
}
return nil
},
)
if resp.Error != reason {
t.Fatalf(
"%s answered %q (%s) for cloudflare.com, want REFUSED",
ns, resp.Status, resp.Error,
)
}
hs := watcher.BuildHostnameState(
map[string]*resolver.NameserverResponse{ns: resp}, time.Now(),
)
got := hs.RecordsByNameserver[ns]
if got.Status != failed().Status || got.Error != reason {
t.Errorf(
"saved status %q, error %q; want status %q, error %q",
got.Status, got.Error, failed().Status, reason,
)
}
}
+95 -50
View File
@@ -13,6 +13,7 @@ import (
"sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/logger" "sneak.berlin/go/dnswatcher/internal/logger"
"sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
"sneak.berlin/go/dnswatcher/internal/tlscheck" "sneak.berlin/go/dnswatcher/internal/tlscheck"
) )
@@ -227,7 +228,7 @@ func (w *Watcher) checkDomain(
// Also look up A/AAAA records for the apex domain so that // Also look up A/AAAA records for the apex domain so that
// port and TLS checks (which read HostnameState) can find // port and TLS checks (which read HostnameState) can find
// the domain's IP addresses. // the domain's IP addresses.
records, err := w.resolver.LookupAllRecords(ctx, domain) results, err := w.resolver.LookupAllRecords(ctx, domain)
if err != nil { if err != nil {
w.log.Error( w.log.Error(
"failed to lookup records for domain", "failed to lookup records for domain",
@@ -238,12 +239,13 @@ func (w *Watcher) checkDomain(
return return
} }
newState := buildHostnameState(results, now)
prevHS, hasPrevHS := w.state.GetHostnameState(domain) prevHS, hasPrevHS := w.state.GetHostnameState(domain)
if hasPrevHS && !w.firstRun { if hasPrevHS && !w.firstRun {
w.detectHostnameChanges(ctx, domain, prevHS, records) w.detectHostnameChanges(ctx, domain, prevHS, newState)
} }
newState := buildHostnameState(records, now)
w.state.SetHostnameState(domain, newState) w.state.SetHostnameState(domain, newState)
} }
@@ -292,7 +294,7 @@ func (w *Watcher) checkHostname(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
) { ) {
records, err := w.resolver.LookupAllRecords(ctx, hostname) results, err := w.resolver.LookupAllRecords(ctx, hostname)
if err != nil { if err != nil {
w.log.Error( w.log.Error(
"failed to lookup records", "failed to lookup records",
@@ -303,19 +305,22 @@ func (w *Watcher) checkHostname(
return return
} }
now := time.Now().UTC() newState := buildHostnameState(results, time.Now().UTC())
prev, hasPrev := w.state.GetHostnameState(hostname)
prev, hasPrev := w.state.GetHostnameState(hostname)
if hasPrev && !w.firstRun { if hasPrev && !w.firstRun {
w.detectHostnameChanges(ctx, hostname, prev, records) w.detectHostnameChanges(ctx, hostname, prev, newState)
} }
newState := buildHostnameState(records, now)
w.state.SetHostnameState(hostname, newState) w.state.SetHostnameState(hostname, newState)
} }
// buildHostnameState saves each nameserver's response. A nameserver
// that answered, even with NXDOMAIN or no records, is saved as ok; one
// that timed out or failed is saved as error with the reason, and its
// empty record set is not an answer.
func buildHostnameState( func buildHostnameState(
records map[string]map[string][]string, results map[string]*resolver.NameserverResponse,
now time.Time, now time.Time,
) *state.HostnameState { ) *state.HostnameState {
hs := &state.HostnameState{ hs := &state.HostnameState{
@@ -325,12 +330,20 @@ func buildHostnameState(
LastChecked: now, LastChecked: now,
} }
for ns, recs := range records { for ns, resp := range results {
hs.RecordsByNameserver[ns] = &state.NameserverRecordState{ nsState := &state.NameserverRecordState{
Records: recs, Records: resp.Records,
Status: statusOK, Status: statusOK,
LastChecked: now, LastChecked: now,
} }
if resp.Status == resolver.StatusTimeout ||
resp.Status == resolver.StatusError {
nsState.Status = statusError
nsState.Error = resp.Error
}
hs.RecordsByNameserver[ns] = nsState
} }
return hs return hs
@@ -339,27 +352,29 @@ func buildHostnameState(
func (w *Watcher) detectHostnameChanges( func (w *Watcher) detectHostnameChanges(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
prev *state.HostnameState, prev, current *state.HostnameState,
current map[string]map[string][]string,
) { ) {
w.detectRecordChanges(ctx, hostname, prev, current) w.detectRecordChanges(ctx, hostname, prev, current)
w.detectNSDisappearances(ctx, hostname, prev, current) w.detectNSDisappearances(ctx, hostname, prev, current)
w.detectNSFailures(ctx, hostname, prev, current)
w.detectInconsistencies(ctx, hostname, prev, current) w.detectInconsistencies(ctx, hostname, prev, current)
} }
// detectRecordChanges compares each nameserver's records with those of
// the previous check. Only answers are compared: a nameserver that
// failed on either check has no records to compare.
func (w *Watcher) detectRecordChanges( func (w *Watcher) detectRecordChanges(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
prev *state.HostnameState, prev, current *state.HostnameState,
current map[string]map[string][]string,
) { ) {
for ns, recs := range current { for ns, cur := range current.RecordsByNameserver {
prevNS, ok := prev.RecordsByNameserver[ns] prevNS, ok := prev.RecordsByNameserver[ns]
if !ok { if !ok || prevNS.Status != statusOK || cur.Status != statusOK {
continue continue
} }
if recordsEqual(prevNS.Records, recs) { if recordsEqual(prevNS.Records, cur.Records) {
continue continue
} }
@@ -367,7 +382,7 @@ func (w *Watcher) detectRecordChanges(
"Hostname: %s\nNameserver: %s\n"+ "Hostname: %s\nNameserver: %s\n"+
"Old: %v\nNew: %v", "Old: %v\nNew: %v",
hostname, ns, hostname, ns,
prevNS.Records, recs, prevNS.Records, cur.Records,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -382,11 +397,10 @@ func (w *Watcher) detectRecordChanges(
func (w *Watcher) detectNSDisappearances( func (w *Watcher) detectNSDisappearances(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
prev *state.HostnameState, prev, current *state.HostnameState,
current map[string]map[string][]string,
) { ) {
for ns, prevNS := range prev.RecordsByNameserver { for ns, prevNS := range prev.RecordsByNameserver {
if _, ok := current[ns]; ok || prevNS.Status != statusOK { if _, ok := current.RecordsByNameserver[ns]; ok || prevNS.Status != statusOK {
continue continue
} }
@@ -402,32 +416,55 @@ func (w *Watcher) detectNSDisappearances(
"error", "error",
) )
} }
}
for ns := range current { // detectNSFailures notifies when a nameserver that answered on the
// previous check fails, and when one that failed answers again. A
// nameserver missing from the previous check is not compared.
func (w *Watcher) detectNSFailures(
ctx context.Context,
hostname string,
prev, current *state.HostnameState,
) {
for ns, cur := range current.RecordsByNameserver {
prevNS, ok := prev.RecordsByNameserver[ns] prevNS, ok := prev.RecordsByNameserver[ns]
if !ok || prevNS.Status != statusError { if !ok {
continue continue
} }
msg := fmt.Sprintf( switch {
"Hostname: %s\nNameserver: %s recovered", case prevNS.Status == statusOK && cur.Status == statusError:
hostname, ns, msg := fmt.Sprintf(
) "Hostname: %s\nNameserver: %s\nError: %s",
hostname, ns, cur.Error,
)
w.notify.SendNotification( w.notify.SendNotification(
ctx, ctx,
"NS Recovery: "+hostname, "NS Failure: "+hostname,
msg, msg,
"success", "error",
) )
case prevNS.Status == statusError && cur.Status == statusOK:
msg := fmt.Sprintf(
"Hostname: %s\nNameserver: %s recovered",
hostname, ns,
)
w.notify.SendNotification(
ctx,
"NS Recovery: "+hostname,
msg,
"success",
)
}
} }
} }
func (w *Watcher) detectInconsistencies( func (w *Watcher) detectInconsistencies(
ctx context.Context, ctx context.Context,
hostname string, hostname string,
prev *state.HostnameState, prev, current *state.HostnameState,
current map[string]map[string][]string,
) { ) {
for _, pair := range newlyDisagreeingPairs(prev, current) { for _, pair := range newlyDisagreeingPairs(prev, current) {
ns1, ns2 := pair[0], pair[1] ns1, ns2 := pair[0], pair[1]
@@ -435,8 +472,8 @@ func (w *Watcher) detectInconsistencies(
msg := fmt.Sprintf( msg := fmt.Sprintf(
"Hostname: %s\n%s: %v\n%s: %v", "Hostname: %s\n%s: %v\n%s: %v",
hostname, hostname,
ns1, current[ns1], ns1, current.RecordsByNameserver[ns1].Records,
ns2, current[ns2], ns2, current.RecordsByNameserver[ns2].Records,
) )
w.notify.SendNotification( w.notify.SendNotification(
@@ -448,17 +485,20 @@ func (w *Watcher) detectInconsistencies(
} }
} }
// newlyDisagreeingPairs returns every pair of nameservers whose records // newlyDisagreeingPairs returns every pair of nameservers that answered
// differ in current, in sorted order of name, except pairs where both // in current and whose records differ there, in sorted order of name,
// nameservers were in prev and already differed there. A nameserver // except pairs where both nameservers answered in prev and already
// missing from prev is paired with every nameserver it differs from. // differed there. A nameserver missing from prev, or that failed there,
// is paired with every nameserver it differs from. A nameserver that
// failed in current has no records to compare and is in no pair.
func newlyDisagreeingPairs( func newlyDisagreeingPairs(
prev *state.HostnameState, prev, current *state.HostnameState,
current map[string]map[string][]string,
) [][2]string { ) [][2]string {
nameservers := make([]string, 0, len(current)) nameservers := make([]string, 0, len(current.RecordsByNameserver))
for ns := range current { for ns, cur := range current.RecordsByNameserver {
nameservers = append(nameservers, ns) if cur.Status == statusOK {
nameservers = append(nameservers, ns)
}
} }
sort.Strings(nameservers) sort.Strings(nameservers)
@@ -467,14 +507,19 @@ func newlyDisagreeingPairs(
for i, ns1 := range nameservers { for i, ns1 := range nameservers {
for _, ns2 := range nameservers[i+1:] { for _, ns2 := range nameservers[i+1:] {
if recordsEqual(current[ns1], current[ns2]) { if recordsEqual(
current.RecordsByNameserver[ns1].Records,
current.RecordsByNameserver[ns2].Records,
) {
continue continue
} }
prev1, ok1 := prev.RecordsByNameserver[ns1] prev1, ok1 := prev.RecordsByNameserver[ns1]
prev2, ok2 := prev.RecordsByNameserver[ns2] prev2, ok2 := prev.RecordsByNameserver[ns2]
if ok1 && ok2 && !recordsEqual(prev1.Records, prev2.Records) { if ok1 && ok2 &&
prev1.Status == statusOK && prev2.Status == statusOK &&
!recordsEqual(prev1.Records, prev2.Records) {
continue continue
} }
+10 -3
View File
@@ -687,11 +687,12 @@ func TestNSFailureAndRecovery(t *testing.T) {
cfg.Hostnames = []string{testHost} cfg.Hostnames = []string{testHost}
// Between the checks, save every nameserver the first check found // Between the checks, save every nameserver the first check found
// as failed, and add, as answering, one that live DNS does not list. // as one that did not answer, and add, as answering, one that live
// DNS does not list, which then disappears.
deps := runChecks(t, cfg, nil, func(deps *testDeps) { deps := runChecks(t, cfg, nil, func(deps *testDeps) {
hs, _ := deps.state.GetHostnameState(testHost) hs, _ := deps.state.GetHostnameState(testHost)
for _, nsState := range hs.RecordsByNameserver { for ns := range hs.RecordsByNameserver {
nsState.Status = "error" hs.RecordsByNameserver[ns] = failed()
} }
hs.RecordsByNameserver[oldNS1] = &state.NameserverRecordState{ hs.RecordsByNameserver[oldNS1] = &state.NameserverRecordState{
@@ -704,4 +705,10 @@ func TestNSFailureAndRecovery(t *testing.T) {
assertNotified(t, deps, "NS Failure: "+testHost, "error") assertNotified(t, deps, "NS Failure: "+testHost, "error")
assertNotified(t, deps, "NS Recovery: "+testHost, "success") assertNotified(t, deps, "NS Recovery: "+testHost, "success")
// A nameserver that did not answer has no records to compare, so
// its recovery is not also a record change.
if n := countNotifications(deps, "Record Change: "+testHost); n != 0 {
t.Errorf("sent %d record changes on recovery, want 0", n)
}
} }
+9 -1
View File
@@ -12,7 +12,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache-filter=lint,builder -t "$("$SCRIPT_DIR/projectname")" . # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. VERSION is computed here because .dockerignore
# excludes .git, so `git describe` in a build stage cannot find it.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache-filter=lint,builder \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
+14 -1
View File
@@ -7,7 +7,20 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
hook=".git/hooks/pre-commit" # Stop if this directory is not the top of its own git checkout, for
# example a copy inside another repository, whose hook must not be
# replaced.
if [ "$(git rev-parse --show-toplevel)" != "$ROOT" ]; then
echo "install-precommit: $ROOT is not the top of a git checkout" >&2
exit 1
fi
# Ask git for the repository's own git directory: .git is a file, not
# a directory, in some checkouts (for example a clone made with
# --separate-git-dir). core.hooksPath is deliberately not followed, so
# the hook is never written outside this repository.
hooks="$(git rev-parse --git-common-dir)/hooks"
mkdir -p "$hooks"
hook="$hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook" printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook"
chmod +x "$hook" chmod +x "$hook"
echo "pre-commit hook installed: runs script/precommit" echo "pre-commit hook installed: runs script/precommit"