Files
bryanpedini 0a6023e942 feat(cli): add help, token and audit commands
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.
2026-10-06 10:52:25 +02:00

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
}