diff --git a/pkg/devcontainer/config/config.go b/pkg/devcontainer/config/config.go index b858114e6..9737fb0b7 100644 --- a/pkg/devcontainer/config/config.go +++ b/pkg/devcontainer/config/config.go @@ -103,6 +103,16 @@ type DevContainerConfigBase struct { // DEPRECATED: Use 'customizations/vscode/devPort' instead // The port VS Code can use to connect to its backend. DevPort int `json:"devPort,omitempty"` + + // Secrets declared by the dev container. Keys are secret names; values describe the secret. + // At runtime, each key is looked up in the host environment and, if set, injected into lifecycle commands. + Secrets map[string]SecretConfig `json:"secrets,omitempty"` +} + +// SecretConfig describes a secret declared in devcontainer.json. +type SecretConfig struct { + Description string `json:"description,omitempty"` + DocumentationUrl string `json:"documentationUrl,omitempty"` } type DevContainerActions struct { diff --git a/pkg/devcontainer/config/parse_test.go b/pkg/devcontainer/config/parse_test.go index 0d2313525..f4279a9a4 100644 --- a/pkg/devcontainer/config/parse_test.go +++ b/pkg/devcontainer/config/parse_test.go @@ -7,6 +7,86 @@ import ( "testing" ) +//nolint:cyclop // test function with multiple assertions +func TestParseSecrets(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "devcontainer.json") + raw := `{ + "name": "test-secrets", + "secrets": { + "MY_TOKEN": { + "description": "API token", + "documentationUrl": "https://example.com/docs" + }, + "EMPTY_SECRET": {} + } + }` + // #nosec G306 -- test file + if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := ParseDevContainerJSONFile(configPath) + if err != nil { + t.Fatal(err) + } + + if len(cfg.Secrets) != 2 { + t.Fatalf("expected 2 secrets, got %d", len(cfg.Secrets)) + } + myToken, ok := cfg.Secrets["MY_TOKEN"] + if !ok { + t.Fatal("expected MY_TOKEN secret") + } + if myToken.Description != "API token" { + t.Errorf("expected description 'API token', got %q", myToken.Description) + } + if myToken.DocumentationUrl != "https://example.com/docs" { + t.Errorf( + "expected documentationUrl 'https://example.com/docs', got %q", + myToken.DocumentationUrl, + ) + } + empty, ok := cfg.Secrets["EMPTY_SECRET"] + if !ok { + t.Fatal("expected EMPTY_SECRET secret") + } + if empty.Description != "" || empty.DocumentationUrl != "" { + t.Errorf("expected empty SecretConfig, got %+v", empty) + } +} + +func TestSecretsRoundTrip(t *testing.T) { + tmpDir := t.TempDir() + cfg := &DevContainerConfig{ + DevContainerConfigBase: DevContainerConfigBase{ + Name: "round-trip", + Secrets: map[string]SecretConfig{ + "DB_PASSWORD": {Description: "database password"}, + }, + }, + } + cfg.Origin = filepath.Join(tmpDir, "devcontainer.json") + + if err := SaveDevContainerJSON(cfg); err != nil { + t.Fatal(err) + } + + loaded, err := ParseDevContainerJSONFile(cfg.Origin) + if err != nil { + t.Fatal(err) + } + if len(loaded.Secrets) != 1 { + t.Fatalf("expected 1 secret after round-trip, got %d", len(loaded.Secrets)) + } + if loaded.Secrets["DB_PASSWORD"].Description != "database password" { + t.Errorf( + "expected 'database password', got %q", + loaded.Secrets["DB_PASSWORD"].Description, + ) + } +} + func TestSaveDevContainerJSON(t *testing.T) { type args struct { config *DevContainerConfig diff --git a/pkg/devcontainer/setup/lifecyclehooks.go b/pkg/devcontainer/setup/lifecyclehooks.go index d70c4e49c..e86bb3039 100644 --- a/pkg/devcontainer/setup/lifecyclehooks.go +++ b/pkg/devcontainer/setup/lifecyclehooks.go @@ -41,10 +41,19 @@ func resolveLifecycleEnv( ) } + env := mergeRemoteEnv(mergedConfig.RemoteEnv, probedEnv, remoteUser) + + // Resolve declared secrets from the host environment. + for name := range mergedConfig.Secrets { + if val := os.Getenv(name); val != "" { + env[name] = val + } + } + return lifecycleEnv{ remoteUser: remoteUser, workspaceFolder: setupInfo.SubstitutionContext.ContainerWorkspaceFolder, - remoteEnv: mergeRemoteEnv(mergedConfig.RemoteEnv, probedEnv, remoteUser), + remoteEnv: env, } } diff --git a/pkg/devcontainer/setup/lifecyclehooks_test.go b/pkg/devcontainer/setup/lifecyclehooks_test.go index 5fb4d29cf..7aefd9044 100644 --- a/pkg/devcontainer/setup/lifecyclehooks_test.go +++ b/pkg/devcontainer/setup/lifecyclehooks_test.go @@ -101,6 +101,36 @@ func (s *LifecycleHookTestSuite) TestLifecycleHooksNoOpWithEmptyConfig() { assert.NoError(s.T(), err) } +func (s *LifecycleHookTestSuite) TestResolveLifecycleEnvIncludesSecrets() { + t := s.T() + + // Set one secret in the environment, leave another absent. + t.Setenv("SECRET_PRESENT", "s3cret") + + result := &config.Result{ + MergedConfig: &config.MergedDevContainerConfig{ + DevContainerConfigBase: config.DevContainerConfigBase{ + Secrets: map[string]config.SecretConfig{ + "SECRET_PRESENT": {Description: "a present secret"}, + "SECRET_ABSENT": {Description: "a missing secret"}, + }, + }, + }, + ContainerDetails: &config.ContainerDetails{ + State: config.ContainerDetailsState{}, + }, + SubstitutionContext: &config.SubstitutionContext{ + ContainerWorkspaceFolder: "/workspaces/test", + }, + } + + env := resolveLifecycleEnv(context.Background(), result) + + assert.Equal(t, "s3cret", env.remoteEnv["SECRET_PRESENT"]) + _, found := env.remoteEnv["SECRET_ABSENT"] + assert.False(t, found, "SECRET_ABSENT should not be in remoteEnv when not set in environment") +} + func TestLifecycleHookTestSuite(t *testing.T) { suite.Run(t, new(LifecycleHookTestSuite)) }