feat: scaffold project with config, store, health endpoint, CI, and Dockerfile

Phase 1 of foreman: initialize the Go module, project layout, and core
infrastructure. Includes env-based configuration (FOREMAN_* namespace),
SQLite-backed durable job queue with WAL mode via modernc.org/sqlite,
stdlib HTTP server with /healthz and optional bearer-token auth middleware,
subcommand dispatch (serve + stubs), Gitea CI workflow, multi-stage
distroless Dockerfile, and comprehensive tests for all packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 17:58:36 -04:00
parent d5702f7a75
commit 9cdf4b2472
15 changed files with 1474 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
// Package config loads foreman's runtime configuration from environment variables.
//
// Why: centralizes all env-based configuration into a single, validated struct so
// callers never read raw env vars directly.
// What: reads FOREMAN_* environment variables, applies defaults, and validates
// required values.
// Test: set required env vars, call Load(), assert fields match; omit required vars,
// assert Load() returns an error.
package config
import (
"fmt"
"os"
"time"
)
// Config holds all runtime configuration for the foreman daemon.
type Config struct {
// Addr is the listen address for the HTTP server (default ":8080").
Addr string
// OllamaURL is the base URL of the Ollama target (required).
OllamaURL string
// OllamaToken is an optional bearer token sent to the Ollama target.
OllamaToken string
// Token is an optional bearer token that callers must present to foreman.
Token string
// EmbedModel is the always-resident embedder model name (e.g. "nomic-embed-text").
EmbedModel string
// DBPath is the path to the SQLite database file (default "foreman.db").
DBPath string
// PollInterval controls how often the model poller hits the target (default 30s).
PollInterval time.Duration
// WebhookSecret is an optional HMAC key for signing webhook payloads.
WebhookSecret string
}
// Load reads configuration from environment variables and returns a validated Config.
//
// Why: provides a single entry point for configuration with sensible defaults.
// What: reads FOREMAN_* env vars, applies defaults, validates required fields.
// Test: call with FOREMAN_OLLAMA_URL set, assert success; call without it, assert error.
func Load() (Config, error) {
cfg := Config{
Addr: envOr("FOREMAN_ADDR", ":8080"),
OllamaURL: os.Getenv("FOREMAN_OLLAMA_URL"),
OllamaToken: os.Getenv("FOREMAN_OLLAMA_TOKEN"),
Token: os.Getenv("FOREMAN_TOKEN"),
EmbedModel: os.Getenv("FOREMAN_EMBED_MODEL"),
DBPath: envOr("FOREMAN_DB_PATH", "foreman.db"),
WebhookSecret: os.Getenv("FOREMAN_WEBHOOK_SECRET"),
}
pollStr := envOr("FOREMAN_POLL_INTERVAL", "30s")
dur, err := time.ParseDuration(pollStr)
if err != nil {
return Config{}, fmt.Errorf("invalid FOREMAN_POLL_INTERVAL %q: %w", pollStr, err)
}
cfg.PollInterval = dur
if cfg.OllamaURL == "" {
return Config{}, fmt.Errorf("FOREMAN_OLLAMA_URL is required")
}
return cfg, nil
}
// envOr returns the value of the environment variable named by key, or fallback
// if the variable is empty or unset.
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+96
View File
@@ -0,0 +1,96 @@
package config
import (
"os"
"testing"
"time"
)
func TestLoad_Defaults(t *testing.T) {
// Set required env var, leave optional ones at defaults.
t.Setenv("FOREMAN_OLLAMA_URL", "http://localhost:11434")
// Clear any other vars that might be set.
t.Setenv("FOREMAN_ADDR", "")
t.Setenv("FOREMAN_DB_PATH", "")
t.Setenv("FOREMAN_POLL_INTERVAL", "")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Addr != ":8080" {
t.Errorf("Addr = %q, want %q", cfg.Addr, ":8080")
}
if cfg.OllamaURL != "http://localhost:11434" {
t.Errorf("OllamaURL = %q, want %q", cfg.OllamaURL, "http://localhost:11434")
}
if cfg.DBPath != "foreman.db" {
t.Errorf("DBPath = %q, want %q", cfg.DBPath, "foreman.db")
}
if cfg.PollInterval != 30*time.Second {
t.Errorf("PollInterval = %v, want %v", cfg.PollInterval, 30*time.Second)
}
}
func TestLoad_AllEnvVars(t *testing.T) {
t.Setenv("FOREMAN_ADDR", ":9090")
t.Setenv("FOREMAN_OLLAMA_URL", "http://mac.tail:11434")
t.Setenv("FOREMAN_OLLAMA_TOKEN", "ollama-secret")
t.Setenv("FOREMAN_TOKEN", "my-token")
t.Setenv("FOREMAN_EMBED_MODEL", "nomic-embed-text")
t.Setenv("FOREMAN_DB_PATH", "/data/foreman.db")
t.Setenv("FOREMAN_POLL_INTERVAL", "1m")
t.Setenv("FOREMAN_WEBHOOK_SECRET", "hmac-key")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.Addr != ":9090" {
t.Errorf("Addr = %q, want %q", cfg.Addr, ":9090")
}
if cfg.OllamaURL != "http://mac.tail:11434" {
t.Errorf("OllamaURL = %q", cfg.OllamaURL)
}
if cfg.OllamaToken != "ollama-secret" {
t.Errorf("OllamaToken = %q", cfg.OllamaToken)
}
if cfg.Token != "my-token" {
t.Errorf("Token = %q", cfg.Token)
}
if cfg.EmbedModel != "nomic-embed-text" {
t.Errorf("EmbedModel = %q", cfg.EmbedModel)
}
if cfg.DBPath != "/data/foreman.db" {
t.Errorf("DBPath = %q", cfg.DBPath)
}
if cfg.PollInterval != time.Minute {
t.Errorf("PollInterval = %v, want %v", cfg.PollInterval, time.Minute)
}
if cfg.WebhookSecret != "hmac-key" {
t.Errorf("WebhookSecret = %q", cfg.WebhookSecret)
}
}
func TestLoad_MissingOllamaURL(t *testing.T) {
// Ensure FOREMAN_OLLAMA_URL is unset.
os.Unsetenv("FOREMAN_OLLAMA_URL")
t.Setenv("FOREMAN_OLLAMA_URL", "")
_, err := Load()
if err == nil {
t.Fatal("Load() should fail when FOREMAN_OLLAMA_URL is empty")
}
}
func TestLoad_InvalidPollInterval(t *testing.T) {
t.Setenv("FOREMAN_OLLAMA_URL", "http://localhost:11434")
t.Setenv("FOREMAN_POLL_INTERVAL", "not-a-duration")
_, err := Load()
if err == nil {
t.Fatal("Load() should fail with invalid FOREMAN_POLL_INTERVAL")
}
}