Entry point and dispatch, token list [all] / new / delete, audit [all | n | n<unit>] with padded table output, help, usage and exit codes; unit and e2e tests for commands, configuration precedence, logging and token lifecycle.
211 lines
6.3 KiB
Go
211 lines
6.3 KiB
Go
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
// Copyright (C) 2026 Bryan Joshua Pedini
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// Exit codes
|
|
const (
|
|
exitOK = 0
|
|
exitFailure = 1
|
|
exitUsage = 2
|
|
)
|
|
|
|
// errUsage marks command-line usage errors (exit code 2, usage text printed)
|
|
var errUsage = errors.New("usage error")
|
|
|
|
// usageText is printed to stderr after a usage error and to stdout by `help`
|
|
const usageText = `usage:
|
|
dpsapi start the HTTP server
|
|
dpsapi help print this usage text
|
|
dpsapi token list [all] list the active tokens, or every token with "all"
|
|
dpsapi token new [description] create a token and print it
|
|
dpsapi token delete <token> deactivate a token
|
|
dpsapi audit print the latest 10 audit rows
|
|
dpsapi audit <n> print the latest n audit rows
|
|
dpsapi audit <n><unit> print the audit rows of the last n units (s, m, h, d, w)
|
|
dpsapi audit all print every audit row
|
|
`
|
|
|
|
// usageErrorf builds an error wrapping errUsage
|
|
func usageErrorf(format string, args ...any) error {
|
|
return fmt.Errorf("%w: "+format, append([]any{errUsage}, args...)...)
|
|
}
|
|
|
|
// dockerConn is a closable container lister
|
|
type dockerConn interface {
|
|
ContainerLister
|
|
Close() error
|
|
}
|
|
|
|
// app holds the process-level dependencies; every field is replaceable in tests
|
|
type app struct {
|
|
lookupEnv func(string) (string, bool)
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
now func() time.Time
|
|
generateToken func() (string, error)
|
|
newDocker func(log *slog.Logger) (dockerConn, error)
|
|
listen func(network, address string) (net.Listener, error)
|
|
shutdownTimeout time.Duration
|
|
}
|
|
|
|
// newApp returns an app wired with the production dependencies
|
|
func newApp(lookupEnv func(string) (string, bool), stdout, stderr io.Writer) *app {
|
|
return &app{
|
|
lookupEnv: lookupEnv,
|
|
stdout: stdout,
|
|
stderr: stderr,
|
|
now: time.Now,
|
|
generateToken: GenerateToken,
|
|
newDocker: func(log *slog.Logger) (dockerConn, error) {
|
|
return NewDockerClient(dockerHost, log)
|
|
},
|
|
listen: net.Listen,
|
|
shutdownTimeout: defaultShutdownTimeout,
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
os.Exit(run(os.Args[1:], os.LookupEnv, os.Stdout, os.Stderr))
|
|
}
|
|
|
|
// run is the process entry point: it wires SIGINT/SIGTERM to cancellation and returns the exit code
|
|
func run(args []string, lookupEnv func(string) (string, bool), stdout, stderr io.Writer) int {
|
|
// Create a context cancelled by the OS signals
|
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
return newApp(lookupEnv, stdout, stderr).run(ctx, args)
|
|
}
|
|
|
|
// run executes the command and maps its outcome to the exit code
|
|
// The "dpsapi: ..." error line is therefore always the last line written to stderr
|
|
func (a *app) run(ctx context.Context, args []string) int {
|
|
return a.exitCode(a.execute(ctx, args))
|
|
}
|
|
|
|
// execute loads the configuration, initializes the logger and dispatches on the arguments
|
|
func (a *app) execute(ctx context.Context, args []string) (err error) {
|
|
// Load and validate the configuration before anything else
|
|
cfg, err := LoadConfig(a.lookupEnv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Initialize the logger
|
|
log := NewLogger(a.stderr, cfg.LogLevel)
|
|
defer traceFunc(ctx, log, "app.execute")()
|
|
log.DebugContext(ctx, "configuration resolved",
|
|
"db_path", cfg.DBPath, "http_addr", cfg.HTTPAddr, "http_port", cfg.HTTPPort, "log_level", LevelName(cfg.LogLevel))
|
|
|
|
// Dispatch
|
|
switch {
|
|
case len(args) == 0:
|
|
log.DebugContext(ctx, "dispatch", "mode", "server")
|
|
err = a.runServer(ctx, cfg, log)
|
|
case args[0] == "help":
|
|
log.DebugContext(ctx, "dispatch", "mode", "cli")
|
|
err = a.runHelpCommand(ctx, log, args[1:])
|
|
case args[0] == "token":
|
|
log.DebugContext(ctx, "dispatch", "mode", "cli")
|
|
err = a.runTokenCommand(ctx, cfg, log, args[1:])
|
|
case args[0] == "audit":
|
|
log.DebugContext(ctx, "dispatch", "mode", "cli")
|
|
err = a.runAuditCommand(ctx, cfg, log, args[1:])
|
|
default:
|
|
err = usageErrorf("unknown command")
|
|
}
|
|
return err
|
|
}
|
|
|
|
// runHelpCommand prints the usage text to stdout; the database is never opened
|
|
func (a *app) runHelpCommand(ctx context.Context, log *slog.Logger, args []string) error {
|
|
defer traceFunc(ctx, log, "app.runHelpCommand")()
|
|
|
|
if len(args) != 0 {
|
|
return usageErrorf("help takes no arguments")
|
|
}
|
|
log.DebugContext(ctx, "cli dispatch", "command", "help")
|
|
if _, err := fmt.Fprint(a.stdout, usageText); err != nil {
|
|
return fmt.Errorf("write output: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// exitCode prints err (if any) to stderr as a single line and maps it to the process exit code
|
|
func (a *app) exitCode(err error) int {
|
|
if err == nil {
|
|
return exitOK
|
|
}
|
|
msg := strings.NewReplacer("\r", " ", "\n", " ").Replace(err.Error())
|
|
fmt.Fprintf(a.stderr, "dpsapi: %s\n", msg)
|
|
switch {
|
|
case errors.Is(err, errUsage):
|
|
fmt.Fprint(a.stderr, usageText)
|
|
return exitUsage
|
|
case errors.Is(err, errConfig):
|
|
return exitUsage
|
|
default:
|
|
return exitFailure
|
|
}
|
|
}
|
|
|
|
// closeDatabase closes the pool, keeping the first error
|
|
func closeDatabase(ctx context.Context, db *sql.DB, log *slog.Logger, err *error) {
|
|
if cerr := db.Close(); cerr != nil && *err == nil {
|
|
*err = fmt.Errorf("close database: %w", cerr)
|
|
}
|
|
log.DebugContext(ctx, "database closed")
|
|
}
|
|
|
|
// runServer runs the HTTP server until ctx is cancelled
|
|
func (a *app) runServer(ctx context.Context, cfg Config, log *slog.Logger) (err error) {
|
|
defer traceFunc(ctx, log, "app.runServer")()
|
|
|
|
// Open the database
|
|
db, err := OpenDatabase(ctx, cfg.DBPath, log)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer closeDatabase(ctx, db, log, &err)
|
|
tokens := NewTokenStore(db, log, a.now, a.generateToken)
|
|
audit := NewAuditStore(db, log, a.now)
|
|
|
|
// Create the Docker client (lazy: no connection is made yet)
|
|
docker, err := a.newDocker(log)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer docker.Close()
|
|
log.DebugContext(ctx, "docker client created", "host", dockerHost)
|
|
|
|
// Create the web server and register the routes
|
|
ws := NewWebServer(log, tokens, audit, docker)
|
|
|
|
// Listen in the foreground
|
|
ln, err := a.listen(cfg.ListenNetwork(), cfg.ListenAddress())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := ws.Serve(ctx, ln, a.shutdownTimeout); err != nil {
|
|
return err
|
|
}
|
|
log.InfoContext(ctx, "server stopped")
|
|
return nil
|
|
}
|