Compare commits
2 Commits
0bea45b770
...
fe3ab9c10e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe3ab9c10e | ||
|
|
16ae887196 |
281
AGENTS.md
Normal file
281
AGENTS.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
```go
|
||||||
|
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
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 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:
|
||||||
|
```go
|
||||||
|
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 |
|
||||||
|
|
||||||
|
## 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):
|
||||||
|
```go
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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:
|
||||||
|
- Consider alternative compression algorithms (bz2, xz, lzo, lz4)
|
||||||
|
- Configurable filename prefix for `Content-Disposition` header
|
||||||
@@ -10,8 +10,6 @@ import (
|
|||||||
|
|
||||||
"backer/internal/backer"
|
"backer/internal/backer"
|
||||||
"backer/internal/log"
|
"backer/internal/log"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -39,14 +37,14 @@ func main() {
|
|||||||
server, err := backer.NewServer(cfgPath)
|
server, err := backer.NewServer(cfgPath)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create server: %v", errors.WithStack(err))
|
log.Fatalf("Failed to create server: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Starting server at %s", server.Addr)
|
log.Infof("Starting server at %s", server.Addr)
|
||||||
|
|
||||||
if backer.C.NoHTTPS {
|
if backer.C.NoHTTPS {
|
||||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("Failed to start server: %v", errors.WithStack(err))
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if _, err := os.Stat(backer.C.Cert); err != nil {
|
if _, err := os.Stat(backer.C.Cert); err != nil {
|
||||||
@@ -58,7 +56,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := server.ListenAndServeTLS(backer.C.Cert, backer.C.Key); err != nil && err != http.ErrServerClosed {
|
if err := server.ListenAndServeTLS(backer.C.Cert, backer.C.Key); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("Failed to start server: %v", errors.WithStack(err))
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -2,7 +2,4 @@ module backer
|
|||||||
|
|
||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
require (
|
require github.com/hjson/hjson-go v3.3.0+incompatible
|
||||||
github.com/hjson/hjson-go v3.3.0+incompatible
|
|
||||||
github.com/pkg/errors v0.9.1
|
|
||||||
)
|
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -1,4 +1,2 @@
|
|||||||
github.com/hjson/hjson-go v3.3.0+incompatible h1:Rqr+Ya+0aCJMjaE4s8E9YKvuJLuLVpEvz4ONum52vnI=
|
github.com/hjson/hjson-go v3.3.0+incompatible h1:Rqr+Ya+0aCJMjaE4s8E9YKvuJLuLVpEvz4ONum52vnI=
|
||||||
github.com/hjson/hjson-go v3.3.0+incompatible/go.mod h1:qsetwF8NlsTsOTwZTApNlTCerV+b2GjYRRcIk4JMFio=
|
github.com/hjson/hjson-go v3.3.0+incompatible/go.mod h1:qsetwF8NlsTsOTwZTApNlTCerV+b2GjYRRcIk4JMFio=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// isExcluded checks if path matches any of the configured exclude patterns.
|
// isExcluded checks if path matches any of the configured exclude patterns.
|
||||||
|
// Paths are normalized to forward slashes for cross-platform regex matching.
|
||||||
func isExcluded(path string) bool {
|
func isExcluded(path string) bool {
|
||||||
|
normalized := filepath.ToSlash(path)
|
||||||
|
|
||||||
for _, re := range excludePatterns {
|
for _, re := range excludePatterns {
|
||||||
if re.MatchString(path) {
|
if re.MatchString(normalized) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ func normalizePath(p string) string {
|
|||||||
|
|
||||||
// TestGetFilesFromDirectories checks if GetFilesFromDirectories returns the proper amount of strings, correct strings and no error.
|
// TestGetFilesFromDirectories checks if GetFilesFromDirectories returns the proper amount of strings, correct strings and no error.
|
||||||
func TestGetFilesFromDirectories(t *testing.T) {
|
func TestGetFilesFromDirectories(t *testing.T) {
|
||||||
|
// Ensure empty_dir exists for the test.
|
||||||
|
os.MkdirAll("../../test_data/test1/foo/empty_dir", 0755)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
input = Config{
|
input = Config{
|
||||||
Directories: []string{"../../test_data/test1/foo", "../../test_data/test1/bar"},
|
Directories: []string{"../../test_data/test1/foo", "../../test_data/test1/bar"},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,6 +23,8 @@ func withServerHeader(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
// NewServer starts new server instance with given config file.
|
// NewServer starts new server instance with given config file.
|
||||||
func NewServer(configPath string) (*http.Server, error) {
|
func NewServer(configPath string) (*http.Server, error) {
|
||||||
|
_ = log.Init("info", "") // Initialize logger with defaults for early logging.
|
||||||
|
|
||||||
if err := LoadConfig(configPath); err != nil {
|
if err := LoadConfig(configPath); err != nil {
|
||||||
return nil, fmt.Errorf("Failed to load config: %w", err)
|
return nil, fmt.Errorf("Failed to load config: %w", err)
|
||||||
}
|
}
|
||||||
@@ -107,47 +110,22 @@ func NewServer(configPath string) (*http.Server, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeWithContext executes fn in a goroutine and returns its result.
|
// writeWithContext checks if context is canceled before executing fn.
|
||||||
// If the context is canceled before fn completes, ctx.Err() is returned.
|
// Returns ctx.Err() if context is already canceled, otherwise returns fn().
|
||||||
// fn should perform I/O that will unblock when the underlying pipe is closed
|
|
||||||
// on context cancellation (e.g., writes to a pipe writer). Panics in fn are
|
|
||||||
// recovered and returned as errors.
|
|
||||||
func writeWithContext(ctx context.Context, fn func() error) error {
|
func writeWithContext(ctx context.Context, fn func() error) error {
|
||||||
done := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
done <- fmt.Errorf("panic: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
done <- ctx.Err()
|
|
||||||
default:
|
|
||||||
done <- fn()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
case err := <-done:
|
default:
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyWithContext copies data from src to dst using a 32KB buffer, respecting
|
// copyWithContext copies data from src to dst using a 32KB buffer, respecting
|
||||||
// context cancellation. If the context is canceled, the copy stops and returns
|
// context cancellation. If the context is canceled, the copy stops and returns
|
||||||
// ctx.Err(). Panics are recovered and returned as errors.
|
// ctx.Err().
|
||||||
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (err error) { //nolint:nonamedreturns
|
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) error {
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
err = fmt.Errorf("panic: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
buf := make([]byte, 32*1024)
|
buf := make([]byte, 32*1024)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -178,15 +156,15 @@ func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (err err
|
|||||||
// It checks X-Forwarded-For header first (for proxied requests), then falls back to RemoteAddr.
|
// It checks X-Forwarded-For header first (for proxied requests), then falls back to RemoteAddr.
|
||||||
func getClientIP(r *http.Request) string {
|
func getClientIP(r *http.Request) string {
|
||||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
return strings.Split(xff, ",")[0]
|
return strings.TrimSpace(strings.Split(xff, ",")[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
ip := r.RemoteAddr
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
if colon := strings.LastIndex(ip, ":"); colon != -1 {
|
if err != nil {
|
||||||
return ip[:colon]
|
return r.RemoteAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
return ip
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||||
|
|||||||
@@ -338,37 +338,4 @@ func TestCopyWithContextCancellation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteWithContextPanic(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
err := writeWithContext(ctx, func() error {
|
|
||||||
panic("test panic")
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error from panic, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "panic") {
|
|
||||||
t.Errorf("Expected panic error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCopyWithContextPanic(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
// Use a reader that panics on Read
|
|
||||||
panicReader := &panicReader{}
|
|
||||||
dst := &strings.Builder{}
|
|
||||||
err := copyWithContext(ctx, dst, panicReader)
|
|
||||||
if err == nil {
|
|
||||||
t.Error("Expected error from panic, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "panic") {
|
|
||||||
t.Errorf("Expected panic error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type panicReader struct{}
|
|
||||||
|
|
||||||
func (pr *panicReader) Read(p []byte) (n int, err error) {
|
|
||||||
panic("read panic")
|
|
||||||
}
|
|
||||||
|
|
||||||
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ func CreateTarGzStream(ctx context.Context, filepaths []string) io.ReadCloser {
|
|||||||
linkTarget, err = os.Readlink(fpath)
|
linkTarget, err = os.Readlink(fpath)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warnf("Skipping %s: readlink failed: %s", fpath, err)
|
log.Warnf("Skipping %s: readlink failed: %v", fpath, err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -156,7 +156,6 @@ func CreateTarGzStream(ctx context.Context, filepaths []string) io.ReadCloser {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ func Init(level, filename string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error passes message directly to [slog.Error()].
|
// Error logs args at error level using [slog.Error()].
|
||||||
func Error(message string) {
|
func Error(args ...any) {
|
||||||
slog.Error(message)
|
slog.Error(fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errorf logs message on error log level and allows to supply arguments in printf() style.
|
// Errorf logs message on error log level and allows to supply arguments in printf() style.
|
||||||
@@ -92,9 +92,9 @@ func Errorf(format string, a ...any) {
|
|||||||
slog.Error(fmt.Sprintf(format, a...))
|
slog.Error(fmt.Sprintf(format, a...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn passes message directly to [slog.Warn()].
|
// Warn logs args at warn level using [slog.Warn()].
|
||||||
func Warn(message string) {
|
func Warn(args ...any) {
|
||||||
slog.Warn(message)
|
slog.Warn(fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warnf logs message on warn log level and allows to supply arguments in printf() style.
|
// Warnf logs message on warn log level and allows to supply arguments in printf() style.
|
||||||
@@ -102,9 +102,9 @@ func Warnf(format string, a ...any) {
|
|||||||
slog.Warn(fmt.Sprintf(format, a...))
|
slog.Warn(fmt.Sprintf(format, a...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info passes message directly to [slog.Info()].
|
// Info logs args at info level using [slog.Info()].
|
||||||
func Info(message string) {
|
func Info(args ...any) {
|
||||||
slog.Info(message)
|
slog.Info(fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infof logs message on info log level and allows to supply arguments in printf() style.
|
// Infof logs message on info log level and allows to supply arguments in printf() style.
|
||||||
@@ -112,9 +112,9 @@ func Infof(format string, a ...any) {
|
|||||||
slog.Info(fmt.Sprintf(format, a...))
|
slog.Info(fmt.Sprintf(format, a...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug passes message directly to [slog.Debug()].
|
// Debug logs args at debug level using [slog.Debug()].
|
||||||
func Debug(message string) {
|
func Debug(args ...any) {
|
||||||
slog.Debug(message)
|
slog.Debug(fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debugf logs message on debug log level and allows to supply arguments in printf() style.
|
// Debugf logs message on debug log level and allows to supply arguments in printf() style.
|
||||||
@@ -122,9 +122,9 @@ func Debugf(format string, a ...any) {
|
|||||||
slog.Debug(fmt.Sprintf(format, a...))
|
slog.Debug(fmt.Sprintf(format, a...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fatal passes message directly to [slog.Error()] and quits with status code 1.
|
// Fatal logs args at error level and quits with status code 1.
|
||||||
func Fatal(message string) {
|
func Fatal(args ...any) {
|
||||||
slog.Error(message)
|
slog.Error(fmt.Sprint(args...))
|
||||||
Close()
|
Close()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -139,6 +139,10 @@ func Fatalf(format string, a ...any) {
|
|||||||
|
|
||||||
// Close closes log file descriptor.
|
// Close closes log file descriptor.
|
||||||
func Close() {
|
func Close() {
|
||||||
|
if Log == nil || Log == os.Stderr {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := Log.Close(); err != nil {
|
if err := Log.Close(); err != nil {
|
||||||
msg := fmt.Sprintln("Failed to close log file:", err)
|
msg := fmt.Sprintln("Failed to close log file:", err)
|
||||||
slog.Error(msg)
|
slog.Error(msg)
|
||||||
|
|||||||
357
internal/log/main_test.go
Normal file
357
internal/log/main_test.go
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// logFile holds the path to the current test log file for reading.
|
||||||
|
var logFilePath string
|
||||||
|
|
||||||
|
// initTestLog sets up a temp log file and returns cleanup function.
|
||||||
|
func initTestLog(t *testing.T, level string) func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp(t.TempDir(), "test_log_*.log")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logFilePath = tmpFile.Name()
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
if err := Init(level, logFilePath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
Close()
|
||||||
|
logFilePath = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLogContent reads the log file and returns its contents.
|
||||||
|
func getLogContent(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
if logFilePath == "" {
|
||||||
|
t.Fatal("logFilePath not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(logFilePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitDefaultLevel tests Init with empty level defaults to info.
|
||||||
|
func TestInitDefaultLevel(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
Info("info message")
|
||||||
|
Debug("debug message")
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "info message") {
|
||||||
|
t.Error("Expected info message in log")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(content, "debug message") {
|
||||||
|
t.Error("Debug message should not appear at info level")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitErrorLevel tests Init with error level.
|
||||||
|
func TestInitErrorLevel(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "error")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
Error("error msg")
|
||||||
|
Warn("warn msg")
|
||||||
|
Info("info msg")
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "error msg") {
|
||||||
|
t.Error("Expected error message in log")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(content, "warn msg") {
|
||||||
|
t.Error("Warn message should not appear at error level")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(content, "info msg") {
|
||||||
|
t.Error("Info message should not appear at error level")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitWarnLevel tests Init with warn level.
|
||||||
|
func TestInitWarnLevel(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "warn")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
Error("error msg")
|
||||||
|
Warn("warn msg")
|
||||||
|
Info("info msg")
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "error msg") {
|
||||||
|
t.Error("Expected error message in log")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(content, "warn msg") {
|
||||||
|
t.Error("Expected warn message in log")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(content, "info msg") {
|
||||||
|
t.Error("Info message should not appear at warn level")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitDebugLevel tests Init with debug level shows everything.
|
||||||
|
func TestInitDebugLevel(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "debug")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
Error("error msg")
|
||||||
|
Warn("warn msg")
|
||||||
|
Info("info msg")
|
||||||
|
Debug("debug msg")
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "error msg") {
|
||||||
|
t.Error("Expected error message in log")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(content, "warn msg") {
|
||||||
|
t.Error("Expected warn message in log")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(content, "info msg") {
|
||||||
|
t.Error("Expected info message in log")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(content, "debug msg") {
|
||||||
|
t.Error("Expected debug message in log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitStderr tests Init with empty filename uses stderr.
|
||||||
|
func TestInitStderr(t *testing.T) {
|
||||||
|
err := Init("info", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if Log != os.Stderr {
|
||||||
|
t.Error("Expected Log to be os.Stderr")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInitInvalidPath tests Init with invalid file path returns error.
|
||||||
|
func TestInitInvalidPath(t *testing.T) {
|
||||||
|
err := Init("info", "/nonexistent/path/to/log.log")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for invalid path, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorWithString tests Error with a string argument.
|
||||||
|
func TestErrorWithString(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "error")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
Error("test error message")
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "test error message") {
|
||||||
|
t.Error("Expected error message in log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorWithError tests Error with an error argument.
|
||||||
|
func TestErrorWithError(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "error")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
err := errors.New("something went wrong")
|
||||||
|
Error(err)
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "something went wrong") {
|
||||||
|
t.Error("Expected error message in log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorWithMultipleArgs tests Error with multiple arguments.
|
||||||
|
func TestErrorWithMultipleArgs(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "error")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
err := errors.New("disk full")
|
||||||
|
Error("Failed to write: ", err)
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "Failed to write:") {
|
||||||
|
t.Error("Expected prefix in log")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(content, "disk full") {
|
||||||
|
t.Error("Expected error message in log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWarnWithString tests Warn with a string argument.
|
||||||
|
func TestWarnWithString(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "warn")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
Warn("test warning")
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "test warning") {
|
||||||
|
t.Error("Expected warning message in log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInfoWithString tests Info with a string argument.
|
||||||
|
func TestInfoWithString(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "info")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
Info("test info")
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "test info") {
|
||||||
|
t.Error("Expected info message in log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDebugWithString tests Debug with a string argument.
|
||||||
|
func TestDebugWithString(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "debug")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
Debug("test debug")
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "test debug") {
|
||||||
|
t.Error("Expected debug message in log")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestErrorf tests Errorf with format string.
|
||||||
|
func TestErrorf(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "error")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
Errorf("Failed to read %s: %v", "config.json", errors.New("not found"))
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "Failed to read config.json: not found") {
|
||||||
|
t.Errorf("Expected formatted error in log, got: %s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWarnf tests Warnf with format string.
|
||||||
|
func TestWarnf(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "warn")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
Warnf("Skipping %s: %d files", "dir", 5)
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "Skipping dir: 5 files") {
|
||||||
|
t.Errorf("Expected formatted warning in log, got: %s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInfof tests Infof with format string.
|
||||||
|
func TestInfof(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "info")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
Infof("Server started on %s:%d", "0.0.0.0", 8086)
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "Server started on 0.0.0.0:8086") {
|
||||||
|
t.Errorf("Expected formatted info in log, got: %s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDebugf tests Debugf with format string.
|
||||||
|
func TestDebugf(t *testing.T) {
|
||||||
|
cleanup := initTestLog(t, "debug")
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
Debugf("Processing file %s (%d bytes)", "test.txt", 1024)
|
||||||
|
|
||||||
|
content := getLogContent(t)
|
||||||
|
|
||||||
|
if !strings.Contains(content, "Processing file test.txt (1024 bytes)") {
|
||||||
|
t.Errorf("Expected formatted debug in log, got: %s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClose tests that Close closes the log file.
|
||||||
|
func TestClose(t *testing.T) {
|
||||||
|
tmpFile, err := os.CreateTemp(t.TempDir(), "test_log_*.log")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logFilePath = tmpFile.Name()
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
if err := Init("info", logFilePath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Close()
|
||||||
|
|
||||||
|
// Verify file is closed by trying to write (should fail).
|
||||||
|
// But we can't easily test this without reopening.
|
||||||
|
// Just verify no panic occurred.
|
||||||
|
logFilePath = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloseNil tests that Close doesn't panic when Log is nil.
|
||||||
|
func TestCloseNil(t *testing.T) {
|
||||||
|
Log = nil
|
||||||
|
|
||||||
|
// Should not panic.
|
||||||
|
Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCloseStderr tests that Close doesn't close stderr.
|
||||||
|
func TestCloseStderr(t *testing.T) {
|
||||||
|
Log = os.Stderr
|
||||||
|
|
||||||
|
// Should not panic and should not close stderr.
|
||||||
|
Close()
|
||||||
|
|
||||||
|
// Verify stderr is still usable.
|
||||||
|
_, err := os.Stderr.WriteString("")
|
||||||
|
if err != nil {
|
||||||
|
t.Error("stderr should still be usable after Close()")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* vim: setlocal ft=go noet ai ts=4 sw=4 sts=4: */
|
||||||
Reference in New Issue
Block a user