cmd/netwatch-server/main.go parsed the command line, ran the check-cidr and prepare-data-dir subcommands and built the fx application itself; the Go style guide puts all of that in internal/ or pkg/. The new package internal/cli does it, with the same arguments, messages and exit codes. main() is now os.Exit(cli.Main(Appname, Version, os.Args[1:], cli.RunServer)): Main takes the function that runs the server, so its tests call it with one that fails them. Appname and Version stay in main, where the build sets Version. Each subcommand writes its error to a writer it is given, and prepare-data-dir also takes the volume and the user's name, so its own tests give it a temporary directory and the user they run as. Model: opus-5-5
This commit is contained in:
@@ -207,6 +207,9 @@ stops. Its routes:
|
||||
It is an `fx`-wired Go application: `cmd/netwatch-server/` starts it, and its
|
||||
packages are in `internal/`:
|
||||
|
||||
- **`cli`**: Runs `netwatch-server` from its command line: the `check-cidr` and
|
||||
`prepare-data-dir` subcommands, which `bin/entrypoint.sh` runs before it
|
||||
starts the server, and otherwise the server, with the `fx` wiring.
|
||||
- **`config`**: Loads configuration from environment variables and config files
|
||||
via Viper.
|
||||
- **`handlers`**: HTTP request handlers for the API (health check, report
|
||||
|
||||
@@ -21,6 +21,18 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-19: `main()` in `cmd/netwatch-server/main.go` is one call into a new
|
||||
package, `internal/cli`, as the Go style guide asks
|
||||
([#160](https://git.eeqj.de/sneak/netwatch/issues/160)). `internal/cli` runs
|
||||
the `check-cidr` and `prepare-data-dir` subcommands, with the same arguments,
|
||||
messages and exit codes, and otherwise the server, with the `fx` wiring.
|
||||
`Appname` and `Version` stay in `main`, where the build sets `Version`, and
|
||||
`main` gives `cli.Main` the function that runs the server, which the tests
|
||||
replace with one that fails them. Unit tests call `cli.Main` with `check-cidr`
|
||||
and a CIDR it takes and one it refuses, and with `prepare-data-dir` and a path
|
||||
outside `/data`, and check the exit code. Others give each subcommand input it
|
||||
takes and input it refuses, and check the exit code and what it writes to
|
||||
stderr
|
||||
- 2026-10-19: report file writes no longer overlap
|
||||
([#150](https://git.eeqj.de/sneak/netwatch/issues/150)), so the reports of
|
||||
failed writes are written in the order they arrived. Two writes that failed at
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
// Package main is the entry point for netwatch-server.
|
||||
// Package main is the entry point for netwatch-server; package cli
|
||||
// does the work.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/handlers"
|
||||
"sneak.berlin/go/netwatch/internal/healthcheck"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/middleware"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
"sneak.berlin/go/netwatch/internal/server"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxevent"
|
||||
"sneak.berlin/go/netwatch/internal/cli"
|
||||
)
|
||||
|
||||
//nolint:gochecknoglobals // set via ldflags at build time
|
||||
@@ -26,64 +15,5 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// "netwatch-server check-cidr CIDR" exits 1, with the error, if
|
||||
// this server would refuse CIDR in its TRUSTED_PROXIES.
|
||||
// bin/entrypoint.sh runs it on each entry it gives nginx.
|
||||
if len(os.Args) == 3 && os.Args[1] == "check-cidr" {
|
||||
_, err := middleware.ParseTrustedProxies(os.Args[2:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// "netwatch-server prepare-data-dir DATA_DIR" gets DATA_DIR ready
|
||||
// for the netwatch user, or exits 1 with the error; see
|
||||
// reportbuf.PrepareDataDir. bin/entrypoint.sh runs it as root
|
||||
// before it starts this server as that user.
|
||||
if len(os.Args) == 3 && os.Args[1] == "prepare-data-dir" {
|
||||
netwatch, err := user.Lookup("netwatch")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = reportbuf.PrepareDataDir("/data", os.Args[2], netwatch)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "DATA_DIR '%s': %v\n", os.Args[2], err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
globals.Appname = Appname
|
||||
globals.Version = Version
|
||||
|
||||
fx.New(
|
||||
// fx logs each step of starting and stopping through the
|
||||
// server's own logger, so off a terminal those lines are
|
||||
// JSON like every other line.
|
||||
fx.WithLogger(func(log *logger.Logger) fxevent.Logger {
|
||||
return &fxevent.SlogLogger{Logger: log.Get()}
|
||||
}),
|
||||
fx.Provide(
|
||||
config.New,
|
||||
globals.New,
|
||||
handlers.New,
|
||||
healthcheck.New,
|
||||
logger.New,
|
||||
middleware.New,
|
||||
reportbuf.New,
|
||||
server.New,
|
||||
),
|
||||
fx.Invoke(
|
||||
// First, so the name and version are logged even when
|
||||
// a setting stops the start.
|
||||
func(log *logger.Logger) { log.Identify() },
|
||||
func(*server.Server) {},
|
||||
),
|
||||
).Run()
|
||||
os.Exit(cli.Main(Appname, Version, os.Args[1:], cli.RunServer))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// Package cli runs netwatch-server from its command line: the
|
||||
// check-cidr and prepare-data-dir subcommands, which bin/entrypoint.sh
|
||||
// runs before it starts the server, and otherwise the server, with the
|
||||
// fx wiring.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/user"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/handlers"
|
||||
"sneak.berlin/go/netwatch/internal/healthcheck"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/middleware"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
"sneak.berlin/go/netwatch/internal/server"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxevent"
|
||||
)
|
||||
|
||||
// Main runs netwatch-server with args, its command line after the
|
||||
// program's name, and returns the exit code. Unless args are
|
||||
// "check-cidr CIDR" or "prepare-data-dir DATA_DIR", Main runs the
|
||||
// server by calling serve with appname and version, the name and
|
||||
// version the server logs and reports. main passes RunServer; the
|
||||
// tests pass a function that fails them, so that no test starts the
|
||||
// server. When serve is RunServer and the server fails to start or to
|
||||
// stop, fx's Run exits the process with status 1 and Main never
|
||||
// returns.
|
||||
func Main(
|
||||
appname, version string,
|
||||
args []string,
|
||||
serve func(appname, version string),
|
||||
) int {
|
||||
// "netwatch-server check-cidr CIDR" exits 1, with the error, if
|
||||
// this server would refuse CIDR in its TRUSTED_PROXIES.
|
||||
// bin/entrypoint.sh runs it on each entry it gives nginx.
|
||||
if len(args) == 2 && args[0] == "check-cidr" {
|
||||
return checkCIDR(os.Stderr, args[1])
|
||||
}
|
||||
|
||||
// "netwatch-server prepare-data-dir DATA_DIR" gets DATA_DIR ready
|
||||
// for the netwatch user, or exits 1 with the error; see
|
||||
// reportbuf.PrepareDataDir. bin/entrypoint.sh runs it as root
|
||||
// before it starts this server as that user.
|
||||
if len(args) == 2 && args[0] == "prepare-data-dir" {
|
||||
return prepareDataDir(os.Stderr, "/data", args[1], "netwatch")
|
||||
}
|
||||
|
||||
serve(appname, version)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// checkCIDR returns 0 if the server would take cidr in its
|
||||
// TRUSTED_PROXIES, and otherwise writes the error to stderr and
|
||||
// returns 1.
|
||||
func checkCIDR(stderr io.Writer, cidr string) int {
|
||||
_, err := middleware.ParseTrustedProxies([]string{cidr})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, err)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// prepareDataDir gets dataDir ready for the user named username with
|
||||
// reportbuf.PrepareDataDir, volume being where the data volume is
|
||||
// mounted, and returns 0. Otherwise it writes the error to stderr and
|
||||
// returns 1.
|
||||
func prepareDataDir(stderr io.Writer, volume, dataDir, username string) int {
|
||||
owner, err := user.Lookup(username)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(stderr, err)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
err = reportbuf.PrepareDataDir(volume, dataDir, owner)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "DATA_DIR '%s': %v\n", dataDir, err)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// RunServer runs the server until it is told to stop, with appname and
|
||||
// version as the name and version it logs and reports. If the server
|
||||
// fails to start or to stop, fx's Run exits the process with status 1
|
||||
// itself.
|
||||
func RunServer(appname, version string) {
|
||||
globals.Appname = appname
|
||||
globals.Version = version
|
||||
|
||||
fx.New(
|
||||
// fx logs each step of starting and stopping through the
|
||||
// server's own logger, so off a terminal those lines are
|
||||
// JSON like every other line.
|
||||
fx.WithLogger(func(log *logger.Logger) fxevent.Logger {
|
||||
return &fxevent.SlogLogger{Logger: log.Get()}
|
||||
}),
|
||||
fx.Provide(
|
||||
config.New,
|
||||
globals.New,
|
||||
handlers.New,
|
||||
healthcheck.New,
|
||||
logger.New,
|
||||
middleware.New,
|
||||
reportbuf.New,
|
||||
server.New,
|
||||
),
|
||||
fx.Invoke(
|
||||
// First, so the name and version are logged even when
|
||||
// a setting stops the start.
|
||||
func(log *logger.Logger) { log.Identify() },
|
||||
func(*server.Server) {},
|
||||
),
|
||||
).Run()
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/cli"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
)
|
||||
|
||||
// TestMainRunsCheckCIDR: Main given check-cidr returns 0 for a CIDR the
|
||||
// server takes in its TRUSTED_PROXIES and 1 for one it refuses, and
|
||||
// does not start the server.
|
||||
func TestMainRunsCheckCIDR(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for cidr, want := range map[string]int{"10.0.0.0/8": 0, "1.2.3/32": 1} {
|
||||
code := cli.Main("netwatch", "test", []string{"check-cidr", cidr},
|
||||
failIfServerStarts(t))
|
||||
if code != want {
|
||||
t.Errorf("check-cidr %q: exit %d, want %d", cidr, code, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMainRunsPrepareDataDir: Main given prepare-data-dir returns 1 for
|
||||
// a DATA_DIR outside /data, and does not start the server. The test
|
||||
// image has no netwatch user, so there the 1 comes from looking that
|
||||
// user up; TestPrepareDataDirRefusesPathOutsideVolume checks the path.
|
||||
func TestMainRunsPrepareDataDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
code := cli.Main("netwatch", "test", []string{"prepare-data-dir", "/etc"},
|
||||
failIfServerStarts(t))
|
||||
if code != 1 {
|
||||
t.Errorf("prepare-data-dir /etc: exit %d, want 1", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckCIDRTakesCIDR: check-cidr exits 0, writing nothing, for a
|
||||
// CIDR the server takes in its TRUSTED_PROXIES.
|
||||
func TestCheckCIDRTakesCIDR(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, cidr := range []string{"10.0.0.0/8", "192.0.2.1/32", "2001:db8::/32"} {
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := cli.CheckCIDR(&stderr, cidr)
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Errorf("%q: exit %d, stderr %q; want exit 0 and no output",
|
||||
cidr, code, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckCIDRRefusesOtherInput: check-cidr exits 1, with an error
|
||||
// naming TRUSTED_PROXIES and the input, for anything the server would
|
||||
// refuse in its TRUSTED_PROXIES, such as 1.2.3/32, which nginx would
|
||||
// look up as a hostname.
|
||||
func TestCheckCIDRRefusesOtherInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, cidr := range []string{
|
||||
"1.2.3/32", "example.com/32", "10.0.0.0/33", "10.0.0.1", "",
|
||||
} {
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := cli.CheckCIDR(&stderr, cidr)
|
||||
|
||||
want := "TRUSTED_PROXIES " + strconv.Quote(cidr) + ": "
|
||||
if code != 1 || !strings.HasPrefix(stderr.String(), want) {
|
||||
t.Errorf("%q: exit %d, stderr %q; want exit 1 and %q...",
|
||||
cidr, code, stderr.String(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareDataDirTakesPathInVolume: prepare-data-dir exits 0,
|
||||
// writing nothing, for a DATA_DIR below the volume, and creates it.
|
||||
func TestPrepareDataDirTakesPathInVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
dataDir := filepath.Join(volume, "reports")
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := cli.PrepareDataDir(&stderr, volume, dataDir, currentUsername(t))
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Errorf("exit %d, stderr %q; want exit 0 and no output",
|
||||
code, stderr.String())
|
||||
}
|
||||
|
||||
info, err := os.Stat(dataDir)
|
||||
if err != nil || !info.IsDir() {
|
||||
t.Errorf("DATA_DIR not created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareDataDirRefusesPathOutsideVolume: prepare-data-dir exits
|
||||
// 1, with an error naming DATA_DIR and its value, for a DATA_DIR
|
||||
// outside the volume.
|
||||
func TestPrepareDataDirRefusesPathOutsideVolume(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
volume := t.TempDir()
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
||||
code := cli.PrepareDataDir(&stderr, volume, "/etc", currentUsername(t))
|
||||
|
||||
want := "DATA_DIR '/etc': " + reportbuf.ErrDataDirOutsideVolume.Error() + "\n"
|
||||
if code != 1 || stderr.String() != want {
|
||||
t.Errorf("exit %d, stderr %q; want exit 1 and %q",
|
||||
code, stderr.String(), want)
|
||||
}
|
||||
}
|
||||
|
||||
// currentUsername is the name of the user the test runs as, the only
|
||||
// owner a test not run as root can give files to.
|
||||
func currentUsername(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
current, err := user.Current()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return current.Username
|
||||
}
|
||||
|
||||
// failIfServerStarts is what the tests give Main in place of
|
||||
// cli.RunServer: it fails the test instead of starting the server.
|
||||
func failIfServerStarts(t *testing.T) func(appname, version string) {
|
||||
t.Helper()
|
||||
|
||||
return func(string, string) {
|
||||
t.Error("Main started the server")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package cli
|
||||
|
||||
import "io"
|
||||
|
||||
// CheckCIDR exposes the check-cidr subcommand to the external tests,
|
||||
// which read what it writes to stderr.
|
||||
func CheckCIDR(stderr io.Writer, cidr string) int {
|
||||
return checkCIDR(stderr, cidr)
|
||||
}
|
||||
|
||||
// PrepareDataDir exposes the prepare-data-dir subcommand to the
|
||||
// external tests, which give it a volume and a user of their own in
|
||||
// place of /data and netwatch.
|
||||
func PrepareDataDir(stderr io.Writer, volume, dataDir, username string) int {
|
||||
return prepareDataDir(stderr, volume, dataDir, username)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ package globals
|
||||
|
||||
import "go.uber.org/fx"
|
||||
|
||||
//nolint:gochecknoglobals // set from main before fx starts
|
||||
//nolint:gochecknoglobals // set by cli.RunServer before fx starts
|
||||
var (
|
||||
// Appname is the application name.
|
||||
Appname string
|
||||
|
||||
Reference in New Issue
Block a user