Compare commits
2
Commits
fc511cd2d7
...
49c5fd132f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c5fd132f | ||
|
|
d7cf010e00 |
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
# Lint stage — fast feedback on formatting and lint issues. The
|
# Lint stage — fast feedback on formatting and lint issues. The
|
||||||
# golangci/golangci-lint image ships Go, gofmt, make and the linter, so
|
# golangci/golangci-lint image ships Go, gofmt, make and the linter, so
|
||||||
# nothing is installed here.
|
# nothing is installed here.
|
||||||
# golangci/golangci-lint:v2.7.2 (2026-08-09)
|
# golangci/golangci-lint:v2.12.2 (2026-08-10)
|
||||||
FROM golangci/golangci-lint@sha256:5d6d5c70a61f1356adfd9dd6316ce286799fefc9d743421356ff1b00842368ba AS lint
|
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY backend/go.mod backend/go.sum ./
|
COPY backend/go.mod backend/go.sum ./
|
||||||
|
|||||||
@@ -10,23 +10,41 @@
|
|||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
pre-1.0. No git tags. Backend work in flight on feat/reportbuf-storage (dirty:
|
pre-1.0. No git tags. `feat/reportbuf-storage` is merged; the backend, the CI
|
||||||
src/main.js). Frontend is functional; backend is new and unmerged.
|
workflow, and the backend repo standard files are all on `main`. Frontend and
|
||||||
|
backend are both functional. Working toward the 1.0.0 milestone by closing the
|
||||||
|
remaining repo-compliance issues on the tracker.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Land feat/reportbuf-storage: finish the in-progress src/main.js change, get make
|
Confirm the `.gitea/workflows/check.yml` run is green (main always green
|
||||||
check green, and merge the branch to main. The branch adds the backend (buffered
|
policy). The workflow file is already on `main`; what is unverified is that its
|
||||||
zstd-compressed report storage), the CI workflow, and backend repo standard
|
latest run passes.
|
||||||
files, so merging it also closes most compliance gaps.
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-21: shutdown lifecycle correctness. The process now shuts down through
|
||||||
|
fx instead of `os.Exit`, so every component's `OnStop` runs and buffered
|
||||||
|
reports are flushed to disk on `SIGTERM` — previously a full flush window of
|
||||||
|
telemetry was silently lost on every restart. The `http.Server` is now built
|
||||||
|
before its serving goroutine starts, so shutdown can no longer race or
|
||||||
|
nil-deref it; a listen failure exits non-zero via `fx.Shutdowner`; `reportbuf`
|
||||||
|
`OnStop` is idempotent; and `writeTimeout` now exceeds the chi per-request
|
||||||
|
budget so that budget is actually reachable. Dead `startupTime`, `exitCode`,
|
||||||
|
and `cancelFunc` fields were removed
|
||||||
- 2026-09-21: backend HTTP hardening (issue #19): added `ReadHeaderTimeout` and
|
- 2026-09-21: backend HTTP hardening (issue #19): added `ReadHeaderTimeout` and
|
||||||
`IdleTimeout` to the server, a `SecurityHeaders` middleware (HSTS, tight CSP,
|
`IdleTimeout` to the server, a `SecurityHeaders` middleware (HSTS, tight CSP,
|
||||||
frame/sniff/referrer/permissions headers) registered before CORS, and
|
frame/sniff/referrer/permissions headers) registered before CORS, and
|
||||||
trusted-proxy client IP resolution honouring `X-Forwarded-For` / `X-Real-IP`
|
trusted-proxy client IP resolution honouring `X-Forwarded-For` / `X-Real-IP`
|
||||||
only from a `TRUSTED_PROXIES` allowlist (loopback plus RFC1918 by default)
|
only from a `TRUSTED_PROXIES` allowlist (loopback plus RFC1918 by default)
|
||||||
|
- 2026-08-10: adopted the org-standard `backend/.golangci.yml` verbatim and
|
||||||
|
moved the pinned golangci-lint from v2.7.2 to v2.12.2 (the `lint` stage of
|
||||||
|
`Dockerfile.backend` now pins the `golangci/golangci-lint:v2.12.2` image by
|
||||||
|
digest); the previous config declared `version: "2"` but used v1 schema keys,
|
||||||
|
so every threshold in it was inert and its green result was meaningless.
|
||||||
|
`backend/Makefile`'s `lint` target now asserts the config's sha256 against the
|
||||||
|
canonical file first, so drift from the org standard fails the build instead
|
||||||
|
of silently degrading to defaults
|
||||||
- 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap
|
- 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap
|
||||||
target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow
|
target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow
|
||||||
viewports, `#pause-btn`). The pin button's hit area grows via matching
|
viewports, `#pause-btn`). The pin button's hit area grows via matching
|
||||||
@@ -56,7 +74,7 @@ files, so merging it also closes most compliance gaps.
|
|||||||
shims, README Entrypoints section
|
shims, README Entrypoints section
|
||||||
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
|
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
|
||||||
and backend repo standard files; backend Dockerfile fixed (Go 1.25,
|
and backend repo standard files; backend Dockerfile fixed (Go 1.25,
|
||||||
golangci-lint) and moved to repo root (feat/reportbuf-storage, unmerged)
|
golangci-lint) and moved to repo root (feat/reportbuf-storage)
|
||||||
- 2026-02-26: host row layout redesigned with CSS grid; overflow and spacing
|
- 2026-02-26: host row layout redesigned with CSS grid; overflow and spacing
|
||||||
fixes; nginx config extracted; port hardcoded to 8080
|
fixes; nginx config extracted; port hardcoded to 8080
|
||||||
- 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix,
|
- 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix,
|
||||||
@@ -78,3 +96,9 @@ files, so merging it also closes most compliance gaps.
|
|||||||
(main always green policy)
|
(main always green policy)
|
||||||
- Decide what to do with untracked resume.sh: commit it, gitignore it, or delete
|
- Decide what to do with untracked resume.sh: commit it, gitignore it, or delete
|
||||||
it
|
it
|
||||||
|
- Upstream fix needed in `sneak/prompts`: the org-standard `.golangci.yml`
|
||||||
|
enables `gomodguard`, which golangci-lint v2.12.2 reports as deprecated since
|
||||||
|
v2.12.0 and replaced by `gomodguard_v2`, so every backend lint run prints a
|
||||||
|
deprecation warning. The file is standardized and must never be edited in this
|
||||||
|
repo, so nothing can be done here beyond tracking it — tracked at
|
||||||
|
<https://git.eeqj.de/sneak/netwatch/issues/41>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
|
# Config schema uses the golangci-lint v2 layout (settings live under
|
||||||
|
# linters.settings, not top-level linters-settings) so that the
|
||||||
|
# thresholds below are actually applied by golangci-lint >= v2.
|
||||||
|
|
||||||
run:
|
run:
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
modules-download-mode: readonly
|
modules-download-mode: readonly
|
||||||
@@ -14,8 +18,7 @@ linters:
|
|||||||
- wsl # Deprecated, replaced by wsl_v5
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
|
settings:
|
||||||
linters-settings:
|
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
funlen:
|
funlen:
|
||||||
@@ -27,6 +30,5 @@ linters-settings:
|
|||||||
threshold: 100
|
threshold: 100
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
exclude-use-default: false
|
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ GOLDFLAGS += -s -w
|
|||||||
GOLDFLAGS += -X main.Version=$(VERSION)
|
GOLDFLAGS += -X main.Version=$(VERSION)
|
||||||
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
|
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
|
||||||
|
|
||||||
|
# macOS ships shasum rather than sha256sum.
|
||||||
|
SHA256SUM := $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || echo shasum -a 256)
|
||||||
|
|
||||||
|
# .golangci.yml is standardized org-wide and must never be edited here
|
||||||
|
# (REPO_POLICIES.md). Its last silent drift replaced the v2 schema with
|
||||||
|
# v1 keys, which left every threshold in the file inert while the build
|
||||||
|
# stayed green. The lint target therefore asserts the file still matches
|
||||||
|
# the canonical copy byte for byte. The check is a local hash comparison:
|
||||||
|
# no network, no remote schema, nothing unpinned in the build path.
|
||||||
|
GOLANGCI_CONFIG_SHA256 := 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb
|
||||||
|
|
||||||
.PHONY: all build test lint fmt fmt-check check docker hooks run clean
|
.PHONY: all build test lint fmt fmt-check check docker hooks run clean
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
@@ -22,6 +33,14 @@ test:
|
|||||||
timeout 30 go test ./...
|
timeout 30 go test ./...
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
|
@actual=$$($(SHA256SUM) .golangci.yml | cut -d' ' -f1); \
|
||||||
|
if [ "$$actual" != "$(GOLANGCI_CONFIG_SHA256)" ]; then \
|
||||||
|
echo ".golangci.yml has drifted from the org standard."; \
|
||||||
|
echo " expected $(GOLANGCI_CONFIG_SHA256)"; \
|
||||||
|
echo " actual $$actual"; \
|
||||||
|
echo "Restore it verbatim from sneak/prompts; do not edit it."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
golangci-lint run ./...
|
golangci-lint run ./...
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ import (
|
|||||||
"sneak.berlin/go/netwatch/internal/middleware"
|
"sneak.berlin/go/netwatch/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// loopbackPeer is a remote address inside the trusted-proxy allowlist.
|
||||||
|
loopbackPeer = "127.0.0.1:5000"
|
||||||
|
// forwardedIP is the client address presented via X-Forwarded-For.
|
||||||
|
forwardedIP = "203.0.113.7"
|
||||||
|
// realIP is the client address presented via X-Real-IP.
|
||||||
|
realIP = "203.0.113.9"
|
||||||
|
)
|
||||||
|
|
||||||
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
|
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -41,32 +50,32 @@ func clientIPCases() []clientIPCase {
|
|||||||
return []clientIPCase{
|
return []clientIPCase{
|
||||||
{
|
{
|
||||||
name: "trusted proxy uses forwarded-for",
|
name: "trusted proxy uses forwarded-for",
|
||||||
remoteAddr: "127.0.0.1:5000",
|
remoteAddr: loopbackPeer,
|
||||||
xff: "203.0.113.7",
|
xff: forwardedIP,
|
||||||
want: "203.0.113.7",
|
want: forwardedIP,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted proxy uses left-most of chain",
|
name: "trusted proxy uses left-most of chain",
|
||||||
remoteAddr: "10.1.2.3:5000",
|
remoteAddr: "10.1.2.3:5000",
|
||||||
xff: "203.0.113.7, 10.1.2.3",
|
xff: forwardedIP + ", 10.1.2.3",
|
||||||
want: "203.0.113.7",
|
want: forwardedIP,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted proxy falls back to x-real-ip",
|
name: "trusted proxy falls back to x-real-ip",
|
||||||
remoteAddr: "127.0.0.1:5000",
|
remoteAddr: loopbackPeer,
|
||||||
xRealIP: "203.0.113.9",
|
xRealIP: realIP,
|
||||||
want: "203.0.113.9",
|
want: realIP,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "untrusted peer ignores forwarded-for",
|
name: "untrusted peer ignores forwarded-for",
|
||||||
remoteAddr: "198.51.100.4:5000",
|
remoteAddr: "198.51.100.4:5000",
|
||||||
xff: "203.0.113.7",
|
xff: forwardedIP,
|
||||||
want: "198.51.100.4",
|
want: "198.51.100.4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "untrusted peer ignores x-real-ip",
|
name: "untrusted peer ignores x-real-ip",
|
||||||
remoteAddr: "198.51.100.4:5000",
|
remoteAddr: "198.51.100.4:5000",
|
||||||
xRealIP: "203.0.113.9",
|
xRealIP: realIP,
|
||||||
want: "198.51.100.4",
|
want: "198.51.100.4",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -76,7 +85,7 @@ func clientIPCases() []clientIPCase {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "trusted proxy with garbage header uses peer",
|
name: "trusted proxy with garbage header uses peer",
|
||||||
remoteAddr: "127.0.0.1:5000",
|
remoteAddr: loopbackPeer,
|
||||||
xff: "not-an-ip",
|
xff: "not-an-ip",
|
||||||
want: "127.0.0.1",
|
want: "127.0.0.1",
|
||||||
},
|
},
|
||||||
@@ -119,7 +128,7 @@ func TestSecurityHeaders(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
want := map[string]string{
|
want := map[string]string{
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type Buffer struct {
|
|||||||
done chan struct{}
|
done chan struct{}
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
stopOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Buffer and registers lifecycle hooks to
|
// New creates a Buffer and registers lifecycle hooks to
|
||||||
@@ -76,8 +77,13 @@ func New(
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
OnStop: func(_ context.Context) error {
|
OnStop: func(_ context.Context) error {
|
||||||
|
// stopOnce makes OnStop idempotent: a second
|
||||||
|
// invocation must not close an already-closed channel
|
||||||
|
// (which would panic) or flush again.
|
||||||
|
b.stopOnce.Do(func() {
|
||||||
close(b.done)
|
close(b.done)
|
||||||
b.flushLocked()
|
b.flushLocked()
|
||||||
|
})
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
@@ -163,7 +169,9 @@ func (b *Buffer) writeFile(data []byte) {
|
|||||||
name := fmt.Sprintf("reports-%s.jsonl.zst", ts)
|
name := fmt.Sprintf("reports-%s.jsonl.zst", ts)
|
||||||
path := filepath.Join(b.dataDir, name)
|
path := filepath.Join(b.dataDir, name)
|
||||||
|
|
||||||
f, err := os.OpenFile( //nolint:gosec // path built from controlled dataDir + timestamp
|
// path is built from the operator-supplied dataDir plus a
|
||||||
|
// generated timestamp, so it carries no external input.
|
||||||
|
f, err := os.OpenFile( //nolint:gosec // see comment above
|
||||||
path,
|
path,
|
||||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||||
filePerms,
|
filePerms,
|
||||||
|
|||||||
@@ -1,13 +1,77 @@
|
|||||||
package reportbuf_test
|
package reportbuf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
_ "sneak.berlin/go/netwatch/internal/reportbuf"
|
"sneak.berlin/go/netwatch/internal/config"
|
||||||
|
"sneak.berlin/go/netwatch/internal/globals"
|
||||||
|
"sneak.berlin/go/netwatch/internal/logger"
|
||||||
|
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestImport(t *testing.T) {
|
// TestFlushOnShutdown proves the flush-on-shutdown path: a
|
||||||
t.Parallel()
|
// report appended after start but before the periodic flush
|
||||||
// Compilation check — verifies the package parses
|
// window must reach disk when the fx lifecycle stops. This is
|
||||||
// and all imports resolve.
|
// the exact case that silent data loss on restart used to
|
||||||
|
// destroy.
|
||||||
|
func TestFlushOnShutdown(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("DATA_DIR", dir)
|
||||||
|
|
||||||
|
var buf *reportbuf.Buffer
|
||||||
|
|
||||||
|
app := fxtest.New(t,
|
||||||
|
fx.Provide(
|
||||||
|
globals.New,
|
||||||
|
logger.New,
|
||||||
|
config.New,
|
||||||
|
reportbuf.New,
|
||||||
|
),
|
||||||
|
fx.Populate(&buf),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
err := buf.Append(map[string]string{"probe": "shutdown"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("append report: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireStop runs the reportbuf OnStop hook, which is the
|
||||||
|
// only code path that flushes buffered reports on shutdown.
|
||||||
|
app.RequireStop()
|
||||||
|
|
||||||
|
if !hasReportFile(t, dir) {
|
||||||
|
t.Fatal("no report file on disk after shutdown; " +
|
||||||
|
"the buffered report was lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasReportFile(t *testing.T, dir string) bool {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read data dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasSuffix(e.Name(), ".jsonl.zst") {
|
||||||
|
info, statErr := e.Info()
|
||||||
|
if statErr != nil {
|
||||||
|
t.Fatalf("stat %s: %v", e.Name(), statErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Size() > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,31 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
readTimeout = 10 * time.Second
|
readTimeout = 10 * time.Second
|
||||||
readHeaderTimeout = 5 * time.Second
|
readHeaderTimeout = 5 * time.Second
|
||||||
writeTimeout = 10 * time.Second
|
|
||||||
idleTimeout = 60 * time.Second
|
idleTimeout = 60 * time.Second
|
||||||
maxHeaderBytes = 1 << 20 // 1 MiB
|
maxHeaderBytes = 1 << 20 // 1 MiB
|
||||||
|
|
||||||
|
// requestTimeout (routes.go) is the single per-request
|
||||||
|
// processing budget, enforced by chi's middleware.Timeout.
|
||||||
|
// writeTimeout must exceed that budget so a handler can write
|
||||||
|
// its 503 when the chi timeout fires; if it were shorter the
|
||||||
|
// server would abort the write first and the chi budget would
|
||||||
|
// be unreachable dead configuration.
|
||||||
|
writeTimeout = requestTimeout + 5*time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) serveUntilShutdown() {
|
// newHTTPServer constructs the http.Server. It performs no I/O
|
||||||
|
// and does not start listening.
|
||||||
|
func (s *Server) newHTTPServer() *http.Server {
|
||||||
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
|
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
|
||||||
|
|
||||||
s.httpServer = &http.Server{
|
return &http.Server{
|
||||||
Addr: listenAddr,
|
Addr: listenAddr,
|
||||||
Handler: s,
|
Handler: s,
|
||||||
MaxHeaderBytes: maxHeaderBytes,
|
MaxHeaderBytes: maxHeaderBytes,
|
||||||
@@ -27,21 +38,29 @@ func (s *Server) serveUntilShutdown() {
|
|||||||
WriteTimeout: writeTimeout,
|
WriteTimeout: writeTimeout,
|
||||||
IdleTimeout: idleTimeout,
|
IdleTimeout: idleTimeout,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.SetupRoutes()
|
// listenAndServe runs the listener until the server is shut
|
||||||
|
// down. A genuine listen failure (not the expected
|
||||||
|
// ErrServerClosed from a clean shutdown) requests process
|
||||||
|
// shutdown through fx with a non-zero exit code, so the failure
|
||||||
|
// is visible to any supervisor.
|
||||||
|
func (s *Server) listenAndServe() {
|
||||||
s.log.Info("http begin listen",
|
s.log.Info("http begin listen",
|
||||||
"listenaddr", listenAddr,
|
"listenaddr", s.httpServer.Addr,
|
||||||
"version", s.params.Globals.Version,
|
"version", s.params.Globals.Version,
|
||||||
"buildarch", s.params.Globals.Buildarch,
|
"buildarch", s.params.Globals.Buildarch,
|
||||||
)
|
)
|
||||||
|
|
||||||
err := s.httpServer.ListenAndServe()
|
err := s.httpServer.ListenAndServe()
|
||||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err == nil || errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.log.Error("listen error", "error", err)
|
s.log.Error("listen error", "error", err)
|
||||||
|
|
||||||
if s.cancelFunc != nil {
|
shutdownErr := s.shutdowner.Shutdown(fx.ExitCode(1))
|
||||||
s.cancelFunc()
|
if shutdownErr != nil {
|
||||||
}
|
s.log.Error("request shutdown failed", "error", shutdownErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
// Package server provides the HTTP server lifecycle,
|
// Package server provides the HTTP server lifecycle,
|
||||||
// including startup, routing, signal handling, and graceful
|
// including startup, routing, and graceful shutdown. The
|
||||||
// shutdown.
|
// process lifetime is owned by fx: shutdown is requested
|
||||||
|
// through fx.Shutdowner so every component's OnStop hook runs
|
||||||
|
// in dependency order.
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"sneak.berlin/go/netwatch/internal/config"
|
"sneak.berlin/go/netwatch/internal/config"
|
||||||
"sneak.berlin/go/netwatch/internal/globals"
|
"sneak.berlin/go/netwatch/internal/globals"
|
||||||
@@ -31,19 +29,18 @@ type Params struct {
|
|||||||
Handlers *handlers.Handlers
|
Handlers *handlers.Handlers
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Middleware *middleware.Middleware
|
Middleware *middleware.Middleware
|
||||||
|
Shutdowner fx.Shutdowner
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is the top-level HTTP server orchestrator.
|
// Server is the top-level HTTP server orchestrator.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
cancelFunc context.CancelFunc
|
|
||||||
exitCode int
|
|
||||||
h *handlers.Handlers
|
h *handlers.Handlers
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
mw *middleware.Middleware
|
mw *middleware.Middleware
|
||||||
params Params
|
params Params
|
||||||
router *chi.Mux
|
router *chi.Mux
|
||||||
startupTime time.Time
|
shutdowner fx.Shutdowner
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Server and registers lifecycle hooks for
|
// New creates a Server and registers lifecycle hooks for
|
||||||
@@ -57,23 +54,25 @@ func New(
|
|||||||
s.mw = params.Middleware
|
s.mw = params.Middleware
|
||||||
s.h = params.Handlers
|
s.h = params.Handlers
|
||||||
s.log = params.Logger.Get()
|
s.log = params.Logger.Get()
|
||||||
|
s.shutdowner = params.Shutdowner
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
s.startupTime = time.Now().UTC()
|
// Build the router and http.Server synchronously
|
||||||
|
// here, before spawning the serving goroutine, so
|
||||||
|
// httpServer is fully constructed by the time OnStop
|
||||||
|
// (or an early signal) can read it. fx guarantees
|
||||||
|
// OnStart returns before OnStop runs, so no
|
||||||
|
// synchronization or nil check is needed at shutdown.
|
||||||
|
s.SetupRoutes()
|
||||||
|
s.httpServer = s.newHTTPServer()
|
||||||
|
|
||||||
go func() { //nolint:contextcheck // fx OnStart ctx is startup-only; run() creates its own
|
go s.listenAndServe()
|
||||||
s.run()
|
|
||||||
}()
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
OnStop: func(_ context.Context) error {
|
OnStop: func(ctx context.Context) error {
|
||||||
if s.cancelFunc != nil {
|
return s.shutdown(ctx)
|
||||||
s.cancelFunc()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -88,60 +87,17 @@ func (s *Server) ServeHTTP(
|
|||||||
s.router.ServeHTTP(w, r)
|
s.router.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) run() {
|
// shutdown gracefully stops the HTTP server within the
|
||||||
exitCode := s.serve()
|
// deadline of the context fx provides for OnStop.
|
||||||
os.Exit(exitCode)
|
func (s *Server) shutdown(ctx context.Context) error {
|
||||||
}
|
err := s.httpServer.Shutdown(ctx)
|
||||||
|
|
||||||
func (s *Server) serve() int {
|
|
||||||
var ctx context.Context //nolint:wsl // ctx must be declared before multi-assign
|
|
||||||
|
|
||||||
ctx, s.cancelFunc = context.WithCancel(
|
|
||||||
context.Background(),
|
|
||||||
)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
c := make(chan os.Signal, 1)
|
|
||||||
|
|
||||||
signal.Ignore(syscall.SIGPIPE)
|
|
||||||
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
|
|
||||||
|
|
||||||
sig := <-c
|
|
||||||
s.log.Info("signal received", "signal", sig)
|
|
||||||
|
|
||||||
if s.cancelFunc != nil {
|
|
||||||
s.cancelFunc()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
s.serveUntilShutdown()
|
|
||||||
}()
|
|
||||||
|
|
||||||
<-ctx.Done()
|
|
||||||
s.cleanShutdown()
|
|
||||||
|
|
||||||
return s.exitCode
|
|
||||||
}
|
|
||||||
|
|
||||||
const shutdownTimeout = 5 * time.Second
|
|
||||||
|
|
||||||
func (s *Server) cleanShutdown() {
|
|
||||||
s.exitCode = 0
|
|
||||||
|
|
||||||
ctxShutdown, shutdownCancel := context.WithTimeout(
|
|
||||||
context.Background(),
|
|
||||||
shutdownTimeout,
|
|
||||||
)
|
|
||||||
defer shutdownCancel()
|
|
||||||
|
|
||||||
err := s.httpServer.Shutdown(ctxShutdown)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error(
|
s.log.Error("server clean shutdown failed", "error", err)
|
||||||
"server clean shutdown failed",
|
|
||||||
"error", err,
|
return err
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
s.log.Info("server stopped")
|
s.log.Info("server stopped")
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user