diff --git a/builder/build.go b/builder/build.go index 5cdfd285..c3c1839c 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 7ef3c24a..f0078d54 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 873717cf..7d183c01 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 diff --git a/builder/remote_builder.go b/builder/remote_builder.go index 37180ea9..8d2a53e9 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 8a8be903..44ec19d4 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") + } +}