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 }