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.

ci/container/container_test.go +67

@@ -1,6 +1,8 @@
1 1 package container
2 2
3 3 import (
4 "context"
5 "os/exec"
4 6 "strings"
5 7 "testing"
6 8 )
@@ -155,3 +157,68 @@ func TestInvalidVolumePathsAreRefused(t *testing.T) {
155 157 t.Fatalf("a valid spec was refused: %v", err)
156 158 }
157 159 }
160
161 // A secret's value must reach the step's shell exactly, and must never be an
162 // argument to the runtime. These two tests are the two halves of that: the file the
163 // step sources, and the command line that sources it.
164 func TestSecretsEnvIsQuotedForTheShell(t *testing.T) {
165 t.Parallel()
166 values := map[string]string{
167 "PLAIN": "s3cret",
168 "MULTI": "line one\nline two",
169 "QUOTES": "it's a 'value'",
170 "MONEY": "$HOME `id` $(id)",
171 }
172 script, err := SecretsEnv(values)
173 if err != nil {
174 t.Fatal(err)
175 }
176 // Read them back through the same shell the steps use: a value that survives
177 // the round trip is one the step sees unchanged.
178 cmd := exec.Command("/bin/sh", "-c", string(script)+`
179 printf '%s|%s|%s|%s' "$PLAIN" "$MULTI" "$QUOTES" "$MONEY"`)
180 out, err := cmd.Output()
181 if err != nil {
182 t.Fatalf("the generated file did not run: %v\n%s", err, script)
183 }
184 want := "s3cret|line one\nline two|it's a 'value'|$HOME `id` $(id)"
185 if string(out) != want {
186 t.Fatalf("read back %q, want %q\n%s", out, want, script)
187 }
188
189 if _, err := SecretsEnv(map[string]string{"KEY": "a\x00b"}); err == nil {
190 t.Fatal("a value with a NUL byte was rendered")
191 }
192 }
193
194 func TestAStepSourcesTheSecretsFileFirst(t *testing.T) {
195 t.Parallel()
196 box := &Container{cfg: Config{Runtime: "docker"}, name: "job", work: t.TempDir(), secrets: "/run/vole/secrets.env"}
197 args := box.Exec(context.Background(), "echo hi").Args
198 script := args[len(args)-1]
199 if !strings.HasPrefix(script, ". /run/vole/secrets.env\necho hi") {
200 t.Fatalf("the step does not source the file first: %q", script)
201 }
202 // A value never becomes an argument, so no part of the command line carries one.
203 for _, arg := range args {
204 if strings.Contains(arg, "s3cret") {
205 t.Fatalf("a value reached the command line: %q", arg)
206 }
207 }
208
209 plain := (&Container{cfg: Config{Runtime: "docker"}, name: "job", work: t.TempDir()}).Exec(context.Background(), "echo hi")
210 if got := plain.Args[len(plain.Args)-1]; got != "echo hi" {
211 t.Fatalf("a job with no secrets had its script changed: %q", got)
212 }
213 }
214
215 func TestASecretsFileMustBeAContainerPath(t *testing.T) {
216 t.Parallel()
217 err := ValidatePaths(Spec{Work: "/work/job", SecretsFile: "secrets.env"})
218 if err == nil {
219 t.Fatal("a relative secrets path was accepted")
220 }
221 if err := ValidatePaths(Spec{Work: "/work/job", SecretsFile: "/run/vole/secrets.env"}); err != nil {
222 t.Fatalf("a container path was refused: %v", err)
223 }
224 }