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.
This commit is contained in:
1 parent
b792ad256e
commit
0a6023e942
10 files changed
+4138
No files matched your search
@@ -0,0 +1,210 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user