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.go +50−4

@@ -139,6 +139,12 @@ type Spec struct {
139 139 // Env is the job's environment on top of the fixed set below. Names here cannot
140 140 // collide with the reserves: the workflow validator refuses those.
141 141 Env map[string]string
142 // SecretsFile, when set, is the path of a file inside the container holding
143 // `export NAME='value'` lines. Every step sources it before its own script, so a
144 // secret reaches the step's shell and never an argv: the runtime's own argument
145 // list is visible to every process on the host. It is a value the caller writes
146 // into the job tree, so the caller owns what is in it.
147 SecretsFile string
142 148 }
143 149
144 150 // ValidatePaths refuses a spec whose paths cannot be passed as `--volume`
@@ -151,6 +157,12 @@ func ValidatePaths(s Spec) error {
151 157 for _, m := range s.Mounts {
152 158 paths = append(paths, m.Host, m.Path)
153 159 }
160 if s.SecretsFile != "" {
161 if !strings.HasPrefix(s.SecretsFile, "/") {
162 return fmt.Errorf("the secrets file %q is not a path in the container", s.SecretsFile)
163 }
164 paths = append(paths, s.SecretsFile)
165 }
154 166 for _, p := range paths {
155 167 if p == "" {
156 168 return fmt.Errorf("container path is empty")
@@ -235,11 +247,38 @@ func RunArgs(cfg Config, s Spec) []string {
235 247 )
236 248 }
237 249
250 // SecretsEnv renders the values a job runs with as the file a step sources. Each
251 // line exports one variable, and the value is POSIX single-quoted, so a quote
252 // becomes '\” and a newline stays a newline; nothing is interpolated and no value
253 // is part of any command line. Names are sorted so the file is the same bytes for
254 // the same values.
255 func SecretsEnv(values map[string]string) ([]byte, error) {
256 names := make([]string, 0, len(values))
257 for name := range values {
258 names = append(names, name)
259 }
260 sort.Strings(names)
261 var b strings.Builder
262 for _, name := range names {
263 value := values[name]
264 if strings.ContainsRune(value, 0) {
265 // A NUL would end the string the shell reads and turn the rest into a
266 // command, so it cannot be written even though the store refuses it.
267 return nil, fmt.Errorf("secret %s contains a NUL byte", name)
268 }
269 b.WriteString("export " + name + "='")
270 b.WriteString(strings.ReplaceAll(value, "'", `'\''`))
271 b.WriteString("'\n")
272 }
273 return []byte(b.String()), nil
274 }
275
238 276 // Container is one running container.
239 277 type Container struct {
240 cfg Config
241 name string
242 work string
278 cfg Config
279 name string
280 work string
281 secrets string
243 282 }
244 283
245 284 // Start creates the container and waits for it to be running. A runtime that
@@ -249,7 +288,7 @@ func Start(cfg Config, s Spec) (*Container, error) {
249 288 if err := ValidatePaths(s); err != nil {
250 289 return nil, err
251 290 }
252 c := &Container{cfg: cfg, name: s.Name, work: s.Work}
291 c := &Container{cfg: cfg, name: s.Name, work: s.Work, secrets: s.SecretsFile}
253 292 cmd := exec.Command(cfg.Runtime, RunArgs(cfg, s)...)
254 293 var stderr strings.Builder
255 294 cmd.Stderr = &stderr
@@ -265,6 +304,13 @@ func Start(cfg Config, s Spec) (*Container, error) {
265 304 // which is what gives it its own log, timeout and exit status while keeping the
266 305 // filesystem and background processes shared with the steps around it.
267 306 func (c *Container) Exec(ctx context.Context, script string) *exec.Cmd {
307 // A step's script is prefixed with the secrets file rather than handed the
308 // values, so each step's shell exports them and no value appears in the
309 // runtime's argument list. A step that shadows one of its own is the step's
310 // business: the file is its own environment.
311 if c.secrets != "" {
312 script = ". " + c.secrets + "\n" + script
313 }
268 314 cmd := exec.CommandContext(ctx, c.cfg.Runtime, "exec",
269 315 "-w", "/workspace", c.name, "/bin/sh", "-c", script)
270 316 // The runtime ignores the client's working directory, but keeping it at the job