vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
70b70264
Keenan Nemetz
15d ago
|
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 | } |