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.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 24m ago 4b8c9e1d
history blame raw 7.1 KiB
1package 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
27import (
28 "crypto/sha256"
29 "encoding/json"
30 "fmt"
31 "regexp"
32 "sort"
33 "strconv"
34 "strings"
35
36 "gopkg.in/yaml.v3"
37)
38
39const (
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
49var (
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.
59func 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.
81func 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.
91func 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.
96func 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.
102type ImageDef struct {
103 Version int
104 From string
105 Env map[string]string
106 Run []string
107}
108
109type 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.
117func 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.
195func (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
224func 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.
234func 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}
241