package docker import ( "errors" "fmt" "os" "os/exec" "path/filepath" "slices" "strconv" "strings" "gitea.thegux.fr/hmdeploy/connection" "gitea.thegux.fr/hmdeploy/models" "gitea.thegux.fr/hmdeploy/utils" ) const ( appPartsLength = 2 imagePartsLength = 2 portPartsLength = 2 portOutPartsLength = 2 replicasPartLength = 2 ) var ( ErrDockerClientSave = errors.New("unable to save image into tar") ErrDockerClientExtractServicesInputLength = errors.New("bad input length") ErrDockerClientExtractServicesParse = errors.New("parse error") ) type Port struct { In int Out int } type Service struct { App string Name string Image struct { Name string Tag string } Ports []Port Replicas int } func (s Service) String() string { return fmt.Sprintf( "%-20s | %-20s | %-30s | %-10s | %-5d", s.App, s.Name, s.Image.Name, s.Image.Tag, s.Replicas, ) } type Services []Service func (s Services) String() string { data := []string{} header := fmt.Sprintf( "%-20s | %-20s | %-30s | %-10s | %-5s", "Service name", "App name", "Image name", "Image Tag", "Replicas", ) hr := "" for i := 0; i < len(header); i++ { hr += "-" } data = append(data, hr) data = append(data, header) data = append(data, hr) for idx := range s { data = append(data, s[idx].String()) } return strings.Join(data, "\n") } type serviceDetailsHeader map[string]int func (h serviceDetailsHeader) getPos(value string) int { v, ok := h[value] if !ok { return -1 } return v } // serviceFromString instantiates a new service from the `docker service ls` command output. // TODO: it's ugly... To rework, if i'm not too lazy func serviceFromString( //nolint:all // todo header serviceDetailsHeader, value string, ) (Service, error) { var srv Service parts := strings.Split(value, " ") parts = slices.DeleteFunc(parts, func(e string) bool { return e == "" }) if pos := header.getPos("name"); pos != -1 { appParts := strings.Split(parts[pos], "_") if len(appParts) >= appPartsLength { srv.App = appParts[len(appParts)-1] srv.Name = appParts[0] } } if pos := header.getPos("image"); pos != -1 { imageParts := strings.Split(parts[pos], ":") if len(imageParts) >= imagePartsLength { srv.Image.Name = imageParts[0] srv.Image.Tag = imageParts[1] } } if pos := header.getPos("replicas"); pos != -1 { replicasParts := strings.Split(parts[pos], "/") if len(replicasParts) >= replicasPartLength { replicas, err := strconv.Atoi(replicasParts[1]) if err != nil { return srv, fmt.Errorf( "%w, replicas conversion failed: %s", ErrDockerClientExtractServicesParse, string(replicasParts[1]), ) } srv.Replicas = replicas } } srv.Ports = []Port{} if pos := header.getPos("ports"); pos != -1 && pos < len(parts) { // ports parts must exceed the header pos so, to find missing ports // the pos difference is computed in order to retrieve them portsParts := []string{} diff := len(parts) - pos for i := 0; i < diff; i++ { portsParts = append(portsParts, parts[pos+i]) } for _, port := range portsParts { servicePort := Port{} // port in/out are displayed like "out->in" portParts := strings.Split(port, "->") if len(portParts) >= portPartsLength { // out port is displayed like "ip:port" portOutParts := strings.Split(portParts[0], ":") if len(portOutParts) == portOutPartsLength { portOut, err := utils.CatchAndConvertNumber(portOutParts[1]) if err != nil { return srv, fmt.Errorf( "%w, out port conversion failed: %s", ErrDockerClientExtractServicesParse, string(portOutParts[1]), ) } servicePort.Out = portOut } portIn, err := utils.CatchAndConvertNumber(portParts[1]) if err != nil { return srv, fmt.Errorf( "%w, in port conversion failed: %s", ErrDockerClientExtractServicesParse, string(portParts[1]), ) } servicePort.In = portIn srv.Ports = append(srv.Ports, servicePort) } } } return srv, nil } // Parse the `docker service ls` header (ID NAME MODE REPLICAS IMAGE PORTS) func parseServiceDetailsHeader(value string) serviceDetailsHeader { header := serviceDetailsHeader{} parts := strings.Split(value, " ") parts = slices.DeleteFunc(parts, func(e string) bool { return e == "" }) for idx, part := range parts { header[part] = idx } return header } func parseServiceDetails(cmdOutput string) (Services, error) { services := Services{} var header serviceDetailsHeader bufLine := []rune{} isHeaderCatched := false for _, c := range cmdOutput { if c == '\n' { if !isHeaderCatched { header = parseServiceDetailsHeader(strings.ToLower(string(bufLine))) bufLine = bufLine[:0] isHeaderCatched = true continue } service, err := serviceFromString(header, string(bufLine)) if err != nil { return services, fmt.Errorf( "%w, err=%v", ErrDockerClientExtractServicesParse, err, ) } services = append(services, service) bufLine = bufLine[:0] continue } bufLine = append(bufLine, c) } return services, nil } type IClient interface { Save(imageName, dest string) (string, error) } // LocalClient is a simple Docker client wrapping the local Docker daemon. // It does not use the Docker API but instead shell command and collect the output. // // NOTE: for now, it's ok, it only needs one command so, no need to add a fat dedicated // library with full Docker client API. type LocalClient struct{} var _ IClient = (*LocalClient)(nil) func NewLocalClient() LocalClient { return LocalClient{} } // Save saves the `imageName` (tag included) in tar format in the target directory: `dest`. // The `dest` directory must exist with correct permissions. func (c *LocalClient) Save(imageName, dest string) (string, error) { destInfo, err := os.Stat(dest) if err != nil { return "", fmt.Errorf("unable to stat file, dir=%s, err=%v", dest, err) } if !destInfo.IsDir() { return "", fmt.Errorf("dest file must be a directory, dir=%s, err=%v", dest, err) } tarFile := fmt.Sprintf("%s.tar", imageName) cmd := exec.Command("docker", "save", "-o", tarFile, imageName) cmd.Dir = dest if _, err := cmd.Output(); err != nil { return "", fmt.Errorf( "%w, dir=%s, image=%s, err=%v", ErrDockerClientSave, dest, imageName, err, ) } return filepath.Join(dest, tarFile), nil } // RemoteClient is a simple Docker client for remote daemon. // It does not use the Docker API but instead shell command over an SSH connection and collect the output. // // NOTE: for now, it's ok, it only needs one command so, no need to add a fat dedicated // library with full Docker client API. type RemoteClient struct { conn connection.SSHConn } func NewRemoteClient(netInfo *models.HMNetInfo) (RemoteClient, error) { var rc RemoteClient conn, err := connection.NewSSHConn( netInfo.IP.String(), netInfo.SSH.User, netInfo.SSH.Port, netInfo.SSH.PrivKey, ) if err != nil { return rc, nil } rc.conn = conn return rc, nil } func (c *RemoteClient) ExtractServicesDetails() (Services, error) { output, err := c.conn.Execute("docker service ls") if err != nil { return nil, err } services, err := parseServiceDetails(output) if err != nil { return nil, err } return services, nil }