feat: print warning on some unsupported compose features (#288)

* feat: error on unsupported compose features

This implements a check for the unimplemented features of the uncloud
support matrix and points to the matrix in the error.

Fixes: #237

This redos #277, as I messed up my branch, (basically rebase gone
wrong).

Signed-off-by: Miek Gieben <miek@miek.nl>

* Fix tests by capturing stderr

The error should be nil, but we _do_ want to know if the user saw an
warning. Redirect stderr to capture this as the error is emited by
printing to stderr.

Signed-off-by: Miek Gieben <miek@miek.nl>

---------

Signed-off-by: Miek Gieben <miek@miek.nl>
This commit is contained in:
Miek Gieben
2026-03-31 16:37:35 +10:00
committed by GitHub
parent 636307d3ea
commit d38312fec6
3 changed files with 143 additions and 0 deletions
+5
View File
@@ -12,6 +12,7 @@ import (
"github.com/compose-spec/compose-go/v2/transform"
"github.com/compose-spec/compose-go/v2/tree"
"github.com/compose-spec/compose-go/v2/types"
"github.com/psviderski/uncloud/internal/cli/tui"
"github.com/psviderski/uncloud/pkg/api"
)
@@ -66,6 +67,10 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
return nil, err
}
if err = validateServicesFeatures(project); err != nil {
tui.PrintWarning(err.Error())
}
// Process image templates in services to expand Go template expressions using git repo state.
if project, err = ProcessImageTemplates(project); err != nil {
return nil, err
+89
View File
@@ -2,6 +2,7 @@ package compose
import (
"context"
"io"
"os"
"path/filepath"
"testing"
@@ -186,3 +187,91 @@ REDIS_URL=redis://localhost:6379
})
}
}
// TestLoadProject_Unsupported checks that unsupported features lead to an error.
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:")
}
tests := []struct {
name string
composeYAML string
verify func(t *testing.T, projectDir string)
}{
{
name: "unsupported dns",
composeYAML: `services:
app:
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)
},
},
{
name: "unsupported networks",
composeYAML: `services:
app:
image: myapp:latest
networks:
- frontend
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()
_, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
require.NoError(t, err)
requireWarning(t, r)
},
},
{
name: "supported networks",
composeYAML: `services:
app:
image: myapp:latest
networks:
- default
networks:
default: {}
`,
verify: func(t *testing.T, projectDir string) {
_, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
require.NoError(t, err)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
composeFile := filepath.Join(tempDir, "compose.yaml")
err := os.WriteFile(composeFile, []byte(tt.composeYAML), 0o644)
require.NoError(t, err)
tt.verify(t, tempDir)
})
}
}
+49
View File
@@ -422,3 +422,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 {
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")
}
for _, service := range project.Services {
if service.SecurityOpt != nil {
return err(service.Name, "security_opt")
}
if service.DNS != nil {
return err(service.Name, "dns")
}
if service.DNSSearch != nil {
return err(service.Name, "dns_search")
}
if service.Labels != nil {
return err(service.Name, "labels")
}
if service.Links != nil {
return err(service.Name, "links")
}
if service.MemSwappiness > 0 {
return err(service.Name, "mem_swappiness")
}
if service.MemSwapLimit > 0 {
return err(service.Name, "memswap_limit")
}
if service.Secrets != nil {
return err(service.Name, "secrets")
}
if service.StorageOpt != nil {
return 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")
}
}
}
return nil
}