config.go

v1.3.7
Doc Versions Source
1
package config
2
3
import (
4
	"fmt"
5
	"os"
6
	"strings"
7
8
	"github.com/spf13/viper"
9
)
10
11
// ResolveValue resolves a configuration value. If it starts with "@",
12
// the remainder is treated as a file path and the file contents are returned.
13
// Otherwise the value is returned as-is.
14
func ResolveValue(value string) (string, error) {
15
	path, ok := strings.CutPrefix(value, "@")
16
	if !ok {
17
		return value, nil
18
	}
19
20
	data, err := os.ReadFile(path)
21
	if err != nil {
22
		return "", fmt.Errorf("read %s: %w", path, err)
23
	}
24
	return strings.TrimSpace(string(data)), nil
25
}
26
27
// Module represents a Go module configuration.
28
type Module struct {
29
	VCS            string `json:"vcs"`
30
	Repo           string `json:"repo"`
31
	Web            string `json:"web"`
32
	Private        bool   `json:"private,omitempty"`
33
	CredentialName string `json:"credential_name,omitempty"`
34
}
35
36
// FallbackMode controls behavior for modules not defined in config.
37
type FallbackMode string
38
39
const (
40
	FallbackNone FallbackMode = ""
41
42
	FallbackRedirect FallbackMode = "redirect" // 302 redirect to upstream
43
	FallbackSync     FallbackMode = "sync"     // fetch upstream, cache synchronously, then serve
44
	FallbackAsync    FallbackMode = "async"    // fetch upstream, serve immediately, cache in background
45
)
46
47
// RequiresUpstream reports whether the mode needs an upstream URL.
48
func (m FallbackMode) RequiresUpstream() bool {
49
	switch m {
50
	case FallbackRedirect, FallbackSync, FallbackAsync:
51
		return true
52
	}
53
	return false
54
}
55
56
type FallbackConfig struct {
57
	Mode     FallbackMode `mapstructure:"mode"`
58
	Upstream string       `mapstructure:"upstream"`
59
}
60
61
// S3Config is kept for backward compatibility with NewS3Storage.
62
type S3Config struct {
63
	Endpoint     string `mapstructure:"endpoint"`
64
	Bucket       string `mapstructure:"bucket"`
65
	Region       string `mapstructure:"region"`
66
	UsePathStyle bool   `mapstructure:"use_path_style"`
67
}
68
69
// StorageConfig supports multiple storage backends.
70
type StorageConfig struct {
71
	Type         string `mapstructure:"type"`           // "memory", "disk", "s3" (default: "s3")
72
	Root         string `mapstructure:"root"`           // disk only
73
	Endpoint     string `mapstructure:"endpoint"`       // s3 only
74
	Bucket       string `mapstructure:"bucket"`         // s3 only
75
	Region       string `mapstructure:"region"`         // s3 only
76
	UsePathStyle bool   `mapstructure:"use_path_style"` // s3 only: path-style addressing for Garage, MinIO, etc.
77
	MaxBytes     int64  `mapstructure:"max_bytes"`      // memory only (0 = unlimited)
78
}
79
80
// S3 returns S3Config extracted from StorageConfig.
81
func (c *StorageConfig) S3() S3Config {
82
	return S3Config{Endpoint: c.Endpoint, Bucket: c.Bucket, Region: c.Region, UsePathStyle: c.UsePathStyle}
83
}
84
85
type SumdbConfig struct {
86
	Enabled  bool   `mapstructure:"enabled"`
87
	Key      string `mapstructure:"key"`
88
	Upstream string `mapstructure:"upstream"` // upstream sumdb URL for non-local lookups
89
}
90
91
// OIDCConfig configures OpenID Connect authentication for the admin UI.
92
type OIDCConfig struct {
93
	Issuer       string `mapstructure:"issuer"`
94
	ClientID     string `mapstructure:"client_id"`
95
	ClientSecret string `mapstructure:"client_secret"` // supports @file
96
	RedirectURL  string `mapstructure:"redirect_url"`
97
}
98
99
type InstrumentationConfig struct {
100
	Listen      string `mapstructure:"listen"`
101
	MetricsPath string `mapstructure:"metrics_path"`
102
	HealthPath  string `mapstructure:"health_path"`
103
}
104
105
// DatabaseConfig configures the module store database backend.
106
type DatabaseConfig struct {
107
	Type string `mapstructure:"type"` // "sqlite" (default), "postgres"
108
	DSN  string `mapstructure:"dsn"`  // file path for sqlite, connection string for postgres
109
}
110
111
type Config struct {
112
	Host            string                 `mapstructure:"host"`
113
	Database        *DatabaseConfig        `mapstructure:"database"`
114
	Storage         *StorageConfig         `mapstructure:"storage"`
115
	Fallback        *FallbackConfig        `mapstructure:"fallback"`
116
	Sumdb           *SumdbConfig           `mapstructure:"sumdb"`
117
	Instrumentation *InstrumentationConfig `mapstructure:"instrumentation"`
118
	OIDC            *OIDCConfig            `mapstructure:"oidc"`
119
	Listen          string                 `mapstructure:"listen"`
120
	Cache           string                 `mapstructure:"cache"`
121
	AuthTokens      string                 `mapstructure:"auth_tokens"`
122
	AdminToken      string                 `mapstructure:"admin_token"`
123
}
124
125
// Dump returns a human-readable configuration summary with secrets masked.
126
func (cfg *Config) Dump() string {
127
	var b strings.Builder
128
129
	fmt.Fprintf(&b, "host: %s\n", cfg.Host)
130
	fmt.Fprintf(&b, "listen: %s\n", cfg.Listen)
131
	fmt.Fprintf(&b, "cache: %s\n", cfg.Cache)
132
	fmt.Fprintf(&b, "admin_token: %s\n", mask(cfg.AdminToken))
133
	fmt.Fprintf(&b, "auth_tokens: %s\n", mask(cfg.AuthTokens))
134
135
	if cfg.Database != nil {
136
		fmt.Fprintf(&b, "database:\n")
137
		fmt.Fprintf(&b, "  type: %s\n", cfg.Database.Type)
138
		fmt.Fprintf(&b, "  dsn: %s\n", maskDSN(cfg.Database.Type, cfg.Database.DSN))
139
	}
140
141
	if cfg.Storage != nil {
142
		t := cfg.Storage.Type
143
		if t == "" {
144
			t = "s3"
145
		}
146
		fmt.Fprintf(&b, "storage:\n")
147
		fmt.Fprintf(&b, "  type: %s\n", t)
148
		switch t {
149
		case "s3":
150
			fmt.Fprintf(&b, "  endpoint: %s\n", cfg.Storage.Endpoint)
151
			fmt.Fprintf(&b, "  bucket: %s\n", cfg.Storage.Bucket)
152
			fmt.Fprintf(&b, "  region: %s\n", cfg.Storage.Region)
153
			fmt.Fprintf(&b, "  use_path_style: %v\n", cfg.Storage.UsePathStyle)
154
		case "disk":
155
			fmt.Fprintf(&b, "  root: %s\n", cfg.Storage.Root)
156
		case "memory":
157
			fmt.Fprintf(&b, "  max_bytes: %d\n", cfg.Storage.MaxBytes)
158
		}
159
	}
160
161
	if cfg.Fallback != nil {
162
		fmt.Fprintf(&b, "fallback:\n")
163
		fmt.Fprintf(&b, "  mode: %s\n", cfg.Fallback.Mode)
164
		fmt.Fprintf(&b, "  upstream: %s\n", cfg.Fallback.Upstream)
165
	}
166
167
	if cfg.Sumdb != nil {
168
		fmt.Fprintf(&b, "sumdb:\n")
169
		fmt.Fprintf(&b, "  enabled: %v\n", cfg.Sumdb.Enabled)
170
		fmt.Fprintf(&b, "  key: %s\n", mask(cfg.Sumdb.Key))
171
		fmt.Fprintf(&b, "  upstream: %s\n", cfg.Sumdb.Upstream)
172
	}
173
174
	if cfg.Instrumentation != nil {
175
		fmt.Fprintf(&b, "instrumentation:\n")
176
		fmt.Fprintf(&b, "  listen: %s\n", cfg.Instrumentation.Listen)
177
		fmt.Fprintf(&b, "  metrics_path: %s\n", cfg.Instrumentation.MetricsPath)
178
		fmt.Fprintf(&b, "  health_path: %s\n", cfg.Instrumentation.HealthPath)
179
	}
180
181
	if cfg.OIDC != nil {
182
		fmt.Fprintf(&b, "oidc:\n")
183
		fmt.Fprintf(&b, "  issuer: %s\n", cfg.OIDC.Issuer)
184
		fmt.Fprintf(&b, "  client_id: %s\n", cfg.OIDC.ClientID)
185
		fmt.Fprintf(&b, "  client_secret: %s\n", mask(cfg.OIDC.ClientSecret))
186
		fmt.Fprintf(&b, "  redirect_url: %s\n", cfg.OIDC.RedirectURL)
187
	}
188
189
	return b.String()
190
}
191
192
func mask(s string) string {
193
	if s == "" {
194
		return "(not set)"
195
	}
196
	if strings.HasPrefix(s, "@") {
197
		return s // file reference, safe to show
198
	}
199
	if len(s) <= 4 {
200
		return "****"
201
	}
202
	return s[:2] + "****" + s[len(s)-2:]
203
}
204
205
func maskDSN(dbType, dsn string) string {
206
	if dbType == "sqlite" {
207
		return dsn // file path, safe to show
208
	}
209
	// Mask password in connection strings like postgres://user:pass@host/db
210
	if at := strings.Index(dsn, "@"); at > 0 {
211
		prefix := dsn[:at]
212
		if colon := strings.LastIndex(prefix, ":"); colon > 0 {
213
			return prefix[:colon+1] + "****" + dsn[at:]
214
		}
215
	}
216
	return dsn
217
}
218
219
var validKeys = map[string]bool{
220
	"host":                          true,
221
	"listen":                        true,
222
	"cache":                         true,
223
	"database":                      true,
224
	"database.type":                 true,
225
	"database.dsn":                  true,
226
	"auth_tokens":                   true,
227
	"admin_token":                   true,
228
	"sumdb":                         true,
229
	"sumdb.enabled":                 true,
230
	"sumdb.key":                     true,
231
	"sumdb.upstream":                true,
232
	"instrumentation":               true,
233
	"instrumentation.listen":        true,
234
	"instrumentation.metrics_path":  true,
235
	"instrumentation.health_path":   true,
236
	"storage":                       true,
237
	"storage.type":                  true,
238
	"storage.root":                  true,
239
	"storage.endpoint":              true,
240
	"storage.bucket":                true,
241
	"storage.region":                true,
242
	"storage.use_path_style":        true,
243
	"storage.max_bytes":             true,
244
	"fallback":                      true,
245
	"fallback.mode":                 true,
246
	"fallback.upstream":             true,
247
	"oidc":                          true,
248
	"oidc.issuer":                   true,
249
	"oidc.client_id":                true,
250
	"oidc.client_secret":            true,
251
	"oidc.redirect_url":             true,
252
}
253
254
// Load reads configuration from a YAML file and environment variables.
255
func Load(path string) (*Config, error) {
256
	v := viper.New()
257
258
	v.SetDefault("listen", ":8080")
259
	v.SetDefault("cache", "./cache")
260
261
	// Bind each config key to its exact CURATOR_* env var.
262
	// We avoid SetEnvKeyReplacer/AutomaticEnv because "." → "_"
263
	// is ambiguous for keys with underscores in the leaf name
264
	// (e.g., storage.use_path_style vs storage.use.path.style),
265
	// and SetDefault for parent sections clobbers env-bound children.
266
	for key := range validKeys {
267
		// Skip parent section keys (e.g., "storage", "sumdb") — binding
268
		// them to CURATOR_STORAGE (unset) makes Viper treat the whole
269
		// section as nil, hiding env-bound child keys during Unmarshal.
270
		if !strings.Contains(key, ".") {
271
			continue
272
		}
273
		envVar := "CURATOR_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
274
		_ = v.BindEnv(key, envVar)
275
	}
276
	// Bind top-level keys that aren't sections.
277
	for _, key := range []string{"host", "listen", "cache", "admin_token", "auth_tokens"} {
278
		_ = v.BindEnv(key, "CURATOR_"+strings.ToUpper(key))
279
	}
280
281
	if path != "" {
282
		v.SetConfigFile(path)
283
		if err := v.ReadInConfig(); err != nil {
284
			if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
285
				return nil, fmt.Errorf("read config: %w", err)
286
			}
287
		}
288
	}
289
290
	if err := validateKeys(v); err != nil {
291
		return nil, err
292
	}
293
294
	var cfg Config
295
	if err := v.Unmarshal(&cfg); err != nil {
296
		return nil, fmt.Errorf("parse config: %w", err)
297
	}
298
299
	// Database defaults.
300
	if cfg.Database == nil {
301
		cfg.Database = &DatabaseConfig{}
302
	}
303
	if cfg.Database.Type == "" {
304
		cfg.Database.Type = "sqlite"
305
	}
306
307
	switch cfg.Database.Type {
308
	case "sqlite":
309
		if cfg.Database.DSN == "" {
310
			cfg.Database.DSN = "curator.db"
311
		}
312
	case "postgres":
313
		if cfg.Database.DSN == "" {
314
			return nil, fmt.Errorf("config: database.dsn is required for postgres")
315
		}
316
	default:
317
		return nil, fmt.Errorf("config: unknown database type %q (use sqlite or postgres)", cfg.Database.Type)
318
	}
319
320
	if cfg.Host == "" {
321
		return nil, fmt.Errorf("config: host is required (set via config file or CURATOR_HOST)")
322
	}
323
324
	if cfg.Sumdb != nil && cfg.Sumdb.Enabled && cfg.Sumdb.Key == "" {
325
		return nil, fmt.Errorf("config: sumdb.enabled is true but sumdb.key is not set")
326
	}
327
328
	// Storage validation.
329
	if cfg.Storage != nil {
330
		switch cfg.Storage.Type {
331
		case "s3", "":
332
			if cfg.Storage.Bucket == "" {
333
				return nil, fmt.Errorf("config: storage.bucket is required for s3 storage")
334
			}
335
		case "disk":
336
			if cfg.Storage.Root == "" {
337
				return nil, fmt.Errorf("config: storage.root is required for disk storage")
338
			}
339
		case "memory":
340
			// no required fields
341
		default:
342
			return nil, fmt.Errorf("config: unknown storage type %q (use s3, disk, or memory)", cfg.Storage.Type)
343
		}
344
	}
345
346
	// Fallback validation.
347
	if cfg.Fallback != nil && cfg.Fallback.Mode.RequiresUpstream() && cfg.Fallback.Upstream == "" {
348
		return nil, fmt.Errorf("config: fallback mode %q requires upstream URL", cfg.Fallback.Mode)
349
	}
350
351
	// OIDC validation.
352
	if cfg.OIDC != nil && cfg.OIDC.Issuer != "" {
353
		if cfg.OIDC.ClientID == "" {
354
			return nil, fmt.Errorf("config: oidc.client_id is required when oidc.issuer is set")
355
		}
356
		if cfg.OIDC.ClientSecret == "" {
357
			return nil, fmt.Errorf("config: oidc.client_secret is required when oidc.issuer is set")
358
		}
359
		if cfg.OIDC.RedirectURL == "" {
360
			return nil, fmt.Errorf("config: oidc.redirect_url is required when oidc.issuer is set")
361
		}
362
	}
363
364
	return &cfg, nil
365
}
366
367
// FallbackMode returns the configured fallback mode, or "" if none.
368
func (cfg *Config) FallbackMode() FallbackMode {
369
	if cfg.Fallback != nil {
370
		return cfg.Fallback.Mode
371
	}
372
	return FallbackNone
373
}
374
375
func validateKeys(v *viper.Viper) error {
376
	for _, key := range v.AllKeys() {
377
		if !validKeys[key] {
378
			return fmt.Errorf("config: unknown key %q", key)
379
		}
380
	}
381
	return nil
382
}
383

Source Files