diff --git a/cmd/up.go b/cmd/up.go index 633fe2f8d..c3ff78439 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -56,8 +56,9 @@ type UpCmd struct { OpenIDE bool Reconfigure bool - SSHConfigPath string - SecretsFile string + SSHConfigPath string + SecretsFile string + FeatureSecretsFile string DotfilesSource string DotfilesScript string @@ -344,6 +345,10 @@ func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) { upCmd.Flags(). StringVar(&cmd.SecretsFile, "secrets-file", "", "Path to a dotenv-style file containing KEY=VALUE secrets injected into lifecycle commands") + upCmd.Flags(). + StringVar(&cmd.FeatureSecretsFile, "feature-secrets-file", "", + "Path to a JSON file containing secret values for features, format: "+ + `{"featureId": {"optionName": "value"}}`) upCmd.Flags(). StringArrayVar(&cmd.InitEnv, "init-env", []string{}, "Extra env variables to inject during the initialization of the workspace, e.g. MY_ENV_VAR=MY_VALUE") @@ -885,6 +890,13 @@ func (cmd *UpCmd) prepareClient( } } + if cmd.FeatureSecretsFile == "" { + cmd.FeatureSecretsFile = os.Getenv("DEVCONTAINER_SECRETS_FILE") + } + if cmd.FeatureSecretsFile != "" { + cmd.CLIOptions.FeatureSecretsFile = cmd.FeatureSecretsFile + } + cmd.WorkspaceEnv = options2.InheritFromEnvironment( cmd.WorkspaceEnv, inheritedEnvironmentVariables, diff --git a/e2e/tests/up-features/testdata/docker-features-secret-option/.devcontainer.json b/e2e/tests/up-features/testdata/docker-features-secret-option/.devcontainer.json new file mode 100644 index 000000000..d6b2384f7 --- /dev/null +++ b/e2e/tests/up-features/testdata/docker-features-secret-option/.devcontainer.json @@ -0,0 +1,6 @@ +{ + "image": "ghcr.io/devsy-org/test-images/base:alpine", + "features": { + "./features/secret-test": {} + } +} diff --git a/e2e/tests/up-features/testdata/docker-features-secret-option/features/secret-test/devcontainer-feature.json b/e2e/tests/up-features/testdata/docker-features-secret-option/features/secret-test/devcontainer-feature.json new file mode 100644 index 000000000..9eb098dcc --- /dev/null +++ b/e2e/tests/up-features/testdata/docker-features-secret-option/features/secret-test/devcontainer-feature.json @@ -0,0 +1,17 @@ +{ + "id": "secret-test", + "version": "1.0.0", + "name": "Secret Test Feature", + "description": "Feature for testing secret option handling", + "options": { + "secretToken": { + "type": "secret", + "description": "A secret token for testing" + }, + "publicOption": { + "type": "string", + "default": "hello", + "description": "A non-secret option" + } + } +} diff --git a/e2e/tests/up-features/testdata/docker-features-secret-option/features/secret-test/install.sh b/e2e/tests/up-features/testdata/docker-features-secret-option/features/secret-test/install.sh new file mode 100644 index 000000000..eddc4fac3 --- /dev/null +++ b/e2e/tests/up-features/testdata/docker-features-secret-option/features/secret-test/install.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +echo "Installing secret-test feature" +echo "Secret token value: ${SECRETTOKEN}" +echo "Public option value: ${PUBLICOPTION}" + +# Write the secret value to a file for verification +echo "${SECRETTOKEN}" >/secret-test-result.txt diff --git a/e2e/tests/up-features/up_features.go b/e2e/tests/up-features/up_features.go index e1565b8d0..546847b99 100644 --- a/e2e/tests/up-features/up_features.go +++ b/e2e/tests/up-features/up_features.go @@ -680,4 +680,34 @@ var _ = ginkgo.Describe("testing up command", ginkgo.Label("up-features", "suite }, ginkgo.SpecTimeout(framework.TimeoutShort()), ) + + ginkgo.It( + "should resolve secret options from environment variables", + ginkgo.Label("features", "secret-option"), + func(ctx context.Context) { + f, err := setupDockerProvider(initialDir+"/bin", "docker") + framework.ExpectNoError(err) + + tempDir, err := framework.CopyToTempDir( + "tests/up-features/testdata/docker-features-secret-option", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + + wsName := filepath.Base(tempDir) + ginkgo.DeferCleanup(f.DevsyWorkspaceDelete, wsName) + + ginkgo.GinkgoT().Setenv( + "DEVCONTAINER_FEATURE_SECRET__FEATURES_SECRET_TEST_SECRETTOKEN", "e2e-test-secret", + ) + + err = f.DevsyUp(ctx, tempDir) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, wsName, "cat /secret-test-result.txt") + framework.ExpectNoError(err) + gomega.Expect(strings.TrimSpace(out)).To(gomega.Equal("e2e-test-secret")) + }, + ginkgo.SpecTimeout(framework.TimeoutShort()), + ) }) diff --git a/pkg/agent/tunnel/tunnel_grpc.pb.go b/pkg/agent/tunnel/tunnel_grpc.pb.go index ec69ce4c7..e6167ea99 100644 --- a/pkg/agent/tunnel/tunnel_grpc.pb.go +++ b/pkg/agent/tunnel/tunnel_grpc.pb.go @@ -28,7 +28,7 @@ const ( Tunnel_GitCredentials_FullMethodName = "/tunnel.Tunnel/GitCredentials" Tunnel_GitSSHSignature_FullMethodName = "/tunnel.Tunnel/GitSSHSignature" Tunnel_GitUser_FullMethodName = "/tunnel.Tunnel/GitUser" - Tunnel_DevsyConfig_FullMethodName = "/tunnel.Tunnel/DevsyConfig" + Tunnel_DevsyConfig_FullMethodName = "/tunnel.Tunnel/DevsyConfig" Tunnel_GPGPublicKeys_FullMethodName = "/tunnel.Tunnel/GPGPublicKeys" Tunnel_KubeConfig_FullMethodName = "/tunnel.Tunnel/KubeConfig" Tunnel_ForwardPort_FullMethodName = "/tunnel.Tunnel/ForwardPort" diff --git a/pkg/devcontainer/build.go b/pkg/devcontainer/build.go index ec330b22c..a18519b8b 100644 --- a/pkg/devcontainer/build.go +++ b/pkg/devcontainer/build.go @@ -77,6 +77,7 @@ func (r *runner) extendImage( imageBase, parsedConfig, options.ForceBuild, + featureSecretOpts(options), ) if err != nil { return nil, fmt.Errorf("get extended build info: %w", err) @@ -158,6 +159,7 @@ func (r *runner) buildAndExtendImage( imageBase, parsedConfig, options.ForceBuild, + featureSecretOpts(options), ) if err != nil { return nil, fmt.Errorf("get extended build info: %w", err) @@ -477,6 +479,7 @@ func (r *runner) buildDevImageCompose( composeHelper, &composeService, composeGlobalArgs, + options.FeatureSecretsFile, ) if err != nil { return nil, fmt.Errorf("build and extend docker-compose: %w", err) @@ -582,3 +585,10 @@ func cleanupBuildInformation(c *config.DevContainerConfig) { contextPath := config.GetContextPath(c) _ = os.RemoveAll(filepath.Join(contextPath, config.DevsyContextFeatureFolder)) } + +func featureSecretOpts(options provider.BuildOptions) *feature.SecretOptions { + if options.FeatureSecretsFile == "" { + return nil + } + return &feature.SecretOptions{SecretsFile: options.FeatureSecretsFile} +} diff --git a/pkg/devcontainer/compose.go b/pkg/devcontainer/compose.go index 82c4f8083..28bcb63b3 100644 --- a/pkg/devcontainer/compose.go +++ b/pkg/devcontainer/compose.go @@ -473,6 +473,7 @@ func (r *runner) startContainer( composeHelper, &composeService, composeGlobalArgs, + options.FeatureSecretsFile, ) if err != nil { return nil, fmt.Errorf("build and extend docker-compose: %w", err) @@ -658,6 +659,7 @@ func (r *runner) buildAndExtendDockerCompose( composeHelper *compose.ComposeHelper, composeService *composetypes.ServiceConfig, globalArgs []string, + featureSecretsFile string, ) (composeExtendResult, error) { var dockerFilePath, dockerfileContents, dockerComposeFilePath string var imageBuildInfo *config.ImageBuildInfo @@ -679,12 +681,17 @@ func (r *runner) buildAndExtendDockerCompose( dockerfileContents = buildInfo.dockerfileContents buildTarget = buildInfo.buildTarget + var secretOpts *feature.SecretOptions + if featureSecretsFile != "" { + secretOpts = &feature.SecretOptions{SecretsFile: featureSecretsFile} + } extendImageBuildInfo, err := feature.GetExtendedBuildInfo( substitutionContext, imageBuildInfo, buildTarget, parsedConfig, false, + secretOpts, ) if err != nil { return composeExtendResult{}, err diff --git a/pkg/devcontainer/feature/extend.go b/pkg/devcontainer/feature/extend.go index baece4006..99039ca34 100644 --- a/pkg/devcontainer/feature/extend.go +++ b/pkg/devcontainer/feature/extend.go @@ -57,8 +57,9 @@ func GetExtendedBuildInfo( target string, devContainerConfig *config.SubstitutedConfig, forceBuild bool, + secretOpts *SecretOptions, ) (*ExtendedBuildInfo, error) { - features, err := fetchFeatures(devContainerConfig.Config, forceBuild) + features, err := fetchFeatures(devContainerConfig.Config, forceBuild, secretOpts) if err != nil { return nil, fmt.Errorf("fetch features: %w", err) } @@ -284,10 +285,12 @@ func findContainerUsers( func fetchFeatures( devContainerConfig *config.DevContainerConfig, forceBuild bool, + secretOpts *SecretOptions, ) ([]*config.FeatureSet, error) { processor := &featureProcessor{ devContainerConfig: devContainerConfig, forceBuild: forceBuild, + secretOpts: secretOpts, } userFeatures, err := getUserFeatures(processor, devContainerConfig) @@ -332,6 +335,7 @@ func getUserFeatures( type featureProcessor struct { devContainerConfig *config.DevContainerConfig forceBuild bool + secretOpts *SecretOptions } func (p *featureProcessor) processFeature( @@ -353,15 +357,59 @@ func (p *featureProcessor) processFeature( return nil, err } + resolvedOptions, err := resolveSecretsForFeature( + featureID, + featureConfig, + featureOptions, + p.secretOpts, + ) + if err != nil { + return nil, err + } + return &config.FeatureSet{ ConfigID: normalizeFeatureID(featureID), Version: extractVersionFromFeatureID(featureID), Folder: featureFolder, Config: featureConfig, - Options: featureOptions, + Options: resolvedOptions, }, nil } +func resolveSecretsForFeature( + featureID string, + featureCfg *config.FeatureConfig, + featureOptions any, + secretOpts *SecretOptions, +) (any, error) { + if featureCfg == nil || len(featureCfg.Options) == 0 { + return featureOptions, nil + } + + hasSecrets := false + for _, opt := range featureCfg.Options { + if opt.Type == optionTypeSecret { + hasSecrets = true + break + } + } + if !hasSecrets { + return featureOptions, nil + } + + userMap := toOptionsMap(featureOptions, featureCfg) + if userMap == nil { + userMap = map[string]any{} + } + + resolved, err := ResolveSecretOptions(featureID, featureCfg, userMap, secretOpts) + if err != nil { + return nil, err + } + + return resolved, nil +} + type featureDependencyResolver struct { features map[string]*config.FeatureSet resolved map[string]*config.FeatureSet diff --git a/pkg/devcontainer/feature/features.go b/pkg/devcontainer/feature/features.go index 25877c0e4..828441694 100644 --- a/pkg/devcontainer/feature/features.go +++ b/pkg/devcontainer/feature/features.go @@ -38,7 +38,8 @@ func getFeatureInstallWrapperScript( description := escapeQuotesForShell(feature.Description) version := escapeQuotesForShell(feature.Version) documentation := escapeQuotesForShell(feature.DocumentationURL) - optionsIndented := escapeQuotesForShell(" " + strings.Join(options, "\n ")) + maskedOptions := maskSecretOptions(feature, options) + optionsIndented := escapeQuotesForShell(" " + strings.Join(maskedOptions, "\n ")) warningHeader := "" if feature.Deprecated { @@ -81,8 +82,6 @@ echo 'Version : ` + version + `' echo 'Documentation : ` + documentation + `' echo 'Options :' echo '` + optionsIndented + `' -echo 'Environment :' -printenv echo =========================================================================== chmod +x ./install.sh @@ -98,6 +97,34 @@ func escapeQuotesForShell(str string) string { return strings.ReplaceAll(str, "'", `'\''`) } +func maskSecretOptions(feature *config.FeatureConfig, options []string) []string { + if feature.Options == nil { + return options + } + + secretKeys := make(map[string]bool) + for name, opt := range feature.Options { + if opt.Type == optionTypeSecret { + secretKeys[getFeatureSafeID(name)] = true + } + } + + if len(secretKeys) == 0 { + return options + } + + masked := make([]string, len(options)) + for i, opt := range options { + key, _, found := strings.Cut(opt, "=") + if found && secretKeys[key] { + masked[i] = key + `="****"` + } else { + masked[i] = opt + } + } + return masked +} + func ProcessFeatureID( id string, devContainerConfig *config.DevContainerConfig, diff --git a/pkg/devcontainer/feature/options.go b/pkg/devcontainer/feature/options.go index 3eca03914..e20460b60 100644 --- a/pkg/devcontainer/feature/options.go +++ b/pkg/devcontainer/feature/options.go @@ -1,8 +1,10 @@ package feature import ( + "encoding/json" "fmt" "maps" + "os" "slices" "sort" "strings" @@ -10,7 +12,128 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/config" ) -const optionTypeBoolean = "boolean" +const ( + optionTypeBoolean = "boolean" + optionTypeSecret = "secret" +) + +// SecretOptions holds configuration for resolving secret-typed feature options. +type SecretOptions struct { + SecretsFile string +} + +// ResolveSecretOptions resolves secret-typed options for a feature. For each option +// with type "secret", it checks (in order): user-provided value, environment variable, +// secrets file, default value. Returns an error if a secret is required but not provided. +func ResolveSecretOptions( + featureID string, + featureCfg *config.FeatureConfig, + userOptions map[string]any, + opts *SecretOptions, +) (map[string]any, error) { + if featureCfg == nil || len(featureCfg.Options) == 0 { + return userOptions, nil + } + + resolver, err := newSecretResolver(featureID, opts) + if err != nil { + return nil, err + } + + result := make(map[string]any, len(userOptions)) + maps.Copy(result, userOptions) + + for name, option := range featureCfg.Options { + if option.Type != optionTypeSecret { + continue + } + if _, ok := result[name]; ok { + continue + } + val, err := resolver.resolve(name, option) + if err != nil { + return nil, err + } + result[name] = val + } + + return result, nil +} + +type secretResolver struct { + featureID string + featureSafeID string + fileData map[string]map[string]string +} + +func newSecretResolver(featureID string, opts *SecretOptions) (*secretResolver, error) { + r := &secretResolver{ + featureID: featureID, + featureSafeID: getFeatureSafeID(featureID), + } + if opts != nil && opts.SecretsFile != "" { + var err error + r.fileData, err = parseFeatureSecretsFile(opts.SecretsFile) + if err != nil { + return nil, err + } + } + return r, nil +} + +func (r *secretResolver) resolve(name string, option config.FeatureConfigOption) (string, error) { + envVarName := "DEVCONTAINER_FEATURE_SECRET_" + r.featureSafeID + "_" + getFeatureSafeID(name) + if envVal := os.Getenv(envVarName); envVal != "" { + return envVal, nil + } + + if val, ok := r.lookupFileSecret(name); ok { + return val, nil + } + + if string(option.Default) != "" { + return string(option.Default), nil + } + + return "", fmt.Errorf( + "feature %q: secret option %q is required but no value was provided. "+ + "Set via devcontainer.json options, environment variable %s, or --feature-secrets-file", + r.featureID, name, envVarName, + ) +} + +func (r *secretResolver) lookupFileSecret(name string) (string, bool) { + if r.fileData == nil { + return "", false + } + featureSecrets, ok := r.fileData[r.featureID] + if !ok { + return "", false + } + val, ok := featureSecrets[name] + return val, ok +} + +// IsSecretOption returns true if the given option has type "secret". +func IsSecretOption(option config.FeatureConfigOption) bool { + return option.Type == optionTypeSecret +} + +func parseFeatureSecretsFile(path string) (map[string]map[string]string, error) { + data, err := os.ReadFile( + path, + ) // #nosec G304 -- User-specified secrets file path is intentional. + if err != nil { + return nil, fmt.Errorf("read feature secrets file: %w", err) + } + + var secrets map[string]map[string]string + if err := json.Unmarshal(data, &secrets); err != nil { + return nil, fmt.Errorf("parse feature secrets file %s: %w", path, err) + } + + return secrets, nil +} func getFeatureEnvVariables(feature *config.FeatureConfig, featureOptions any) []string { options := getFeatureValueObject(feature, featureOptions) diff --git a/pkg/devcontainer/feature/options_secret_test.go b/pkg/devcontainer/feature/options_secret_test.go new file mode 100644 index 000000000..88bbb1bef --- /dev/null +++ b/pkg/devcontainer/feature/options_secret_test.go @@ -0,0 +1,240 @@ +package feature + +import ( + "os" + "path/filepath" + "testing" + + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/stretchr/testify/suite" +) + +const testOptionToken = "token" + +type SecretOptionsTestSuite struct { + suite.Suite +} + +func TestSecretOptionsTestSuite(t *testing.T) { + suite.Run(t, new(SecretOptionsTestSuite)) +} + +func (s *SecretOptionsTestSuite) TestSecretOptionDetected() { + cfg := &config.FeatureConfig{ + Options: map[string]config.FeatureConfigOption{ + "apiKey": {Type: optionTypeSecret}, + "name": {Type: optionTypeString}, + }, + } + s.True(IsSecretOption(cfg.Options["apiKey"])) + s.False(IsSecretOption(cfg.Options["name"])) +} + +func (s *SecretOptionsTestSuite) TestSecretResolvedFromUserOptions() { + cfg := &config.FeatureConfig{ + Options: map[string]config.FeatureConfigOption{ + testOptionToken: {Type: optionTypeSecret}, + }, + } + userOpts := map[string]any{testOptionToken: "user-provided-value"} + + resolved, err := ResolveSecretOptions(testFeatureID, cfg, userOpts, nil) + s.NoError(err) + s.Equal("user-provided-value", resolved[testOptionToken]) +} + +func (s *SecretOptionsTestSuite) TestSecretResolvedFromEnvVar() { + cfg := &config.FeatureConfig{ + Options: map[string]config.FeatureConfigOption{ + testOptionToken: {Type: optionTypeSecret}, + }, + } + + safeFeatureID := getFeatureSafeID(testFeatureID) + safeOptionID := getFeatureSafeID(testOptionToken) + envVar := "DEVCONTAINER_FEATURE_SECRET_" + safeFeatureID + "_" + safeOptionID + s.T().Setenv(envVar, "env-secret-value") + + resolved, err := ResolveSecretOptions(testFeatureID, cfg, map[string]any{}, nil) + s.NoError(err) + s.Equal("env-secret-value", resolved[testOptionToken]) +} + +func (s *SecretOptionsTestSuite) TestSecretResolvedFromSecretsFile() { + cfg := &config.FeatureConfig{ + Options: map[string]config.FeatureConfigOption{ + "apiKey": {Type: optionTypeSecret}, + }, + } + + secretsFile := filepath.Join(s.T().TempDir(), "secrets.json") + content := `{"` + testFeatureID + `": {"apiKey": "file-secret-value"}}` + err := os.WriteFile(secretsFile, []byte(content), 0o600) + s.Require().NoError(err) + + opts := &SecretOptions{SecretsFile: secretsFile} + resolved, err := ResolveSecretOptions(testFeatureID, cfg, map[string]any{}, opts) + s.NoError(err) + s.Equal("file-secret-value", resolved["apiKey"]) +} + +func (s *SecretOptionsTestSuite) TestSecretPrecedenceUserOverEnv() { + cfg := &config.FeatureConfig{ + Options: map[string]config.FeatureConfigOption{ + testOptionToken: {Type: optionTypeSecret}, + }, + } + + safeFeatureID := getFeatureSafeID(testFeatureID) + safeOptionID := getFeatureSafeID(testOptionToken) + envVar := "DEVCONTAINER_FEATURE_SECRET_" + safeFeatureID + "_" + safeOptionID + s.T().Setenv(envVar, "env-value") + + userOpts := map[string]any{testOptionToken: "user-value"} + resolved, err := ResolveSecretOptions(testFeatureID, cfg, userOpts, nil) + s.NoError(err) + s.Equal("user-value", resolved[testOptionToken]) +} + +func (s *SecretOptionsTestSuite) TestSecretPrecedenceEnvOverFile() { + cfg := &config.FeatureConfig{ + Options: map[string]config.FeatureConfigOption{ + testOptionToken: {Type: optionTypeSecret}, + }, + } + + safeFeatureID := getFeatureSafeID(testFeatureID) + safeOptionID := getFeatureSafeID(testOptionToken) + envVar := "DEVCONTAINER_FEATURE_SECRET_" + safeFeatureID + "_" + safeOptionID + s.T().Setenv(envVar, "env-value") + + secretsFile := filepath.Join(s.T().TempDir(), "secrets.json") + content := `{"` + testFeatureID + `": {"` + testOptionToken + `": "file-value"}}` + err := os.WriteFile(secretsFile, []byte(content), 0o600) + s.Require().NoError(err) + + opts := &SecretOptions{SecretsFile: secretsFile} + resolved, err := ResolveSecretOptions(testFeatureID, cfg, map[string]any{}, opts) + s.NoError(err) + s.Equal("env-value", resolved[testOptionToken]) +} + +func (s *SecretOptionsTestSuite) TestSecretFallsBackToDefault() { + cfg := &config.FeatureConfig{ + Options: map[string]config.FeatureConfigOption{ + testOptionToken: {Type: optionTypeSecret, Default: "default-secret"}, + }, + } + + resolved, err := ResolveSecretOptions(testFeatureID, cfg, map[string]any{}, nil) + s.NoError(err) + s.Equal("default-secret", resolved[testOptionToken]) +} + +func (s *SecretOptionsTestSuite) TestSecretMissingReturnsError() { + cfg := &config.FeatureConfig{ + Options: map[string]config.FeatureConfigOption{ + testOptionToken: {Type: optionTypeSecret}, + }, + } + + _, err := ResolveSecretOptions(testFeatureID, cfg, map[string]any{}, nil) + s.Error(err) + s.Contains(err.Error(), "secret option") + s.Contains(err.Error(), testOptionToken) + s.Contains(err.Error(), "required but no value was provided") + s.Contains(err.Error(), "DEVCONTAINER_FEATURE_SECRET_") +} + +func (s *SecretOptionsTestSuite) TestSecretMaskingInOptions() { + cfg := &config.FeatureConfig{ + Options: map[string]config.FeatureConfigOption{ + testOptionToken: {Type: optionTypeSecret}, + testOptionName: {Type: optionTypeString}, + }, + } + options := []string{ + `TOKEN="my-secret-value"`, + `VERSION="1.0"`, + } + + masked := maskSecretOptions(cfg, options) + s.Equal(`TOKEN="****"`, masked[0]) + s.Equal(`VERSION="1.0"`, masked[1]) +} + +func (s *SecretOptionsTestSuite) TestSecretMaskingNoSecrets() { + cfg := &config.FeatureConfig{ + Options: map[string]config.FeatureConfigOption{ + testOptionName: {Type: optionTypeString}, + }, + } + options := []string{`VERSION="1.0"`} + + masked := maskSecretOptions(cfg, options) + s.Equal(options, masked) +} + +func (s *SecretOptionsTestSuite) TestParseFeatureSecretsFileValid() { + secretsFile := filepath.Join(s.T().TempDir(), "secrets.json") + content := `{ + "ghcr.io/owner/feature:1": {"secret1": "value1", "secret2": "value2"}, + "ghcr.io/owner/other:2": {"key": "val"} + }` + err := os.WriteFile(secretsFile, []byte(content), 0o600) + s.Require().NoError(err) + + data, err := parseFeatureSecretsFile(secretsFile) + s.NoError(err) + s.Equal("value1", data["ghcr.io/owner/feature:1"]["secret1"]) + s.Equal("value2", data["ghcr.io/owner/feature:1"]["secret2"]) + s.Equal("val", data["ghcr.io/owner/other:2"]["key"]) +} + +func (s *SecretOptionsTestSuite) TestParseFeatureSecretsFileMissing() { + _, err := parseFeatureSecretsFile("/nonexistent/path/secrets.json") + s.Error(err) + s.Contains(err.Error(), "read feature secrets file") +} + +func (s *SecretOptionsTestSuite) TestParseFeatureSecretsFileInvalidJSON() { + secretsFile := filepath.Join(s.T().TempDir(), "secrets.json") + err := os.WriteFile(secretsFile, []byte("not json"), 0o600) + s.Require().NoError(err) + + _, err = parseFeatureSecretsFile(secretsFile) + s.Error(err) + s.Contains(err.Error(), "parse feature secrets file") +} + +func (s *SecretOptionsTestSuite) TestNonSecretOptionsUnaffected() { + cfg := &config.FeatureConfig{ + Options: map[string]config.FeatureConfigOption{ + testOptionName: {Type: optionTypeString}, + "install": {Type: optionTypeBoolean}, + }, + } + + userOpts := map[string]any{testOptionName: "1.0", "install": "true"} + resolved, err := ResolveSecretOptions(testFeatureID, cfg, userOpts, nil) + s.NoError(err) + s.Equal("1.0", resolved[testOptionName]) + s.Equal("true", resolved["install"]) +} + +func (s *SecretOptionsTestSuite) TestNilFeatureConfigReturnsUserOptions() { + userOpts := map[string]any{"key": "val"} + resolved, err := ResolveSecretOptions(testFeatureID, nil, userOpts, nil) + s.NoError(err) + s.Equal(userOpts, resolved) +} + +func (s *SecretOptionsTestSuite) TestEmptyOptionsMapReturnsEarly() { + cfg := &config.FeatureConfig{ + Options: map[string]config.FeatureConfigOption{}, + } + userOpts := map[string]any{"key": "val"} + resolved, err := ResolveSecretOptions(testFeatureID, cfg, userOpts, nil) + s.NoError(err) + s.Equal(userOpts, resolved) +} diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 0f92f1537..42e219333 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -217,6 +217,7 @@ type CLIOptions struct { WorkspaceEnv []string `json:"workspaceEnv,omitempty"` WorkspaceEnvFile []string `json:"workspaceEnvFile,omitempty"` SecretsEnv []string `json:"secretsEnv,omitempty"` + FeatureSecretsFile string `json:"featureSecretsFile,omitempty"` InitEnv []string `json:"initEnv,omitempty"` Recreate bool `json:"recreate,omitempty"` Prebuild bool `json:"prebuild,omitempty"`