Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"image": "ghcr.io/devsy-org/test-images/base:alpine",
"features": {
"./features/secret-test": {}
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#!/bin/sh
set -e

echo "Installing secret-test feature"
echo "Secret token value: ${SECRETTOKEN}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove or comment out the secret-echo line in the test fixture.

echo "Secret token value: ${SECRETTOKEN}" prints the resolved secret in plain text in the Docker build log, directly counteracting the wrapper-script masking this PR introduces. The assertion only requires writing to the file on line 9; the echo isn't needed for correctness.

🔧 Proposed fix
-echo "Secret token value: ${SECRETTOKEN}"
 echo "Public option value: ${PUBLICOPTION}"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@e2e/tests/up-features/testdata/docker-features-secret-option/features/secret-test/install.sh`
at line 5, In install.sh remove or comment out the line that echoes the secret
token (the echo "Secret token value: ${SECRETTOKEN}" invocation) so the resolved
secret is not printed to the Docker build log; keep the rest of the script
(including the file-write on line 9) unchanged so the test still writes
SECRETTOKEN to the expected file without leaking it to stdout.

echo "Public option value: ${PUBLICOPTION}"

# Write the secret value to a file for verification
echo "${SECRETTOKEN}" >/secret-test-result.txt
30 changes: 30 additions & 0 deletions e2e/tests/up-features/up_features.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
)
})
2 changes: 1 addition & 1 deletion pkg/agent/tunnel/tunnel_grpc.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions pkg/devcontainer/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}
}
7 changes: 7 additions & 0 deletions pkg/devcontainer/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
52 changes: 50 additions & 2 deletions pkg/devcontainer/feature/extend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -332,6 +335,7 @@ func getUserFeatures(
type featureProcessor struct {
devContainerConfig *config.DevContainerConfig
forceBuild bool
secretOpts *SecretOptions
}

func (p *featureProcessor) processFeature(
Expand All @@ -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
Expand Down
33 changes: 30 additions & 3 deletions pkg/devcontainer/feature/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -81,8 +82,6 @@ echo 'Version : ` + version + `'
echo 'Documentation : ` + documentation + `'
echo 'Options :'
echo '` + optionsIndented + `'
echo 'Environment :'
printenv
echo ===========================================================================

chmod +x ./install.sh
Expand All @@ -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,
Expand Down
Loading
Loading