Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions src/cmd/cli/command/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"os"
"slices"
"strings"
"time"
Expand Down Expand Up @@ -553,6 +554,104 @@ func makeComposeConfigCmd() *cobra.Command {
}
}

func makeComposeLintCmd() *cobra.Command {
var fix bool
cmd := &cobra.Command{
Use: "lint",
Args: cobra.NoArgs,
Short: "Validate a Compose file without deploying",
RunE: func(cmd *cobra.Command, args []string) error {
loader := newLoaderForCommand(cmd)

project, loadErr := loader.LoadProject(cmd.Context())
if loadErr != nil {
return handleInvalidComposeFileErr(cmd.Context(), loadErr)
}

if fix {
fixes := compose.FixProject(project)
printFixResults(fixes)
if len(fixes) > 0 {
if err := writeFixedCompose(project); err != nil {
return err
}
}
}

var errs []error

if err := compose.ValidateServiceDockerfiles(project); err != nil {
errs = append(errs, err)
}

if err := compose.ValidateProject(project, modes.ModeUnspecified); err != nil {
errs = append(errs, err)
}

if len(errs) > 0 {
return fmt.Errorf("compose file has errors:\n%w", errors.Join(errs...))
}

if term.HadWarnings() {
term.Info("Compose file is valid with warnings")
} else {
term.Info("Compose file is valid")
}
return nil
},
}
cmd.Flags().BoolVar(&fix, "fix", false, "apply safe mechanical fixes to the Compose file")
return cmd
}

func writeFixedCompose(project *compose.Project) error {
if len(project.ComposeFiles) == 0 {
return fmt.Errorf("no compose file to write to")
}
data, err := compose.MarshalYAML(project)
if err != nil {
return err
}
path := project.ComposeFiles[0]
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
term.Info("Updated", path)
return nil
}

func printFixResults(fixes []compose.FixResult) {
if len(fixes) == 0 {
term.Info("No fixes needed.")
return
}
term.Println("Fixes applied:")
for _, fix := range fixes {
term.Printf(" service %q: %s\n", fix.Service, describeFixResult(fix))
}
term.Printf("\n%d fix(es) applied.\n", len(fixes))
}

func describeFixResult(fix compose.FixResult) string {
switch fix.Action {
case "removed":
return fmt.Sprintf("removed unsupported directive: %s (%s)", fix.Field, fix.Reason)
case "changed":
if fix.Before != "" {
return fmt.Sprintf("changed %s from %q to %q (%s)", fix.Field, fix.Before, fix.After, fix.Reason)
}
return fmt.Sprintf("changed %s to %q (%s)", fix.Field, fix.After, fix.Reason)
default:
if fix.Field == "mode" {
return fmt.Sprintf("added mode: %s to %s", fix.After, fix.Reason)
}
if fix.Reason != "" {
return fmt.Sprintf("added %s: %s (%s)", fix.Field, fix.After, fix.Reason)
}
return fmt.Sprintf("added %s: %s", fix.Field, fix.After)
}
}

func makeComposePsCmd() *cobra.Command {
getServicesCmd := &cobra.Command{
Use: "ps",
Expand Down Expand Up @@ -763,6 +862,7 @@ services:
composeCmd.PersistentFlags().StringVar(&byoc.DefangPulumiBackend, "pulumi-backend", "", `specify an alternate Pulumi backend URL or "pulumi-cloud"`)
composeCmd.AddCommand(makeComposeUpCmd())
composeCmd.AddCommand(makeComposeConfigCmd())
composeCmd.AddCommand(makeComposeLintCmd())
composeCmd.AddCommand(makeComposeDownCmd())
composeCmd.AddCommand(makeComposePsCmd())
composeCmd.AddCommand(makeLogsCmd())
Expand Down
41 changes: 41 additions & 0 deletions src/cmd/cli/command/compose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"os"
"strings"
"testing"

"connectrpc.com/connect"
Expand Down Expand Up @@ -79,3 +80,43 @@ func TestComposeConfig(t *testing.T) {
}
})
}

func TestComposeLint(t *testing.T) {
defaultTerm := term.DefaultTerm
t.Cleanup(func() {
term.DefaultTerm = defaultTerm
})

t.Run("Valid", func(t *testing.T) {
t.Chdir("testdata/without-stack")
var stdout, stderr bytes.Buffer
term.DefaultTerm = term.NewTerm(os.Stdin, &stdout, &stderr)

cmd := makeComposeLintCmd()
err := cmd.Execute()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if !strings.Contains(stdout.String(), "Compose file is valid") {
t.Fatalf("expected valid lint output, got stdout %q stderr %q", stdout.String(), stderr.String())
}
})

t.Run("Invalid", func(t *testing.T) {
t.Chdir("testdata/lint-invalid")
var stdout, stderr bytes.Buffer
term.DefaultTerm = term.NewTerm(os.Stdin, &stdout, &stderr)

cmd := makeComposeLintCmd()
err := cmd.Execute()
if err == nil {
t.Fatal("expected lint error")
}
if !strings.Contains(err.Error(), "compose file has errors:") {
t.Fatalf("expected error heading, got error %q stdout %q stderr %q", err.Error(), stdout.String(), stderr.String())
}
if !strings.Contains(err.Error(), "unsupported compose directive: hostname; use 'domainname' instead") {
t.Fatalf("expected remediation hint, got error %q", err.Error())
}
})
}
4 changes: 4 additions & 0 deletions src/cmd/cli/command/testdata/lint-invalid/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
services:
web:
image: nginx:latest
hostname: web
194 changes: 194 additions & 0 deletions src/pkg/cli/compose/fix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package compose

import (
"fmt"
"sort"

composeTypes "github.com/compose-spec/compose-go/v2/types"
)

const defaultRestartPolicy = "unless-stopped"

type FixResult struct {
Service string
Field string
Action string // "added", "removed", "changed"
Before string
After string
Reason string
}

func FixProject(project *Project) []FixResult {
if project == nil {
return nil
}

var results []FixResult
for _, name := range sortedServiceNames(project) {
service := project.Services[name]
results = append(results, fixService(&service)...)
project.Services[name] = service
}
return results
}

func sortedServiceNames(project *Project) []string {
names := make([]string, 0, len(project.Services))
for name := range project.Services {
names = append(names, name)
}
sort.Strings(names)
return names
}

func fixService(service *composeTypes.ServiceConfig) []FixResult {
var results []FixResult
repo := GetImageRepo(service.Image)
isManagedStoreImage := IsPostgresRepo(repo) || IsRedisRepo(repo) || IsMongoRepo(repo)

results = append(results, fixPorts(service, isManagedStoreImage)...)
results = append(results, fixLimitsToReservations(service)...)
results = append(results, fixRestart(service)...)
results = append(results, fixUnsupportedDirectives(service)...)

return results
}

func fixPorts(service *composeTypes.ServiceConfig, isManagedStoreImage bool) []FixResult {
var results []FixResult
for i := range service.Ports {
port := &service.Ports[i]
if port.Mode != "" {
continue
}

mode := Mode_INGRESS
reason := ""
if port.Protocol == Protocol_UDP {
mode = Mode_HOST
reason = "UDP port"
} else if isManagedStoreImage {
mode = Mode_HOST
reason = "database image"
}
port.Mode = mode
results = append(results, FixResult{
Service: service.Name,
Field: "mode",
Action: "added",
After: mode,
Reason: portReason(port.Target, reason),
})
}
return results
}

func portReason(target uint32, reason string) string {
if reason == "" {
return fmt.Sprintf("port %d", target)
}
return fmt.Sprintf("port %d (%s)", target, reason)
}

func fixLimitsToReservations(service *composeTypes.ServiceConfig) []FixResult {
if service.Deploy == nil {
return nil
}
if service.Deploy.Resources.Limits == nil || service.Deploy.Resources.Reservations != nil {
return nil
}
limits := *service.Deploy.Resources.Limits
service.Deploy.Resources.Reservations = &limits
return []FixResult{{
Service: service.Name,
Field: "deploy.resources.reservations",
Action: "added",
After: "copied from deploy.resources.limits",
Reason: "Defang uses reservations for scheduling, not limits",
}}
}

func fixRestart(service *composeTypes.ServiceConfig) []FixResult {
restart := restartFromDeployPolicy(service)
if restart == "" && isSupportedRestart(service.Restart) {
return nil
}

before := service.Restart
if restart == "" {
restart = defaultRestartPolicy
}
service.Restart = restart

reason := "unsupported restart policy"
if service.Deploy != nil && service.Deploy.RestartPolicy != nil {
reason = "deploy.restart_policy is unsupported; converted to service-level restart"
service.Deploy.RestartPolicy = nil
} else if before == "" {
reason = "missing restart policy"
}

action := "changed"
if before == "" {
action = "added"
}
return []FixResult{{
Service: service.Name,
Field: "restart",
Action: action,
Before: before,
After: restart,
Reason: reason,
}}
}

func restartFromDeployPolicy(service *composeTypes.ServiceConfig) string {
if service.Deploy == nil || service.Deploy.RestartPolicy == nil {
return ""
}
switch service.Deploy.RestartPolicy.Condition {
case "", "any":
return "always"
default:
return defaultRestartPolicy
}
}

func isSupportedRestart(restart string) bool {
return restart == "always" || restart == defaultRestartPolicy
}

func fixUnsupportedDirectives(service *composeTypes.ServiceConfig) []FixResult {
var results []FixResult
if len(service.DNS) != 0 {
service.DNS = nil
results = append(results, removedDirective(service.Name, "dns"))
}
if len(service.DNSSearch) != 0 {
service.DNSSearch = nil
results = append(results, removedDirective(service.Name, "dns_search"))
}
if len(service.Devices) != 0 {
service.Devices = nil
results = append(results, removedDirective(service.Name, "devices"))
}
if len(service.DeviceCgroupRules) != 0 {
service.DeviceCgroupRules = nil
results = append(results, removedDirective(service.Name, "device_cgroup_rules"))
}
if len(service.GroupAdd) != 0 {
service.GroupAdd = nil
results = append(results, removedDirective(service.Name, "group_add"))
}
return results
}

func removedDirective(service, field string) FixResult {
return FixResult{
Service: service,
Field: field,
Action: "removed",
Before: "present",
Reason: "unsupported directive",
}
}
Loading
Loading