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 | |