Files
uncloud/pkg/client/compose/project_test.go
T

487 lines
12 KiB
Go

package compose
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/compose-spec/compose-go/v2/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestLoadProject_EnvFileInterpolation tests that environment variables from .env files
// are properly loaded and available for interpolation in compose files.
func TestLoadProject_EnvFileInterpolation(t *testing.T) {
tests := []struct {
name string
composeYAML string
envFile string
verify func(t *testing.T, projectDir string)
}{
// This is a regression test for https://github.com/psviderski/uncloud/issues/144.
{
name: "simple variable interpolation",
envFile: `KEY1=value1
KEY2=value2
`,
composeYAML: `services:
web:
image: nginx
command: ["echo", "${KEY1}", "${KEY2}"]
`,
verify: func(t *testing.T, projectDir string) {
project, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
require.NoError(t, err)
require.NotNil(t, project)
require.Len(t, project.Services, 1)
webService := project.Services["web"]
require.NotNil(t, webService)
require.Len(t, webService.Command, 3)
assert.Equal(t, "echo", webService.Command[0])
assert.Equal(t, "value1", webService.Command[1], "KEY1 should be interpolated from .env file")
assert.Equal(t, "value2", webService.Command[2], "KEY2 should be interpolated from .env file")
},
},
{
name: "environment variable interpolation in service environment",
envFile: `DATABASE_URL=postgres://localhost:5432/mydb
REDIS_URL=redis://localhost:6379
`,
composeYAML: `services:
app:
image: myapp:latest
environment:
- DB_CONNECTION=${DATABASE_URL}
- CACHE_URL=${REDIS_URL}
`,
verify: func(t *testing.T, projectDir string) {
project, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
require.NoError(t, err)
require.NotNil(t, project)
require.Len(t, project.Services, 1)
appService := project.Services["app"]
require.NotNil(t, appService)
require.NotNil(t, appService.Environment)
assert.Equal(t, "postgres://localhost:5432/mydb", *appService.Environment["DB_CONNECTION"],
"DATABASE_URL should be interpolated from .env file")
assert.Equal(t, "redis://localhost:6379", *appService.Environment["CACHE_URL"],
"REDIS_URL should be interpolated from .env file")
},
},
{
name: "variable with default value when not in env file",
envFile: `EXISTING_VAR=exists
`,
composeYAML: `services:
test:
image: busybox
environment:
- VAR1=${EXISTING_VAR}
- VAR2=${MISSING_VAR:-default_value}
`,
verify: func(t *testing.T, projectDir string) {
project, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
require.NoError(t, err)
require.NotNil(t, project)
testService := project.Services["test"]
require.NotNil(t, testService)
assert.Equal(t, "exists", *testService.Environment["VAR1"],
"EXISTING_VAR should be interpolated from .env file")
assert.Equal(t, "default_value", *testService.Environment["VAR2"],
"MISSING_VAR should use default value")
},
},
{
name: "os environment overrides .env file",
envFile: `MY_VAR=from_env_file
`,
composeYAML: `services:
override:
image: alpine
environment:
- TEST_VAR=${MY_VAR}
`,
verify: func(t *testing.T, projectDir string) {
t.Setenv("MY_VAR", "from_os_env")
project, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
require.NoError(t, err)
require.NotNil(t, project)
overrideService := project.Services["override"]
require.NotNil(t, overrideService)
// OS environment should win over .env file (WithOsEnv comes before WithDotEnv).
assert.Equal(t, "from_os_env", *overrideService.Environment["TEST_VAR"],
"OS environment should override .env file")
},
},
{
name: "no .env file - missing variables get empty string",
envFile: "",
composeYAML: `services:
missing:
image: ubuntu
command: ["echo", "${UNDEFINED_VAR}"]
`,
verify: func(t *testing.T, projectDir string) {
os.Remove(filepath.Join(projectDir, ".env"))
project, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
require.NoError(t, err)
require.NotNil(t, project)
missingService := project.Services["missing"]
require.NotNil(t, missingService)
require.Len(t, missingService.Command, 2)
assert.Equal(t, "", missingService.Command[1], "undefined variable should be empty string")
},
},
{
name: "COMPOSE_DISABLE_ENV_FILE disables .env loading",
envFile: `MY_VAR=from_env_file`,
composeYAML: `services:
web:
image: nginx
command: ["echo", "${MY_VAR}"]
`,
verify: func(t *testing.T, projectDir string) {
t.Setenv("COMPOSE_DISABLE_ENV_FILE", "true")
project, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
require.NoError(t, err)
require.NotNil(t, project)
webService := project.Services["web"]
require.NotNil(t, webService)
require.Len(t, webService.Command, 2)
assert.Equal(t, "", webService.Command[1], "variable should be empty when .env file is disabled")
},
},
}
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)
// Write .env file if provided.
if tt.envFile != "" {
envFile := filepath.Join(tempDir, ".env")
err = os.WriteFile(envFile, []byte(tt.envFile), 0o644)
require.NoError(t, err)
}
tt.verify(t, tempDir)
})
}
}
// TestLoadProject_Unsupported checks that unsupported features lead to warnings or errors.
func TestLoadProject_Unsupported(t *testing.T) {
// 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
warnCount int
warnContains []string
shouldErr bool
}{
{
name: "unsupported dns",
composeYAML: `services:
app:
image: myapp:latest
dns: 8.8.8.8
`,
warnCount: 1,
warnContains: []string{"dns"},
},
{
name: "unsupported networks",
composeYAML: `services:
app:
image: myapp:latest
networks:
- frontend
networks:
frontend:
`,
warnCount: 1,
warnContains: []string{"networks"},
},
{
name: "unsupported depends_on service_completed_successfully",
composeYAML: `services:
migrate:
image: alpine
command: ["true"]
app:
image: nginx
depends_on:
migrate:
condition: service_completed_successfully
`,
warnCount: 1,
warnContains: []string{
"service_completed_successfully",
"pre-deploy hook",
"https://uncloud.run/docs/guides/deployments/pre-deploy-hooks",
},
},
{
name: "multiple unsupported features",
composeYAML: `services:
app:
image: myapp:latest
dns: 8.8.8.8
links:
- db
db:
image: postgres:latest
secrets:
- db_password
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:
`,
warnCount: 0,
},
{
name: "relative volume sources",
composeYAML: `services:
app:
image: myapp:latest
volumes:
- ./mypath/conf:/conf:ro
- data:/var/lib/data
volumes:
data:
`,
shouldErr: true,
},
{
name: "home-relative volume source",
composeYAML: `services:
app:
image: myapp:latest
volumes:
- ~/conf:/conf:ro
`,
shouldErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stderr := captureStderr(t, func() {
_, err := LoadProjectFromContent(context.Background(), tt.composeYAML)
if tt.shouldErr {
require.Error(t, err)
return
}
require.NoError(t, err)
})
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)
}
}
})
}
}
// TestLoadProject_Secrets covers loading and validation of all top-level secret source combinations.
func TestLoadProject_Secrets(t *testing.T) {
t.Parallel()
tests := []struct {
name string
secret string // YAML body under 'secrets.token'
want types.SecretConfig
errContains string
}{
// Valid sources.
{
name: "file source",
secret: " file: /tmp/token",
want: types.SecretConfig{File: "/tmp/token"},
},
{
name: "environment source",
secret: " environment: UNSET_SECRET_VAR",
want: types.SecretConfig{Environment: "UNSET_SECRET_VAR"},
},
{
name: "x-command short form expands to exec driver",
secret: " x-command: printf abc",
want: types.SecretConfig{Driver: "exec", DriverOpts: map[string]string{"command": "printf abc"}},
},
{
name: "exec driver long form",
secret: ` driver: exec
driver_opts:
command: printf abc`,
want: types.SecretConfig{Driver: "exec", DriverOpts: map[string]string{"command": "printf abc"}},
},
// Invalid combinations.
{
name: "x-command with driver",
secret: ` x-command: printf abc
driver: exec`,
errContains: "cannot be combined with 'driver'",
},
{
name: "x-command with driver_opts",
secret: ` x-command: printf abc
driver_opts:
command: printf abc`,
errContains: "cannot be combined with 'driver'",
},
{
name: "x-command with file",
secret: ` x-command: printf abc
file: /tmp/token`,
errContains: "cannot be combined with 'file' or 'environment'",
},
{
name: "x-command with environment",
secret: ` x-command: printf abc
environment: SOME_VAR`,
errContains: "cannot be combined with 'file' or 'environment'",
},
{
name: "x-command empty",
secret: ` x-command: ""`,
errContains: "must be a non-empty string",
},
{
name: "unsupported driver",
secret: ` driver: vault
driver_opts:
key: token`,
errContains: "unsupported driver 'vault'",
},
{
name: "exec driver without command",
secret: " driver: exec",
errContains: "requires 'driver_opts.command'",
},
{
name: "exec driver with file",
secret: ` driver: exec
driver_opts:
command: printf abc
file: /tmp/token`,
errContains: "cannot also define 'file' or 'environment'",
},
{
name: "external not supported",
secret: " external: true",
errContains: "external secrets are not supported",
},
{
name: "external with exec driver not supported",
secret: ` driver: exec
driver_opts:
command: printf abc
external: true`,
errContains: "external secrets are not supported",
},
{
name: "file and environment mutually exclusive",
secret: ` file: /tmp/token
environment: SOME_VAR`,
errContains: "mutually exclusive",
},
{
name: "no source",
secret: " name: token",
errContains: "must be set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
content := `
services:
foo:
image: foo
secrets:
token:
` + tt.secret + "\n"
project, err := LoadProjectFromContent(context.Background(), content)
if tt.errContains != "" {
require.Error(t, err)
assert.ErrorContains(t, err, tt.errContains)
return
}
require.NoError(t, err)
got := project.Secrets["token"]
got.Name = ""
assert.Equal(t, tt.want, got)
})
}
}