vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Require an image for every CI job
70b702649b875d841e294f5a085ca784d43e5bc4 · Keenan Nemetz · 15d ago · parent 7da9111e
Require an image for every CI job Runners now require an OCI runtime, and each workflow job must name either a registry image or an image-file definition under .vole/images. Definitions are validated at push time, pinned with the run, built locally by the runner, and cached by definition digest. Records and the run page carry the image and definition digest. Split the CI guide into workflows and runners, and add reference pages for workflow and image definition fields.
ci/image.go +240
| @@ -0,0 +1,240 @@ | |||
| 1 | package ci | ||
| 2 | |||
| 3 | // Image definitions for jobs. | ||
| 4 | // | ||
| 5 | // A workflow can name a registry image directly: | ||
| 6 | // | ||
| 7 | // image: golang:1.27-bookworm | ||
| 8 | // | ||
| 9 | // or a definition kept under .vole/images: | ||
| 10 | // | ||
| 11 | // image-file: go | ||
| 12 | // | ||
| 13 | // which resolves .vole/images/go.yaml: | ||
| 14 | // | ||
| 15 | // version: 1 | ||
| 16 | // from: golang:1.27-bookworm | ||
| 17 | // env: | ||
| 18 | // CGO_ENABLED: "0" | ||
| 19 | // run: | ||
| 20 | // - apt-get update && apt-get install -y --no-install-recommends jq | ||
| 21 | // | ||
| 22 | // The file is a small, validated build recipe rather than a full Dockerfile. It is | ||
| 23 | // pinned when the job is queued, so editing the definition later cannot change what | ||
| 24 | // an already-queued run builds. The runner turns it into a Dockerfile and builds it | ||
| 25 | // with the container runtime it already has. | ||
| 26 | |||
| 27 | import ( | ||
| 28 | "crypto/sha256" | ||
| 29 | "encoding/json" | ||
| 30 | "fmt" | ||
| 31 | "regexp" | ||
| 32 | "sort" | ||
| 33 | "strconv" | ||
| 34 | "strings" | ||
| 35 | |||
| 36 | "gopkg.in/yaml.v3" | ||
| 37 | ) | ||
| 38 | |||
| 39 | const ( | ||
| 40 | // ImageDir is where custom image definitions live, relative to the repository root. | ||
| 41 | ImageDir = ".vole/images" | ||
| 42 | |||
| 43 | MaxImageFileBytes = 64 << 10 | ||
| 44 | MaxImageRuns = 100 | ||
| 45 | MaxImageEnv = 64 | ||
| 46 | MaxImageValue = 8 << 10 | ||
| 47 | ) | ||
| 48 | |||
| 49 | var ( | ||
| 50 | imageNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) | ||
| 51 | imageRefRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/:@+~-]*$`) | ||
| 52 | envNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) | ||
| 53 | ) | ||
| 54 | |||
| 55 | // ValidImageRef reports whether s is a plausible OCI image reference. It is | ||
| 56 | // deliberately narrower than the distribution grammar: it accepts the spellings | ||
| 57 | // people use in workflows and refuses whitespace, shell metacharacters, and a | ||
| 58 | // leading dash that a runtime CLI would read as an option. | ||
| 59 | func ValidImageRef(s string) error { | ||
| 60 | switch { | ||
| 61 | case s == "": | ||
| 62 | return fmt.Errorf("an image reference is required") | ||
| 63 | case len(s) > 255: | ||
| 64 | return fmt.Errorf("image reference is %d bytes, limit 255", len(s)) | ||
| 65 | case strings.HasPrefix(s, "-"): | ||
| 66 | return fmt.Errorf("%q starts with '-'", s) | ||
| 67 | case strings.HasPrefix(s, "file:"): | ||
| 68 | return fmt.Errorf("%q starts with 'file:', which is reserved for image-file definitions", s) | ||
| 69 | case strings.Contains(s, ".."): | ||
| 70 | return fmt.Errorf("%q contains '..'", s) | ||
| 71 | case strings.Contains(s, "://"): | ||
| 72 | return fmt.Errorf("%q is a URL, not an image reference", s) | ||
| 73 | case !imageRefRE.MatchString(s): | ||
| 74 | return fmt.Errorf("%q is not an image reference (letters, digits, '.', '_', '/', ':', '@', '+', '~' and '-')", s) | ||
| 75 | } | ||
| 76 | return nil | ||
| 77 | } | ||
| 78 | |||
| 79 | // ImageName normalizes an image-file reference. The extension is optional: | ||
| 80 | // image-file: go and image-file: go.yaml both name .vole/images/go.yaml. | ||
| 81 | func ImageName(s string) (string, error) { | ||
| 82 | name := strings.TrimSpace(s) | ||
| 83 | name = strings.TrimSuffix(strings.TrimSuffix(name, ".yaml"), ".yml") | ||
| 84 | if !imageNameRE.MatchString(name) { | ||
| 85 | return "", fmt.Errorf("%q is not an image name (1-64 characters: lowercase letters, digits, '.', '_' and '-', starting with a letter or digit)", s) | ||
| 86 | } | ||
| 87 | return name, nil | ||
| 88 | } | ||
| 89 | |||
| 90 | // ImagePath is where a normalized image name lives. | ||
| 91 | func ImagePath(name string) string { | ||
| 92 | return ImageDir + "/" + name + ".yaml" | ||
| 93 | } | ||
| 94 | |||
| 95 | // ImageDigest identifies the exact definition bytes. It is what a queued run pins. | ||
| 96 | func ImageDigest(content string) string { | ||
| 97 | sum := sha256.Sum256([]byte(content)) | ||
| 98 | return fmt.Sprintf("%x", sum) | ||
| 99 | } | ||
| 100 | |||
| 101 | // ImageDef is a validated custom image recipe. | ||
| 102 | type ImageDef struct { | ||
| 103 | Version int | ||
| 104 | From string | ||
| 105 | Env map[string]string | ||
| 106 | Run []string | ||
| 107 | } | ||
| 108 | |||
| 109 | type rawImageDef struct { | ||
| 110 | Version int `yaml:"version"` | ||
| 111 | From string `yaml:"from"` | ||
| 112 | Env map[string]string `yaml:"env"` | ||
| 113 | Run []string `yaml:"run"` | ||
| 114 | } | ||
| 115 | |||
| 116 | // ParseImage validates one .vole/images file. Every problem is reported. | ||
| 117 | func ParseImage(path, content string) (*ImageDef, []string) { | ||
| 118 | var problems []string | ||
| 119 | bad := func(format string, args ...any) { | ||
| 120 | problems = append(problems, fmt.Sprintf("%s: %s", path, fmt.Sprintf(format, args...))) | ||
| 121 | } | ||
| 122 | |||
| 123 | if len(content) > MaxImageFileBytes { | ||
| 124 | bad("file is %d bytes, limit %d", len(content), MaxImageFileBytes) | ||
| 125 | return nil, problems | ||
| 126 | } | ||
| 127 | var rw rawImageDef | ||
| 128 | dec := yaml.NewDecoder(strings.NewReader(content)) | ||
| 129 | dec.KnownFields(true) | ||
| 130 | if err := dec.Decode(&rw); err != nil { | ||
| 131 | if err.Error() == "EOF" { | ||
| 132 | bad("file is empty") | ||
| 133 | return nil, problems | ||
| 134 | } | ||
| 135 | bad("%s", tidyYAMLError(err)) | ||
| 136 | return nil, problems | ||
| 137 | } | ||
| 138 | |||
| 139 | if rw.Version == 0 { | ||
| 140 | bad("version is required (currently 1)") | ||
| 141 | } else if rw.Version != 1 { | ||
| 142 | bad("version %d is not supported; this server runs version 1", rw.Version) | ||
| 143 | } | ||
| 144 | from := strings.TrimSpace(rw.From) | ||
| 145 | if err := ValidImageRef(from); err != nil { | ||
| 146 | bad("from: %v", err) | ||
| 147 | } | ||
| 148 | if len(rw.Env) > MaxImageEnv { | ||
| 149 | bad("%d environment entries, limit %d", len(rw.Env), MaxImageEnv) | ||
| 150 | } | ||
| 151 | env := make(map[string]string, len(rw.Env)) | ||
| 152 | keys := make([]string, 0, len(rw.Env)) | ||
| 153 | for k := range rw.Env { | ||
| 154 | keys = append(keys, k) | ||
| 155 | } | ||
| 156 | sort.Strings(keys) | ||
| 157 | for _, k := range keys { | ||
| 158 | v := rw.Env[k] | ||
| 159 | switch { | ||
| 160 | case !envNameRE.MatchString(k): | ||
| 161 | bad("env: %q is not an environment variable name", k) | ||
| 162 | case len(v) > MaxImageValue: | ||
| 163 | bad("env.%s: value is %d bytes, limit %d", k, len(v), MaxImageValue) | ||
| 164 | case strings.ContainsAny(v, "\r\n"): | ||
| 165 | bad("env.%s: value must be one line", k) | ||
| 166 | default: | ||
| 167 | env[k] = v | ||
| 168 | } | ||
| 169 | } | ||
| 170 | if len(rw.Run) > MaxImageRuns { | ||
| 171 | bad("%d build steps, limit %d", len(rw.Run), MaxImageRuns) | ||
| 172 | } | ||
| 173 | var run []string | ||
| 174 | for i, raw := range rw.Run { | ||
| 175 | cmd := strings.TrimSpace(raw) | ||
| 176 | switch { | ||
| 177 | case cmd == "": | ||
| 178 | bad("run[%d]: command is empty", i) | ||
| 179 | case len(cmd) > MaxStepBytes: | ||
| 180 | bad("run[%d]: command is %d bytes, limit %d", i, len(cmd), MaxStepBytes) | ||
| 181 | case strings.ContainsAny(cmd, "\r\n"): | ||
| 182 | bad("run[%d]: command must be one line (use && to join commands)", i) | ||
| 183 | default: | ||
| 184 | run = append(run, cmd) | ||
| 185 | } | ||
| 186 | } | ||
| 187 | if len(problems) > 0 { | ||
| 188 | return nil, problems | ||
| 189 | } | ||
| 190 | return &ImageDef{Version: rw.Version, From: from, Env: env, Run: run}, nil | ||
| 191 | } | ||
| 192 | |||
| 193 | // Dockerfile renders the recipe as the small Dockerfile the runner builds. Env | ||
| 194 | // values are quoted so spaces and quotes survive the Dockerfile parser. | ||
| 195 | func (d *ImageDef) Dockerfile() string { | ||
| 196 | var b strings.Builder | ||
| 197 | b.WriteString("# generated by vole from a .vole/images definition\n") | ||
| 198 | b.WriteString("FROM ") | ||
| 199 | b.WriteString(d.From) | ||
| 200 | b.WriteByte('\n') | ||
| 201 | keys := make([]string, 0, len(d.Env)) | ||
| 202 | for k := range d.Env { | ||
| 203 | keys = append(keys, k) | ||
| 204 | } | ||
| 205 | sort.Strings(keys) | ||
| 206 | for _, k := range keys { | ||
| 207 | b.WriteString("ENV ") | ||
| 208 | b.WriteString(k) | ||
| 209 | b.WriteByte('=') | ||
| 210 | b.WriteString(strconv.Quote(d.Env[k])) | ||
| 211 | b.WriteByte('\n') | ||
| 212 | } | ||
| 213 | for _, cmd := range d.Run { | ||
| 214 | // Exec form keeps the command's first word from being read as a Dockerfile | ||
| 215 | // flag such as RUN --network=host. The recipe is still shell code, but it is | ||
| 216 | // shell code inside the builder, not a build option. | ||
| 217 | b.WriteString("RUN [\"/bin/sh\", \"-c\", ") | ||
| 218 | b.WriteString(jsonString(cmd)) | ||
| 219 | b.WriteString("]\n") | ||
| 220 | } | ||
| 221 | return b.String() | ||
| 222 | } | ||
| 223 | |||
| 224 | func jsonString(s string) string { | ||
| 225 | var b strings.Builder | ||
| 226 | enc := json.NewEncoder(&b) | ||
| 227 | enc.SetEscapeHTML(false) | ||
| 228 | _ = enc.Encode(s) | ||
| 229 | return strings.TrimSpace(b.String()) | ||
| 230 | } | ||
| 231 | |||
| 232 | // RenderImage parses a pinned definition and returns its Dockerfile. The runner | ||
| 233 | // receives the definition text from the queue and validates it again before build. | ||
| 234 | func RenderImage(content string) (string, error) { | ||
| 235 | def, problems := ParseImage("image definition", content) | ||
| 236 | if len(problems) > 0 { | ||
| 237 | return "", fmt.Errorf("%s", strings.Join(problems, "; ")) | ||
| 238 | } | ||
| 239 | return def.Dockerfile(), nil | ||
| 240 | } | ||