diff --git a/connection/ssh_connection.go b/connection/ssh_connection.go index 6b6ae59..ed471e7 100644 --- a/connection/ssh_connection.go +++ b/connection/ssh_connection.go @@ -158,5 +158,5 @@ func (c *SSHConn) Execute(cmd string) (string, error) { return "", fmt.Errorf("%w, addr=%s, cmd=%s, err=%v", ErrSSHExecute, c.addr, cmd, err) } - return "", nil + return buf.String(), nil } diff --git a/docker/client.go b/docker/client.go index c007e00..1bf144a 100644 --- a/docker/client.go +++ b/docker/client.go @@ -6,30 +6,250 @@ import ( "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) } -var ErrDockerClientSave = errors.New("unable to save image into tar") - -// Client is a simple Docker client wrapping the local Docker daemon. +// 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 Client struct{} +type LocalClient struct{} -var _ IClient = (*Client)(nil) +var _ IClient = (*LocalClient)(nil) -func NewClient() Client { - return Client{} +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 *Client) Save(imageName, dest string) (string, error) { +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) @@ -55,3 +275,42 @@ func (c *Client) Save(imageName, dest string) (string, error) { 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 +} diff --git a/main.go b/main.go index 7733462..7a5be54 100644 --- a/main.go +++ b/main.go @@ -177,7 +177,6 @@ func loadHMMap() (models.HMMap, error) { ) } - log.Info().Str("conf", hmmap_path).Msg("hmmap load successfully") return hmmap, nil } @@ -205,7 +204,7 @@ func initDeployers( return deps, fmt.Errorf("%w, swarm net info does not exist", ErrNetInfoNotFound) } - dcli := docker.NewClient() + dcli := docker.NewLocalClient() sd, err := deployers.NewSwarmDeployer(ctx, project, swarmNet, &dcli) if err != nil { return deps, fmt.Errorf("%w, unable to init swarm deployer, err=%v", ErrDeployerInit, err) @@ -240,7 +239,27 @@ func initDeployers( return deps, nil } -func main() { //nolint: all //hjf +func getSwarmServicesDetails(hm *models.HMMap) error { + swarmNet := hm.GetSwarmNetInfo() + if swarmNet == nil { + return fmt.Errorf("%w, swarm net info does not exist", ErrNetInfoNotFound) + } + + cli, err := docker.NewRemoteClient(swarmNet) + if err != nil { + return err + } + + services, err := cli.ExtractServicesDetails() + if err != nil { + return err + } + + fmt.Println(services) + return nil +} + +func main() { //nolint: funlen //TODO: to reduce ctx, fnCancel := signal.NotifyContext( context.Background(), os.Interrupt, @@ -251,6 +270,7 @@ func main() { //nolint: all //hjf destroy := flag.Bool("destroy", false, "delete the deployed project") noNginx := flag.Bool("no-nginx", false, "no Nginx deployment") debug := flag.Bool("debug", false, "show debug logs") + details := flag.Bool("details", false, "extract swarm details and return") confirm := flag.Bool( "confirm", false, @@ -259,13 +279,19 @@ func main() { //nolint: all //hjf flag.Parse() initLogger(*debug) - log.Info().Msg("hmdeploy started") hmmap, err := loadHMMap() if err != nil { log.Fatal().Err(err).Msg("failed to load conf") } + if *details { + if err = getSwarmServicesDetails(&hmmap); err != nil { + log.Fatal().Err(err).Msg("unable to extract swarm services details") + } + return + } + project, err := models.ProjectFromDir(*projectDir) if err != nil { log.Fatal().Str("dir", *projectDir).Err(err).Msg("unable to init project from directory") diff --git a/utils/utils.go b/utils/utils.go index 4ecedff..b664604 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -8,6 +8,7 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "time" @@ -98,3 +99,24 @@ func Confirm(ctx context.Context, destroy bool) error { return nil } + +// CatchAndConvertNumber finds and converts the first consecutives digits in integer. +func CatchAndConvertNumber(value string) (int, error) { + buf := []rune{} + + hasLeftTrimmed := false + for _, c := range value { + if c >= '0' && c <= '9' { + hasLeftTrimmed = true + buf = append(buf, c) + continue + } + + // early return if no number catch + if hasLeftTrimmed { + break + } + } + + return strconv.Atoi(string(buf)) +}