316 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			316 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| package docker
 | |
| 
 | |
| import (
 | |
| 	"context"
 | |
| 	"encoding/json"
 | |
| 	"errors"
 | |
| 	"fmt"
 | |
| 	"os"
 | |
| 	"os/exec"
 | |
| 	"path/filepath"
 | |
| 	"sync"
 | |
| 	"time"
 | |
| 
 | |
| 	"gitea.thegux.fr/hmdeploy/connection"
 | |
| 	"gitea.thegux.fr/hmdeploy/models"
 | |
| 	"github.com/rs/zerolog/log"
 | |
| )
 | |
| 
 | |
| const (
 | |
| 	stateTickDuration   = 4 * time.Second
 | |
| 	defaultStateTimeout = 30 * time.Second
 | |
| )
 | |
| 
 | |
| var (
 | |
| 	ErrDockerClientSave = errors.New("unable to save image into tar")
 | |
| 
 | |
| 	ErrDockerClientExtractServicesInputLength = errors.New("bad input length")
 | |
| 	ErrDockerClientExtractServicesParse       = errors.New("parse error")
 | |
| 
 | |
| 	ErrContextDone = errors.New("unable to execute, context done")
 | |
| )
 | |
| 
 | |
| type stackOption struct {
 | |
| 	checkState bool
 | |
| }
 | |
| 
 | |
| type fnStackOption func(s *stackOption)
 | |
| 
 | |
| func WithCheckState() fnStackOption {
 | |
| 	return func(s *stackOption) {
 | |
| 		s.checkState = true
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func parseIDs(cmdOutput string) []string {
 | |
| 	ids := []string{}
 | |
| 	bufLine := []rune{}
 | |
| 	for _, c := range cmdOutput {
 | |
| 		if c == '\n' {
 | |
| 			ids = append(ids, string(bufLine))
 | |
| 			bufLine = bufLine[:0]
 | |
| 
 | |
| 			continue
 | |
| 		}
 | |
| 		bufLine = append(bufLine, c)
 | |
| 	}
 | |
| 	return ids
 | |
| }
 | |
| 
 | |
| 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
 | |
| }
 | |
| 
 | |
| type extractOption struct {
 | |
| 	filter string
 | |
| }
 | |
| 
 | |
| type fnExtractOption func(*extractOption)
 | |
| 
 | |
| func WithName(name string) fnExtractOption {
 | |
| 	return func(o *extractOption) {
 | |
| 		o.filter = name
 | |
| 	}
 | |
| }
 | |
| 
 | |
| func (c *RemoteClient) getIDS(name string) ([]string, error) {
 | |
| 	cmd := "docker service ls -q"
 | |
| 	if name != "" {
 | |
| 		cmd += " --filter name=" + name
 | |
| 	}
 | |
| 
 | |
| 	output, err := c.conn.Execute(cmd)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return parseIDs(output), nil
 | |
| }
 | |
| 
 | |
| func (c *RemoteClient) getServiceDetails(id string) (Service, error) {
 | |
| 	output, err := c.conn.Execute(
 | |
| 		fmt.Sprintf(
 | |
| 			`echo "{\"services\": [$(echo $(cmd=$(docker service ps %s --format json); cmd=$(echo $cmd | tr '} {' '},{'); echo $cmd))], \"details\": $(docker service inspect %s --format=json)}"`,
 | |
| 			id,
 | |
| 			id,
 | |
| 		),
 | |
| 	)
 | |
| 	if err != nil {
 | |
| 		return Service{}, err
 | |
| 	}
 | |
| 
 | |
| 	sc := Service{}
 | |
| 	if err := json.Unmarshal([]byte(output), &sc); err != nil {
 | |
| 		return sc, err
 | |
| 	}
 | |
| 
 | |
| 	return sc, nil
 | |
| }
 | |
| 
 | |
| func (c *RemoteClient) extractServicesDetails(ids ...string) (Services, error) {
 | |
| 	services := Services{}
 | |
| 	for _, id := range ids {
 | |
| 		srv, err := c.getServiceDetails(id)
 | |
| 		if err != nil {
 | |
| 			return nil, err
 | |
| 		}
 | |
| 		services = append(services, srv)
 | |
| 	}
 | |
| 
 | |
| 	return services, nil
 | |
| }
 | |
| 
 | |
| func (c *RemoteClient) LoadImages(imageNames ...string) error {
 | |
| 	for idx := range imageNames {
 | |
| 		if _, err := c.conn.Execute("docker image load -i " + imageNames[idx] + ".tar"); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 	}
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (c *RemoteClient) DeployStack(
 | |
| 	ctx context.Context,
 | |
| 	projectName, composeFilepath string,
 | |
| 	options ...fnStackOption,
 | |
| ) error {
 | |
| 	if _, err := c.conn.Execute(fmt.Sprintf("docker stack deploy -c %s %s --with-registry-auth", composeFilepath, projectName)); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	var opts stackOption
 | |
| 	for _, opt := range options {
 | |
| 		opt(&opts)
 | |
| 	}
 | |
| 
 | |
| 	if opts.checkState {
 | |
| 		return c.checkState(ctx, projectName, Running)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (c *RemoteClient) DestroyStack(
 | |
| 	ctx context.Context,
 | |
| 	projectName string,
 | |
| 	options ...fnStackOption,
 | |
| ) error {
 | |
| 	if _, err := c.conn.Execute(fmt.Sprintf("docker stack rm %s", projectName)); err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	var opts stackOption
 | |
| 	for _, opt := range options {
 | |
| 		opt(&opts)
 | |
| 	}
 | |
| 
 | |
| 	if opts.checkState {
 | |
| 		return c.checkState(ctx, projectName, Shutdown)
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func (c *RemoteClient) ExtractServicesDetails(options ...fnExtractOption) (Services, error) {
 | |
| 	var opts extractOption
 | |
| 	for _, opt := range options {
 | |
| 		opt(&opts)
 | |
| 	}
 | |
| 
 | |
| 	ids, err := c.getIDS(opts.filter)
 | |
| 	if err != nil {
 | |
| 		return nil, err
 | |
| 	}
 | |
| 
 | |
| 	return c.extractServicesDetails(ids...)
 | |
| }
 | |
| 
 | |
| // checkState checks the state of the deployment.
 | |
| // It loops over all the services deployed for the project (replicas included) and
 | |
| // checks if the `target` state match the services states.
 | |
| //
 | |
| // There's a timeout (default: 30s) that you can set with the options: `WithTimeout`.
 | |
| func (c *RemoteClient) checkState(
 | |
| 	ctx context.Context,
 | |
| 	projectName string,
 | |
| 	target ServiceStatus,
 | |
| ) error {
 | |
| 	var checkErr error
 | |
| 	var wg sync.WaitGroup
 | |
| 	wg.Add(1)
 | |
| 	go func() {
 | |
| 		defer wg.Done()
 | |
| 
 | |
| 		ticker := time.NewTicker(stateTickDuration)
 | |
| 		ctxTimeout, fnCancel := context.WithDeadline(ctx, time.Now().UTC().Add(defaultStateTimeout))
 | |
| 		defer fnCancel()
 | |
| 
 | |
| 		for {
 | |
| 			select {
 | |
| 			case <-ticker.C:
 | |
| 				log.Info().
 | |
| 					Str("project", projectName).
 | |
| 					Str("state", string(target)).
 | |
| 					Msg("checking project state...")
 | |
| 				srvs, err := c.ExtractServicesDetails(WithName(projectName))
 | |
| 				if err != nil {
 | |
| 					checkErr = err
 | |
| 					return
 | |
| 				}
 | |
| 
 | |
| 				ready := true
 | |
| 			mainloop:
 | |
| 				for idx := range srvs {
 | |
| 					for idy := range srvs[idx].Replicas {
 | |
| 						if srvs[idx].Replicas[idy].State != target {
 | |
| 							log.Info().Dur("retry (ms)", stateTickDuration).Msg("project not in good state yet, retrying...")
 | |
| 							ready = false
 | |
| 							break mainloop
 | |
| 						}
 | |
| 					}
 | |
| 				}
 | |
| 				if ready {
 | |
| 					return
 | |
| 				}
 | |
| 			case <-ctxTimeout.Done():
 | |
| 				msg := "swarm deployment skipped"
 | |
| 				if errors.Is(ctxTimeout.Err(), context.DeadlineExceeded) {
 | |
| 					msg = "swarm check state timeout"
 | |
| 				}
 | |
| 				checkErr = fmt.Errorf("%w, %s", ErrContextDone, msg)
 | |
| 				return
 | |
| 			}
 | |
| 		}
 | |
| 	}()
 | |
| 
 | |
| 	wg.Wait()
 | |
| 	return checkErr
 | |
| }
 | 
