config.go

v1.0.1
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
type InstrumentationConfig struct {
91
	Listen      string `mapstructure:"listen"`
92
	MetricsPath string `mapstructure:"metrics_path"`
93
	HealthPath  string `mapstructure:"health_path"`
94
}
95
96
// DatabaseConfig configures the module store database backend.
97
type DatabaseConfig struct {
98
	Type string `mapstructure:"type"` // "sqlite" (default), "postgres"
99
	DSN  string `mapstructure:"dsn"`  // file path for sqlite, connection string for postgres
100
}
101
102
type Config struct {
103
	Host            string                 `mapstructure:"host"`
104
	Database        *DatabaseConfig        `mapstructure:"database"`
105
	Storage         *StorageConfig         `mapstructure:"storage"`
106
	Fallback        *FallbackConfig        `mapstructure:"fallback"`
107
	Sumdb           *SumdbConfig           `mapstructure:"sumdb"`
108
	Instrumentation *InstrumentationConfig `mapstructure:"instrumentation"`
109
	Listen          string                 `mapstructure:"listen"`
110
	Cache           string                 `mapstructure:"cache"`
111
	AuthTokens      string                 `mapstructure:"auth_tokens"`
112
	AdminToken      string                 `mapstructure:"admin_token"`
113
}
114
115
// Dump returns a human-readable configuration summary with secrets masked.
116
func (cfg *Config) Dump() string {
117
	var b strings.Builder
118
119
	fmt.Fprintf(&b, "host: %s\n", cfg.Host)
120
	fmt.Fprintf(&b, "listen: %s\n", cfg.Listen)
121
	fmt.Fprintf(&b, "cache: %s\n", cfg.Cache)
122
	fmt.Fprintf(&b, "admin_token: %s\n", mask(cfg.AdminToken))
123
	fmt.Fprintf(&b, "auth_tokens: %s\n", mask(cfg.AuthTokens))
124
125
	if cfg.Database != nil {
126
		fmt.Fprintf(&b, "database:\n")
127
		fmt.Fprintf(&b, "  type: %s\n", cfg.Database.Type)
128
		fmt.Fprintf(&b, "  dsn: %s\n", maskDSN(cfg.Database.Type, cfg.Database.DSN))
129
	}
130
131
	if cfg.Storage != nil {
132
		t := cfg.Storage.Type
133
		if t == "" {
134
			t = "s3"
135
		}
136
		fmt.Fprintf(&b, "storage:\n")
137
		fmt.Fprintf(&b, "  type: %s\n", t)
138
		switch t {
139
		case "s3":
140
			fmt.Fprintf(&b, "  endpoint: %s\n", cfg.Storage.Endpoint)
141
			fmt.Fprintf(&b, "  bucket: %s\n", cfg.Storage.Bucket)
142
			fmt.Fprintf(&b, "  region: %s\n", cfg.Storage.Region)
143
			fmt.Fprintf(&b, "  use_path_style: %v\n", cfg.Storage.UsePathStyle)
144
		case "disk":
145
			fmt.Fprintf(&b, "  root: %s\n", cfg.Storage.Root)
146
		case "memory":
147
			fmt.Fprintf(&b, "  max_bytes: %d\n", cfg.Storage.MaxBytes)
148
		}
149
	}
150
151
	if cfg.Fallback != nil {
152
		fmt.Fprintf(&b, "fallback:\n")
153
		fmt.Fprintf(&b, "  mode: %s\n", cfg.Fallback.Mode)
154
		fmt.Fprintf(&b, "  upstream: %s\n", cfg.Fallback.Upstream)
155
	}
156
157
	if cfg.Sumdb != nil {
158
		fmt.Fprintf(&b, "sumdb:\n")
159
		fmt.Fprintf(&b, "  enabled: %v\n", cfg.Sumdb.Enabled)
160
		fmt.Fprintf(&b, "  key: %s\n", mask(cfg.Sumdb.Key))
161
		fmt.Fprintf(&b, "  upstream: %s\n", cfg.Sumdb.Upstream)
162
	}
163
164
	if cfg.Instrumentation != nil {
165
		fmt.Fprintf(&b, "instrumentation:\n")
166
		fmt.Fprintf(&b, "  listen: %s\n", cfg.Instrumentation.Listen)
167
		fmt.Fprintf(&b, "  metrics_path: %s\n", cfg.Instrumentation.MetricsPath)
168
		fmt.Fprintf(&b, "  health_path: %s\n", cfg.Instrumentation.HealthPath)
169
	}
170
171
	return b.String()
172
}
173
174
func mask(s string) string {
175
	if s == "" {
176
		return "(not set)"
177
	}
178
	if strings.HasPrefix(s, "@") {
179
		return s // file reference, safe to show
180
	}
181
	if len(s) <= 4 {
182
		return "****"
183
	}
184
	return s[:2] + "****" + s[len(s)-2:]
185
}
186
187
func maskDSN(dbType, dsn string) string {
188
	if dbType == "sqlite" {
189
		return dsn // file path, safe to show
190
	}
191
	// Mask password in connection strings like postgres://user:pass@host/db
192
	if at := strings.Index(dsn, "@"); at > 0 {
193
		prefix := dsn[:at]
194
		if colon := strings.LastIndex(prefix, ":"); colon > 0 {
195
			return prefix[:colon+1] + "****" + dsn[at:]
196
		}
197
	}
198
	return dsn
199
}
200
201
var validKeys = map[string]bool{
202
	"host":                          true,
203
	"listen":                        true,
204
	"cache":                         true,
205
	"database":                      true,
206
	"database.type":                 true,
207
	"database.dsn":                  true,
208
	"auth_tokens":                   true,
209
	"admin_token":                   true,
210
	"sumdb":                         true,
211
	"sumdb.enabled":                 true,
212
	"sumdb.key":                     true,
213
	"sumdb.upstream":                true,
214
	"instrumentation":               true,
215
	"instrumentation.listen":        true,
216
	"instrumentation.metrics_path":  true,
217
	"instrumentation.health_path":   true,
218
	"storage":                       true,
219
	"storage.type":                  true,
220
	"storage.root":                  true,
221
	"storage.endpoint":              true,
222
	"storage.bucket":                true,
223
	"storage.region":                true,
224
	"storage.use_path_style":        true,
225
	"storage.max_bytes":             true,
226
	"fallback":                      true,
227
	"fallback.mode":                 true,
228
	"fallback.upstream":             true,
229
}
230
231
// Load reads configuration from a YAML file and environment variables.
232
func Load(path string) (*Config, error) {
233
	v := viper.New()
234
235
	v.SetDefault("listen", ":8080")
236
	v.SetDefault("cache", "./cache")
237
238
	// Bind each config key to its exact CURATOR_* env var.
239
	// We avoid SetEnvKeyReplacer/AutomaticEnv because "." → "_"
240
	// is ambiguous for keys with underscores in the leaf name
241
	// (e.g., storage.use_path_style vs storage.use.path.style),
242
	// and SetDefault for parent sections clobbers env-bound children.
243
	for key := range validKeys {
244
		// Skip parent section keys (e.g., "storage", "sumdb") — binding
245
		// them to CURATOR_STORAGE (unset) makes Viper treat the whole
246
		// section as nil, hiding env-bound child keys during Unmarshal.
247
		if !strings.Contains(key, ".") {
248
			continue
249
		}
250
		envVar := "CURATOR_" + strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
251
		_ = v.BindEnv(key, envVar)
252
	}
253
	// Bind top-level keys that aren't sections.
254
	for _, key := range []string{"host", "listen", "cache", "admin_token", "auth_tokens"} {
255
		_ = v.BindEnv(key, "CURATOR_"+strings.ToUpper(key))
256
	}
257
258
	if path != "" {
259
		v.SetConfigFile(path)
260
		if err := v.ReadInConfig(); err != nil {
261
			if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
262
				return nil, fmt.Errorf("read config: %w", err)
263
			}
264
		}
265
	}
266
267
	if err := validateKeys(v); err != nil {
268
		return nil, err
269
	}
270
271
	var cfg Config
272
	if err := v.Unmarshal(&cfg); err != nil {
273
		return nil, fmt.Errorf("parse config: %w", err)
274
	}
275
276
	// Database defaults.
277
	if cfg.Database == nil {
278
		cfg.Database = &DatabaseConfig{}
279
	}
280
	if cfg.Database.Type == "" {
281
		cfg.Database.Type = "sqlite"
282
	}
283
284
	switch cfg.Database.Type {
285
	case "sqlite":
286
		if cfg.Database.DSN == "" {
287
			cfg.Database.DSN = "curator.db"
288
		}
289
	case "postgres":
290
		if cfg.Database.DSN == "" {
291
			return nil, fmt.Errorf("config: database.dsn is required for postgres")
292
		}
293
	default:
294
		return nil, fmt.Errorf("config: unknown database type %q (use sqlite or postgres)", cfg.Database.Type)
295
	}
296
297
	if cfg.Host == "" {
298
		return nil, fmt.Errorf("config: host is required (set via config file or CURATOR_HOST)")
299
	}
300
301
	if cfg.Sumdb != nil && cfg.Sumdb.Enabled && cfg.Sumdb.Key == "" {
302
		return nil, fmt.Errorf("config: sumdb.enabled is true but sumdb.key is not set")
303
	}
304
305
	// Storage validation.
306
	if cfg.Storage != nil {
307
		switch cfg.Storage.Type {
308
		case "s3", "":
309
			if cfg.Storage.Bucket == "" {
310
				return nil, fmt.Errorf("config: storage.bucket is required for s3 storage")
311
			}
312
		case "disk":
313
			if cfg.Storage.Root == "" {
314
				return nil, fmt.Errorf("config: storage.root is required for disk storage")
315
			}
316
		case "memory":
317
			// no required fields
318
		default:
319
			return nil, fmt.Errorf("config: unknown storage type %q (use s3, disk, or memory)", cfg.Storage.Type)
320
		}
321
	}
322
323
	// Fallback validation.
324
	if cfg.Fallback != nil && cfg.Fallback.Mode.RequiresUpstream() && cfg.Fallback.Upstream == "" {
325
		return nil, fmt.Errorf("config: fallback mode %q requires upstream URL", cfg.Fallback.Mode)
326
	}
327
328
	return &cfg, nil
329
}
330
331
// FallbackMode returns the configured fallback mode, or "" if none.
332
func (cfg *Config) FallbackMode() FallbackMode {
333
	if cfg.Fallback != nil {
334
		return cfg.Fallback.Mode
335
	}
336
	return FallbackNone
337
}
338
339
func validateKeys(v *viper.Viper) error {
340
	for _, key := range v.AllKeys() {
341
		if !validKeys[key] {
342
			return fmt.Errorf("config: unknown key %q", key)
343
		}
344
	}
345
	return nil
346
}
347

Source Files