From f99ca7e95a6bb2084370de1788d3502ceedd1e7e Mon Sep 17 00:00:00 2001 From: Hooman Date: Tue, 8 Sep 2026 17:27:43 +0330 Subject: [PATCH] fix(publish): support short-form port mapping with variable interpolation When publishing a Compose project with short-form port syntax containing environment variable substitutions (e.g. `${PORT:-3000}:3000`), `publish` previously failed with: 'services[...].ports[0]' expected a map or struct, got "string" During `preChecks`, `loadUnresolvedFile` loads each file with `SkipInterpolation = true` to detect raw un-interpolated literals and secrets. Because variable interpolation is skipped, `types.ParsePortConfig` cannot parse the non-numeric port strings into `ServicePortConfig` definitions, leaving them as raw string slices in the canonical dictionary. When `loader.Transform` attempts to decode this into `types.Project`, mapstructure fails because `types.ServiceConfig.Ports` expects a slice of structs, not strings. Neither `collectEnvCheckFindings` nor `checkForSensitiveData` inspects service ports (only environment, env_files, extends, and configs are checked). Load the raw model via `loader.LoadModelWithContext` and strip `ports` from services before calling `loader.Transform`. Additionally, have `composeFileAsByteReader` read the raw compose file directly from disk so all file content is preserved for secret scanning without unnecessary decoding. Fixes #13672 Signed-off-by: Hooman --- pkg/compose/publish.go | 39 ++++++++++++++++------ pkg/compose/publish_test.go | 66 +++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/pkg/compose/publish.go b/pkg/compose/publish.go index f0fb5c2313..a5718534af 100644 --- a/pkg/compose/publish.go +++ b/pkg/compose/publish.go @@ -632,9 +632,9 @@ func buildConfigContentPromptMessage(configs []string) string { // loadUnresolvedFile loads a single compose file with interpolation and // environment resolution skipped, so callers can inspect raw user-provided -// values. Used by both checkEnvironmentVariables and composeFileAsByteReader. +// values. Used by checkEnvironmentVariables. func loadUnresolvedFile(ctx context.Context, project *types.Project, filePath string) (*types.Project, error) { - return loader.LoadWithContext(ctx, types.ConfigDetails{ + dict, err := loader.LoadModelWithContext(ctx, types.ConfigDetails{ WorkingDir: project.WorkingDir, Environment: project.Environment, ConfigFiles: []types.ConfigFile{{Filename: filePath}}, @@ -651,6 +651,29 @@ func loadUnresolvedFile(ctx context.Context, project *types.Project, filePath st options.SkipResolveEnvironment = true options.Profiles = project.Profiles }) + if err != nil { + return nil, err + } + + // Remove ports from services so loader.Transform doesn't fail on short-syntax + // ports containing variable references (which cannot be parsed into + // ServicePortConfig when interpolation is skipped). Callers of loadUnresolvedFile + // only inspect service environment, env_files, extends, and project configs. + if services, ok := dict["services"].(map[string]any); ok { + for _, s := range services { + if serviceMap, ok := s.(map[string]any); ok { + delete(serviceMap, "ports") + } + } + } + + var p types.Project + p.WorkingDir = project.WorkingDir + p.Environment = project.Environment + if err := loader.Transform(dict, &p); err != nil { + return nil, err + } + return &p, nil } func envFileLayers(files map[string]string) []v1.Descriptor { @@ -793,14 +816,10 @@ func scanFiles(scan secrets.Scanner, kind string, paths []string) ([]secrets.Det return allFindings, nil } -func composeFileAsByteReader(ctx context.Context, filePath string, project *types.Project) (io.Reader, error) { - base, err := loadUnresolvedFile(ctx, project, filePath) +func composeFileAsByteReader(_ context.Context, filePath string, _ *types.Project) (io.Reader, error) { + f, err := os.ReadFile(filePath) if err != nil { - return nil, fmt.Errorf("failed to load compose file %s: %w", filePath, err) - } - in, err := base.MarshalYAML() - if err != nil { - return nil, err + return nil, fmt.Errorf("failed to read compose file %s: %w", filePath, err) } - return bytes.NewBuffer(in), nil + return bytes.NewReader(f), nil } diff --git a/pkg/compose/publish_test.go b/pkg/compose/publish_test.go index c78808ac25..505f72db09 100644 --- a/pkg/compose/publish_test.go +++ b/pkg/compose/publish_test.go @@ -209,6 +209,56 @@ services: assert.Equal(t, len(envFiles), 1, "present optional env file should be added") } +func Test_loadUnresolvedFile_short_port_mapping(t *testing.T) { + dir := t.TempDir() + composePath := filepath.Join(dir, "compose.yaml") + composeContent := `name: test +services: + whoami: + image: docker.io/traefik/whoami:v1.11 + ports: + - ${DASHBOARD_PORT:-3000}:3000 + - $PORT:80 + - 8080:${TARGET_PORT:-8080} + environment: + API_KEY: "$ENV_KEY" +` + assert.NilError(t, os.WriteFile(composePath, []byte(composeContent), 0o600)) + + project := &types.Project{ + WorkingDir: dir, + ComposeFiles: []string{composePath}, + } + + unresolved, err := loadUnresolvedFile(t.Context(), project, composePath) + assert.NilError(t, err) + assert.Assert(t, unresolved.Services["whoami"].Environment != nil) + assert.Equal(t, *unresolved.Services["whoami"].Environment["API_KEY"], "$ENV_KEY") +} + +func Test_checkForSensitiveData_short_port_mapping(t *testing.T) { + dir := t.TempDir() + composePath := filepath.Join(dir, "compose.yaml") + composeContent := `name: test +services: + whoami: + image: docker.io/traefik/whoami:v1.11 + ports: + - ${DASHBOARD_PORT:-3000}:3000 +` + assert.NilError(t, os.WriteFile(composePath, []byte(composeContent), 0o600)) + + project := &types.Project{ + WorkingDir: dir, + ComposeFiles: []string{composePath}, + } + + svc := &composeService{} + findings, err := svc.checkForSensitiveData(t.Context(), project) + assert.NilError(t, err) + assert.Equal(t, len(findings), 0) +} + func Test_checkForSensitiveData_optional_env_file_missing(t *testing.T) { dir := t.TempDir() project := &types.Project{ @@ -326,6 +376,22 @@ services: environment: DB_PASSWORD: "${DB_PASSWORD}" API_KEY: "$API_KEY" +`, + }, + }, + { + name: "short-form port mapping with variable interpolation does not fail env check", + files: map[string]string{ + "compose.yaml": `name: test +services: + whoami: + image: traefik/whoami:v1.11 + ports: + - ${DASHBOARD_PORT:-3000}:3000 + - $PORT:80 + - 8080:${TARGET_PORT:-8080} + environment: + API_KEY: "$ENV_KEY" `, }, },