From c2c71987663e9a12f05be3ea10606c138b52b755 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Wed, 1 Apr 2026 15:33:15 +0200 Subject: [PATCH 1/2] Add support for build secrets in local Docker builds Previously, build_secrets defined in stack.yml were only used when building with the remote builder. For local docker build and docker buildx build commands, the secrets were silently ignored. This change ports the build secrets support from the pro plugin so that local builds pass --secret id=,src= flags to Docker. DOCKER_BUILDKIT=1 is also set automatically when build secrets are present, since BuildKit is required for the --secret flag. Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- builder/build.go | 36 ++++++++++- builder/build_test.go | 146 ++++++++++++++++++++++++++++++++++++++++++ builder/publish.go | 12 ++++ 3 files changed, 193 insertions(+), 1 deletion(-) diff --git a/builder/build.go b/builder/build.go index 5cdfd285a..c3c1839ca 100644 --- a/builder/build.go +++ b/builder/build.go @@ -114,6 +114,11 @@ func BuildImage(image string, handler string, functionName string, language stri fmt.Printf("Building: %s with %s template. Please wait..\n", imageName, language) + buildSecrets, err = resolveSecretPaths(buildSecrets) + if err != nil { + return err + } + if remoteBuilder != "" { tempDir, err := os.MkdirTemp(os.TempDir(), "openfaas-build-*") if err != nil { @@ -152,12 +157,13 @@ func BuildImage(image string, handler string, functionName string, language stri BuildArgMap: buildArgMap, BuildLabelMap: buildLabelMap, ForcePull: forcePull, + BuildSecrets: buildSecrets, } command, args := getDockerBuildCommand(dockerBuildVal) envs := os.Environ() - if mountSSH { + if mountSSH || len(buildSecrets) > 0 { envs = append(envs, "DOCKER_BUILDKIT=1") } log.Printf("Build flags: %+v\n", args) @@ -289,6 +295,12 @@ func getDockerBuildCommand(build dockerBuild) (string, []string) { args = append(args, "--tag", build.Image, ".") + if len(build.BuildSecrets) > 0 { + for k, v := range build.BuildSecrets { + args = append(args, "--secret", fmt.Sprintf("id=%s,src=%s", k, v)) + } + } + command := "docker" return command, args @@ -311,6 +323,8 @@ type dockerBuild struct { ExtraTags []string ForcePull bool + + BuildSecrets map[string]string } // pathInScope returns the absolute path to `path` and ensures that it is located within the @@ -450,6 +464,26 @@ func getPackages(availableBuildOptions []stack.BuildOption, requestedBuildOption return deDuplicate(buildPackages), true } +// resolveSecretPaths converts relative build secret file paths to absolute +// paths. Relative paths are resolved against the current working directory, +// consistent with how other relative paths (e.g. handler) are handled in +// faas-cli. Absolute paths are returned unchanged. +func resolveSecretPaths(secrets map[string]string) (map[string]string, error) { + if len(secrets) == 0 { + return secrets, nil + } + + resolved := make(map[string]string, len(secrets)) + for k, v := range secrets { + absPath, err := filepath.Abs(v) + if err != nil { + return nil, fmt.Errorf("unable to resolve path for build secret %q: %w", k, err) + } + resolved[k] = absPath + } + return resolved, nil +} + func deDuplicate(buildOptPackages []string) []string { seenPackages := map[string]bool{} retPackages := []string{} diff --git a/builder/build_test.go b/builder/build_test.go index 7ef3c24a8..f0078d54e 100644 --- a/builder/build_test.go +++ b/builder/build_test.go @@ -2,6 +2,7 @@ package builder import ( "fmt" + "os" "path/filepath" "reflect" "strings" @@ -115,6 +116,87 @@ func Test_getDockerBuildCommand_WithBuildArg(t *testing.T) { } } +func Test_getDockerBuildCommand_WithBuildSecrets(t *testing.T) { + dockerBuildVal := dockerBuild{ + Image: "imagename:latest", + NoCache: false, + Squash: false, + BuildArgMap: make(map[string]string), + BuildSecrets: map[string]string{ + "npmrc": "/home/user/.npmrc", + "netrc": "/home/user/.netrc", + }, + } + + _, values := getDockerBuildCommand(dockerBuildVal) + + joined := strings.Join(values, " ") + wantSecret1 := "--secret id=npmrc,src=/home/user/.npmrc" + wantSecret2 := "--secret id=netrc,src=/home/user/.netrc" + + if !strings.Contains(joined, wantSecret1) { + t.Errorf("want %s in %s, but didn't find it", wantSecret1, joined) + } + if !strings.Contains(joined, wantSecret2) { + t.Errorf("want %s in %s, but didn't find it", wantSecret2, joined) + } +} + +func Test_getDockerBuildCommand_NoBuildSecrets(t *testing.T) { + dockerBuildVal := dockerBuild{ + Image: "imagename:latest", + NoCache: false, + Squash: false, + BuildArgMap: make(map[string]string), + } + + _, values := getDockerBuildCommand(dockerBuildVal) + + joined := strings.Join(values, " ") + if strings.Contains(joined, "--secret") { + t.Errorf("did not expect --secret in %s", joined) + } +} + +func Test_getDockerBuildxCommand_WithBuildSecrets(t *testing.T) { + dockerBuildVal := dockerBuild{ + Image: "imagename:latest", + NoCache: false, + Squash: false, + BuildArgMap: make(map[string]string), + Platforms: "linux/amd64", + BuildSecrets: map[string]string{ + "pipconf": "/home/user/.config/pip/pip.conf", + }, + } + + _, values := getDockerBuildxCommand(dockerBuildVal) + + joined := strings.Join(values, " ") + wantSecret := "--secret id=pipconf,src=/home/user/.config/pip/pip.conf" + + if !strings.Contains(joined, wantSecret) { + t.Errorf("want %s in %s, but didn't find it", wantSecret, joined) + } +} + +func Test_getDockerBuildxCommand_NoBuildSecrets(t *testing.T) { + dockerBuildVal := dockerBuild{ + Image: "imagename:latest", + NoCache: false, + Squash: false, + BuildArgMap: make(map[string]string), + Platforms: "linux/amd64", + } + + _, values := getDockerBuildxCommand(dockerBuildVal) + + joined := strings.Join(values, " ") + if strings.Contains(joined, "--secret") { + t.Errorf("did not expect --secret in %s", joined) + } +} + func Test_buildFlagSlice(t *testing.T) { var buildFlagOpts = []struct { @@ -612,3 +694,67 @@ func Test_appendAdditionalPackages(t *testing.T) { }) } } + +func Test_resolveSecretPaths_RelativePaths(t *testing.T) { + secrets := map[string]string{ + "npmrc": ".secrets/npmrc", + "api_key": "secrets/api_key.txt", + } + + resolved, err := resolveSecretPaths(secrets) + if err != nil { + t.Fatalf("resolveSecretPaths returned error: %v", err) + } + + for k, v := range resolved { + if !filepath.IsAbs(v) { + t.Errorf("expected absolute path for %q, got %q", k, v) + } + } + + // Verify relative paths were resolved against CWD + cwd, _ := os.Getwd() + if want := filepath.Join(cwd, ".secrets/npmrc"); resolved["npmrc"] != want { + t.Errorf("want %q, got %q", want, resolved["npmrc"]) + } + if want := filepath.Join(cwd, "secrets/api_key.txt"); resolved["api_key"] != want { + t.Errorf("want %q, got %q", want, resolved["api_key"]) + } +} + +func Test_resolveSecretPaths_AbsolutePaths(t *testing.T) { + secrets := map[string]string{ + "npmrc": "/home/user/.npmrc", + "netrc": "/home/user/.netrc", + } + + resolved, err := resolveSecretPaths(secrets) + if err != nil { + t.Fatalf("resolveSecretPaths returned error: %v", err) + } + + if resolved["npmrc"] != "/home/user/.npmrc" { + t.Errorf("expected absolute path unchanged, got %q", resolved["npmrc"]) + } + if resolved["netrc"] != "/home/user/.netrc" { + t.Errorf("expected absolute path unchanged, got %q", resolved["netrc"]) + } +} + +func Test_resolveSecretPaths_EmptyMap(t *testing.T) { + resolved, err := resolveSecretPaths(nil) + if err != nil { + t.Fatalf("resolveSecretPaths returned error: %v", err) + } + if resolved != nil { + t.Errorf("expected nil for nil input, got %v", resolved) + } + + resolved, err = resolveSecretPaths(map[string]string{}) + if err != nil { + t.Fatalf("resolveSecretPaths returned error: %v", err) + } + if len(resolved) != 0 { + t.Errorf("expected empty map, got %v", resolved) + } +} diff --git a/builder/publish.go b/builder/publish.go index 873717cfe..7d183c011 100644 --- a/builder/publish.go +++ b/builder/publish.go @@ -67,6 +67,11 @@ func PublishImage(image string, handler string, functionName string, language st fmt.Printf("Building: %s with %s template. Please wait..\n", imageName, language) + buildSecrets, err = resolveSecretPaths(buildSecrets) + if err != nil { + return err + } + if remoteBuilder != "" { if forcePull { @@ -114,6 +119,7 @@ func PublishImage(image string, handler string, functionName string, language st Platforms: platforms, ExtraTags: extraTags, ForcePull: forcePull, + BuildSecrets: buildSecrets, } command, args := getDockerBuildxCommand(dockerBuildVal) @@ -170,6 +176,12 @@ func getDockerBuildxCommand(build dockerBuild) (string, []string) { args = append(args, "--tag", tag) } + if len(build.BuildSecrets) > 0 { + for k, v := range build.BuildSecrets { + args = append(args, "--secret", fmt.Sprintf("id=%s,src=%s", k, v)) + } + } + command := "docker" return command, args From 34c8dceb01d0aa95f30fff4c99095100975d3bc0 Mon Sep 17 00:00:00 2001 From: "Han Verstraete (OpenFaaS Ltd)" Date: Wed, 1 Apr 2026 16:11:19 +0200 Subject: [PATCH 2/2] Read build secret files before sending to remote builder For parity with local builds, read file contents from build_secrets paths in stack.yaml before sealing and sending to the remote builder. Literal secret values are no longer supported. Signed-off-by: Han Verstraete (OpenFaaS Ltd) --- builder/remote_builder.go | 21 ++++++++++++- builder/remote_builder_test.go | 57 ++++++++++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/builder/remote_builder.go b/builder/remote_builder.go index 37180ea9a..8d2a53e90 100644 --- a/builder/remote_builder.go +++ b/builder/remote_builder.go @@ -45,7 +45,11 @@ func runRemoteBuild(builderURL *url.URL, tarPath, payloadSecretPath, builderPubl var stream *sdkbuilder.BuildResultStream if len(buildSecrets) > 0 { - stream, err = b.BuildWithSecretsStream(tarPath, buildSecrets) + resolvedSecrets, err := readBuildSecrets(buildSecrets) + if err != nil { + return err + } + stream, err = b.BuildWithSecretsStream(tarPath, resolvedSecrets) } else { stream, err = b.BuildWithStream(tarPath) } @@ -57,6 +61,21 @@ func runRemoteBuild(builderURL *url.URL, tarPath, payloadSecretPath, builderPubl return consumeBuildStream(stream, quietBuild, functionName, imageName) } +// readBuildSecrets resolves build secret values by reading file contents. +// Each value in the buildSecrets map is treated as a file path. The file +// is read and its contents replace the path in the returned map. +func readBuildSecrets(buildSecrets map[string]string) (map[string]string, error) { + resolved := make(map[string]string, len(buildSecrets)) + for k, v := range buildSecrets { + data, err := os.ReadFile(v) + if err != nil { + return nil, fmt.Errorf("unable to read build secret %q from %s: %w", k, v, err) + } + resolved[k] = string(data) + } + return resolved, nil +} + func resolveRemoteBuilderPublicKey(builderURL *url.URL, builderPublicKeyPath string) (*remoteBuilderPublicKeyResponse, error) { if builderPublicKeyPath == "" { return fetchRemoteBuilderPublicKey(builderURL) diff --git a/builder/remote_builder_test.go b/builder/remote_builder_test.go index 8a8be9032..44ec19d44 100644 --- a/builder/remote_builder_test.go +++ b/builder/remote_builder_test.go @@ -109,7 +109,7 @@ func TestRunRemoteBuildWithSecrets(t *testing.T) { } if err := runRemoteBuild(builderURL, tarPath, payloadSecretPath, "", map[string]string{ - "pip_token": "s3cr3t", + "pip_token": writeTempSecret(t, "s3cr3t"), }, true, "fn", "ttl.sh/test:latest"); err != nil { t.Fatalf("runRemoteBuild returned error: %v", err) } @@ -160,7 +160,7 @@ func TestRunRemoteBuildWithPinnedPublicKey(t *testing.T) { } if err := runRemoteBuild(builderURL, tarPath, payloadSecretPath, publicKeyPath, map[string]string{ - "pip_token": "s3cr3t", + "pip_token": writeTempSecret(t, "s3cr3t"), }, true, "fn", "ttl.sh/test:latest"); err != nil { t.Fatalf("runRemoteBuild returned error: %v", err) } @@ -198,7 +198,7 @@ func TestRunRemoteBuildWithLiteralPublicKey(t *testing.T) { } if err := runRemoteBuild(builderURL, tarPath, payloadSecretPath, string(pub), map[string]string{ - "pip_token": "s3cr3t", + "pip_token": writeTempSecret(t, "s3cr3t"), }, true, "fn", "ttl.sh/test:latest"); err != nil { t.Fatalf("runRemoteBuild returned error: %v", err) } @@ -224,3 +224,54 @@ func createTestTar(t *testing.T) []byte { } return buf.Bytes() } + +func writeTempSecret(t *testing.T, content string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "secret") + if err := os.WriteFile(p, []byte(content), 0o600); err != nil { + t.Fatalf("os.WriteFile returned error: %v", err) + } + return p +} + +func TestReadBuildSecrets(t *testing.T) { + secretDir := t.TempDir() + + npmrcPath := filepath.Join(secretDir, ".npmrc") + if err := os.WriteFile(npmrcPath, []byte("//registry.npmjs.org/:_authToken=TOKEN123"), 0o600); err != nil { + t.Fatal(err) + } + + netrcPath := filepath.Join(secretDir, ".netrc") + if err := os.WriteFile(netrcPath, []byte("machine github.com login user password pat"), 0o600); err != nil { + t.Fatal(err) + } + + secrets := map[string]string{ + "npmrc": npmrcPath, + "netrc": netrcPath, + } + + resolved, err := readBuildSecrets(secrets) + if err != nil { + t.Fatalf("readBuildSecrets returned error: %v", err) + } + + if got := resolved["npmrc"]; got != "//registry.npmjs.org/:_authToken=TOKEN123" { + t.Errorf("want npmrc %q, got %q", "//registry.npmjs.org/:_authToken=TOKEN123", got) + } + if got := resolved["netrc"]; got != "machine github.com login user password pat" { + t.Errorf("want netrc %q, got %q", "machine github.com login user password pat", got) + } +} + +func TestReadBuildSecrets_FileNotFound(t *testing.T) { + secrets := map[string]string{ + "missing": "/nonexistent/path/secret.txt", + } + + _, err := readBuildSecrets(secrets) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +}