Refuse invalid settings at start, health check follows PORT (closes #31) #32
+2
-2
@@ -96,8 +96,8 @@ EXPOSE 8080
|
||||
|
||||
COPY ./entrypoint.sh /entrypoint.sh
|
||||
|
||||
# Health check using the health endpoint
|
||||
# Health check using the health endpoint, on the port PORT names
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -sf http://localhost:8080/.well-known/healthcheck.json || exit 1
|
||||
CMD curl -sf "http://localhost:${PORT:-8080}/.well-known/healthcheck.json" || exit 1
|
||||
|
||||
ENTRYPOINT ["/bin/bash", "/entrypoint.sh" ]
|
||||
|
||||
@@ -166,14 +166,21 @@ Configuration is handled via environment variables and OS-specific paths:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|----------|-------------|
|
||||
| `PORT` | `8080` | HTTP server port |
|
||||
| `PORT` | `8080` | HTTP server port, a whole number from 1 to 65535 |
|
||||
| `DEBUG` | (empty) | Set to `routewatch` for debug logging |
|
||||
| `XDG_DATA_HOME` | `/var/lib` (in the Docker image) | Base of the state directory; must be an absolute path |
|
||||
| `GOMEMLIMIT` | `1536MiB` (in the Docker image) | Go soft memory limit; see Memory |
|
||||
| `MALLOC_ARENA_MAX` | `2` (in the Docker image) | glibc malloc arena cap; see Memory |
|
||||
| `MALLOC_ARENA_MAX` | `2` (in the Docker image) | glibc malloc arena cap, a positive whole number; see Memory |
|
||||
|
||||
A variable that is set to an invalid value stops the start with an error and a
|
||||
non-zero exit. An empty variable counts as unset.
|
||||
|
||||
State directory (database location):
|
||||
- macOS: `~/Library/Application Support/routewatch/`
|
||||
- Linux: `/var/lib/routewatch/` or `~/.local/share/routewatch/`
|
||||
- Linux: `/var/lib/berlin.sneak.app.routewatch/` when running as root,
|
||||
otherwise `$XDG_DATA_HOME/berlin.sneak.app.routewatch/` (with `XDG_DATA_HOME`
|
||||
unset, `~/.local/share/berlin.sneak.app.routewatch/`). In the Docker image
|
||||
this is `/var/lib/berlin.sneak.app.routewatch/`.
|
||||
|
||||
## Memory
|
||||
|
||||
@@ -220,6 +227,24 @@ What happens at each limit:
|
||||
With `DEBUG=routewatch` the daemon logs a `System stats` line every 60 seconds
|
||||
with the goroutine count and Go memory figures.
|
||||
|
||||
## Running under upaas
|
||||
|
||||
What the [upaas](https://git.eeqj.de/sneak/upaas) app needs:
|
||||
|
||||
- Container port: `8080`.
|
||||
- Volume: one, at container path `/var/lib/berlin.sneak.app.routewatch`. upaas
|
||||
does not create the host directory, so create it before the first deploy. The
|
||||
entrypoint takes ownership of it, so a root-owned directory works.
|
||||
- Environment: nothing is required. Leave `XDG_DATA_HOME`, `GOMEMLIMIT` and
|
||||
`MALLOC_ARENA_MAX` at the image's values. `DEBUG=routewatch` is optional and
|
||||
adds the `System stats` memory line to the log.
|
||||
- Memory Limit: `5g`, the 5 GiB limit from Memory above. upaas sets no swap
|
||||
limit, so on a host with swap Docker allows the same amount of swap again.
|
||||
- Health check: the image's `HEALTHCHECK` requests
|
||||
`/.well-known/healthcheck.json` on the container port. upaas reads the
|
||||
container's health 60 seconds after a deploy and fails the deploy unless it
|
||||
is `healthy`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
||||
@@ -23,6 +23,10 @@ runs make check on main.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-28: ready to run under upaas: a set but invalid `PORT`,
|
||||
`XDG_DATA_HOME` or `MALLOC_ARENA_MAX` stops the start, the health
|
||||
check follows `PORT`, README "Running under upaas" section (closes
|
||||
#31)
|
||||
- 2026-09-22: realtime in-memory database statistics: counts seeded at
|
||||
startup and adjusted on every write, oldest/newest route timestamps via
|
||||
index-end lookups; `/api/v1/stats` no longer scans the tables (closes
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
#!/bin/bash
|
||||
|
||||
# glibc silently ignores a malformed MALLOC_ARENA_MAX, so refuse it here.
|
||||
if [[ -n "${MALLOC_ARENA_MAX:-}" && ! "$MALLOC_ARENA_MAX" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "MALLOC_ARENA_MAX must be a positive whole number, got '$MALLOC_ARENA_MAX'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd /var/lib/berlin.sneak.app.routewatch
|
||||
chown -R routewatch:routewatch .
|
||||
chmod 700 .
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,12 @@ const (
|
||||
|
||||
// defaultRouteExpirationMinutes is the default route expiration timeout in minutes
|
||||
defaultRouteExpirationMinutes = 5
|
||||
|
||||
// defaultPort is the HTTP port used when PORT is not set
|
||||
defaultPort = 8080
|
||||
|
||||
// maxPort is the highest TCP port number
|
||||
maxPort = 65535
|
||||
)
|
||||
|
||||
// Config holds configuration for the entire application
|
||||
@@ -25,6 +32,9 @@ type Config struct {
|
||||
// StateDir is the directory for all application state (database, snapshots)
|
||||
StateDir string
|
||||
|
||||
// Port is the TCP port the HTTP server listens on
|
||||
Port int
|
||||
|
||||
// MaxRuntime is the maximum runtime (0 = run forever)
|
||||
MaxRuntime time.Duration
|
||||
|
||||
@@ -43,8 +53,14 @@ func New() (*Config, error) {
|
||||
return nil, fmt.Errorf("failed to determine state directory: %w", err)
|
||||
}
|
||||
|
||||
port, err := getPort()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Config{
|
||||
StateDir: stateDir,
|
||||
Port: port,
|
||||
MaxRuntime: 0, // Run forever by default
|
||||
EnableBatchedDatabaseWrites: true, // Enable batching by default
|
||||
RouteExpirationTimeout: defaultRouteExpirationMinutes * time.Minute, // For active route monitoring
|
||||
@@ -69,13 +85,20 @@ func getStateDirectory() (string, error) {
|
||||
return filepath.Join(home, "Library", "Application Support", AppIdentifier), nil
|
||||
|
||||
case "linux", "freebsd", "openbsd", "netbsd":
|
||||
// The XDG spec requires an absolute path; a relative one would put
|
||||
// the database somewhere unexpected.
|
||||
xdgData := os.Getenv("XDG_DATA_HOME")
|
||||
if xdgData != "" && !filepath.IsAbs(xdgData) {
|
||||
return "", fmt.Errorf("XDG_DATA_HOME must be an absolute path, got %q", xdgData)
|
||||
}
|
||||
|
||||
// Unix-like: /var/lib/berlin.sneak.app.routewatch if root, else XDG_DATA_HOME
|
||||
if os.Geteuid() == 0 {
|
||||
return filepath.Join("/var/lib", AppIdentifier), nil
|
||||
}
|
||||
|
||||
// Check XDG_DATA_HOME first
|
||||
if xdgData := os.Getenv("XDG_DATA_HOME"); xdgData != "" {
|
||||
if xdgData != "" {
|
||||
return filepath.Join(xdgData, AppIdentifier), nil
|
||||
}
|
||||
|
||||
@@ -92,6 +115,21 @@ func getStateDirectory() (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// getPort returns the HTTP port from PORT, or defaultPort when PORT is not set
|
||||
func getPort() (int, error) {
|
||||
value := os.Getenv("PORT")
|
||||
if value == "" {
|
||||
return defaultPort, nil
|
||||
}
|
||||
|
||||
port, err := strconv.Atoi(value)
|
||||
if err != nil || port < 1 || port > maxPort {
|
||||
return 0, fmt.Errorf("PORT must be a whole number from 1 to %d, got %q", maxPort, value)
|
||||
}
|
||||
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// EnsureDirectories creates all necessary directories if they don't exist
|
||||
func (c *Config) EnsureDirectories() error {
|
||||
// Ensure state directory exists
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewReadsPort(t *testing.T) {
|
||||
tests := map[string]int{
|
||||
"": defaultPort,
|
||||
"1": 1,
|
||||
"9090": 9090,
|
||||
"65535": 65535,
|
||||
}
|
||||
|
||||
for value, want := range tests {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Setenv("PORT", value)
|
||||
t.Setenv("XDG_DATA_HOME", "")
|
||||
|
||||
cfg, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() with PORT=%q: %v", value, err)
|
||||
}
|
||||
|
||||
if cfg.Port != want {
|
||||
t.Errorf("New() with PORT=%q: Port = %d, want %d", value, cfg.Port, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRefusesInvalidPort(t *testing.T) {
|
||||
for _, value := range []string{"0", "65536", "-1", "http", "80.5"} {
|
||||
t.Run(value, func(t *testing.T) {
|
||||
t.Setenv("PORT", value)
|
||||
t.Setenv("XDG_DATA_HOME", "")
|
||||
|
||||
if _, err := New(); err == nil {
|
||||
t.Errorf("New() with PORT=%q returned no error", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRefusesRelativeXDGDataHome(t *testing.T) {
|
||||
if runtime.GOOS == "darwin" {
|
||||
t.Skip("macOS does not read XDG_DATA_HOME")
|
||||
}
|
||||
|
||||
t.Setenv("PORT", "")
|
||||
|
||||
t.Setenv("XDG_DATA_HOME", "relative/path")
|
||||
if _, err := New(); err == nil {
|
||||
t.Error("New() with a relative XDG_DATA_HOME returned no error")
|
||||
}
|
||||
|
||||
t.Setenv("XDG_DATA_HOME", "/var/lib")
|
||||
if _, err := New(); err != nil {
|
||||
t.Errorf("New() with XDG_DATA_HOME=/var/lib: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -450,7 +450,7 @@ func TestRouteWatchLiveFeed(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create server
|
||||
srv := server.New(mockDB, s, logger)
|
||||
srv := server.New(mockDB, s, logger, cfg)
|
||||
|
||||
// Create RouteWatch with 5 second limit
|
||||
deps := Dependencies{
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||
@@ -38,7 +39,7 @@ func (d blockingStatsDB) GetStatsContext(_ context.Context) (database.Stats, err
|
||||
func TestStatsHandlersDoNotLeakOnTimeout(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
db := blockingStatsDB{release: release}
|
||||
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New())
|
||||
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
|
||||
|
||||
handlers := map[string]http.HandlerFunc{
|
||||
"status.json": s.handleStatusJSON(),
|
||||
|
||||
@@ -4,9 +4,10 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||
@@ -33,16 +34,18 @@ type Server struct {
|
||||
db database.Store
|
||||
streamer *streamer.Streamer
|
||||
logger *logger.Logger
|
||||
port int
|
||||
srv *http.Server
|
||||
asnFetcher ASNFetcher
|
||||
}
|
||||
|
||||
// New creates a new HTTP server
|
||||
func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger) *Server {
|
||||
func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger, cfg *config.Config) *Server {
|
||||
s := &Server{
|
||||
db: db,
|
||||
streamer: streamer,
|
||||
logger: logger,
|
||||
port: cfg.Port,
|
||||
}
|
||||
|
||||
s.setupRoutes()
|
||||
@@ -52,11 +55,6 @@ func New(db database.Store, streamer *streamer.Streamer, logger *logger.Logger)
|
||||
|
||||
// Start starts the HTTP server
|
||||
func (s *Server) Start() error {
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
const (
|
||||
readHeaderTimeout = 40 * time.Second
|
||||
readTimeout = 60 * time.Second
|
||||
@@ -65,7 +63,7 @@ func (s *Server) Start() error {
|
||||
)
|
||||
|
||||
s.srv = &http.Server{
|
||||
Addr: ":" + port,
|
||||
Addr: ":" + strconv.Itoa(s.port),
|
||||
Handler: s.router,
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
ReadTimeout: readTimeout,
|
||||
@@ -73,7 +71,7 @@ func (s *Server) Start() error {
|
||||
IdleTimeout: idleTimeout,
|
||||
}
|
||||
|
||||
s.logger.Info("Starting HTTP server", "port", port, "addr", s.srv.Addr)
|
||||
s.logger.Info("Starting HTTP server", "port", s.port, "addr", s.srv.Addr)
|
||||
|
||||
// Start in goroutine but log when actually listening
|
||||
go func() {
|
||||
|
||||
Reference in New Issue
Block a user