Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
d1ed89b
feat(secrets): support SOPS-encrypted secret sources
skevetter Sep 3, 2026
223027b
fix(secrets): follow-up SOPS review cleanup
skevetter Sep 3, 2026
8cbdbe5
fix: resolve lint issues in SOPS review follow-up
skevetter Sep 3, 2026
6694f74
fix: resolve remaining SOPS review issues
skevetter Sep 3, 2026
53849f0
Merge branch 'main' into feat/sops-secret-sources
skevetter Sep 4, 2026
3e76510
fix(secrets): address review findings
skevetter Sep 4, 2026
c5fdfab
Merge remote-tracking branch 'origin/feat/sops-secret-sources' into f…
skevetter Sep 4, 2026
dc2dc09
fix(secrets): satisfy lint formatting
skevetter Sep 4, 2026
00fcef8
test(sops): avoid empty age command
skevetter Sep 4, 2026
9bc6d7f
ci: stabilize Linux Podman E2E setup
skevetter Sep 4, 2026
0c0df58
style(ci): apply shfmt formatting to podman setup script
skevetter Sep 4, 2026
eb56dcb
fix(secrets): resolve bootstrap git token in headless up and reject t…
skevetter Sep 4, 2026
853fd5a
fix(git): honor subpath when discovering repository secrets
skevetter Sep 4, 2026
d3072b0
fix(up): discover project secrets after workspace source resolution
skevetter Sep 4, 2026
ff2244c
fix(secrets): harden repo-relative path handling and source registry …
skevetter Sep 4, 2026
2323357
fix(up): fix inspection and error cleanup
skevetter Sep 6, 2026
ce09e8a
fix(secrets): serialize source config and fix up
skevetter Sep 6, 2026
b337a8b
docs: update contents
skevetter Sep 6, 2026
6db09a1
feat(secrets): support customizations.devsy in devcontainer.json
skevetter Sep 7, 2026
1de6b03
fix(git): preserve inherited process GIT_CONFIG_* entries
skevetter Sep 9, 2026
4f02670
Merge branch 'main' into feat/sops-secret-sources
skevetter Sep 9, 2026
1d796df
test(git): add regression coverage for inherited git config and forma…
skevetter Sep 9, 2026
007fc2f
fix(test): use testOAuth2Username constant in project_secrets_test.go
skevetter Sep 9, 2026
3cde1c5
Merge branch 'main' into feat/sops-secret-sources
skevetter Sep 10, 2026
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
349 changes: 205 additions & 144 deletions THIRD_PARTY_LICENSES.md

Large diffs are not rendered by default.

15 changes: 10 additions & 5 deletions cmd/ci/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,21 @@ func (cmd *CICmd) registerSecretsFlags(ciCmd *cobra.Command) {
cliflags.String(&cmd.FeatureSecretsFile, names.FeatureSecretsFile, "",
"Path to a JSON file containing secret values for features, format: "+
`{"featureId": {"optionName": "value"}}`),
cliflags.StringArray(&cmd.Secrets, names.Secret, nil,
"Stored Devsy secret to inject, as NAME[,type=env|mount][,target=X]; "+
"type=env (default) sets an env var, type=mount writes /run/secrets/<target>. Repeatable"),
cliflags.StringArray(
&cmd.Secrets,
names.Secret,
nil,
"Secret to inject: NAME or source-qualified TYPE:SOURCE/NAME[,type=env|mount][,target=X]; "+
"type=env (default) sets an env var, type=mount writes /run/secrets/<target>. Repeatable",
),
cliflags.StringArray(&cmd.EnvVars, names.Env, nil,
"Stored Devsy env var to inject into the workspace as NAME[=TARGET]. Repeatable"),
cliflags.StringArray(&cmd.BuildSecretNames, names.BuildSecret, nil,
"Stored Devsy secret exposed to the build via BuildKit "+
"Secret to expose to the build via BuildKit; accepts NAME or TYPE:SOURCE/NAME "+
"(RUN --mount=type=secret,id=NAME). Repeatable"),
cliflags.String(&cmd.GitTokenSecret, names.GitToken, "",
"Stored Devsy secret holding an access token for cloning a private HTTP repository"),
"Secret holding an access token for cloning a private HTTP repository; "+
"accepts NAME or TYPE:SOURCE/NAME; repository-owned sources cannot bootstrap their own clone"),
cliflags.String(&cmd.GitTokenUsername, names.GitTokenUsername, "",
"Username for --git-token (default inferred from the repo host)"),
)
Expand Down
56 changes: 43 additions & 13 deletions cmd/secrets/bind.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func NewAttachCmd(flags *flags.GlobalFlags) *cobra.Command {
cmd := &AttachCmd{GlobalFlags: flags}
attachCmd := &cobra.Command{
Use: "attach NAME",
Short: "Bind a secret to the active context so it is injected automatically on up",
Short: "Bind a secret reference to the active context so it is injected automatically on up",
Args: cobra.ExactArgs(1),
RunE: func(cobraCmd *cobra.Command, args []string) error {
return cmd.Run(cobraCmd.Context(), args[0])
Expand All @@ -30,7 +30,7 @@ func NewAttachCmd(flags *flags.GlobalFlags) *cobra.Command {
return attachCmd
}

func verifySensitive(devsyConfig *config.Config, contextName, name string) error {
func verifyLocalSensitive(devsyConfig *config.Config, contextName, name string) error {
store, err := secrets.NewStoreForConfig(devsyConfig)
if err != nil {
return err
Expand All @@ -45,36 +45,61 @@ func verifySensitive(devsyConfig *config.Config, contextName, name string) error
return nil
}

func (cmd *AttachCmd) Run(_ context.Context, name string) error {
if err := secrets.ValidateName(name); err != nil {
func verifySecretReference(
ctx context.Context,
devsyConfig *config.Config,
ref secrets.SecretRef,
) error {
if ref.Source == secrets.LocalSourceName &&
(ref.Type == "" || ref.Type == secrets.LocalSourceName) {
return verifyLocalSensitive(devsyConfig, devsyConfig.DefaultContext, ref.Name)
}
resolver, err := secrets.NewResolverForConfig(devsyConfig)
if err != nil {
return err
}
resolved, err := resolver.Resolve(ctx, ref)
if err != nil {
return fmt.Errorf("cannot attach secret %q: %w", ref.String(), err)
}
if !resolved.Sensitive {
return fmt.Errorf("%q is not a sensitive secret", ref.String())
}
return nil
}

func (cmd *AttachCmd) Run(ctx context.Context, name string) error {
ref, err := secrets.ParseRef(name)
if err != nil {
return err
}
canonical := ref.String()

devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider)
if err != nil {
return err
}
contextName := devsyConfig.DefaultContext

if err := verifySensitive(devsyConfig, contextName, name); err != nil {
if err := verifySecretReference(ctx, devsyConfig, ref); err != nil {
return err
}

ctxConfig := devsyConfig.Contexts[contextName]
if ctxConfig == nil {
return fmt.Errorf("context %q doesn't exist", contextName)
}
if slices.Contains(ctxConfig.Secrets, name) {
log.Infof("secret %q already attached to context %q", name, contextName)
if slices.Contains(ctxConfig.Secrets, canonical) {
log.Infof("secret %q already attached to context %q", canonical, contextName)
return nil
}
ctxConfig.Secrets = append(ctxConfig.Secrets, name)
ctxConfig.Secrets = append(ctxConfig.Secrets, canonical)

if err := config.SaveConfig(devsyConfig); err != nil {
return fmt.Errorf("save config: %w", err)
}

log.Infof("secret %q attached to context %q", name, contextName)
log.Infof("secret %q attached to context %q", canonical, contextName)
return nil
}

Expand All @@ -86,7 +111,7 @@ func NewDetachCmd(flags *flags.GlobalFlags) *cobra.Command {
cmd := &DetachCmd{GlobalFlags: flags}
detachCmd := &cobra.Command{
Use: "detach NAME",
Short: "Unbind a secret from the active context",
Short: "Unbind a secret reference from the active context",
Args: cobra.ExactArgs(1),
RunE: func(cobraCmd *cobra.Command, args []string) error {
return cmd.Run(cobraCmd.Context(), args[0])
Expand All @@ -97,6 +122,11 @@ func NewDetachCmd(flags *flags.GlobalFlags) *cobra.Command {
}

func (cmd *DetachCmd) Run(_ context.Context, name string) error {
ref, err := secrets.ParseRef(name)
if err != nil {
return err
}
canonical := ref.String()
devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider)
if err != nil {
return err
Expand All @@ -108,9 +138,9 @@ func (cmd *DetachCmd) Run(_ context.Context, name string) error {
return fmt.Errorf("context %q doesn't exist", contextName)
}

idx := slices.Index(ctxConfig.Secrets, name)
idx := slices.Index(ctxConfig.Secrets, canonical)
if idx < 0 {
log.Infof("secret %q is not attached to context %q", name, contextName)
log.Infof("secret %q is not attached to context %q", canonical, contextName)
return nil
}
ctxConfig.Secrets = slices.Delete(ctxConfig.Secrets, idx, idx+1)
Expand All @@ -119,6 +149,6 @@ func (cmd *DetachCmd) Run(_ context.Context, name string) error {
return fmt.Errorf("save config: %w", err)
}

log.Infof("secret %q detached from context %q", name, contextName)
log.Infof("secret %q detached from context %q", canonical, contextName)
return nil
}
12 changes: 7 additions & 5 deletions cmd/secrets/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ import (
func NewSecretsCmd(flags *flags.GlobalFlags) *cobra.Command {
secretsCmd := &cobra.Command{
Use: "secret",
Short: "Manage secrets",
Long: `Manage named secrets stored locally and injected into workspaces.
Short: "Manage secrets and external secret sources",
Long: `Manage secrets and external secret sources used by Devsy workspaces.

Secret values are kept in the OS keyring (macOS Keychain, Windows Credential
Manager, or libsecret) when available, and in an age-encrypted file otherwise.
Only non-sensitive metadata is stored in the Devsy config directory.`,
Managed secret values are kept in the OS keyring (macOS Keychain,
Windows Credential Manager, or libsecret) when available, and in an
age-encrypted file otherwise. External sources such as SOPS remain owned by
their encrypted source file and are resolved only when needed.`,
}

secretsCmd.AddCommand(NewSetCmd(flags))
Expand All @@ -24,6 +25,7 @@ Only non-sensitive metadata is stored in the Devsy config directory.`,
secretsCmd.AddCommand(NewDeleteCmd(flags))
secretsCmd.AddCommand(NewAttachCmd(flags))
secretsCmd.AddCommand(NewDetachCmd(flags))
secretsCmd.AddCommand(NewSourceCmd(flags))
return secretsCmd
}

Expand Down
Loading
Loading