config.go

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

Source Files