diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..1d58cd6 --- /dev/null +++ b/internal/config/config.go @@ -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 /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") +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..c8fce0d --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +}