package config import ( "fmt" "os" "strconv" "strings" "time" ) type Config struct { DataDir string RepoRoots []string HTTPAddr string BaseURL string InboxDir string PodmanSocket string SSHAddr string SSHAuthorizedKeys string ThemeCSS string DefaultTimeout time.Duration DefaultMemory string DefaultCPUs string CacheMaxVersions int CacheMaxBytes int64 BuildContextMaxBytes int64 BuildContextMaxFiles int BuildCacheMaxBytes int64 BuildCacheMaxAge time.Duration BuildCleanupTimeout time.Duration BuildMinFreeBytes int64 LogLevel string PublishRegistryAuthFile string PublishPkgRoot string PublishSiteRoot string } func Load() (Config, error) { var cfg Config var err error if cfg.DataDir, err = required("LUCI_DATA_DIR"); err != nil { return Config{}, err } repoRoots, err := required("LUCI_REPO_ROOTS") if err != nil { return Config{}, err } cfg.RepoRoots = splitList(repoRoots) if cfg.HTTPAddr, err = required("LUCI_HTTP_ADDR"); err != nil { return Config{}, err } if cfg.BaseURL, err = required("LUCI_BASE_URL"); err != nil { return Config{}, err } if cfg.InboxDir, err = required("LUCI_INBOX_DIR"); err != nil { return Config{}, err } if cfg.PodmanSocket, err = required("LUCI_PODMAN_SOCKET"); err != nil { return Config{}, err } cfg.SSHAddr = optional("LUCI_SSH_ADDR", "") cfg.SSHAuthorizedKeys = optional("LUCI_SSH_AUTHORIZED_KEYS", "") cfg.ThemeCSS = optional("LUCI_THEME_CSS", "") cfg.DefaultMemory = optional("LUCI_DEFAULT_MEMORY", "2g") cfg.DefaultCPUs = optional("LUCI_DEFAULT_CPUS", "2") cfg.LogLevel = optional("LUCI_LOG_LEVEL", "info") cfg.PublishRegistryAuthFile = optional("LUCI_PUBLISH_REGISTRY_AUTH_FILE", "") cfg.PublishPkgRoot = optional("LUCI_PUBLISH_PKG_ROOT", "") cfg.PublishSiteRoot = optional("LUCI_PUBLISH_SITE_ROOT", "") timeoutText := optional("LUCI_DEFAULT_TIMEOUT", "20m") cfg.DefaultTimeout, err = time.ParseDuration(timeoutText) if err != nil { return Config{}, fmt.Errorf("invalid LUCI_DEFAULT_TIMEOUT: %w", err) } if versionsText := optional("LUCI_CACHE_MAX_VERSIONS", "3"); versionsText != "" { cfg.CacheMaxVersions, err = strconv.Atoi(versionsText) if err != nil || cfg.CacheMaxVersions < 1 { return Config{}, fmt.Errorf("invalid LUCI_CACHE_MAX_VERSIONS: %q", versionsText) } } if bytesText := optional("LUCI_CACHE_MAX_BYTES", "2GiB"); bytesText != "" { cfg.CacheMaxBytes, err = parseBytes(bytesText) if err != nil { return Config{}, fmt.Errorf("invalid LUCI_CACHE_MAX_BYTES: %w", err) } } for _, setting := range []struct { name string value *int64 defaultValue string }{ {"LUCI_BUILD_CONTEXT_MAX_BYTES", &cfg.BuildContextMaxBytes, "1GiB"}, {"LUCI_BUILD_CACHE_MAX_BYTES", &cfg.BuildCacheMaxBytes, "10GiB"}, {"LUCI_BUILD_MIN_FREE_BYTES", &cfg.BuildMinFreeBytes, "5GiB"}, } { *setting.value, err = parseBytes(optional(setting.name, setting.defaultValue)) if err != nil { return Config{}, fmt.Errorf("invalid %s: %w", setting.name, err) } if *setting.value < 1 { return Config{}, fmt.Errorf("invalid %s: must be positive", setting.name) } } if cfg.BuildContextMaxFiles, err = strconv.Atoi(optional("LUCI_BUILD_CONTEXT_MAX_FILES", "10000")); err != nil || cfg.BuildContextMaxFiles < 1 { return Config{}, fmt.Errorf("invalid LUCI_BUILD_CONTEXT_MAX_FILES") } for _, setting := range []struct { name string value *time.Duration defaultValue string }{ {"LUCI_BUILD_CACHE_MAX_AGE", &cfg.BuildCacheMaxAge, "168h"}, {"LUCI_BUILD_CLEANUP_TIMEOUT", &cfg.BuildCleanupTimeout, "10s"}, } { *setting.value, err = time.ParseDuration(optional(setting.name, setting.defaultValue)) if err != nil || *setting.value <= 0 { return Config{}, fmt.Errorf("invalid %s", setting.name) } } return cfg, nil } // parseBytes accepts a plain byte count or a binary suffix (KiB, MiB, GiB, // TiB, and their one-letter forms). func parseBytes(text string) (int64, error) { units := map[string]int64{"": 1, "k": 1 << 10, "m": 1 << 20, "g": 1 << 30, "t": 1 << 40} trimmed := strings.ToLower(strings.TrimSpace(text)) if trimmed == "" { return 0, fmt.Errorf("invalid byte size %q", text) } for _, suffix := range []string{"kib", "mib", "gib", "tib"} { if strings.HasSuffix(trimmed, suffix) { trimmed = strings.TrimSuffix(trimmed, suffix) + suffix[:1] break } } trimmed = strings.TrimSuffix(trimmed, "b") unit := "" if last := trimmed[len(trimmed)-1]; last >= 'a' && last <= 'z' { if _, ok := units[string(last)]; ok { unit = string(last) trimmed = trimmed[:len(trimmed)-1] } } value, err := strconv.ParseInt(trimmed, 10, 64) if err != nil || value < 0 || value > (1<<63-1)/units[unit] { return 0, fmt.Errorf("invalid byte size %q", text) } return value * units[unit], nil } func required(name string) (string, error) { value, ok := os.LookupEnv(name) if !ok || strings.TrimSpace(value) == "" { return "", fmt.Errorf("missing required env %s", name) } return value, nil } func optional(name string, fallback string) string { value, ok := os.LookupEnv(name) if !ok || strings.TrimSpace(value) == "" { return fallback } return value } func splitList(value string) []string { parts := strings.Split(value, ":") out := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { out = append(out, part) } } return out }