Files
backer/AGENTS.md

11 KiB

AGENTS.md

Project Overview

Backer is a Go-based HTTP/HTTPS backup server that creates tar.gz archives on-the-fly from configured directories. It serves a single endpoint that clients (e.g., curl) can query to download a timestamped backup archive. Licensed under MIT.

Workflow Rules

  • Do not apply changes without explicit user consent. When asked to analyze, review, or suggest improvements, present your proposal first and wait for confirmation before editing any files. Always ask "Should I implement this?" or "Should I apply these changes?" before writing code.
  • Keep the codebase simple, small, maintainable, safe, and human-readable. Favor minimal, clear solutions over clever or abstract ones. Do not introduce patterns, layers, or indirection unless they directly solve a concrete problem. Every line of code must earn its place.
  • When stuck, ask a clarification question, or propose a short plan, or both. Do not guess intent or make assumptions — ask.
  • Always make a TODO plan before acting. And try to follow this plan.
  • If you notice defects, bugs, or issues, always report it.
  • Track new features, known issues, bugs, and other defects in TODO.org. Update it as you implement features, fix issues, fix bugs, or discover new ones — check off completed items, add new findings.
  • Update AGENTS.md as the project evolves. Keep it comprehensive but dense.
  • Don't use context package unless absolutely necessary. Try to find another solution first.
  • Create unit tests if possible/necessary to verify changes that are not covered by unit tests.

Build & Test Commands

make build                    # Build binary (version from git or "dev")
make build VERSION=0.1.0     # Build with specific version tag
make test                     # Run all tests (go test ./...)
make clean                    # Remove built binary
make                          # Clean + build
make upgrade                  # Update dependencies (go get -u, mod tidy, vendor)
make help                     # Show all targets

Version is injected at build time via ldflags: -X main.version=$(VERSION).

Linting

golangci-lint run             # Run linter (configured via .golangci.yml)

The project uses golangci-lint v2 with a strict set of linters. See .golangci.yml for the full configuration.

Code Style & Conventions

  • Language: Go 1.25
  • Formatting: gofmt + goimports (enforced by linter)
  • Imports: Grouped imports, single import block required (grouper linter)
  • Declaration order: Type, const, var, func (decorder linter)
  • Named returns: Avoid named return values, especially in defer blocks (nonamedreturns linter)
  • Naming: Follow Go initialisms (HTTP, TLS, IP, etc.) as defined in staticcheck config
  • File footer: Each source file ends with /* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */

Godoc Comments

All exported types, functions, and methods must have godoc comments (godoclint, godot). Comments must end with a period.

Error Handling

  • Error messages must be capitalized (e.g., "Failed to read config", not "failed to read config"). The ST1005 linter rule is disabled to allow this.
  • Use fmt.Errorf with %w for error wrapping.
  • Use %v for errors in format strings (not %s).
  • Past tense style: "Failed to...", "Unable to...".
  • Always handle errors explicitly; never discard with _ except in tests where intentional.

Logging Conventions

  • Use backer/internal/log package (wrapper around Go's slog).
  • Log levels: error, warn, info, debug.
  • Past tense with context: "Skipping %s: %v", filename, err.
  • Appropriate severity: Warn for recoverable issues, Error for problems, Fatal for startup failures.

Config Validation Pattern

For each config option: set default first, then immediately validate range/required value:

if C.Port == 0 {
    C.Port = 8086
    log.Warnf("Config option port is not set, fallback to %d", C.Port)
}

if C.Port < 1 || C.Port > 65535 {
    return fmt.Errorf("Port must be between 1 and 65535, got %d", C.Port)
}

Function Naming

// Exported (public): VerbNoun pattern
NewServer()
LoadConfig()
CreateTarGzStream()
GetFilesFromDirectories()

// Unexported (private): verbNoun or isNoun pattern
writeWithContext()
copyWithContext()
isPipeClosedError()
defaultConfigPath()

JSON Tags

Struct fields use PascalCase, json tags use snake_case:

BackupTimeout     int `json:"backup_timeout"`
CompressionLevel  int `json:"compression_level"`

Intentional Linter Suppressions

Rule Reason
ST1005 Disable — allows capitalized error messages (e.g., "Failed to...")
QF1001 De Morgan's law disabled for auth condition clarity
revive (error strings) Same reason as ST1005

Project Structure

cmd/
  backer/
    main.go                         # CLI entrypoint, flag parsing, TLS setup, server start

internal/
  backer/
    types.go                        # Config struct definition
    globals.go                      # Global C Config variable, compiled excludePatterns
    loadConfig.go                   # HJSON/JSON config parsing with validation
    loadConfig_test.go              # Config validation tests
    newServer.go                    # HTTP server creation, auth handler, context helpers
    newServer_test.go               # Server, auth, and context helper tests
    utils.go                        # CreateTarGzStream (tar.gz streaming via io.Pipe)
    utils_test.go                   # Tar.gz stream tests
    getFilesFromDirectories.go      # Directory walking, exclusion filtering (isExcluded)
    getFilesFromDirectories_test.go # File traversal and exclude pattern tests
  log/
    main.go                         # Logging wrapper around stdlib slog
    main_test.go                    # Log package tests

data/
  config_example.json               # Annotated example config (HJSON with comments)

contrib/
  backer                            # OpenRC init script (Alpine/Gentoo)
  backer.service                    # systemd unit file
  backer.freebsd                    # FreeBSD rc.d script

test_data/
  test_config.json                  # Test config fixture
  example.crt / example.key         # TLS test certificates
  test1/                            # File/directory fixtures for tests

Makefile                            # Build targets
TODO.org                            # Project backlog (Org mode)
README.md                           # User documentation
LICENSE                             # MIT License
llms.txt                            # LLM-friendly project reference

Configuration

Config is JSON (HJSON with comments supported). Default path depends on OS:

  • Linux/macOS: /etc/backer.json
  • FreeBSD/NetBSD/OpenBSD/DragonFly: /usr/local/etc/backer.json
  • Override with -c /path/to/config.json

Config Options

Option Default Description
address 0.0.0.0 Listen address
port 8086 Listen port (1-65535)
cert — TLS certificate file path
key — TLS key file path
nohttps false Disable HTTPS (for dev/lab)
location /archive API endpoint path
user — Basic auth username (required)
password — Basic auth password (required)
log stderr Log output file path
loglevel info Log verbosity: error/warn/info/debug
directories — Directories to backup (required, validated for existence)
backup_timeout 60 Stream timeout in minutes (1-1440)
compression_level 9 Gzip level (1-9)
exclude_patterns [] Regex patterns to exclude from backup
filename_prefix backup Prefix for backup filename in Content-Disposition header
compression_algorithm gzip Compression algorithm: gzip, bzip2 or zstd

Architecture & Key Patterns

Global Config

Uses a global C Config variable (internal/backer/globals.go). This is intentional — config is loaded once at startup and never modified concurrently.

HJSON Config Parsing

Requires double conversion (hjson → map → json → struct):

hjson.Unmarshal(buf, &tmp)  // parse HJSON into map[string]any
json.Marshal(tmp)            // re-encode as strict JSON
json.Unmarshal(buf, &C)     // decode into Config struct

Streaming Archive Creation

No temp files on disk. Uses io.Pipe with a goroutine to stream tar.gz on-the-fly:

  • CreateTarGzStream() returns an io.ReadCloser
  • Goroutine writes tar entries → gzip writer → pipe writer
  • HTTP handler copies from pipe reader directly to HTTP response

Context-Aware I/O

Two helper functions in newServer.go handle context cancellation during streaming:

  • writeWithContext(ctx, fn) — checks context before calling fn
  • copyWithContext(ctx, dst, src) — buffered copy (32KB) with cancellation checks

Special File Type Handling

  • Regular files: streamed into tar archive with content
  • Directories: included as tar directory entries
  • Symlinks: stored as symlink entries (target path preserved)
  • Device files (char/block): included with correct major/minor
  • Named pipes: included (header only, no data)
  • Sockets: skipped (not archivable)

Pipe Error Recovery

isPipeClosedError() checks for "io: read/write on closed pipe" errors to avoid noisy logging when a client disconnects mid-stream.

Auth Handler

HTTP Basic Auth. Both username AND password must match exactly. Returns 401 Unauthorized with WWW-Authenticate header on failure. The condition is intentionally written without De Morgan's law simplification for clarity (linter suppression //nolint:staticcheck).

Client IP Detection

Checks X-Forwarded-For header first (for proxied requests), then falls back to RemoteAddr.

Server Configuration

  • ReadHeaderTimeout: 5 seconds
  • WriteTimeout: configurable via backup_timeout (converted to time.Duration)
  • TLS minimum version: TLS 1.3 (when HTTPS enabled)

Dependencies

  • github.com/hjson/hjson-go v3.3.0 — HJSON config parsing

Testing

go test ./...                    # Run all tests
go test -v -run TestName ./...   # Run specific test
make test                        # Via Makefile

Tests live alongside source files as *_test.go (package name, not _test suffix). Coverage is approximately 69% on internal/backer and ~100% on internal/log.

Test Conventions

  • Reset global config before tests: C = Config{}
  • Reset exclude patterns: excludePatterns = nil
  • Use t.TempDir() for temporary files
  • Helper writeTempConfig(t, content) creates temp config files for validation tests
  • Symlink tests skip gracefully with t.Skip() when not supported
  • Context cancellation tests verify correct behavior

Test Data

  • test_data/test_config.json — valid test config (user: JohnnyGoode, password: SharpShooter)
  • test_data/test1/foo/ — test directory with files, subdirs, empty dirs
  • test_data/test1/bar/ — test directory with files and a directory named goodbye.txt/ (edge case: directory with file extension)
  • test_data/example.crt / example.key — TLS test certificates

Service Deployment

Init scripts provided in contrib/:

  • systemd (backer.service): ExecStart=/usr/local/sbin/backer, restart on failure
  • OpenRC (backer): uses supervise-daemon, creates log/pid dirs in start_pre
  • FreeBSD rc.d (backer.freebsd): daemonizes via /usr/sbin/daemon

TODO / Roadmap

See TODO.org for current backlog.