Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
FROM golang:1.22-alpine AS build
# Set by the release pipeline so the binary can report its version; see release.config.js.
ARG VERSION=dev
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/nanopub-router .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/nanopub-router .

FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/nanopub-router /nanopub-router
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,25 @@ slow responders. Swap `pick()` if you want a different policy.
The path portion after the prefix and any query string are appended to the
chosen instance's base URL.

## Version

Every response carries the router's version:

```
Nanopub-Router-Version: 1.4.0
```

This follows the `Nanopub-<service>-Version` convention that the
[Nanopub Monitor](https://github.com/knowledgepixels/nanopub-monitor) reads to
report the version of each instance it scans, as `nanopub-registry`,
`nanopub-query` and Nanodash already do. It is set on every response, including
the `307` redirects and the `503` from a stale `/healthz` — an instance that
cannot route is exactly when knowing its version is most useful.

The value is baked in at build time from the release tag
(`-ldflags "-X main.version=..."`, passed as the `VERSION` build arg by
`release.config.js`). A plain `go build` reports `dev`.

## Configuration

All flags also work as `ROUTER_*` env vars (e.g. `ROUTER_POLL_INTERVAL=2m`).
Expand Down
23 changes: 21 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ import (
"time"
)

// version is the router's release version, set at build time with
// -ldflags "-X main.version=...". It stays "dev" for a plain `go build`.
var version = "dev"

// VersionHeader carries the router's version on every response, following the
// Nanopub-<service>-Version convention the Nanopub Monitor reads to report the
// version of an instance it scans.
const VersionHeader = "Nanopub-Router-Version"

// ServerEntry is a single server record as published by the monitor's /.json feed.
// Field names match the monitor's JsonStatus output.
type ServerEntry struct {
Expand Down Expand Up @@ -342,15 +351,25 @@ func main() {

srv := &http.Server{
Addr: cfg.Addr,
Handler: withAccessLog(mux),
Handler: withAccessLog(withVersionHeader(mux)),
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("nanopub-router listening on %s; monitors=%v; poll=%s", cfg.Addr, cfg.MonitorURLs, cfg.PollInterval)
log.Printf("nanopub-router %s listening on %s; monitors=%v; poll=%s", version, cfg.Addr, cfg.MonitorURLs, cfg.PollInterval)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}

// withVersionHeader sets the version header on every response. It is set before
// the handler runs so that it survives handlers that write their status
// immediately, such as the redirects and the health check's error paths.
func withVersionHeader(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set(VersionHeader, version)
h.ServeHTTP(w, req)
})
}

func withAccessLog(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
start := time.Now()
Expand Down
67 changes: 67 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package main

import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
Expand Down Expand Up @@ -81,3 +83,68 @@ func TestCandidatesFilter(t *testing.T) {
t.Errorf("query candidates = %+v", got)
}
}

func TestVersionHeaderOnEveryResponse(t *testing.T) {
r := NewRouter(defaultConfig())
consensus := "consensus"
r.snapshot.Store(&Snapshot{
FetchedAt: time.Now(),
Servers: []ServerEntry{
{URL: "https://a/", Type: "https://w3id.org/np/o/service/terms/nanopub-registry-1.0", Status: "OK", HashGroup: &consensus},
},
})

mux := http.NewServeMux()
mux.HandleFunc("/registry/", r.handleRedirect("https://w3id.org/np/o/service/terms/nanopub-registry", "/registry/"))
mux.HandleFunc("/healthz", r.handleHealth)
mux.HandleFunc("/", handleRoot(r.cfg))
h := withVersionHeader(mux)

cases := []struct {
name string
path string
wantStatus int
}{
{name: "help page", path: "/", wantStatus: http.StatusOK},
{name: "health", path: "/healthz", wantStatus: http.StatusOK},
// The version has to survive a handler that writes its status straight away,
// which is every routed request.
{name: "redirect", path: "/registry/np/abc", wantStatus: http.StatusTemporaryRedirect},
}

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, c.path, nil))
if rec.Code != c.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, c.wantStatus)
}
if got := rec.Header().Get(VersionHeader); got != version {
t.Errorf("%s = %q, want %q", VersionHeader, got, version)
}
})
}
}

// An unhealthy router is exactly when the monitor most needs to say which instance
// and version it reached, so the header must not depend on the health check passing.
func TestVersionHeaderWhenUnhealthy(t *testing.T) {
r := NewRouter(defaultConfig())

rec := httptest.NewRecorder()
withVersionHeader(http.HandlerFunc(r.handleHealth)).
ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))

if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
}
if got := rec.Header().Get(VersionHeader); got != version {
t.Errorf("%s = %q, want %q", VersionHeader, got, version)
}
}

func TestVersionDefaultsToDev(t *testing.T) {
if version == "" {
t.Error("version must never be empty; it is reported as a header value")
}
}
2 changes: 1 addition & 1 deletion release.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ config.plugins.push(
[
"@semantic-release/exec",
{
"publishCmd": "docker buildx build --push --tag $IMAGE_NAME:${nextRelease.version} --tag $IMAGE_NAME:latest ."
"publishCmd": "docker buildx build --push --build-arg VERSION=${nextRelease.version} --tag $IMAGE_NAME:${nextRelease.version} --tag $IMAGE_NAME:latest ."
}
],
"@semantic-release/github",
Expand Down