buildkit secrets support

- Secrets are never exposed in plaintext in the Cue tree. `dagger query`
  won't dump secrets anymore, Cue errors won't contain them either.
- BuildKit-native secrets support through a new `mount` type. This
  ensures secrets will never be part of containerd layers, buildkit
  cache and generally speaking will never be saved to disk in plaintext.
- Updated netlify as an example
- Added tests
- Changed the Cue definition of a secret to:

```
	@dagger(secret)

	id: string
}
```

This is to ensure both that setting the wrong input type on a secret
(e.g. `dagger input text`) will fail, and attempting to misuse the
secret (e.g. interpolating, passing as an env variable, etc) will also
fail properly.

Signed-off-by: Andrea Luzzardi <aluzzardi@gmail.com>
This commit is contained in:
Andrea Luzzardi 2021-05-25 18:56:16 -07:00
parent 15f4c4877d
commit 9c0e2d1d95
15 changed files with 244 additions and 59 deletions

View File

@ -2,7 +2,6 @@ package client
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
@ -87,13 +86,13 @@ func (c *Client) Do(ctx context.Context, state *state.State, fn DoFunc) (*enviro
// Spawn build function
eg.Go(func() error {
return c.buildfn(gctx, environment, fn, events)
return c.buildfn(gctx, state, environment, fn, events)
})
return environment, eg.Wait()
}
func (c *Client) buildfn(ctx context.Context, env *environment.Environment, fn DoFunc, ch chan *bk.SolveStatus) error {
func (c *Client) buildfn(ctx context.Context, st *state.State, env *environment.Environment, fn DoFunc, ch chan *bk.SolveStatus) error {
lg := log.Ctx(ctx)
// Scan local dirs to grant access
@ -109,10 +108,13 @@ func (c *Client) buildfn(ctx context.Context, env *environment.Environment, fn D
// buildkit auth provider (registry)
auth := solver.NewRegistryAuthProvider()
// secrets
secrets := solver.NewSecretsProvider(st)
// Setup solve options
opts := bk.SolveOpt{
LocalDirs: localdirs,
Session: []session.Attachable{auth},
Session: []session.Attachable{auth, secrets},
}
// Call buildkit solver
@ -127,6 +129,7 @@ func (c *Client) buildfn(ctx context.Context, env *environment.Environment, fn D
Gateway: gw,
Events: ch,
Auth: auth,
Secrets: secrets,
NoCache: c.noCache,
})
@ -165,7 +168,7 @@ func (c *Client) buildfn(ctx context.Context, env *environment.Environment, fn D
return res, nil
}, ch)
if err != nil {
return fmt.Errorf("buildkit solve: %w", bkCleanError(err))
return solver.CleanError(err)
}
for k, v := range resp.ExporterResponse {
// FIXME consume exporter response
@ -243,22 +246,3 @@ func (c *Client) logSolveStatus(ctx context.Context, ch chan *bk.SolveStatus) er
},
)
}
// A helper to remove noise from buildkit error messages.
// FIXME: Obviously a cleaner solution would be nice.
func bkCleanError(err error) error {
noise := []string{
"executor failed running ",
"buildkit-runc did not terminate successfully",
"rpc error: code = Unknown desc = ",
"failed to solve: ",
}
msg := err.Error()
for _, s := range noise {
msg = strings.ReplaceAll(msg, s, "")
}
return errors.New(msg)
}

View File

@ -44,7 +44,7 @@ func New(st *state.State) (*Environment, error) {
// Prepare inputs
for key, input := range st.Inputs {
v, err := input.Compile(st)
v, err := input.Compile(key, st)
if err != nil {
return nil, err
}
@ -86,7 +86,7 @@ func (e *Environment) LoadPlan(ctx context.Context, s solver.Solver) error {
span, ctx := opentracing.StartSpanFromContext(ctx, "environment.LoadPlan")
defer span.Finish()
planSource, err := e.state.PlanSource().Compile(e.state)
planSource, err := e.state.PlanSource().Compile("", e.state)
if err != nil {
return err
}
@ -157,7 +157,7 @@ func (e *Environment) LocalDirs() map[string]string {
}
// 2. Scan the plan
plan, err := e.state.PlanSource().Compile(e.state)
plan, err := e.state.PlanSource().Compile("", e.state)
if err != nil {
panic(err)
}

View File

@ -490,6 +490,25 @@ func (p *Pipeline) mount(ctx context.Context, dest string, mnt *compiler.Value)
return nil, fmt.Errorf("invalid mount source: %q", s)
}
}
// eg. mount: "/foo": secret: mysecret
if secret := mnt.Lookup("secret"); secret.Exists() {
if !secret.HasAttr("secret") {
return nil, fmt.Errorf("invalid secret %q: not a secret", secret.Path().String())
}
idValue := secret.Lookup("id")
if !idValue.Exists() {
return nil, fmt.Errorf("invalid secret %q: no id field", secret.Path().String())
}
id, err := idValue.String()
if err != nil {
return nil, fmt.Errorf("invalid secret id: %w", err)
}
return llb.AddSecret(dest,
llb.SecretID(id),
llb.SecretFileOpt(0, 0, 0400), // uid, gid, mask)
), nil
}
// eg. mount: "/foo": { from: www.source }
from := NewPipeline(mnt.Lookup("from"), p.s)
if err := from.Run(ctx); err != nil {

47
solver/secretsprovider.go Normal file
View File

@ -0,0 +1,47 @@
package solver
import (
"context"
"strings"
"github.com/moby/buildkit/session"
"github.com/moby/buildkit/session/secrets"
"github.com/moby/buildkit/session/secrets/secretsprovider"
"github.com/rs/zerolog/log"
"go.dagger.io/dagger/state"
)
func NewSecretsProvider(st *state.State) session.Attachable {
return secretsprovider.NewSecretProvider(&inputStore{st})
}
type inputStore struct {
st *state.State
}
func (s *inputStore) GetSecret(ctx context.Context, id string) ([]byte, error) {
lg := log.Ctx(ctx)
const secretPrefix = "secret="
if !strings.HasPrefix(id, secretPrefix) {
return nil, secrets.ErrNotFound
}
id = strings.TrimPrefix(id, secretPrefix)
input, ok := s.st.Inputs[id]
if !ok {
return nil, secrets.ErrNotFound
}
if input.Secret == nil {
return nil, secrets.ErrNotFound
}
lg.
Debug().
Str("id", id).
Msg("injecting secret")
return []byte(input.Secret.PlainText()), nil
}

View File

@ -3,7 +3,9 @@ package solver
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
bk "github.com/moby/buildkit/client"
"github.com/moby/buildkit/client/llb"
@ -25,6 +27,7 @@ type Opts struct {
Gateway bkgw.Client
Events chan *bk.SolveStatus
Auth *RegistryAuthProvider
Secrets session.Attachable
NoCache bool
}
@ -100,7 +103,11 @@ func (s Solver) ResolveImageConfig(ctx context.Context, ref string, opts llb.Res
// Solve will block until the state is solved and returns a Reference.
func (s Solver) SolveRequest(ctx context.Context, req bkgw.SolveRequest) (*bkgw.Result, error) {
return s.opts.Gateway.Solve(ctx, req)
res, err := s.opts.Gateway.Solve(ctx, req)
if err != nil {
return nil, CleanError(err)
}
return res, nil
}
// Solve will block until the state is solved and returns a Reference.
@ -150,7 +157,7 @@ func (s Solver) Export(ctx context.Context, st llb.State, img *dockerfile2llb.Im
opts := bk.SolveOpt{
Exports: []bk.ExportEntry{output},
Session: []session.Attachable{s.opts.Auth},
Session: []session.Attachable{s.opts.Auth, s.opts.Secrets},
}
ch := make(chan *bk.SolveStatus)
@ -204,3 +211,22 @@ func dumpLLB(def *bkpb.Definition) ([]byte, error) {
}
return json.Marshal(ops)
}
// A helper to remove noise from buildkit error messages.
// FIXME: Obviously a cleaner solution would be nice.
func CleanError(err error) error {
noise := []string{
"executor failed running ",
"buildkit-runc did not terminate successfully",
"rpc error: code = Unknown desc = ",
"failed to solve: ",
}
msg := err.Error()
for _, s := range noise {
msg = strings.ReplaceAll(msg, s, "")
}
return errors.New(msg)
}

View File

@ -37,24 +37,24 @@ type Input struct {
File *fileInput `yaml:"file,omitempty"`
}
func (i Input) Compile(state *State) (*compiler.Value, error) {
func (i Input) Compile(key string, state *State) (*compiler.Value, error) {
switch {
case i.Dir != nil:
return i.Dir.Compile(state)
return i.Dir.Compile(key, state)
case i.Git != nil:
return i.Git.Compile(state)
return i.Git.Compile(key, state)
case i.Docker != nil:
return i.Docker.Compile(state)
return i.Docker.Compile(key, state)
case i.Text != nil:
return i.Text.Compile(state)
return i.Text.Compile(key, state)
case i.Secret != nil:
return i.Secret.Compile(state)
return i.Secret.Compile(key, state)
case i.JSON != nil:
return i.JSON.Compile(state)
return i.JSON.Compile(key, state)
case i.YAML != nil:
return i.YAML.Compile(state)
return i.YAML.Compile(key, state)
case i.File != nil:
return i.File.Compile(state)
return i.File.Compile(key, state)
default:
return nil, fmt.Errorf("input has not been set")
}
@ -75,7 +75,7 @@ type dirInput struct {
Include []string `json:"include,omitempty"`
}
func (dir dirInput) Compile(state *State) (*compiler.Value, error) {
func (dir dirInput) Compile(_ string, state *State) (*compiler.Value, error) {
// FIXME: serialize an intermediate struct, instead of generating cue source
// json.Marshal([]string{}) returns []byte("null"), which wreaks havoc
@ -122,7 +122,7 @@ func GitInput(remote, ref, dir string) Input {
}
}
func (git gitInput) Compile(_ *State) (*compiler.Value, error) {
func (git gitInput) Compile(_ string, _ *State) (*compiler.Value, error) {
ref := "HEAD"
if git.Ref != "" {
ref = git.Ref
@ -148,7 +148,7 @@ type dockerInput struct {
Ref string `json:"ref,omitempty"`
}
func (i dockerInput) Compile(_ *State) (*compiler.Value, error) {
func (i dockerInput) Compile(_ string, _ *State) (*compiler.Value, error) {
panic("NOT IMPLEMENTED")
}
@ -162,7 +162,7 @@ func TextInput(data string) Input {
type textInput string
func (i textInput) Compile(_ *State) (*compiler.Value, error) {
func (i textInput) Compile(_ string, _ *State) (*compiler.Value, error) {
return compiler.Compile("", fmt.Sprintf("%q", i))
}
@ -176,8 +176,12 @@ func SecretInput(data string) Input {
type secretInput string
func (i secretInput) Compile(_ *State) (*compiler.Value, error) {
return compiler.Compile("", fmt.Sprintf("%q", i))
func (i secretInput) Compile(key string, _ *State) (*compiler.Value, error) {
return compiler.Compile("", fmt.Sprintf(`{id:%q}`, "secret="+key))
}
func (i secretInput) PlainText() string {
return string(i)
}
// An input value encoded as JSON
@ -190,7 +194,7 @@ func JSONInput(data string) Input {
type jsonInput string
func (i jsonInput) Compile(_ *State) (*compiler.Value, error) {
func (i jsonInput) Compile(_ string, _ *State) (*compiler.Value, error) {
return compiler.DecodeJSON("", []byte(i))
}
@ -204,7 +208,7 @@ func YAMLInput(data string) Input {
type yamlInput string
func (i yamlInput) Compile(_ *State) (*compiler.Value, error) {
func (i yamlInput) Compile(_ string, _ *State) (*compiler.Value, error) {
return compiler.DecodeYAML("", []byte(i))
}
@ -220,7 +224,7 @@ type fileInput struct {
Path string `json:"data,omitempty"`
}
func (i fileInput) Compile(_ *State) (*compiler.Value, error) {
func (i fileInput) Compile(_ string, _ *State) (*compiler.Value, error) {
data, err := ioutil.ReadFile(i.Path)
if err != nil {
return nil, err

View File

@ -15,9 +15,8 @@ import (
}
// Secret value
// FIXME: currently aliased as a string to mark secrets
// this requires proper support.
#Secret: {
@dagger(secret)
string | bytes
id: string
}

View File

@ -57,7 +57,7 @@ package op
// `true` means also ignoring the mount cache volumes
always?: true | *false
dir: string | *"/"
mount: [string]: "tmpfs" | "cache" | {from: _, path: string | *"/"}
mount: [string]: "tmpfs" | "cache" | {from: _, path: string | *"/"} | {secret: _}
// Map of hostnames to ip
hosts?: [string]: string
// User to exec with (if left empty, will default to the set user in the image)

View File

@ -81,9 +81,9 @@ import (
NETLIFY_DOMAIN: customDomain
}
NETLIFY_ACCOUNT: account.name
NETLIFY_AUTH_TOKEN: account.token
}
dir: "/src"
mount: "/src": from: contents
mount: "/token": secret: account.token
}
}

View File

@ -1,6 +1,8 @@
package netlify
#Site: ctr: command: #"""
export NETLIFY_AUTH_TOKEN="$(cat /token)"
create_site() {
url="https://api.netlify.com/api/v1/${NETLIFY_ACCOUNT:-}/sites"

View File

@ -52,6 +52,8 @@ import (
mount: [string]: {
from: dagger.#Artifact
// FIXME: support source path
} | {
secret: dagger.#Secret
}
// Mount persistent cache directories
@ -97,7 +99,6 @@ import (
args: [shell.path] + shell.args + [cmd]
"env": env
"dir": dir
"always": always
}
},
// Execute main command with volumes
@ -109,7 +110,7 @@ import (
"always": always
"mount": {
for dest, o in mount {
"\(dest)": from: o.from
"\(dest)": o
// FIXME: support source path
}
for dest in cache {

View File

@ -67,6 +67,33 @@ setup() {
assert_line '{"in":"foobar","test":"received: foobar"}'
}
@test "compute: secrets" {
# secrets used as environment variables must fail
run "$DAGGER" compute "$TESTDIR"/compute/secrets/invalid/env
assert_failure
assert_line --partial "conflicting values"
# strings passed as secrets must fail
run "$DAGGER" compute "$TESTDIR"/compute/secrets/invalid/string
assert_failure
# Setting a text input for a secret value should fail
run "$DAGGER" compute --input-string 'mySecret=SecretValue' "$TESTDIR"/compute/secrets/simple
assert_failure
# Now test with an actual secret and make sure it works
"$DAGGER" init
dagger_new_with_plan secrets "$TESTDIR"/compute/secrets/simple
"$DAGGER" input secret mySecret SecretValue
run "$DAGGER" up
assert_success
# Make sure the secret doesn't show in dagger query
run "$DAGGER" query mySecret.id -f text
assert_success
assert_output "secret=mySecret"
}
@test ".daggerignore" {
"$DAGGER" compute --input-dir TestData="$TESTDIR"/compute/ignore/testdata "$TESTDIR"/compute/ignore
}

View File

@ -0,0 +1,21 @@
package testing
import (
"dagger.io/dagger"
"dagger.io/dagger/op"
"dagger.io/alpine"
)
mySecret: dagger.#Secret
TestSecrets: #up: [
op.#Load & {
from: alpine.#Image & {
package: bash: "=~5.1"
}
},
op.#Exec & {
env: foo: mySecret
},
]

View File

@ -0,0 +1,21 @@
package testing
import (
"dagger.io/dagger/op"
"dagger.io/alpine"
)
mySecret: dagger.#Secret
TestString: #up: [
op.#Load & {
from: alpine.#Image & {
package: bash: "=~5.1"
}
},
op.#Exec & {
mount: "/secret": secret: mySecret
args: ["true"]
},
]

View File

@ -0,0 +1,34 @@
package testing
import (
"dagger.io/dagger"
"dagger.io/dagger/op"
"dagger.io/alpine"
)
mySecret: dagger.#Secret
TestSecrets: #up: [
op.#Load & {
from: alpine.#Image & {
package: bash: "=~5.1"
}
},
op.#Exec & {
mount: "/secret": secret: mySecret
env: PLAIN: mySecret.id
args: [
"/bin/bash",
"--noprofile",
"--norc",
"-eo",
"pipefail",
"-c",
#"""
test "$(cat /secret)" = "SecretValue"
test "$PLAIN" != "SecretValue"
"""#,
]
},
]