Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## v0.4.0 - 2026-10-08

### Breaking Changes

- Logging configuration now lives in the config file under top-level `log` settings instead of `LOG_FORMAT` and `LOG_LEVEL` environment variables.

```yaml
log:
format: text
level: debug
```

### Changed

- `logger.Setup` now receives `config.Log`, keeping logger configuration with the rest of the application config.

## v0.3.0 - 2026-10-08

### Breaking Changes
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,13 +217,13 @@ Run the agent:
GITHUB_TOKEN=your_token_here go run . -config your-config.yaml
```

Logging is JSON by default.
Logging is JSON at `info` level by default. Configure logging in your config
file when you want a different format or level:

Optional logging environment variables:

```sh
LOG_FORMAT=text
LOG_LEVEL=debug
```yaml
log:
format: text
level: debug
```

## Operating Model
Expand Down Expand Up @@ -254,7 +254,7 @@ Example:

```sh
make -f ./test/Makefile dev-up
GITHUB_TOKEN=your_token_here LOG_FORMAT=text go run . -config ./test/sample.config.yaml
GITHUB_TOKEN=your_token_here go run . -config ./test/sample.config.yaml
```

The sample deploy command calls:
Expand All @@ -280,6 +280,8 @@ Required workload fields:

Common optional fields:

- `log.format`: log output format. Set to `text` for local development; JSON is the default.
- `log.level`: minimum log level. Supports `debug`, `info`, `warn`, and `error`; defaults to `info`.
- `release_prefix`: release tag prefix used to scope metadata lookup. Recommended when a repository publishes more than one deployable artifact stream.
- `notification_url`: Slack webhook URL.
- `check_interval`: defaults to `60s` when below `10s`.
Expand Down
13 changes: 13 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,25 @@ import (
)

type Config struct {
// Log is optional and controls reconD's process-level logging output.
Log Log `yaml:"log"`

// Workloads is the required list of container workloads reconD should watch
// and reconcile. Configure one entry for each independently deployed
// service.
Workloads []Workload `yaml:"workloads"`
}

type Log struct {
// Format is optional and controls the log handler format. Set it to "text"
// for local development; any other value uses structured JSON output.
Format string `yaml:"format"`

// Level is optional and controls the minimum log level. Supported values are
// "debug", "info", "warn", and "error"; empty defaults to "info".
Level string `yaml:"level"`
}

// Workload is a standalone unit of deployment (e.g., a container) reconD can watch and reconcile
type Workload struct {
// Name is required and identifies this workload in logs, notifications, and
Expand Down
5 changes: 5 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ func TestLoadWorkloadListYAML(t *testing.T) {

path := filepath.Join(t.TempDir(), "workloads.yml")
require.NoError(t, os.WriteFile(path, []byte(`
log:
format: text
level: debug
workloads:
- name: api
release_prefix: api-prod
Expand All @@ -64,6 +67,8 @@ workloads:

cfg, err := Load(path)
require.NoError(t, err)
require.Equal(t, "text", cfg.Log.Format)
require.Equal(t, "debug", cfg.Log.Level)
require.Len(t, cfg.Workloads, 2)
require.Equal(t, "api", cfg.Workloads[0].Name)
require.Equal(t, "api-prod", cfg.Workloads[0].ReleasePrefix)
Expand Down
31 changes: 7 additions & 24 deletions logger/logger.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,27 @@
//
// Design decisions:
// - JSON output by default for log aggregation (journald, Loki, Datadog)
// - Text output when LOG_FORMAT=text (local development)
// - Level controlled by LOG_LEVEL env var (debug, info, warn, error)
// - Text output when log.format=text (local development)
// - Level controlled by log.level (debug, info, warn, error)
// - Structured fields: workload, phase, digest, duration included consistently
package logger

import (
"context"
"log/slog"
"os"
"strings"
)

// contextKey is the key for the logger stored in context.Context.
type contextKey struct{}
"github.com/ercross/reconD/config"
)

// Setup configures the default global slog logger.
// Call once at startup before spawning goroutines.
func Setup() *slog.Logger {
level := parseLevel(os.Getenv("LOG_LEVEL"))
func Setup(cfg config.Log) *slog.Logger {
level := parseLevel(cfg.Level)
opts := &slog.HandlerOptions{Level: level}

var handler slog.Handler
if strings.ToLower(os.Getenv("LOG_FORMAT")) == "text" {
if strings.ToLower(cfg.Format) == "text" {
handler = slog.NewTextHandler(os.Stdout, opts)
} else {
// JSON is the default: structured logs integrate with journald and
Expand All @@ -43,20 +41,6 @@ func WithContainerName(logger *slog.Logger, containerName string) *slog.Logger {
return logger.With("container", containerName)
}

// WithContext stores logger in ctx, enabling logger retrieval in deep call stacks.
func WithContext(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, contextKey{}, logger)
}

// FromContext retrieves the logger stored in ctx.
// Falls back to the default slog logger if none is stored.
func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok {
return l
}
return slog.Default()
}

func parseLevel(s string) slog.Level {
switch strings.ToLower(s) {
case "debug":
Expand All @@ -77,7 +61,6 @@ const (
PhaseDrift = "drift_check"
PhasePull = "image_pull"
PhaseStrategy = "deployment_strategy"
PhaseMigration = "migration"
PhaseRestart = "restart"
PhaseHealthCheck = "health_check"
PhaseCommit = "commit"
Expand Down
8 changes: 4 additions & 4 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (

func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "agent error: %v\n", err)
_, _ = fmt.Fprintf(os.Stderr, "agent error: %v\n", err)
os.Exit(1)
}
}
Expand All @@ -34,14 +34,14 @@ func run() error {
return fmt.Errorf("missing config path")
}

log := logger.Setup()
log.Info("deployment agent starting", "config", *configPath)

cfg, err := config.Load(*configPath)
if err != nil {
return fmt.Errorf("load config error %q: %w", *configPath, err)
}

log := logger.Setup(cfg.Log)
log.Info("deployment agent starting", "config", *configPath)

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()

Expand Down
Loading