Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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 }