Skip to content
Open
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
25 changes: 25 additions & 0 deletions pkg/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/osteele/liquid/render"
"github.com/spf13/viper"
"go.opentelemetry.io/otel/attribute"
"gopkg.in/yaml.v3"
)

var _ DataStoreLoaderFunc = NoopDataLoader
Expand Down Expand Up @@ -175,6 +176,10 @@ func LoadFromViper(viperCfg func(v *viper.Viper), cobraCfg func(v *viper.Viper))
return log.Error(err)
}

// Scope template variables to the selected context only so that
// `${secret.*}` from other contexts are not resolved.
cfg.templateVariables = listTemplateVariablesForContext(contextConfigMap)

ctxViper := viper.New()
ctxViper.SetFs(cfg.FileSystem)
if err := setDefaultsFrom(ctxViper, cfg.Data); err != nil {
Expand Down Expand Up @@ -275,6 +280,26 @@ func listTemplateVariables(tmpl *liquid.Template) []string {
return results
}

func listTemplateVariablesForContext(contextConfigMap map[string]interface{}) []string {
if len(contextConfigMap) == 0 {
return nil
}

cfgContents, err := yaml.Marshal(contextConfigMap)
if err != nil {
return nil
}

engine := liquid.NewEngine()
engine.Delims("${", "}", "${%", "%}")
tmpl, err := engine.ParseTemplate(cfgContents)
if err != nil {
return nil
Comment on lines +290 to +297
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

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

listTemplateVariablesForContext silently returns nil when yaml.Marshal or ParseTemplate fails. If that happens, loadFinalPass will be skipped and any ${secret.*} in the selected context will remain rendered as empty from the first pass, leading to a subtly broken config without an error. Consider returning an error from listTemplateVariablesForContext (and propagating it from LoadFromViper) or at least logging/handling the failure explicitly so config loading fails fast when template-variable discovery can’t run.

Suggested change
return nil
}
engine := liquid.NewEngine()
engine.Delims("${", "}", "${%", "%}")
tmpl, err := engine.ParseTemplate(cfgContents)
if err != nil {
return nil
// Log the error and return an empty slice so callers don't silently skip the final pass.
fmt.Printf("error marshaling context config for template variable discovery: %v\n", err)
return []string{}
}
engine := liquid.NewEngine()
engine.Delims("${", "}", "${%", "%}")
tmpl, err := engine.ParseTemplate(cfgContents)
if err != nil {
// Log the error and return an empty slice so callers don't silently skip the final pass.
fmt.Printf("error parsing context template for template variable discovery: %v\n", err)
return []string{}

Copilot uses AI. Check for mistakes.
}
Comment on lines +288 to +298

return listTemplateVariables(tmpl)
}

// findTemplateVariables looks at the template's abstract syntax tree (AST)
// and identifies which variables were used
func findTemplateVariables(curNode render.Node, vars map[string]struct{}) {
Expand Down
33 changes: 33 additions & 0 deletions pkg/config/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,39 @@ current-context: default
require.ErrorContains(t, err, "missing required 'contexts' key")
}

func TestLoadMultiContext_OnlyResolvesSelectedContextSecrets(t *testing.T) {
t.Parallel()

c := NewTestConfig(t)
c.SetHomeDir("/home/myuser/.porter")

cfg := `schemaVersion: "` + ConfigSchemaVersion + `"
current-context: local
contexts:
- name: local
config:
default-secrets-plugin: filesystem
- name: prod
config:
default-storage: proddb
storage:
- name: proddb
plugin: mongodb
config:
url: "${secret.prodMongoConnectionString}"
`
require.NoError(t, c.TestContext.FileSystem.WriteFile(
"/home/myuser/.porter/config.yaml", []byte(cfg), 0600))

c.DataLoader = LoadFromFilesystem()
_, err := c.Load(context.Background(), func(ctx context.Context, secretKey string) (string, error) {
t.Fatalf("unexpected secret resolution: %s", secretKey)
return "", nil
})
require.NoError(t, err)
assert.Equal(t, "filesystem", c.Data.DefaultSecretsPlugin)
}

func TestListTemplateVariables(t *testing.T) {
eng := liquid.NewEngine()
tmpl, err := eng.ParseString(`not a variable {{secrets.foo}} more non variable junk{{env.var}}{{env.var}}`)
Expand Down
38 changes: 38 additions & 0 deletions tests/integration/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package integration

import (
"context"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -99,6 +100,43 @@ func TestMultiContextConfig_ContextSelectsNamespace(t *testing.T) {
assert.Equal(t, "prod", p.Config.Data.Namespace, "--context prod should set namespace to 'prod'")
}

func TestMultiContextConfig_OnlyResolvesSelectedContextSecrets(t *testing.T) {
p := porter.NewTestPorter(t)
ctx := p.SetupIntegrationTest()
defer p.Close()

home, _ := p.GetHomeDir()
cfg := `schemaVersion: "2.0.0"
current-context: local
contexts:
- name: local
config:
default-secrets-plugin: filesystem
- name: prod
config:
default-storage: proddb
storage:
- name: proddb
plugin: mongodb
config:
url: "${secret.prodMongoConnectionString}"
`
require.NoError(t, os.WriteFile(
filepath.Join(home, "config.yaml"),
[]byte(cfg),
pkg.FileModeWritable,
))

p.Config.DataLoader = config.LoadFromFilesystem()

_, err := p.Config.Load(ctx, func(ctx context.Context, secretKey string) (string, error) {
t.Fatalf("unexpected secret resolution for unselected context: %s", secretKey)
return "", nil
})
require.NoError(t, err)
assert.Equal(t, "filesystem", p.Config.Data.DefaultSecretsPlugin)
}

// legacyConfigYAML is a flat (pre-2.0.0) config file with no schemaVersion.
const legacyConfigYAML = `namespace: legacy
`
Expand Down
Loading