Skip to content
Closed
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
66 changes: 66 additions & 0 deletions src/cmd/cli/command/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,71 @@ func makeComposeConfigCmd() *cobra.Command {
}
}

func makeComposeFixCmd() *cobra.Command {
var dryRun bool
cmd := &cobra.Command{
Use: "fix",
Args: cobra.NoArgs,
Short: "Apply safe mechanical fixes to a Compose file",
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)
}

fixes := compose.FixProject(project)
printFixResults(fixes)
if dryRun || len(fixes) == 0 {
return nil
}

data, err := compose.MarshalYAML(project)
if err != nil {
return err
}
term.Println()
_, err = term.Print(string(data))
return err
},
}
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show fixes without outputting YAML")
return cmd
}

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 +828,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(makeComposeFixCmd())
composeCmd.AddCommand(makeComposeDownCmd())
composeCmd.AddCommand(makeComposePsCmd())
composeCmd.AddCommand(makeLogsCmd())
Expand Down
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