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.
|
f5456470
Keenan Nemetz
12d ago
|
1 | package agent |
| 2 | ||
| 3 | // A leased job is a payload from the network. These are the fields the runner | |
| 4 | // would otherwise turn into a path, a container argument or a shell script, so | |
| 5 | // each one is pinned as refused when it is the wrong shape. | |
| 6 | ||
| 7 | import ( | |
| 8 | "strings" | |
| 9 | "testing" | |
| 10 | "time" | |
| 11 | ||
|
d37d301f
Keenan Nemetz
15h ago
|
12 | "rodent.software/vole/ci" |
| 13 | "rodent.software/vole/core/id" | |
|
f5456470
Keenan Nemetz
12d ago
|
14 | ) |
| 15 | ||
| 16 | func goodJob(t *testing.T) *runnerJob { | |
| 17 | t.Helper() | |
| 18 | runID, err := id.New(time.Now().UTC()) | |
| 19 | if err != nil { | |
| 20 | t.Fatal(err) | |
| 21 | } | |
| 22 | return &runnerJob{ | |
| 23 | ID: runID, | |
| 24 | Repo: "demo", | |
| 25 | SHA: "0123456789abcdef0123456789abcdef01234567", | |
| 26 | Ref: "refs/heads/main", | |
| 27 | Branch: "main", | |
| 28 | Workflow: "ci", | |
| 29 | Job: "test", | |
| 30 | WorkflowSHA: "0123456789abcdef0123456789abcdef01234567", | |
| 31 | StepsSHA: strings.Repeat("ab", 32), | |
| 32 | Attempt: 1, | |
| 33 | Image: "alpine:3.22", | |
| 34 | Steps: []runnerStep{{Name: "build", Run: "make", Timeout: "10m"}}, | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | func TestAValidJobPasses(t *testing.T) { | |
| 39 | t.Parallel() | |
| 40 | if err := validateJob(goodJob(t)); err != nil { | |
| 41 | t.Fatalf("a valid job was refused: %v", err) | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | func TestJobFieldsAreRevalidated(t *testing.T) { | |
| 46 | t.Parallel() | |
| 47 | cases := []struct { | |
| 48 | name string | |
| 49 | mutate func(*runnerJob) | |
| 50 | field string | |
| 51 | }{ | |
| 52 | {"repository climbs out", func(j *runnerJob) { j.Repo = "../../etc" }, "repository"}, | |
| 53 | {"repository has a slash", func(j *runnerJob) { j.Repo = "a/b" }, "repository"}, | |
| 54 | {"sha is not a commit", func(j *runnerJob) { j.SHA = "HEAD" }, "commit"}, | |
| 55 | {"sha is uppercase", func(j *runnerJob) { j.SHA = strings.ToUpper(j.SHA) }, "commit"}, | |
| 56 | {"ref is a path", func(j *runnerJob) { j.Ref = "refs/heads/../../etc/passwd" }, "ref"}, | |
| 57 | {"ref is not a namespace", func(j *runnerJob) { j.Ref = "main" }, "ref"}, | |
| 58 | {"branch has a colon", func(j *runnerJob) { j.Branch = "main:x" }, "branch"}, | |
| 59 | {"tag has a newline", func(j *runnerJob) { j.Tag = "v1\nv2" }, "tag"}, | |
| 60 | {"pages climbs out", func(j *runnerJob) { j.Pages = &runnerPages{Path: "../../etc"} }, "pages"}, | |
| 61 | {"pages is a glob", func(j *runnerJob) { j.Pages = &runnerPages{Path: "dist/*"} }, "pages"}, | |
| 62 | {"artifact climbs out", func(j *runnerJob) { j.Artifacts = []string{"../secret"} }, "artifacts"}, | |
| 63 | {"artifact is absolute", func(j *runnerJob) { j.Artifacts = []string{"/etc/passwd"} }, "artifacts"}, | |
| 64 | {"release artifact climbs out", func(j *runnerJob) { j.ReleaseArtifacts = []string{"../../x"} }, "release_artifacts"}, | |
| 65 | {"image file climbs out", func(j *runnerJob) { j.Image = "file:../x"; j.ImageFile = "../x"; j.ImageSHA = strings.Repeat("ab", 32) }, "image"}, | |
| 66 | {"image file has no digest", func(j *runnerJob) { j.Image = "file:go"; j.ImageFile = "go" }, "digest"}, | |
| 67 | {"image is a flag", func(j *runnerJob) { j.Image = "--privileged" }, "image"}, | |
| 68 | {"run id is not a run id", func(j *runnerJob) { j.ID = "run-1" }, "run id"}, | |
| 69 | {"attempt is zero", func(j *runnerJob) { j.Attempt = 0 }, "attempt"}, | |
| 70 | {"step has no script", func(j *runnerJob) { j.Steps = []runnerStep{{Name: "x"}} }, "script"}, | |
| 71 | {"step times out for a week", func(j *runnerJob) { | |
| 72 | j.Steps = []runnerStep{{Name: "x", Run: "true", Timeout: "168h"}} | |
| 73 | }, "timeout"}, | |
|
e7aa0785
Keenan Nemetz
6d ago
|
74 | {"job budget is not a duration", func(j *runnerJob) { j.Timeout = "2 hours" }, "timeout"}, |
| 75 | {"job budget is negative", func(j *runnerJob) { j.Timeout = "-5m" }, "timeout"}, | |
| 76 | {"job budget is a week", func(j *runnerJob) { j.Timeout = "168h" }, "timeout"}, | |
|
f5456470
Keenan Nemetz
12d ago
|
77 | {"env value is two lines", func(j *runnerJob) { j.Env = map[string]string{"A": "1\n2"} }, "env.A"}, |
| 78 | {"env name is not a name", func(j *runnerJob) { j.Env = map[string]string{"A B": "1"} }, "variable name"}, | |
| 79 | {"workflow name is empty", func(j *runnerJob) { j.Workflow = "" }, "workflow"}, | |
| 80 | } | |
| 81 | for _, c := range cases { | |
| 82 | job := goodJob(t) | |
| 83 | c.mutate(job) | |
| 84 | err := validateJob(job) | |
| 85 | if err == nil { | |
| 86 | t.Fatalf("%s: accepted", c.name) | |
| 87 | } | |
| 88 | if !strings.Contains(err.Error(), c.field) { | |
| 89 | t.Fatalf("%s: error does not mention %q: %v", c.name, c.field, err) | |
| 90 | } | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | func TestJobWithTooManyStepsIsRefused(t *testing.T) { | |
| 95 | t.Parallel() | |
| 96 | job := goodJob(t) | |
| 97 | job.Steps = make([]runnerStep, ci.MaxSteps+1) | |
| 98 | for i := range job.Steps { | |
| 99 | job.Steps[i] = runnerStep{Name: "s", Run: "true"} | |
| 100 | } | |
| 101 | if err := validateJob(job); err == nil { | |
| 102 | t.Fatal("a job with too many steps was accepted") | |
| 103 | } | |
| 104 | } | |
|
4ac42680
Keenan Nemetz
7d ago
|
105 | |
| 106 | func TestLeasedSecretsAreRevalidated(t *testing.T) { | |
| 107 | t.Parallel() | |
| 108 | cases := []struct { | |
| 109 | name string | |
| 110 | mutate func(*runnerJob) | |
| 111 | want string | |
| 112 | }{ | |
| 113 | {"not a name", func(j *runnerJob) { | |
| 114 | j.Secrets = map[string]runnerSecret{"lower": {Version: 1, Value: "x"}} | |
| 115 | }, "not a secret name"}, | |
| 116 | {"would replace a runner variable", func(j *runnerJob) { | |
| 117 | j.Secrets = map[string]runnerSecret{"HOME": {Version: 1, Value: "/tmp"}} | |
| 118 | }, "variable the runner sets"}, | |
| 119 | {"no version", func(j *runnerJob) { | |
| 120 | j.Secrets = map[string]runnerSecret{"KEY": {Value: "x"}} | |
| 121 | }, "no version"}, | |
| 122 | {"nul byte", func(j *runnerJob) { | |
| 123 | j.Secrets = map[string]runnerSecret{"KEY": {Version: 1, Value: "a\x00b"}} | |
| 124 | }, "NUL"}, | |
| 125 | {"larger than one variable", func(j *runnerJob) { | |
| 126 | j.Secrets = map[string]runnerSecret{"KEY": {Version: 1, Value: strings.Repeat("x", ci.MaxSecretBytes)}} | |
| 127 | }, "larger than one environment variable"}, | |
| 128 | {"missing name is not a name", func(j *runnerJob) { | |
| 129 | j.Missing = []runnerSecretIssue{{Name: "not a name"}} | |
| 130 | }, "the runner can report"}, | |
| 131 | {"missing reason is two lines", func(j *runnerJob) { | |
| 132 | j.Missing = []runnerSecretIssue{{Name: "KEY", Reason: "one\ntwo"}} | |
| 133 | }, "the runner can report"}, | |
| 134 | } | |
| 135 | for _, c := range cases { | |
| 136 | t.Run(c.name, func(t *testing.T) { | |
| 137 | t.Parallel() | |
| 138 | job := goodJob(t) | |
| 139 | c.mutate(job) | |
| 140 | err := validateJob(job) | |
| 141 | if err == nil { | |
| 142 | t.Fatal("the job was accepted") | |
| 143 | } | |
| 144 | if !strings.Contains(err.Error(), c.want) { | |
| 145 | t.Fatalf("error = %v; want one mentioning %q", err, c.want) | |
| 146 | } | |
| 147 | }) | |
| 148 | } | |
| 149 | ||
| 150 | // A delivered value and a missing name are both normal. | |
| 151 | job := goodJob(t) | |
| 152 | job.Secrets = map[string]runnerSecret{"DEPLOY_KEY": {Version: 2, Value: "s3cret"}} | |
| 153 | job.Missing = []runnerSecretIssue{{Name: "NOT_SET"}} | |
| 154 | if err := validateJob(job); err != nil { | |
| 155 | t.Fatalf("a valid job with secrets was refused: %v", err) | |
| 156 | } | |
| 157 | } | |
|
c1929d2f
Keenan Nemetz
5d ago
|
158 | |
| 159 | // Services arrive in the lease, so the runner checks the names, images, and | |
| 160 | // environment it is about to turn into container arguments. | |
| 161 | func TestServicesAreRevalidated(t *testing.T) { | |
| 162 | t.Parallel() | |
| 163 | valid := func() *runnerJob { | |
| 164 | j := goodJob(t) | |
| 165 | j.Network = ci.NetworkBridge | |
| 166 | j.Services = []runnerService{{ | |
| 167 | ID: "db", | |
| 168 | Image: "postgres:17", | |
| 169 | Env: map[string]string{"POSTGRES_PASSWORD": "test"}, | |
| 170 | }} | |
| 171 | return j | |
| 172 | } | |
| 173 | if err := validateJob(valid()); err != nil { | |
| 174 | t.Fatalf("a valid service job was refused: %v", err) | |
| 175 | } | |
| 176 | cases := []struct { | |
| 177 | name string | |
| 178 | mutate func(*runnerJob) | |
| 179 | want string | |
| 180 | }{ | |
| 181 | {"no bridge", func(j *runnerJob) { j.Network = "" }, "network: bridge"}, | |
| 182 | {"none", func(j *runnerJob) { j.Network = ci.NetworkNone }, "network: bridge"}, | |
| 183 | {"bad name", func(j *runnerJob) { j.Services[0].ID = "DB" }, "service name"}, | |
| 184 | {"duplicate name", func(j *runnerJob) { | |
| 185 | j.Services = append(j.Services, j.Services[0]) | |
| 186 | }, "listed twice"}, | |
| 187 | {"no image", func(j *runnerJob) { j.Services[0].Image = "" }, "image"}, | |
| 188 | {"image file", func(j *runnerJob) { j.Services[0].Image = "file:db" }, "reserved for image-file"}, | |
| 189 | {"bad env", func(j *runnerJob) { j.Services[0].Env = map[string]string{"A B": "1"} }, "variable name"}, | |
| 190 | {"multiline env", func(j *runnerJob) { j.Services[0].Env = map[string]string{"A": "1\n2"} }, "one line"}, | |
| 191 | } | |
| 192 | for _, c := range cases { | |
| 193 | job := valid() | |
| 194 | c.mutate(job) | |
| 195 | err := validateJob(job) | |
| 196 | if err == nil { | |
| 197 | t.Fatalf("%s: accepted", c.name) | |
| 198 | } | |
| 199 | if !strings.Contains(err.Error(), c.want) { | |
| 200 | t.Fatalf("%s: error does not mention %q: %v", c.name, c.want, err) | |
| 201 | } | |
| 202 | } | |
| 203 | } | |
| 204 | ||
| 205 | func TestTooManyServicesAreRefused(t *testing.T) { | |
| 206 | t.Parallel() | |
| 207 | j := goodJob(t) | |
| 208 | j.Network = ci.NetworkBridge | |
| 209 | for i := 0; i <= ci.MaxServices; i++ { | |
| 210 | j.Services = append(j.Services, runnerService{ID: "s" + string(rune('a'+i)), Image: "alpine:3.22"}) | |
| 211 | } | |
| 212 | if err := validateJob(j); err == nil || !strings.Contains(err.Error(), "services, limit") { | |
| 213 | t.Fatalf("too many services: %v", err) | |
| 214 | } | |
| 215 | } |