feat(config): typed env-driven configuration
Parse TAPIR_* env into a typed Config with homelab defaults (gateway URL, summarizer model, token ref, redirect addr). Secrets (gateway key, OAuth client secret) come from env only; the refresh token never lives here — it is addressed by an opaque ref behind the SecretStore port. Per-command validation (ValidateForAuth/ValidateForRun) so each command demands only what it needs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
|||||||
|
// Package config parses Tapir's runtime configuration from environment
|
||||||
|
// variables into a typed struct. No secrets are baked into code: the gateway
|
||||||
|
// key and OAuth client secret are read from the environment (later resolved via
|
||||||
|
// op/ESO), and the OAuth refresh token is never held here — it lives behind the
|
||||||
|
// SecretStore port, addressed by the opaque TokenRef.
|
||||||
|
//
|
||||||
|
// Defaults target the homelab snapshot in docs/homelab-integration.md; every
|
||||||
|
// value is overridable so the same binary runs standalone or in-cluster.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is Tapir's fully-resolved runtime configuration.
|
||||||
|
type Config struct {
|
||||||
|
// UserID is the Tapir user the run operates as. Stage 0 has exactly one.
|
||||||
|
// It must be a UUID: it keys the UUID user_id/video_id columns (data-model).
|
||||||
|
UserID string
|
||||||
|
|
||||||
|
// GatewayURL is the OpenAI-compatible LiteLLM base URL (".../v1").
|
||||||
|
GatewayURL string
|
||||||
|
// GatewayKey authorizes the gateway. Read from env, never committed.
|
||||||
|
GatewayKey string
|
||||||
|
// SummarizerModel is the alias in host/name form, e.g. "koala/phi4-mini".
|
||||||
|
SummarizerModel string
|
||||||
|
// SummarizerTimeout bounds a single completion call. Thinking models are
|
||||||
|
// slow, so the default is generous.
|
||||||
|
SummarizerTimeout time.Duration
|
||||||
|
|
||||||
|
// DBDSN is the Postgres DSN for the store sink.
|
||||||
|
DBDSN string
|
||||||
|
|
||||||
|
// YouTube OAuth app credentials (the registered client), read from env.
|
||||||
|
YTClientID string
|
||||||
|
YTClientSecret string
|
||||||
|
// YTTokenRef is the opaque SecretStore reference under which the YouTube
|
||||||
|
// refresh token is persisted/resolved. Not the token itself.
|
||||||
|
YTTokenRef string
|
||||||
|
|
||||||
|
// SecretsFile is the path to the local file-backed SecretStore (0600). A
|
||||||
|
// Stage-0 stand-in for op/ESO, swappable behind the SecretStore port.
|
||||||
|
SecretsFile string
|
||||||
|
|
||||||
|
// OAuthRedirectAddr is the host:port the `auth` command's local listener
|
||||||
|
// binds for the OAuth redirect, e.g. "localhost:8080".
|
||||||
|
OAuthRedirectAddr string
|
||||||
|
|
||||||
|
// PollInterval, when > 0, makes `run` loop on that cadence; 0 means run once.
|
||||||
|
PollInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defaults (see docs/homelab-integration.md). All overridable via env.
|
||||||
|
const (
|
||||||
|
defaultGatewayURL = "http://koala:30401/v1"
|
||||||
|
defaultSummarizerModel = "koala/phi4-mini"
|
||||||
|
defaultSummarizerTimeout = 5 * time.Minute
|
||||||
|
defaultYTTokenRef = "youtube/refresh_token"
|
||||||
|
defaultOAuthRedirectAddr = "localhost:8080"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Load reads the environment into a Config, applying defaults. It does not
|
||||||
|
// validate that required fields are present — call ValidateForAuth or
|
||||||
|
// ValidateForRun for the command being run, so each command demands only what
|
||||||
|
// it needs.
|
||||||
|
func Load() (Config, error) {
|
||||||
|
c := Config{
|
||||||
|
UserID: os.Getenv("TAPIR_USER_ID"),
|
||||||
|
GatewayURL: envOr("TAPIR_GATEWAY_URL", defaultGatewayURL),
|
||||||
|
GatewayKey: os.Getenv("TAPIR_GATEWAY_KEY"),
|
||||||
|
SummarizerModel: envOr("TAPIR_SUMMARIZER_MODEL", defaultSummarizerModel),
|
||||||
|
DBDSN: os.Getenv("TAPIR_DB_DSN"),
|
||||||
|
YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"),
|
||||||
|
YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"),
|
||||||
|
YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef),
|
||||||
|
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
|
||||||
|
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
c.SummarizerTimeout = timeout
|
||||||
|
|
||||||
|
interval, err := durationOr("TAPIR_POLL_INTERVAL", 0)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
c.PollInterval = interval
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateForAuth checks the fields the `auth` command needs: the YouTube OAuth
|
||||||
|
// app credentials, a place to persist the token, and the redirect listener.
|
||||||
|
func (c Config) ValidateForAuth() error {
|
||||||
|
return c.require(map[string]string{
|
||||||
|
"TAPIR_YT_CLIENT_ID": c.YTClientID,
|
||||||
|
"TAPIR_YT_CLIENT_SECRET": c.YTClientSecret,
|
||||||
|
"TAPIR_YT_TOKEN_REF": c.YTTokenRef,
|
||||||
|
"TAPIR_SECRETS_FILE": c.SecretsFile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateForRun checks the fields the `run` command needs end to end.
|
||||||
|
func (c Config) ValidateForRun() error {
|
||||||
|
return c.require(map[string]string{
|
||||||
|
"TAPIR_USER_ID": c.UserID,
|
||||||
|
"TAPIR_GATEWAY_URL": c.GatewayURL,
|
||||||
|
"TAPIR_SUMMARIZER_MODEL": c.SummarizerModel,
|
||||||
|
"TAPIR_DB_DSN": c.DBDSN,
|
||||||
|
"TAPIR_YT_CLIENT_ID": c.YTClientID,
|
||||||
|
"TAPIR_YT_CLIENT_SECRET": c.YTClientSecret,
|
||||||
|
"TAPIR_YT_TOKEN_REF": c.YTTokenRef,
|
||||||
|
"TAPIR_SECRETS_FILE": c.SecretsFile,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Config) require(fields map[string]string) error {
|
||||||
|
var missing []string
|
||||||
|
for name, val := range fields {
|
||||||
|
if strings.TrimSpace(val) == "" {
|
||||||
|
missing = append(missing, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return fmt.Errorf("missing required config: %s", strings.Join(sortedMissing(missing), ", "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortedMissing(xs []string) []string {
|
||||||
|
sort.Strings(xs)
|
||||||
|
return xs
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func durationOr(key string, fallback time.Duration) (time.Duration, error) {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(v)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("config: %s=%q: %w", key, v, err)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultSecretsFile resolves to <user-config-dir>/tapir/secrets.json, falling
|
||||||
|
// back to a cwd-relative path when the config dir is unavailable.
|
||||||
|
func defaultSecretsFile() string {
|
||||||
|
dir, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "tapir-secrets.json"
|
||||||
|
}
|
||||||
|
return filepath.Join(dir, "tapir", "secrets.json")
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setEnv sets env vars for the test and clears them afterward, so cases don't
|
||||||
|
// leak into one another. t.Setenv handles restoration.
|
||||||
|
func setEnv(t *testing.T, kv map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
for k, v := range kv {
|
||||||
|
t.Setenv(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_AppliesDefaults(t *testing.T) {
|
||||||
|
// Clear the ones with defaults so we observe the fallback, not the host env.
|
||||||
|
setEnv(t, map[string]string{
|
||||||
|
"TAPIR_GATEWAY_URL": "",
|
||||||
|
"TAPIR_SUMMARIZER_MODEL": "",
|
||||||
|
"TAPIR_YT_TOKEN_REF": "",
|
||||||
|
"TAPIR_OAUTH_REDIRECT_ADDR": "",
|
||||||
|
"TAPIR_SUMMARIZER_TIMEOUT": "",
|
||||||
|
"TAPIR_POLL_INTERVAL": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
c, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if c.GatewayURL != defaultGatewayURL {
|
||||||
|
t.Errorf("GatewayURL = %q, want default %q", c.GatewayURL, defaultGatewayURL)
|
||||||
|
}
|
||||||
|
if c.SummarizerModel != defaultSummarizerModel {
|
||||||
|
t.Errorf("SummarizerModel = %q, want default %q", c.SummarizerModel, defaultSummarizerModel)
|
||||||
|
}
|
||||||
|
if c.YTTokenRef != defaultYTTokenRef {
|
||||||
|
t.Errorf("YTTokenRef = %q, want default %q", c.YTTokenRef, defaultYTTokenRef)
|
||||||
|
}
|
||||||
|
if c.SummarizerTimeout != defaultSummarizerTimeout {
|
||||||
|
t.Errorf("SummarizerTimeout = %v, want default %v", c.SummarizerTimeout, defaultSummarizerTimeout)
|
||||||
|
}
|
||||||
|
if c.PollInterval != 0 {
|
||||||
|
t.Errorf("PollInterval = %v, want 0 (run once)", c.PollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_ParsesValues(t *testing.T) {
|
||||||
|
setEnv(t, map[string]string{
|
||||||
|
"TAPIR_USER_ID": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"TAPIR_GATEWAY_URL": "http://example/v1",
|
||||||
|
"TAPIR_GATEWAY_KEY": "sk-test",
|
||||||
|
"TAPIR_SUMMARIZER_MODEL": "iguana/deepseek-r1-14b",
|
||||||
|
"TAPIR_SUMMARIZER_TIMEOUT": "90s",
|
||||||
|
"TAPIR_DB_DSN": "postgres://x",
|
||||||
|
"TAPIR_POLL_INTERVAL": "10m",
|
||||||
|
})
|
||||||
|
|
||||||
|
c, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if c.UserID != "11111111-1111-1111-1111-111111111111" {
|
||||||
|
t.Errorf("UserID = %q", c.UserID)
|
||||||
|
}
|
||||||
|
if c.GatewayKey != "sk-test" {
|
||||||
|
t.Errorf("GatewayKey = %q", c.GatewayKey)
|
||||||
|
}
|
||||||
|
if c.SummarizerModel != "iguana/deepseek-r1-14b" {
|
||||||
|
t.Errorf("SummarizerModel = %q", c.SummarizerModel)
|
||||||
|
}
|
||||||
|
if c.SummarizerTimeout != 90*time.Second {
|
||||||
|
t.Errorf("SummarizerTimeout = %v, want 90s", c.SummarizerTimeout)
|
||||||
|
}
|
||||||
|
if c.PollInterval != 10*time.Minute {
|
||||||
|
t.Errorf("PollInterval = %v, want 10m", c.PollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_RejectsBadDuration(t *testing.T) {
|
||||||
|
setEnv(t, map[string]string{"TAPIR_SUMMARIZER_TIMEOUT": "not-a-duration"})
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("want error on unparsable duration, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateForRun_ReportsMissing(t *testing.T) {
|
||||||
|
c := Config{} // nothing set
|
||||||
|
err := c.ValidateForRun()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want error when required run fields are missing")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"TAPIR_USER_ID", "TAPIR_DB_DSN", "TAPIR_YT_CLIENT_ID"} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q must name missing field %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateForAuth_PassesWhenComplete(t *testing.T) {
|
||||||
|
c := Config{
|
||||||
|
YTClientID: "id",
|
||||||
|
YTClientSecret: "secret",
|
||||||
|
YTTokenRef: "youtube/refresh_token",
|
||||||
|
SecretsFile: "/tmp/secrets.json",
|
||||||
|
}
|
||||||
|
if err := c.ValidateForAuth(); err != nil {
|
||||||
|
t.Errorf("ValidateForAuth: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user