Skip to content

Commit a85b23a

Browse files
committed
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 <hooman.ghkhani@gmail.com>
1 parent a255af9 commit a85b23a

2 files changed

Lines changed: 95 additions & 10 deletions

File tree

pkg/compose/publish.go

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -632,9 +632,9 @@ func buildConfigContentPromptMessage(configs []string) string {
632632

633633
// loadUnresolvedFile loads a single compose file with interpolation and
634634
// environment resolution skipped, so callers can inspect raw user-provided
635-
// values. Used by both checkEnvironmentVariables and composeFileAsByteReader.
635+
// values. Used by checkEnvironmentVariables.
636636
func loadUnresolvedFile(ctx context.Context, project *types.Project, filePath string) (*types.Project, error) {
637-
return loader.LoadWithContext(ctx, types.ConfigDetails{
637+
dict, err := loader.LoadModelWithContext(ctx, types.ConfigDetails{
638638
WorkingDir: project.WorkingDir,
639639
Environment: project.Environment,
640640
ConfigFiles: []types.ConfigFile{{Filename: filePath}},
@@ -651,6 +651,29 @@ func loadUnresolvedFile(ctx context.Context, project *types.Project, filePath st
651651
options.SkipResolveEnvironment = true
652652
options.Profiles = project.Profiles
653653
})
654+
if err != nil {
655+
return nil, err
656+
}
657+
658+
// Remove ports from services so loader.Transform doesn't fail on short-syntax
659+
// ports containing variable references (which cannot be parsed into
660+
// ServicePortConfig when interpolation is skipped). Callers of loadUnresolvedFile
661+
// only inspect service environment, env_files, extends, and project configs.
662+
if services, ok := dict["services"].(map[string]any); ok {
663+
for _, s := range services {
664+
if serviceMap, ok := s.(map[string]any); ok {
665+
delete(serviceMap, "ports")
666+
}
667+
}
668+
}
669+
670+
var p types.Project
671+
p.WorkingDir = project.WorkingDir
672+
p.Environment = project.Environment
673+
if err := loader.Transform(dict, &p); err != nil {
674+
return nil, err
675+
}
676+
return &p, nil
654677
}
655678

656679
func envFileLayers(files map[string]string) []v1.Descriptor {
@@ -793,14 +816,10 @@ func scanFiles(scan secrets.Scanner, kind string, paths []string) ([]secrets.Det
793816
return allFindings, nil
794817
}
795818

796-
func composeFileAsByteReader(ctx context.Context, filePath string, project *types.Project) (io.Reader, error) {
797-
base, err := loadUnresolvedFile(ctx, project, filePath)
819+
func composeFileAsByteReader(_ context.Context, filePath string, _ *types.Project) (io.Reader, error) {
820+
f, err := os.ReadFile(filePath)
798821
if err != nil {
799-
return nil, fmt.Errorf("failed to load compose file %s: %w", filePath, err)
800-
}
801-
in, err := base.MarshalYAML()
802-
if err != nil {
803-
return nil, err
822+
return nil, fmt.Errorf("failed to read compose file %s: %w", filePath, err)
804823
}
805-
return bytes.NewBuffer(in), nil
824+
return bytes.NewReader(f), nil
806825
}

pkg/compose/publish_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,56 @@ services:
209209
assert.Equal(t, len(envFiles), 1, "present optional env file should be added")
210210
}
211211

212+
func Test_loadUnresolvedFile_short_port_mapping(t *testing.T) {
213+
dir := t.TempDir()
214+
composePath := filepath.Join(dir, "compose.yaml")
215+
composeContent := `name: test
216+
services:
217+
whoami:
218+
image: docker.io/traefik/whoami:v1.11
219+
ports:
220+
- ${DASHBOARD_PORT:-3000}:3000
221+
- $PORT:80
222+
- 8080:${TARGET_PORT:-8080}
223+
environment:
224+
API_KEY: "$ENV_KEY"
225+
`
226+
assert.NilError(t, os.WriteFile(composePath, []byte(composeContent), 0o600))
227+
228+
project := &types.Project{
229+
WorkingDir: dir,
230+
ComposeFiles: []string{composePath},
231+
}
232+
233+
unresolved, err := loadUnresolvedFile(t.Context(), project, composePath)
234+
assert.NilError(t, err)
235+
assert.Assert(t, unresolved.Services["whoami"].Environment != nil)
236+
assert.Equal(t, *unresolved.Services["whoami"].Environment["API_KEY"], "$ENV_KEY")
237+
}
238+
239+
func Test_checkForSensitiveData_short_port_mapping(t *testing.T) {
240+
dir := t.TempDir()
241+
composePath := filepath.Join(dir, "compose.yaml")
242+
composeContent := `name: test
243+
services:
244+
whoami:
245+
image: docker.io/traefik/whoami:v1.11
246+
ports:
247+
- ${DASHBOARD_PORT:-3000}:3000
248+
`
249+
assert.NilError(t, os.WriteFile(composePath, []byte(composeContent), 0o600))
250+
251+
project := &types.Project{
252+
WorkingDir: dir,
253+
ComposeFiles: []string{composePath},
254+
}
255+
256+
svc := &composeService{}
257+
findings, err := svc.checkForSensitiveData(t.Context(), project)
258+
assert.NilError(t, err)
259+
assert.Equal(t, len(findings), 0)
260+
}
261+
212262
func Test_checkForSensitiveData_optional_env_file_missing(t *testing.T) {
213263
dir := t.TempDir()
214264
project := &types.Project{
@@ -326,6 +376,22 @@ services:
326376
environment:
327377
DB_PASSWORD: "${DB_PASSWORD}"
328378
API_KEY: "$API_KEY"
379+
`,
380+
},
381+
},
382+
{
383+
name: "short-form port mapping with variable interpolation does not fail env check",
384+
files: map[string]string{
385+
"compose.yaml": `name: test
386+
services:
387+
whoami:
388+
image: traefik/whoami:v1.11
389+
ports:
390+
- ${DASHBOARD_PORT:-3000}:3000
391+
- $PORT:80
392+
- 8080:${TARGET_PORT:-8080}
393+
environment:
394+
API_KEY: "$ENV_KEY"
329395
`,
330396
},
331397
},

0 commit comments

Comments
 (0)