From 62976f3338ed47723309507f4495575710aaed7e Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Tue, 31 Mar 2026 17:08:53 +1000 Subject: [PATCH] refactor: print warnings for all detected unsupported Compose features, not only first one --- pkg/client/compose/project.go | 2 +- pkg/client/compose/project_test.go | 107 ++++++++++++++++++----------- pkg/client/compose/service.go | 37 +++++----- 3 files changed, 85 insertions(+), 61 deletions(-) diff --git a/pkg/client/compose/project.go b/pkg/client/compose/project.go index 0e3d3ebe..ecd50daa 100644 --- a/pkg/client/compose/project.go +++ b/pkg/client/compose/project.go @@ -67,7 +67,7 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project return nil, err } - if err = validateServicesFeatures(project); err != nil { + for _, err = range validateServicesFeatures(project) { tui.PrintWarning(err.Error()) } diff --git a/pkg/client/compose/project_test.go b/pkg/client/compose/project_test.go index dc4a50da..1dd6436b 100644 --- a/pkg/client/compose/project_test.go +++ b/pkg/client/compose/project_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -188,18 +189,31 @@ REDIS_URL=redis://localhost:6379 } } -// TestLoadProject_Unsupported checks that unsupported features lead to an error. +// TestLoadProject_Unsupported checks that unsupported features lead to warnings. func TestLoadProject_Unsupported(t *testing.T) { - requireWarning := func(t *testing.T, r io.Reader) { - p := make([]byte, 15) // also room for the (color) escapes - io.ReadFull(r, p) - require.Contains(t, string(p), "WARNING:") + // captureStderr runs fn while capturing stderr output and returns what was written. + captureStderr := func(t *testing.T, fn func()) string { + t.Helper() + old := os.Stderr + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stderr = w + defer func() { os.Stderr = old }() + + fn() + + w.Close() + out, err := io.ReadAll(r) + require.NoError(t, err) + r.Close() + return string(out) } tests := []struct { - name string - composeYAML string - verify func(t *testing.T, projectDir string) + name string + composeYAML string + warnCount int + warnContains []string }{ { name: "unsupported dns", @@ -208,18 +222,8 @@ func TestLoadProject_Unsupported(t *testing.T) { image: myapp:latest dns: 8.8.8.8 `, - verify: func(t *testing.T, projectDir string) { - old := os.Stderr - var r io.ReadCloser - defer func() { os.Stderr = old }() - r, os.Stderr, _ = os.Pipe() - defer r.Close() - defer os.Stderr.Close() - - _, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")}) - require.NoError(t, err) - requireWarning(t, r) - }, + warnCount: 1, + warnContains: []string{"dns"}, }, { name: "unsupported networks", @@ -232,46 +236,65 @@ func TestLoadProject_Unsupported(t *testing.T) { networks: frontend: `, - verify: func(t *testing.T, projectDir string) { - old := os.Stderr - var r io.ReadCloser - defer func() { os.Stderr = old }() - r, os.Stderr, _ = os.Pipe() - defer r.Close() - defer os.Stderr.Close() + warnCount: 1, + warnContains: []string{"networks"}, + }, + { + name: "multiple unsupported features", + composeYAML: `services: + app: + image: myapp:latest + dns: 8.8.8.8 + links: + - db + db: + image: postgres:latest + secrets: + - db_password - _, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")}) - require.NoError(t, err) - requireWarning(t, r) - }, +secrets: + db_password: + file: ./secret.txt +`, + warnCount: 3, + warnContains: []string{"dns", "links", "secrets"}, }, { name: "supported networks", composeYAML: `services: app: image: myapp:latest + networks: + default: {} + + web: + image: nginx networks: - default networks: - default: {} + default: `, - verify: func(t *testing.T, projectDir string) { - _, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")}) - require.NoError(t, err) - }, + warnCount: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tempDir := t.TempDir() + stderr := captureStderr(t, func() { + _, err := LoadProjectFromContent(context.Background(), tt.composeYAML) + require.NoError(t, err) + }) - composeFile := filepath.Join(tempDir, "compose.yaml") - err := os.WriteFile(composeFile, []byte(tt.composeYAML), 0o644) - require.NoError(t, err) - - tt.verify(t, tempDir) + if tt.warnCount == 0 { + assert.Empty(t, stderr) + } else { + assert.Equal(t, tt.warnCount, strings.Count(stderr, "WARNING:"), + "expected %d warnings, got stderr: %s", tt.warnCount, stderr) + for _, substr := range tt.warnContains { + assert.Contains(t, stderr, substr) + } + } }) } } diff --git a/pkg/client/compose/service.go b/pkg/client/compose/service.go index 7bb2fcf5..110a9d24 100644 --- a/pkg/client/compose/service.go +++ b/pkg/client/compose/service.go @@ -423,51 +423,52 @@ func validateServicesExtensions(project *types.Project) error { return nil } -// validateServicesFeatures checks the service for unsupported features and returns an error for this first one found. -func validateServicesFeatures(project *types.Project) error { +// validateServicesFeatures checks services for unsupported features and returns all found. +func validateServicesFeatures(project *types.Project) []error { err := func(service, feature string) error { - return fmt.Errorf("service: '%s': unsupported feature: '%s', see %s", service, feature, "https://uncloud.run/docs/compose-file-reference/support-matrix") + return fmt.Errorf("service '%s': unsupported feature '%s', see %s", + service, feature, "https://uncloud.run/docs/compose-file-reference/support-matrix") } + // TODO: check other commonly used but unsupported features. + var errs []error for _, service := range project.Services { if service.SecurityOpt != nil { - return err(service.Name, "security_opt") + errs = append(errs, err(service.Name, "security_opt")) } if service.DNS != nil { - return err(service.Name, "dns") + errs = append(errs, err(service.Name, "dns")) } if service.DNSSearch != nil { - return err(service.Name, "dns_search") + errs = append(errs, err(service.Name, "dns_search")) } if service.Labels != nil { - return err(service.Name, "labels") + errs = append(errs, err(service.Name, "labels")) } if service.Links != nil { - return err(service.Name, "links") + errs = append(errs, err(service.Name, "links")) } if service.MemSwappiness > 0 { - return err(service.Name, "mem_swappiness") + errs = append(errs, err(service.Name, "mem_swappiness")) } if service.MemSwapLimit > 0 { - return err(service.Name, "memswap_limit") + errs = append(errs, err(service.Name, "memswap_limit")) } if service.Secrets != nil { - return err(service.Name, "secrets") + errs = append(errs, err(service.Name, "secrets")) } if service.StorageOpt != nil { - return err(service.Name, "storage_opt") + errs = append(errs, err(service.Name, "storage_opt")) } // we only allow the 'default' network, nothing else. if x := service.Networks; x != nil { if len(x) != 1 { - return err(service.Name, "networks") - } - config, ok := x["default"] - if !ok || config != nil { - return err(service.Name, "networks") + errs = append(errs, err(service.Name, "networks")) + } else if _, ok := x["default"]; !ok { + errs = append(errs, err(service.Name, "networks")) } } } - return nil + return errs }