mugit/internal/config/config.go (view raw)
Oleksandr Smirnov
Oleksandr Smirnov
olexsmir@gmail.com config: validate, thanks to my boy Claude, 4 months ago
olexsmir@gmail.com config: validate, thanks to my boy Claude, 4 months ago
| 1 | package config |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "time" |
| 9 | |
| 10 | "gopkg.in/yaml.v2" |
| 11 | ) |
| 12 | |
| 13 | var ErrConfigNotFound = errors.New("no config file found") |
| 14 | |
| 15 | type ServerConfig struct { |
| 16 | Host string `yaml:"host"` |
| 17 | Port int `yaml:"port"` |
| 18 | } |
| 19 | |
| 20 | type MetaConfig struct { |
| 21 | Title string `yaml:"title"` |
| 22 | Description string `yaml:"description"` |
| 23 | Host string `yaml:"host"` |
| 24 | } |
| 25 | |
| 26 | type RepoConfig struct { |
| 27 | Dir string `yaml:"dir"` |
| 28 | Readmes []string `yaml:"readmes"` |
| 29 | Masters []string `yaml:"masters"` |
| 30 | } |
| 31 | |
| 32 | type SSHConfig struct { |
| 33 | Enable bool `yaml:"enable"` |
| 34 | Port int `yaml:"port"` |
| 35 | HostKey string `yaml:"host_key"` |
| 36 | Keys []string `yaml:"keys"` |
| 37 | } |
| 38 | |
| 39 | type MirrorConfig struct { |
| 40 | Enable bool `yaml:"enable"` |
| 41 | Interval string `yaml:"interval"` |
| 42 | GithubToken string `yaml:"github_token"` |
| 43 | } |
| 44 | |
| 45 | type Config struct { |
| 46 | Server ServerConfig `yaml:"server"` |
| 47 | Meta MetaConfig `yaml:"meta"` |
| 48 | Repo RepoConfig `yaml:"repo"` |
| 49 | SSH SSHConfig `yaml:"ssh"` |
| 50 | Mirror MirrorConfig `yaml:"mirror"` |
| 51 | } |
| 52 | |
| 53 | // Load loads configuration with the following priority: |
| 54 | // 1. User provided fpath (if provided and exists) |
| 55 | // 2. /var/lib/mugit/config.yaml |
| 56 | // 3. $XDG_CONFIG_HOME/mugit/config.yaml or $HOME/.config/mugit/config.yaml |
| 57 | func Load(fpath string) (*Config, error) { |
| 58 | // 4. /etc/mugit/config.yaml |
| 59 | configPath, err := findConfigFile(fpath) |
| 60 | if err != nil { |
| 61 | return nil, err |
| 62 | } |
| 63 | |
| 64 | configBytes, err := os.ReadFile(configPath) |
| 65 | if err != nil { |
| 66 | return nil, err |
| 67 | } |
| 68 | |
| 69 | var config Config |
| 70 | if cerr := yaml.Unmarshal(configBytes, &config); cerr != nil { |
| 71 | return nil, fmt.Errorf("parsing config: %w", cerr) |
| 72 | } |
| 73 | |
| 74 | if config.Repo.Dir, err = filepath.Abs(config.Repo.Dir); err != nil { |
| 75 | return nil, err |
| 76 | } |
| 77 | |
| 78 | if verr := config.validate(); verr != nil { |
| 79 | return nil, verr |
| 80 | } |
| 81 | |
| 82 | return &config, nil |
| 83 | } |
| 84 | |
| 85 | func (c Config) validate() error { |
| 86 | var errs []error |
| 87 | |
| 88 | // server |
| 89 | if err := validatePort(c.Server.Port, "server.port"); err != nil { |
| 90 | errs = append(errs, err) |
| 91 | } |
| 92 | |
| 93 | // meta |
| 94 | if c.Meta.Host == "" { |
| 95 | errs = append(errs, errors.New("meta.host is required")) |
| 96 | } |
| 97 | |
| 98 | // repo |
| 99 | if err := validateDirExists(c.Repo.Dir, "repo.dir"); err != nil { |
| 100 | errs = append(errs, err) |
| 101 | } |
| 102 | if len(c.Repo.Readmes) == 0 { |
| 103 | errs = append(errs, errors.New("repo.readmes must have at least one value")) |
| 104 | } |
| 105 | if len(c.Repo.Masters) == 0 { |
| 106 | errs = append(errs, errors.New("repo.masters must have at least one value")) |
| 107 | } |
| 108 | |
| 109 | // ssh |
| 110 | if c.SSH.Enable { |
| 111 | if err := validatePort(c.SSH.Port, "ssh.port"); err != nil { |
| 112 | errs = append(errs, err) |
| 113 | } |
| 114 | if c.SSH.Port == c.Server.Port { |
| 115 | errs = append(errs, fmt.Errorf("ssh.port must differ from server.port (both are %d)", c.Server.Port)) |
| 116 | } |
| 117 | if err := validateFileExists(c.SSH.HostKey, "ssh.host_key"); err != nil { |
| 118 | errs = append(errs, err) |
| 119 | } |
| 120 | if len(c.SSH.Keys) == 0 { |
| 121 | errs = append(errs, errors.New("ssh.keys must have at least one value when ssh is enabled")) |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | // mirror |
| 126 | if c.Mirror.Enable { |
| 127 | if c.Mirror.Interval == "" { |
| 128 | errs = append(errs, errors.New("mirror.interval is required when mirror is enabled")) |
| 129 | } else if _, err := time.ParseDuration(c.Mirror.Interval); err != nil { |
| 130 | errs = append(errs, fmt.Errorf("mirror.interval: invalid duration format: %w", err)) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | return errors.Join(errs...) |
| 135 | } |
| 136 | |
| 137 | func findConfigFile(userPath string) (string, error) { |
| 138 | if userPath != "" { |
| 139 | if _, err := os.Stat(userPath); err == nil { |
| 140 | return userPath, nil |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | path := "/var/lib/mugit/config.yaml" |
| 145 | if _, err := os.Stat(path); err == nil { |
| 146 | return path, nil |
| 147 | } |
| 148 | |
| 149 | if configDir, err := os.UserConfigDir(); err == nil { |
| 150 | p := filepath.Join(configDir, "mugit", "config.yaml") |
| 151 | if _, err := os.Stat(p); err == nil { |
| 152 | return p, nil |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | path = "/etc/mugit/config.yaml" |
| 157 | if _, err := os.Stat(path); err == nil { |
| 158 | return path, nil |
| 159 | } |
| 160 | |
| 161 | return "", ErrConfigNotFound |
| 162 | } |
| 163 | |
| 164 | func validatePort(port int, fieldName string) error { |
| 165 | if port < 1 || port > 65535 { |
| 166 | return fmt.Errorf("%s must be between 1 and 65535, got %d", fieldName, port) |
| 167 | } |
| 168 | return nil |
| 169 | } |
| 170 | |
| 171 | func validateDirExists(path string, fieldName string) error { |
| 172 | if path == "" { |
| 173 | return fmt.Errorf("%s is required", fieldName) |
| 174 | } |
| 175 | info, err := os.Stat(path) |
| 176 | if err != nil { |
| 177 | if os.IsNotExist(err) { |
| 178 | return fmt.Errorf("%s: directory does not exist: %s", fieldName, path) |
| 179 | } |
| 180 | return fmt.Errorf("%s: cannot access directory: %w", fieldName, err) |
| 181 | } |
| 182 | if !info.IsDir() { |
| 183 | return fmt.Errorf("%s: path exists but is not a directory: %s", fieldName, path) |
| 184 | } |
| 185 | return nil |
| 186 | } |
| 187 | |
| 188 | func validateFileExists(path string, fieldName string) error { |
| 189 | if path == "" { |
| 190 | return fmt.Errorf("%s is required", fieldName) |
| 191 | } |
| 192 | info, err := os.Stat(path) |
| 193 | if err != nil { |
| 194 | if os.IsNotExist(err) { |
| 195 | return fmt.Errorf("%s: file does not exist: %s", fieldName, path) |
| 196 | } |
| 197 | return fmt.Errorf("%s: cannot access file: %w", fieldName, err) |
| 198 | } |
| 199 | if info.IsDir() { |
| 200 | return fmt.Errorf("%s: path is a directory, not a file: %s", fieldName, path) |
| 201 | } |
| 202 | return nil |
| 203 | } |