hmdeploy/docker/client.go

317 lines
7.2 KiB
Go

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
}