Go · 22471 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package config owns the layered configuration loader.
4 //
5 // Precedence (lowest → highest):
6 //
7 // 1. Built-in defaults (this file)
8 // 2. TOML file (path from $SHITHUB_CONFIG, falling back to /etc/shithub/config.toml)
9 // 3. Environment variables (SHITHUB_<area>_<key>; nested keys joined with "__")
10 // 4. CLI flag overrides handed in by the caller
11 //
12 // The Config struct is the single source of truth for what is configurable.
13 // Every consumer reads from this struct rather than from os.Getenv directly.
14 package config
15
16 import (
17 "errors"
18 "fmt"
19 "net/url"
20 "os"
21 "reflect"
22 "strconv"
23 "strings"
24 "time"
25
26 "github.com/BurntSushi/toml"
27 )
28
29 // Config is the typed root.
30 type Config struct {
31 Env string `toml:"env"` // "dev", "staging", "prod"
32 Web WebConfig `toml:"web"`
33 DB DBConfig `toml:"db"`
34 Log LogConfig `toml:"log"`
35 Metrics MetricsConfig `toml:"metrics"`
36 Tracing TracingConfig `toml:"tracing"`
37 ErrorReporting ErrorReportingConfig `toml:"error_reporting"`
38 Session SessionConfig `toml:"session"`
39 Storage StorageConfig `toml:"storage"`
40 Auth AuthConfig `toml:"auth"`
41 Notif NotifConfig `toml:"notif"`
42 RateLimit RateLimitConfig `toml:"ratelimit"`
43 Billing BillingConfig `toml:"billing"`
44 Actions ActionsConfig `toml:"actions"`
45 }
46
47 // RateLimitConfig configures runtime rate-limit budgets for surfaces that
48 // don't carry a domain-specific limiter. The /api/v1/ JSON surface uses
49 // the API.* sub-block; future surfaces (search, git transports) get their
50 // own sub-blocks here as they're factored out of their handlers.
51 type RateLimitConfig struct {
52 API APIRateLimitConfig `toml:"api"`
53 }
54
55 // APIRateLimitConfig sets the per-hour budgets for /api/v1/* requests.
56 // AuthedPerHour applies when the caller presents a valid PAT (keyed by
57 // token id). AnonPerHour applies to unauthenticated callers (keyed by
58 // remote IP). Defaults are GitHub-aligned: 5000/h authed, 60/h anon —
59 // operators tune via SHITHUB_RATELIMIT__API__AUTHED_PER_HOUR /
60 // SHITHUB_RATELIMIT__API__ANON_PER_HOUR.
61 type APIRateLimitConfig struct {
62 AuthedPerHour int `toml:"authed_per_hour"`
63 AnonPerHour int `toml:"anon_per_hour"`
64 }
65
66 // ActionsConfig groups configuration for the Actions subsystem. Today
67 // it carries only the sealed-box keypair used by the REST secrets
68 // surface; secrets storage encryption (at-rest) is handled by the
69 // shared `auth.totp_key_b64` AEAD key.
70 type ActionsConfig struct {
71 Secrets ActionsSecretsConfig `toml:"secrets"`
72 }
73
74 // ActionsSecretsConfig configures the NaCl sealed-box keypair the
75 // REST `actions/secrets/public-key` endpoint serves. Clients encrypt
76 // secret values against this public key; the server decrypts on PUT
77 // before re-encrypting with the storage AEAD.
78 //
79 // BoxPrivateKeyB64 is base64 of a 32-byte X25519 private key. When
80 // empty the server auto-generates a per-process keypair on startup
81 // and logs a warning — secrets PUT against one process won't be
82 // decryptable by another, so production deployments MUST set it.
83 type ActionsSecretsConfig struct {
84 BoxPrivateKeyB64 string `toml:"box_private_key_b64"`
85 }
86
87 // NotifConfig configures the S29 notification surface. UnsubscribeKeyB64
88 // is the base64-encoded HMAC-SHA256 key that signs one-click
89 // unsubscribe URLs (RFC 8058). When empty (dev default), the
90 // fan-out worker derives a deterministic key from the session key so
91 // links are stable across restarts without operator action — this
92 // derivation is NOT suitable for prod and the wiring layer logs a
93 // warning when it fires.
94 type NotifConfig struct {
95 UnsubscribeKeyB64 string `toml:"unsubscribe_key_b64"`
96 }
97
98 // BillingConfig controls paid-organization enforcement and the payment
99 // processor integration. Stripe is the first supported processor and remains
100 // optional until operators provide live or test-mode credentials.
101 type BillingConfig struct {
102 Enabled bool `toml:"enabled"`
103 GracePeriod time.Duration `toml:"grace_period"`
104 Stripe StripeBillingConfig `toml:"stripe"`
105 // Enforce gates the per-feature flip from PRO05's report-only mode
106 // to hard enforcement for user-kind principals. Each feature carries
107 // its own knob so the launch can revert one feature without rolling
108 // back the deploy (PRO07 pitfall: "do not enforce a feature you
109 // can't unenforce"). Org-kind enforcement has been on since SP05 and
110 // is not gated by this struct.
111 Enforce EnforceConfig `toml:"enforce"`
112 }
113
114 // EnforceConfig is the PRO07 per-feature enforcement matrix. Defaults
115 // (all false) keep production in report-only mode; operators flip
116 // features individually after the 7-day telemetry soak ratifies that
117 // no Free user is currently exercising the gate. The flag order and
118 // names are stable — flipping any to true is a one-way deploy that
119 // operators back out with the same knob, not by reverting code.
120 type EnforceConfig struct {
121 // UserAdvancedBranchProtection: when true, Free users on private
122 // personal repos are blocked from setting prevent_force_push,
123 // prevent_deletion, or require_signed_commits.
124 UserAdvancedBranchProtection bool `toml:"user_advanced_branch_protection"`
125 // UserRequiredReviewers: when true, Free users on private personal
126 // repos are blocked from setting required_review_count > 0.
127 UserRequiredReviewers bool `toml:"user_required_reviewers"`
128 // UserProfilePinsBeyondFree: when true, Free users are blocked from
129 // pinning more than the Free cap (6) profile repos. PRO07 ships this
130 // gate without a report-only phase — the cap is small, the failure
131 // mode is benign, and a Free user has no way to have exceeded the
132 // cap before this knob existed.
133 UserProfilePinsBeyondFree bool `toml:"user_profile_pins_beyond_free"`
134 }
135
136 // StripeBillingConfig holds Stripe Billing API settings. Checkout and portal
137 // URLs are optional; when omitted the web layer derives them from auth.base_url.
138 // PRO04 introduced ProPriceID for the user-tier Pro plan; it stays empty
139 // when the operator has only enabled the org-tier Team plan.
140 type StripeBillingConfig struct {
141 SecretKey string `toml:"secret_key"`
142 WebhookSecret string `toml:"webhook_secret"`
143 TeamPriceID string `toml:"team_price_id"`
144 ProPriceID string `toml:"pro_price_id"`
145 SuccessURL string `toml:"success_url"`
146 CancelURL string `toml:"cancel_url"`
147 PortalReturnURL string `toml:"portal_return_url"`
148 AutomaticTax bool `toml:"automatic_tax"`
149 }
150
151 // WebConfig holds HTTP server settings.
152 type WebConfig struct {
153 Addr string `toml:"addr"`
154 ReadTimeout time.Duration `toml:"read_timeout"`
155 WriteTimeout time.Duration `toml:"write_timeout"`
156 ShutdownTimeout time.Duration `toml:"shutdown_timeout"`
157 }
158
159 // DBConfig holds Postgres settings.
160 type DBConfig struct {
161 URL string `toml:"url"`
162 MaxConns int `toml:"max_conns"`
163 MinConns int `toml:"min_conns"`
164 ConnectTimeout time.Duration `toml:"connect_timeout"`
165 }
166
167 // LogConfig holds slog settings.
168 type LogConfig struct {
169 Level string `toml:"level"` // debug | info | warn | error
170 Format string `toml:"format"` // text (dev) | json (prod)
171 }
172
173 // MetricsConfig configures the /metrics endpoint.
174 type MetricsConfig struct {
175 Enabled bool `toml:"enabled"`
176 BasicAuthUser string `toml:"basic_auth_user"`
177 BasicAuthPass string `toml:"basic_auth_pass"`
178 }
179
180 // TracingConfig configures the OpenTelemetry exporter.
181 type TracingConfig struct {
182 Enabled bool `toml:"enabled"`
183 Endpoint string `toml:"endpoint"` // OTLP HTTP endpoint
184 SampleRate float64 `toml:"sample_rate"`
185 ServiceName string `toml:"service_name"`
186 }
187
188 // ErrorReportingConfig configures the Sentry/GlitchTip-protocol DSN.
189 type ErrorReportingConfig struct {
190 DSN string `toml:"dsn"`
191 Environment string `toml:"environment"`
192 Release string `toml:"release"`
193 }
194
195 // SessionConfig configures the cookie session store.
196 type SessionConfig struct {
197 KeyB64 string `toml:"key_b64"`
198 MaxAge time.Duration `toml:"max_age"`
199 Secure bool `toml:"secure"`
200 }
201
202 // StorageConfig configures repo filesystem storage and S3-compatible
203 // object storage. The S3 block targets MinIO in dev/test and DigitalOcean
204 // Spaces in prod (force_path_style true for MinIO, false for Spaces).
205 type StorageConfig struct {
206 ReposRoot string `toml:"repos_root"` // filesystem root for bare repos
207 S3 S3StorageConfig `toml:"s3"`
208 }
209
210 // AuthConfig configures the email/password auth surface.
211 type AuthConfig struct {
212 RequireEmailVerification bool `toml:"require_email_verification"`
213 BaseURL string `toml:"base_url"` // e.g. https://shithub.example
214 SiteName string `toml:"site_name"`
215 EmailFrom string `toml:"email_from"`
216 EmailBackend string `toml:"email_backend"` // stdout | smtp | postmark | resend
217 SMTP SMTPConfig `toml:"smtp"`
218 Postmark PostmarkConfig `toml:"postmark"`
219 Resend ResendConfig `toml:"resend"`
220 Argon2 Argon2Config `toml:"argon2"`
221 TOTPKeyB64 string `toml:"totp_key_b64"` // base64 32-byte AEAD key for at-rest TOTP secrets
222 SSH SSHAuthConfig `toml:"ssh"`
223 }
224
225 // SSHAuthConfig flips whether the repo pages render an SSH clone URL
226 // alongside the HTTPS one. The actual SSH service (sshd Match-User-git
227 // + AuthorizedKeysCommand → shithubd ssh-authkeys) is operator-side
228 // and orthogonal to this flag — the flag just controls the UI surface.
229 // Operators who haven't wired SSH should leave Enabled=false so users
230 // don't get a clone URL that doesn't work.
231 type SSHAuthConfig struct {
232 Enabled bool `toml:"enabled"`
233 Host string `toml:"host"` // e.g. "git@shithub.sh" — the userinfo + hostname shown in the SSH clone URL
234 }
235
236 // SMTPConfig holds plain-SMTP backend settings (e.g. MailHog in dev).
237 type SMTPConfig struct {
238 Addr string `toml:"addr"` // host:port
239 Username string `toml:"username"`
240 Password string `toml:"password"`
241 }
242
243 // ResendConfig holds Resend transactional API settings.
244 type ResendConfig struct {
245 APIKey string `toml:"api_key"`
246 }
247
248 // PostmarkConfig holds Postmark transactional API settings.
249 type PostmarkConfig struct {
250 ServerToken string `toml:"server_token"`
251 }
252
253 // Argon2Config tunes the password hasher's cost. Defaults match the
254 // internal/auth/password Defaults() — operators bump these on faster
255 // hardware to keep the per-hash cost in the 100–300 ms band.
256 type Argon2Config struct {
257 MemoryKiB uint32 `toml:"memory_kib"`
258 Time uint32 `toml:"time"`
259 Threads uint8 `toml:"threads"`
260 }
261
262 // S3StorageConfig holds the S3-compatible endpoint settings.
263 type S3StorageConfig struct {
264 Endpoint string `toml:"endpoint"` // host[:port], no scheme
265 Region string `toml:"region"` // e.g. "us-east-1", "nyc3"
266 AccessKeyID string `toml:"access_key_id"` //
267 SecretAccessKey string `toml:"secret_access_key"` //
268 Bucket string `toml:"bucket"` // e.g. "shithub-dev"
269 UseSSL bool `toml:"use_ssl"` // true for Spaces, false for local MinIO
270 ForcePathStyle bool `toml:"force_path_style"` // true for MinIO, false for Spaces
271 }
272
273 // Defaults returns the zero-config baseline.
274 func Defaults() Config {
275 return Config{
276 Env: "dev",
277 Web: WebConfig{
278 Addr: ":8080",
279 ReadTimeout: 30 * time.Second,
280 WriteTimeout: 30 * time.Second,
281 ShutdownTimeout: 10 * time.Second,
282 },
283 DB: DBConfig{
284 MaxConns: 10,
285 MinConns: 0,
286 ConnectTimeout: 5 * time.Second,
287 },
288 Log: LogConfig{
289 Level: "info",
290 Format: "text",
291 },
292 Metrics: MetricsConfig{
293 Enabled: true,
294 },
295 Tracing: TracingConfig{
296 Enabled: false,
297 SampleRate: 0.05,
298 ServiceName: "shithubd",
299 },
300 Billing: BillingConfig{
301 GracePeriod: 14 * 24 * time.Hour,
302 },
303 Session: SessionConfig{
304 MaxAge: 30 * 24 * time.Hour,
305 Secure: false,
306 },
307 Storage: StorageConfig{
308 ReposRoot: "/data/repos",
309 S3: S3StorageConfig{
310 Region: "us-east-1",
311 ForcePathStyle: true,
312 },
313 },
314 RateLimit: RateLimitConfig{
315 API: APIRateLimitConfig{
316 AuthedPerHour: 5000,
317 AnonPerHour: 60,
318 },
319 },
320 Auth: AuthConfig{
321 RequireEmailVerification: true,
322 BaseURL: "http://127.0.0.1:8080",
323 SiteName: "shithub",
324 EmailFrom: "shithub <noreply@shithub.local>",
325 EmailBackend: "stdout",
326 SMTP: SMTPConfig{
327 Addr: "127.0.0.1:1025",
328 },
329 Argon2: Argon2Config{
330 MemoryKiB: 64 * 1024,
331 Time: 3,
332 Threads: 2,
333 },
334 },
335 }
336 }
337
338 // Load resolves configuration in the documented precedence order. CLI
339 // overrides may be nil. The TOML file is optional — its absence is not an
340 // error; its existence with bad syntax IS.
341 func Load(cliOverrides map[string]string) (Config, error) {
342 cfg := Defaults()
343 if err := mergeFile(&cfg); err != nil {
344 return cfg, err
345 }
346 if err := mergeEnv(&cfg, os.Environ()); err != nil {
347 return cfg, err
348 }
349 if err := mergeFlags(&cfg, cliOverrides); err != nil {
350 return cfg, err
351 }
352 applyAliases(&cfg)
353 if err := Validate(&cfg); err != nil {
354 return cfg, err
355 }
356 return cfg, nil
357 }
358
359 // applyAliases honors well-known env-var aliases that don't follow the
360 // nested-key convention. SHITHUB_DATABASE_URL is the S01-era name for
361 // db.url and remains supported.
362 func applyAliases(cfg *Config) {
363 if cfg.DB.URL == "" {
364 if v := os.Getenv("SHITHUB_DATABASE_URL"); v != "" {
365 cfg.DB.URL = v
366 }
367 }
368 if cfg.Session.KeyB64 == "" {
369 if v := os.Getenv("SHITHUB_SESSION_KEY"); v != "" {
370 cfg.Session.KeyB64 = v
371 }
372 }
373 if cfg.Auth.TOTPKeyB64 == "" {
374 if v := os.Getenv("SHITHUB_TOTP_KEY"); v != "" {
375 cfg.Auth.TOTPKeyB64 = v
376 }
377 }
378 }
379
380 // Validate enforces invariants. Errors are precise enough to point at the
381 // offending key.
382 func Validate(c *Config) error {
383 switch strings.ToLower(c.Env) {
384 case "dev", "staging", "prod":
385 c.Env = strings.ToLower(c.Env)
386 default:
387 return fmt.Errorf("config: env: must be dev|staging|prod, got %q", c.Env)
388 }
389 switch strings.ToLower(c.Log.Level) {
390 case "debug", "info", "warn", "error":
391 c.Log.Level = strings.ToLower(c.Log.Level)
392 default:
393 return fmt.Errorf("config: log.level: must be debug|info|warn|error, got %q", c.Log.Level)
394 }
395 switch strings.ToLower(c.Log.Format) {
396 case "text", "json":
397 c.Log.Format = strings.ToLower(c.Log.Format)
398 default:
399 return fmt.Errorf("config: log.format: must be text|json, got %q", c.Log.Format)
400 }
401 if c.Web.Addr == "" {
402 return errors.New("config: web.addr is required")
403 }
404 if c.Tracing.Enabled && c.Tracing.Endpoint == "" {
405 return errors.New("config: tracing.endpoint is required when tracing.enabled=true")
406 }
407 if c.Tracing.SampleRate < 0 || c.Tracing.SampleRate > 1 {
408 return fmt.Errorf("config: tracing.sample_rate: must be in [0, 1], got %v", c.Tracing.SampleRate)
409 }
410 if c.Storage.ReposRoot == "" {
411 return errors.New("config: storage.repos_root is required")
412 }
413 if err := validateS3(c.Storage.S3); err != nil {
414 return err
415 }
416 switch c.Auth.EmailBackend {
417 case "stdout", "smtp", "postmark", "resend":
418 default:
419 return fmt.Errorf("config: auth.email_backend: must be stdout|smtp|postmark|resend, got %q", c.Auth.EmailBackend)
420 }
421 if c.Auth.EmailBackend == "smtp" && c.Auth.SMTP.Addr == "" {
422 return errors.New("config: auth.smtp.addr is required when email_backend=smtp")
423 }
424 if c.Auth.EmailBackend == "postmark" && c.Auth.Postmark.ServerToken == "" {
425 return errors.New("config: auth.postmark.server_token is required when email_backend=postmark")
426 }
427 if c.Auth.EmailBackend == "resend" && c.Auth.Resend.APIKey == "" {
428 return errors.New("config: auth.resend.api_key is required when email_backend=resend")
429 }
430 if c.Auth.BaseURL == "" {
431 return errors.New("config: auth.base_url is required (used in email links)")
432 }
433 if c.Auth.SiteName == "" {
434 return errors.New("config: auth.site_name is required")
435 }
436 if c.Auth.EmailFrom == "" {
437 return errors.New("config: auth.email_from is required")
438 }
439 if c.RateLimit.API.AuthedPerHour < 0 {
440 return fmt.Errorf("config: ratelimit.api.authed_per_hour: must be >= 0, got %d", c.RateLimit.API.AuthedPerHour)
441 }
442 if c.RateLimit.API.AnonPerHour < 0 {
443 return fmt.Errorf("config: ratelimit.api.anon_per_hour: must be >= 0, got %d", c.RateLimit.API.AnonPerHour)
444 }
445 if c.RateLimit.API.AuthedPerHour == 0 {
446 c.RateLimit.API.AuthedPerHour = 5000
447 }
448 if c.RateLimit.API.AnonPerHour == 0 {
449 c.RateLimit.API.AnonPerHour = 60
450 }
451 if err := validateBilling(c); err != nil {
452 return err
453 }
454 return nil
455 }
456
457 func validateBilling(c *Config) error {
458 if c.Billing.GracePeriod < 0 {
459 return fmt.Errorf("config: billing.grace_period: must be >= 0, got %v", c.Billing.GracePeriod)
460 }
461 for key, raw := range map[string]string{
462 "billing.stripe.success_url": c.Billing.Stripe.SuccessURL,
463 "billing.stripe.cancel_url": c.Billing.Stripe.CancelURL,
464 "billing.stripe.portal_return_url": c.Billing.Stripe.PortalReturnURL,
465 } {
466 if err := validateOptionalHTTPURL(key, raw); err != nil {
467 return err
468 }
469 }
470 if !c.Billing.Enabled {
471 return nil
472 }
473 if c.Billing.Stripe.SecretKey == "" {
474 return errors.New("config: billing.stripe.secret_key is required when billing.enabled=true")
475 }
476 if c.Billing.Stripe.WebhookSecret == "" {
477 return errors.New("config: billing.stripe.webhook_secret is required when billing.enabled=true")
478 }
479 if c.Billing.Stripe.TeamPriceID == "" {
480 return errors.New("config: billing.stripe.team_price_id is required when billing.enabled=true")
481 }
482 if err := validateOptionalHTTPURL("auth.base_url", c.Auth.BaseURL); err != nil {
483 return err
484 }
485 return nil
486 }
487
488 func validateOptionalHTTPURL(key, raw string) error {
489 if raw == "" {
490 return nil
491 }
492 u, err := url.Parse(raw)
493 if err != nil {
494 return fmt.Errorf("config: %s: parse: %w", key, err)
495 }
496 if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
497 return fmt.Errorf("config: %s: must be an absolute http(s) URL", key)
498 }
499 return nil
500 }
501
502 // validateS3 enforces all-or-nothing on the S3 block: if any of
503 // endpoint/bucket/access keys are set, all must be set.
504 func validateS3(s S3StorageConfig) error {
505 any := s.Endpoint != "" || s.Bucket != "" || s.AccessKeyID != "" || s.SecretAccessKey != ""
506 if !any {
507 return nil
508 }
509 missing := []string{}
510 if s.Endpoint == "" {
511 missing = append(missing, "endpoint")
512 }
513 if s.Bucket == "" {
514 missing = append(missing, "bucket")
515 }
516 if s.AccessKeyID == "" {
517 missing = append(missing, "access_key_id")
518 }
519 if s.SecretAccessKey == "" {
520 missing = append(missing, "secret_access_key")
521 }
522 if len(missing) > 0 {
523 return fmt.Errorf("config: storage.s3: incomplete configuration, missing: %s", strings.Join(missing, ", "))
524 }
525 return nil
526 }
527
528 // mergeFile reads the TOML file (when present) over cfg.
529 func mergeFile(cfg *Config) error {
530 path := os.Getenv("SHITHUB_CONFIG")
531 if path == "" {
532 path = "/etc/shithub/config.toml"
533 }
534 body, err := os.ReadFile(path) //nolint:gosec // operator-supplied path
535 if err != nil {
536 if errors.Is(err, os.ErrNotExist) {
537 return nil
538 }
539 return fmt.Errorf("config: read %s: %w", path, err)
540 }
541 if _, err := toml.Decode(string(body), cfg); err != nil {
542 return fmt.Errorf("config: parse %s: %w", path, err)
543 }
544 return nil
545 }
546
547 // mergeEnv overrides cfg from environment variables. Naming convention:
548 // SHITHUB_<area>__<key> (double-underscore separates nested levels).
549 // Single-segment keys also accept SHITHUB_<key>.
550 func mergeEnv(cfg *Config, environ []string) error {
551 envMap := make(map[string]string, len(environ))
552 for _, kv := range environ {
553 if !strings.HasPrefix(kv, "SHITHUB_") {
554 continue
555 }
556 eq := strings.IndexByte(kv, '=')
557 if eq < 0 {
558 continue
559 }
560 key := strings.TrimPrefix(kv[:eq], "SHITHUB_")
561 envMap[key] = kv[eq+1:]
562 }
563 return walkAndApply(reflect.ValueOf(cfg).Elem(), reflect.TypeOf(*cfg), "", envMap)
564 }
565
566 // mergeFlags applies CLI overrides. Keys use TOML notation
567 // ("web.addr", "tracing.endpoint", etc.).
568 func mergeFlags(cfg *Config, overrides map[string]string) error {
569 if len(overrides) == 0 {
570 return nil
571 }
572 envStyle := make(map[string]string, len(overrides))
573 for k, v := range overrides {
574 envStyle[strings.ToUpper(strings.ReplaceAll(k, ".", "__"))] = v
575 }
576 return walkAndApply(reflect.ValueOf(cfg).Elem(), reflect.TypeOf(*cfg), "", envStyle)
577 }
578
579 // walkAndApply walks struct fields recursively, applying values from src
580 // keyed by uppercased dot-then-double-underscore-joined paths.
581 func walkAndApply(v reflect.Value, t reflect.Type, prefix string, src map[string]string) error {
582 for i := 0; i < t.NumField(); i++ {
583 field := t.Field(i)
584 tag := field.Tag.Get("toml")
585 if tag == "" || tag == "-" {
586 continue
587 }
588 fieldPath := strings.ToUpper(tag)
589 if prefix != "" {
590 fieldPath = prefix + "__" + fieldPath
591 }
592 fv := v.Field(i)
593
594 if field.Type.Kind() == reflect.Struct && field.Type != reflect.TypeOf(time.Duration(0)) {
595 if err := walkAndApply(fv, field.Type, fieldPath, src); err != nil {
596 return err
597 }
598 continue
599 }
600
601 raw, ok := src[fieldPath]
602 if !ok {
603 continue
604 }
605 if err := setField(fv, field.Type, raw); err != nil {
606 return fmt.Errorf("config: %s: %w", strings.ReplaceAll(strings.ToLower(fieldPath), "__", "."), err)
607 }
608 }
609 return nil
610 }
611
612 func setField(v reflect.Value, t reflect.Type, raw string) error {
613 switch t.Kind() {
614 case reflect.String:
615 v.SetString(raw)
616 case reflect.Bool:
617 b, err := strconv.ParseBool(raw)
618 if err != nil {
619 return fmt.Errorf("invalid bool: %w", err)
620 }
621 v.SetBool(b)
622 case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
623 if t == reflect.TypeOf(time.Duration(0)) {
624 d, err := time.ParseDuration(raw)
625 if err != nil {
626 return fmt.Errorf("invalid duration: %w", err)
627 }
628 v.SetInt(int64(d))
629 return nil
630 }
631 n, err := strconv.ParseInt(raw, 10, 64)
632 if err != nil {
633 return fmt.Errorf("invalid int: %w", err)
634 }
635 v.SetInt(n)
636 case reflect.Float32, reflect.Float64:
637 f, err := strconv.ParseFloat(raw, 64)
638 if err != nil {
639 return fmt.Errorf("invalid float: %w", err)
640 }
641 v.SetFloat(f)
642 default:
643 return fmt.Errorf("unsupported field kind %s", t.Kind())
644 }
645 return nil
646 }
647