github.com/benchkram/bob@v0.0.0-20240314204020-b7a57f2f9be9/pkg/composeutil/composeutil.go (about)

     1  package composeutil
     2  
     3  import (
     4  	"fmt"
     5  	"math"
     6  	"net"
     7  	"sort"
     8  	"strconv"
     9  	"strings"
    10  
    11  	"github.com/compose-spec/compose-go/cli"
    12  	"github.com/compose-spec/compose-go/types"
    13  	"github.com/docker/compose/v2/pkg/api"
    14  
    15  	"github.com/benchkram/bob/pkg/usererror"
    16  )
    17  
    18  type PortConfigs []PortConfig
    19  
    20  func (c PortConfigs) String() string {
    21  	s := ""
    22  
    23  	for _, cfg := range c {
    24  		s += fmt.Sprintf(
    25  			" %s/%s\t%s\n",
    26  			cfg.Port,
    27  			cfg.Protocol,
    28  			strings.Join(cfg.Services, ", "),
    29  		)
    30  	}
    31  
    32  	return s
    33  }
    34  
    35  // ResolvePortConflicts mutates the project and returns the port configs with any conflicting ports
    36  // remapped
    37  func ResolvePortConflicts(conflicts PortConfigs) (PortConfigs, error) {
    38  	// indexes reserved ports for easier lookup
    39  	protoPortCfgs := map[string]bool{}
    40  	for _, cfg := range conflicts {
    41  		protoPortCfgs[protoPort(cfg.Port, cfg.Protocol)] = true
    42  	}
    43  
    44  	resolved := []PortConfig{}
    45  
    46  	for _, cfg := range conflicts {
    47  		for i, service := range cfg.Services {
    48  			// skip the first service, as it's either the host or we want to keep this service's ports and change
    49  			// the ports of the other services
    50  			if i == 0 {
    51  				resolved = append(resolved, PortConfig{
    52  					Port:         cfg.Port,
    53  					OriginalPort: cfg.Port,
    54  					Protocol:     cfg.Protocol,
    55  					Services:     []string{service},
    56  				})
    57  			}
    58  
    59  			// check the next port
    60  			intPort, _ := strconv.Atoi(cfg.Port)
    61  
    62  			port := intPort + 1
    63  			for {
    64  				pp := protoPort(fmt.Sprint(port), cfg.Protocol)
    65  
    66  				if _, ok := protoPortCfgs[pp]; !ok && PortAvailable(fmt.Sprint(port), cfg.Protocol) {
    67  					// we found an available port that is not already reserved
    68  					protoPortCfgs[pp] = true
    69  					break
    70  				}
    71  
    72  				port += 1
    73  				if port == math.MaxUint16 {
    74  					return nil, fmt.Errorf("no ports available")
    75  				}
    76  			}
    77  
    78  			resolved = append(resolved, PortConfig{
    79  				Port:         fmt.Sprint(port),
    80  				OriginalPort: cfg.Port,
    81  				Protocol:     cfg.Protocol,
    82  				Services:     []string{service},
    83  			})
    84  		}
    85  	}
    86  
    87  	return resolved, nil
    88  }
    89  
    90  func protoPort(port string, proto string) string {
    91  	return fmt.Sprintf("%s/%s", port, proto)
    92  }
    93  
    94  func ApplyPortMapping(p *types.Project, mapping PortConfigs) {
    95  	// index that associates service with its port configs
    96  	servicePorts := map[string]PortConfigs{}
    97  	for _, cfg := range mapping {
    98  		service := cfg.Services[0]
    99  		servicePorts[service] = append(servicePorts[service], cfg)
   100  	}
   101  
   102  	for i, service := range p.Services {
   103  		servicePortCfgs := servicePorts[service.Name]
   104  
   105  		for j, port := range service.Ports {
   106  			for _, cfg := range servicePortCfgs {
   107  				if port.Published == cfg.OriginalPort && port.Protocol == cfg.Protocol {
   108  					port.Published = cfg.Port
   109  					service.Ports[j] = port
   110  					p.Services[i] = service
   111  				}
   112  			}
   113  		}
   114  	}
   115  }
   116  
   117  // PortConflicts returns all PortConfigs that have a port conflict
   118  func PortConflicts(cfgs PortConfigs) PortConfigs {
   119  	conflicts := []PortConfig{}
   120  
   121  	for _, cfg := range cfgs {
   122  		if len(cfg.Services) > 1 {
   123  			// port conflict detected
   124  			conflicts = append(conflicts, cfg)
   125  		}
   126  	}
   127  
   128  	return conflicts
   129  }
   130  
   131  // HasPortConflicts returns true if there are two services using the same host port
   132  func HasPortConflicts(cfgs PortConfigs) bool {
   133  	for _, cfg := range cfgs {
   134  		if len(cfg.Services) > 1 {
   135  			// port conflict detected
   136  			return true
   137  		}
   138  	}
   139  
   140  	return false
   141  }
   142  
   143  type PortConfig struct {
   144  	Port         string
   145  	OriginalPort string
   146  	Protocol     string
   147  	Services     []string
   148  }
   149  
   150  // ProjectPortConfigs returns a slice of associations of port/proto to services, for a specific protocol, sorted by port
   151  func ProjectPortConfigs(p *types.Project) PortConfigs {
   152  	tcpCfg := portConfigs(p, "tcp")
   153  	udpCfg := portConfigs(p, "udp")
   154  	cfgs := append(tcpCfg, udpCfg...)
   155  
   156  	// sort ports for consistent ordering
   157  	sort.Slice(cfgs, func(i, j int) bool {
   158  		return protoPort(cfgs[i].Port, cfgs[i].Protocol) > protoPort(cfgs[j].Port, cfgs[j].Protocol)
   159  	})
   160  
   161  	return cfgs
   162  }
   163  
   164  func portConfigs(proj *types.Project, typ string) PortConfigs {
   165  	portServices := map[string][]string{}
   166  
   167  	// services' order is undefined, sort them
   168  	services := proj.Services
   169  	sort.Slice(services, func(i, j int) bool {
   170  		return services[i].Name < services[j].Name
   171  	})
   172  
   173  	for _, s := range services {
   174  		for _, spCfg := range s.Ports {
   175  			port := spCfg.Published
   176  
   177  			if spCfg.Protocol == typ {
   178  				if !PortAvailable(port, typ) && len(portServices[port]) == 0 {
   179  					portServices[port] = append(portServices[port], "host")
   180  				}
   181  
   182  				portServices[port] = append(portServices[port], s.Name)
   183  			}
   184  		}
   185  	}
   186  
   187  	portCfgs := []PortConfig{}
   188  	for port, services := range portServices {
   189  		portCfgs = append(portCfgs, PortConfig{
   190  			Port:         port,
   191  			OriginalPort: port,
   192  			Protocol:     typ,
   193  			Services:     services,
   194  		})
   195  	}
   196  
   197  	return portCfgs
   198  }
   199  
   200  // ProjectFromConfig loads a docker-compose config file into a compose Project
   201  func ProjectFromConfig(composePath string) (p *types.Project, err error) {
   202  	opts, err := cli.NewProjectOptions([]string{composePath})
   203  	if err != nil {
   204  		return nil, usererror.Wrapm(err, "error ")
   205  	}
   206  
   207  	p, err = cli.ProjectFromOptions(opts)
   208  	if err != nil {
   209  		return nil, usererror.Wrapm(err, "error loading docker-compose file")
   210  	}
   211  
   212  	for i, s := range p.Services {
   213  		s.CustomLabels = map[string]string{
   214  			api.ProjectLabel:     p.Name,
   215  			api.ServiceLabel:     s.Name,
   216  			api.VersionLabel:     api.ComposeVersion,
   217  			api.WorkingDirLabel:  p.WorkingDir,
   218  			api.ConfigFilesLabel: strings.Join(p.ComposeFiles, ","),
   219  			api.OneoffLabel:      "False",
   220  		}
   221  		p.Services[i] = s
   222  	}
   223  
   224  	return p, nil
   225  }
   226  
   227  // PortAvailable returns true if the port is not currently in use by the host
   228  func PortAvailable(port string, proto string) bool {
   229  	switch proto {
   230  	case "tcp":
   231  		ln, err := net.Listen("tcp", fmt.Sprintf(":%s", port))
   232  		if err != nil {
   233  			return false
   234  		}
   235  
   236  		_ = ln.Close()
   237  
   238  		return true
   239  	case "udp":
   240  		ln, err := net.ListenPacket("udp", fmt.Sprintf(":%s", port))
   241  		if err != nil {
   242  			return false
   243  		}
   244  
   245  		_ = ln.Close()
   246  
   247  		return true
   248  	default:
   249  		return false
   250  	}
   251  }