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.

Let a job declare the secrets it reads

4ac42680eca3181efc70099f65d6e2c9f45e9dd2 · Keenan Nemetz · 7d ago · parent 0c40a1d3

Let a job declare the secrets it reads

A job now names the repository secrets it needs, and the instance delivers
those values when the job is leased. The workflow carries names only, so the
queue row, the metadata branch and the run record never hold a value: the
record names the version of each value the job ran with, which is what makes a
result say which one it used.

    jobs:
      deploy:
        image: alpine:3.22
        secrets: [DEPLOY_KEY]

A name the repository has no value for is not a failure. The variable is left
unset rather than empty, the step's shell can tell the difference, and the
runner writes one line to the log saying which value was missing. That keeps
an optional secret optional and leaves "required" with the step, where someone
can write the message: ${DEPLOY_KEY:?DEPLOY_KEY is not set}.

The value reaches the step through a file the runner writes into the run
directory it already mounts at /run/vole for an event payload, and each step is
prefixed with `. /run/vole/secrets.env`. That is deliberately not an env-file
argument to the runtime, which cannot carry a line break, and not --env, which
puts the value in the process's argument list where every process on the host
can read it. A local `vole workflow run` writes the same file, reading the
values with the developer's own credential, and says which ones it could not
read.

The name rule and the 128 KiB limit moved to ci, next to the environment rules
they mirror, so the store refuses a name no workflow could declare and the
workflow refuses a name no process environment could carry.

Slice 2 of #154.

server/secret_test.go +111

@@ -8,6 +8,10 @@ import (
8 8 "path/filepath"
9 9 "strings"
10 10 "testing"
11 "time"
12
13 "github.com/rodent-software/vole/ci/runs"
14 "github.com/rodent-software/vole/forge/config"
11 15 )
12 16
13 17 // cliAs runs the built binary in dir with a chosen key and stdin, so a test can
@@ -234,3 +238,110 @@ func TestAMaintainerSeesTheSettingsTabForSecrets(t *testing.T) {
234 238 t.Fatalf("a non-maintainer's settings page = %d, want 404", resp.StatusCode)
235 239 }
236 240 }
241
242 func TestAJobReadsTheSecretsItDeclares(t *testing.T) {
243 t.Parallel()
244 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 })
245 f.publish(f.repo)
246
247 m, err := f.srv.Repos.Open(f.repo)
248 if err != nil {
249 t.Fatal(err)
250 }
251 // A value with a newline in it, because a key is not one line and the file the
252 // step sources has to keep the line breaks exact.
253 value := "s3cret\nsecond line"
254 if _, err := f.srv.Secrets.Set(m.ID, "DEPLOY_TOKEN", value, "test"); err != nil {
255 t.Fatal(err)
256 }
257
258 work := workRepo(t, workflowFile(`version: 1
259 jobs:
260 deploy:
261 image: alpine:3.22
262 secrets: [DEPLOY_TOKEN, NOT_SET]
263 steps:
264 - run: printf 'length=%s newlines=%s missing=%s\n' "${#DEPLOY_TOKEN}" "$(printf %s "$DEPLOY_TOKEN" | wc -l)" "${NOT_SET-unset}"
265 `))
266 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
267 t.Fatalf("push: %s", errs)
268 }
269 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
270 f.runOnce(t)
271 done := f.waitForRun(t, 20*time.Second, func(r *runs.Run) bool {
272 return r.Status != runs.StatusQueued && r.Status != runs.StatusRunning
273 })
274 if done.Status != runs.StatusSuccess {
275 code, body := f.get("/r/" + f.repo + "/runs/" + done.ID + "/log")
276 t.Fatalf("run = %s (exit %d), log %d:\n%s", done.Status, done.ExitCode, code, body)
277 }
278 if len(done.Secrets) != 2 || done.Secrets[0] != "DEPLOY_TOKEN" || done.Secrets[1] != "NOT_SET" {
279 t.Fatalf("pinned secrets = %v", done.Secrets)
280 }
281
282 code, log := f.get("/r/" + f.repo + "/runs/" + done.ID + "/log")
283 if code != 200 {
284 t.Fatalf("log = %d", code)
285 }
286 for _, want := range []string{
287 "length=18 newlines=1 missing=unset",
288 "NOT_SET is not set for this repository, so this job runs without it.",
289 } {
290 if !strings.Contains(log, want) {
291 t.Fatalf("the log has no %q:\n%s", want, log)
292 }
293 }
294 // The value reaches the step and not the log: the step prints its length, never
295 // the value, and a run record names the version rather than the value.
296 if strings.Contains(log, "s3cret") || strings.Contains(log, "second line") {
297 t.Fatalf("the log holds a value:\n%s", log)
298 }
299
300 rec, err := f.srv.Runs.Attempt(done.ID, 1)
301 if err != nil || rec == nil {
302 t.Fatalf("attempt: %v", err)
303 }
304 if len(rec.Secrets) != 1 || rec.Secrets[0].Name != "DEPLOY_TOKEN" || rec.Secrets[0].Version != 1 {
305 t.Fatalf("the record names %+v", rec.Secrets)
306 }
307 }
308
309 func TestAJobWithoutItsSecretStillRuns(t *testing.T) {
310 t.Parallel()
311 f := newFixtureWith(t, func(c *config.Config) { c.LeaseSeconds = 5 })
312 f.publish(f.repo)
313
314 work := workRepo(t, workflowFile(`version: 1
315 jobs:
316 deploy:
317 image: alpine:3.22
318 secrets: [DEPLOY_TOKEN]
319 steps:
320 - run: printf 'value=%s\n' "${DEPLOY_TOKEN-unset}"
321 `))
322 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
323 t.Fatalf("push: %s", errs)
324 }
325 f.waitForRun(t, 5*time.Second, func(r *runs.Run) bool { return r.Status == runs.StatusQueued })
326 f.runOnce(t)
327 done := f.waitForRun(t, 20*time.Second, func(r *runs.Run) bool {
328 return r.Status != runs.StatusQueued && r.Status != runs.StatusRunning
329 })
330 if done.Status != runs.StatusSuccess {
331 t.Fatalf("a job with an unset secret did not run: %s", done.Status)
332 }
333 _, log := f.get("/r/" + f.repo + "/runs/" + done.ID + "/log")
334 if !strings.Contains(log, "value=unset") {
335 t.Fatalf("the variable was not left unset:\n%s", log)
336 }
337 if !strings.Contains(log, "DEPLOY_TOKEN is not set for this repository, so this job runs without it.") {
338 t.Fatalf("the log does not say the secret was missing:\n%s", log)
339 }
340 rec, err := f.srv.Runs.Attempt(done.ID, 1)
341 if err != nil || rec == nil {
342 t.Fatalf("attempt: %v", err)
343 }
344 if len(rec.Secrets) != 0 {
345 t.Fatalf("the record names secrets it did not run with: %+v", rec.Secrets)
346 }
347 }