config.go
| 1 | // Package ci runs pipelines described by .hearthforge-ci.toml in Docker or |
| 2 | // Podman containers. See CI.md for the run model. |
| 3 | package ci |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "math" |
| 9 | "net/url" |
| 10 | "path" |
| 11 | "path/filepath" |
| 12 | "regexp" |
| 13 | "slices" |
| 14 | "strconv" |
| 15 | "strings" |
| 16 | |
| 17 | "github.com/BurntSushi/toml" |
| 18 | ) |
| 19 | |
| 20 | // VariableDef is one entry of the [variables] table. The manual run form |
| 21 | // renders one input per variable. |
| 22 | type VariableDef struct { |
| 23 | Default string `toml:"default"` |
| 24 | Description string `toml:"description"` |
| 25 | } |
| 26 | |
| 27 | // Step is one [[steps]] entry. |
| 28 | type Step struct { |
| 29 | Name string `toml:"name"` |
| 30 | RunSh string `toml:"run_sh"` |
| 31 | RunIf string `toml:"run_if"` |
| 32 | Always bool `toml:"always"` |
| 33 | WarnOnFail bool `toml:"warn_on_fail"` |
| 34 | Clear bool `toml:"clear"` |
| 35 | // EngineSocket mounts the Docker/Podman socket and points DOCKER_HOST |
| 36 | // at it. Needs CI_ENGINE_SOCKET on the server. |
| 37 | EngineSocket bool `toml:"engine_socket"` |
| 38 | Timeout int `toml:"timeout"` |
| 39 | PublishFile StringList `toml:"publish_file"` |
| 40 | PublishTar StringList `toml:"publish_tar"` |
| 41 | PublishGzip StringList `toml:"publish_gzip"` |
| 42 | PublishZip StringList `toml:"publish_zip"` |
| 43 | PublishZstd StringList `toml:"publish_zstd"` |
| 44 | } |
| 45 | |
| 46 | // StringList accepts either a bare string or an array of strings. |
| 47 | type StringList []string |
| 48 | |
| 49 | func (l *StringList) UnmarshalTOML(v any) error { |
| 50 | switch t := v.(type) { |
| 51 | case string: |
| 52 | *l = StringList{t} |
| 53 | return nil |
| 54 | case []any: |
| 55 | out := make(StringList, 0, len(t)) |
| 56 | for _, e := range t { |
| 57 | s, ok := e.(string) |
| 58 | if !ok { |
| 59 | return errors.New("expected a string") |
| 60 | } |
| 61 | out = append(out, s) |
| 62 | } |
| 63 | *l = out |
| 64 | return nil |
| 65 | } |
| 66 | return errors.New("expected a string or an array of strings") |
| 67 | } |
| 68 | |
| 69 | // Cache is one cache path, normalised from either form `cache` accepts: a |
| 70 | // bare string, or a table carrying a size cap. |
| 71 | type Cache struct { |
| 72 | Path string |
| 73 | // MaxSize is in bytes. Zero means unbounded. |
| 74 | MaxSize int64 |
| 75 | } |
| 76 | |
| 77 | func (c *Cache) UnmarshalTOML(v any) error { |
| 78 | if s, ok := v.(string); ok { |
| 79 | if s == "" { |
| 80 | return errors.New("cache path is empty") |
| 81 | } |
| 82 | c.Path = s |
| 83 | return nil |
| 84 | } |
| 85 | m, ok := v.(map[string]any) |
| 86 | if !ok { |
| 87 | return errors.New("cache entry must be a string or a table") |
| 88 | } |
| 89 | p, _ := m["path"].(string) |
| 90 | if p == "" { |
| 91 | return errors.New("cache entry has no path") |
| 92 | } |
| 93 | c.Path = p |
| 94 | raw, present := m["max_size"] |
| 95 | if !present { |
| 96 | return nil |
| 97 | } |
| 98 | s, ok := raw.(string) |
| 99 | if !ok { |
| 100 | return errors.New("cache max_size must be a string") |
| 101 | } |
| 102 | size := parseMemoryBytes(s) |
| 103 | // parseMemoryBytes answers 0 for anything it cannot read. Left alone |
| 104 | // that is a limit every cache exceeds, so the cache would be wiped after |
| 105 | // every run and look broken rather than misconfigured. |
| 106 | if size <= 0 { |
| 107 | return fmt.Errorf("cannot read cache max_size %q", s) |
| 108 | } |
| 109 | c.MaxSize = size |
| 110 | return nil |
| 111 | } |
| 112 | |
| 113 | // Copy lifts one file or directory out of another image, like COPY --from. |
| 114 | type Copy struct { |
| 115 | Image string `toml:"image"` |
| 116 | From string `toml:"from"` |
| 117 | To string `toml:"to"` |
| 118 | } |
| 119 | |
| 120 | // On holds the trigger settings. |
| 121 | type On struct { |
| 122 | Push PushSpec `toml:"push"` |
| 123 | Tag bool `toml:"tag"` |
| 124 | // Manual is parsed for compatibility. The manual button ignores it. |
| 125 | Manual bool `toml:"manual"` |
| 126 | } |
| 127 | |
| 128 | // PushSpec is `push = true` or a list of branch globs. |
| 129 | type PushSpec struct { |
| 130 | All bool |
| 131 | Patterns []string |
| 132 | } |
| 133 | |
| 134 | func (p *PushSpec) UnmarshalTOML(v any) error { |
| 135 | switch t := v.(type) { |
| 136 | case bool: |
| 137 | p.All = t |
| 138 | return nil |
| 139 | case []any: |
| 140 | for _, e := range t { |
| 141 | s, ok := e.(string) |
| 142 | if !ok { |
| 143 | return errors.New("push patterns must be strings") |
| 144 | } |
| 145 | p.Patterns = append(p.Patterns, s) |
| 146 | } |
| 147 | return nil |
| 148 | } |
| 149 | return errors.New("on.push must be a boolean or an array of strings") |
| 150 | } |
| 151 | |
| 152 | // WantsEngineSocket reports whether any step asked for the engine socket. |
| 153 | func (c *Config) WantsEngineSocket() bool { |
| 154 | return slices.ContainsFunc(c.Steps, func(s Step) bool { return s.EngineSocket }) |
| 155 | } |
| 156 | |
| 157 | // Config is a parsed .hearthforge-ci.toml. |
| 158 | type Config struct { |
| 159 | Image string `toml:"image"` |
| 160 | WorkDir string `toml:"work_dir"` |
| 161 | CloneProjectTo string `toml:"clone_project_to"` |
| 162 | Shell []string `toml:"shell"` |
| 163 | ShellSetup string `toml:"shell_setup"` |
| 164 | Timeout int `toml:"timeout"` |
| 165 | CPULimit float64 `toml:"cpu_limit"` |
| 166 | MemoryLimit string `toml:"memory_limit"` |
| 167 | Cache []Cache `toml:"cache"` |
| 168 | Copy []Copy `toml:"copy"` |
| 169 | On On `toml:"on"` |
| 170 | Variables map[string]VariableDef `toml:"variables"` |
| 171 | Steps []Step `toml:"steps"` |
| 172 | |
| 173 | // VariableOrder lists the variable names in file order. A Go map has no |
| 174 | // order, and the run form must match the file. |
| 175 | VariableOrder []string `toml:"-"` |
| 176 | } |
| 177 | |
| 178 | // ParseCiConfig reads a .hearthforge-ci.toml. It rejects anything a run |
| 179 | // cannot use, so a broken config fails at parse time and not mid-run. |
| 180 | func ParseCiConfig(src string) (*Config, error) { |
| 181 | var cfg Config |
| 182 | md, err := toml.Decode(src, &cfg) |
| 183 | if err != nil { |
| 184 | return nil, err |
| 185 | } |
| 186 | if cfg.Image == "" { |
| 187 | return nil, errors.New("image is missing") |
| 188 | } |
| 189 | // Steps stay an array, not a table per step, so file order survives. |
| 190 | if len(cfg.Steps) == 0 { |
| 191 | return nil, errors.New("no [[steps]] defined") |
| 192 | } |
| 193 | for _, s := range cfg.Steps { |
| 194 | if s.Name == "" { |
| 195 | return nil, errors.New("a step has no name") |
| 196 | } |
| 197 | } |
| 198 | for _, c := range cfg.Copy { |
| 199 | if c.Image == "" || c.From == "" || c.To == "" { |
| 200 | return nil, errors.New("a [[copy]] entry needs image, from and to") |
| 201 | } |
| 202 | } |
| 203 | for _, key := range md.Keys() { |
| 204 | if len(key) == 2 && key[0] == "variables" { |
| 205 | cfg.VariableOrder = append(cfg.VariableOrder, key[1]) |
| 206 | } |
| 207 | } |
| 208 | return &cfg, nil |
| 209 | } |
| 210 | |
| 211 | // ValidateCiConfig reports the run-start checks ParseCiConfig cannot make. |
| 212 | // It returns an empty string when the config is usable. |
| 213 | func ValidateCiConfig(cfg *Config) string { |
| 214 | for _, c := range cfg.Copy { |
| 215 | // `to` is a directory and the basename is kept, so a `to` that |
| 216 | // repeats the basename means someone expected a rename. Left alone |
| 217 | // it silently produces to/<name>/<name>. |
| 218 | if filepath.Base(c.To) == filepath.Base(c.From) { |
| 219 | return fmt.Sprintf( |
| 220 | "[[copy]] \"to\" is a directory, so %s lands at %s. Drop the last path segment from \"to\".", |
| 221 | c.From, filepath.Join(c.To, filepath.Base(c.From)), |
| 222 | ) |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | // An unparsable limit would otherwise become 0, which Docker reads as |
| 227 | // unlimited. |
| 228 | if cfg.MemoryLimit != "" && parseMemoryBytes(cfg.MemoryLimit) <= 0 { |
| 229 | return fmt.Sprintf( |
| 230 | "memory_limit %q is not a size. Use a number with an optional K, M or G suffix.", |
| 231 | cfg.MemoryLimit, |
| 232 | ) |
| 233 | } |
| 234 | |
| 235 | if cfg.CloneProjectTo == "" { |
| 236 | return "" |
| 237 | } |
| 238 | // The archive endpoint resolves `path` against /, while the execs that |
| 239 | // create and clear the directory resolve against work_dir. A relative |
| 240 | // value would name two different directories. |
| 241 | if !filepath.IsAbs(cfg.CloneProjectTo) { |
| 242 | return fmt.Sprintf("clone_project_to (%q) must be an absolute path.", cfg.CloneProjectTo) |
| 243 | } |
| 244 | |
| 245 | clone := filepath.Clean(cfg.CloneProjectTo) |
| 246 | for _, entry := range cfg.Cache { |
| 247 | cachePath := filepath.Clean(entry.Path) |
| 248 | // Either nesting direction breaks. A cache below the checkout is |
| 249 | // overwritten by the extract and deleted by `clear`. A cache above it |
| 250 | // carries the previous run's tree back in. |
| 251 | if cachePath == clone || |
| 252 | strings.HasPrefix(cachePath, clone+string(filepath.Separator)) || |
| 253 | strings.HasPrefix(clone, cachePath+string(filepath.Separator)) { |
| 254 | return fmt.Sprintf( |
| 255 | "cache path %q overlaps clone_project_to (%q). "+ |
| 256 | "The checkout is extracted over that directory and a `clear` step "+ |
| 257 | "deletes it. Move the cache outside the clone directory.", |
| 258 | entry.Path, cfg.CloneProjectTo, |
| 259 | ) |
| 260 | } |
| 261 | } |
| 262 | return "" |
| 263 | } |
| 264 | |
| 265 | // shouldTriggerPush reports whether a push to branch starts a run. |
| 266 | func shouldTriggerPush(cfg *Config, branch string) bool { |
| 267 | if cfg.On.Push.All { |
| 268 | return true |
| 269 | } |
| 270 | for _, pattern := range cfg.On.Push.Patterns { |
| 271 | if matchGlob(pattern, branch) { |
| 272 | return true |
| 273 | } |
| 274 | } |
| 275 | return false |
| 276 | } |
| 277 | |
| 278 | // matchGlob matches a branch against a config pattern. `*` does not cross a |
| 279 | // `/`, so "release/*" matches "release/1" but not "release/1/x". |
| 280 | func matchGlob(pattern, value string) bool { |
| 281 | if pattern == "*" { |
| 282 | return true |
| 283 | } |
| 284 | ok, err := path.Match(pattern, value) |
| 285 | return err == nil && ok |
| 286 | } |
| 287 | |
| 288 | // VariableOverrides picks the manual run form fields that differ from the |
| 289 | // config default. An untouched field is not an override: sending the default |
| 290 | // back would pin the run to the value the config had at render time. |
| 291 | func VariableOverrides(cfg *Config, form url.Values) map[string]string { |
| 292 | out := map[string]string{} |
| 293 | for name, def := range cfg.Variables { |
| 294 | key := "var_" + name |
| 295 | if !form.Has(key) { |
| 296 | continue |
| 297 | } |
| 298 | if v := form.Get(key); v != def.Default { |
| 299 | out[name] = v |
| 300 | } |
| 301 | } |
| 302 | return out |
| 303 | } |
| 304 | |
| 305 | var memorySizeRe = regexp.MustCompile(`^(\d+(?:\.\d+)?)\s*([kmgKMG]?)b?$`) |
| 306 | |
| 307 | // parseMemoryBytes reads sizes like "512m" or "2g". It answers 0 for |
| 308 | // anything it cannot read. |
| 309 | func parseMemoryBytes(s string) int64 { |
| 310 | m := memorySizeRe.FindStringSubmatch(s) |
| 311 | if m == nil { |
| 312 | return 0 |
| 313 | } |
| 314 | n, err := strconv.ParseFloat(m[1], 64) |
| 315 | if err != nil { |
| 316 | return 0 |
| 317 | } |
| 318 | switch strings.ToLower(m[2]) { |
| 319 | case "k": |
| 320 | n *= 1024 |
| 321 | case "m": |
| 322 | n *= 1024 * 1024 |
| 323 | case "g": |
| 324 | n *= 1024 * 1024 * 1024 |
| 325 | } |
| 326 | return int64(math.Floor(n)) |
| 327 | } |
| 328 | |
| 329 | // formatBytes renders a size the way the cache step log shows it. |
| 330 | func formatBytes(n int64) string { |
| 331 | units := []string{"B", "K", "M", "G", "T"} |
| 332 | i := 0 |
| 333 | v := float64(n) |
| 334 | for v >= 1024 && i < len(units)-1 { |
| 335 | v /= 1024 |
| 336 | i++ |
| 337 | } |
| 338 | if i == 0 { |
| 339 | return strconv.FormatFloat(v, 'f', -1, 64) + units[0] |
| 340 | } |
| 341 | return strconv.FormatFloat(v, 'f', 1, 64) + units[i] |
| 342 | } |
| 343 |