portainer/portainer

View on GitHub
api/exec/swarm_stack.go

Summary

Maintainability
A
35 mins
Test Coverage
package exec

import (
    "bytes"
    "errors"
    "fmt"
    "os"
    "os/exec"
    "path"
    "runtime"
    "strings"

    portainer "github.com/portainer/portainer/api"
    "github.com/portainer/portainer/api/dataservices"
    "github.com/portainer/portainer/api/internal/registryutils"
    "github.com/portainer/portainer/api/stacks/stackutils"

    "github.com/rs/zerolog/log"
    "github.com/segmentio/encoding/json"
)

// SwarmStackManager represents a service for managing stacks.
type SwarmStackManager struct {
    binaryPath           string
    configPath           string
    signatureService     portainer.DigitalSignatureService
    fileService          portainer.FileService
    reverseTunnelService portainer.ReverseTunnelService
    dataStore            dataservices.DataStore
}

// NewSwarmStackManager initializes a new SwarmStackManager service.
// It also updates the configuration of the Docker CLI binary.
func NewSwarmStackManager(
    binaryPath, configPath string,
    signatureService portainer.DigitalSignatureService,
    fileService portainer.FileService,
    reverseTunnelService portainer.ReverseTunnelService,
    datastore dataservices.DataStore,
) (*SwarmStackManager, error) {
    manager := &SwarmStackManager{
        binaryPath:           binaryPath,
        configPath:           configPath,
        signatureService:     signatureService,
        fileService:          fileService,
        reverseTunnelService: reverseTunnelService,
        dataStore:            datastore,
    }

    err := manager.updateDockerCLIConfiguration(manager.configPath)
    if err != nil {
        return nil, err
    }

    return manager, nil
}

// Login executes the docker login command against a list of registries (including DockerHub).
func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoint *portainer.Endpoint) error {
    command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
    if err != nil {
        return err
    }

    for _, registry := range registries {
        if registry.Authentication {
            err = registryutils.EnsureRegTokenValid(manager.dataStore, &registry)
            if err != nil {
                log.
                    Warn().
                    Err(err).
                    Str("RegistryName", registry.Name).
                    Msg("Failed to validate registry token. Skip logging with this registry.")

                continue
            }

            username, password, err := registryutils.GetRegEffectiveCredential(&registry)
            if err != nil {
                log.
                    Warn().
                    Err(err).
                    Str("RegistryName", registry.Name).
                    Msg("Failed to get effective credential. Skip logging with this registry.")

                continue
            }

            registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
            err = runCommandAndCaptureStdErr(command, registryArgs, nil, "")
            if err != nil {
                log.
                    Warn().
                    Err(err).
                    Str("RegistryName", registry.Name).
                    Msg("Failed to login.")
            }
        }
    }

    return nil
}

// Logout executes the docker logout command.
func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
    command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
    if err != nil {
        return err
    }

    args = append(args, "logout")

    return runCommandAndCaptureStdErr(command, args, nil, "")
}

// Deploy executes the docker stack deploy command.
func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, pullImage bool, endpoint *portainer.Endpoint) error {
    filePaths := stackutils.GetStackFilePaths(stack, true)
    command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
    if err != nil {
        return err
    }

    if prune {
        args = append(args, "stack", "deploy", "--prune", "--with-registry-auth")
    } else {
        args = append(args, "stack", "deploy", "--with-registry-auth")
    }

    if !pullImage {
        args = append(args, "--resolve-image=never")
    }

    args = configureFilePaths(args, filePaths)
    args = append(args, stack.Name)

    env := make([]string, 0)
    for _, envvar := range stack.Env {
        env = append(env, envvar.Name+"="+envvar.Value)
    }

    return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath)
}

// Remove executes the docker stack rm command.
func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
    command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
    if err != nil {
        return err
    }

    args = append(args, "stack", "rm", stack.Name)

    return runCommandAndCaptureStdErr(command, args, nil, "")
}

func runCommandAndCaptureStdErr(command string, args []string, env []string, workingDir string) error {
    var stderr bytes.Buffer
    cmd := exec.Command(command, args...)
    cmd.Stderr = &stderr
    cmd.Dir = workingDir

    if env != nil {
        cmd.Env = os.Environ()
        cmd.Env = append(cmd.Env, env...)
    }

    err := cmd.Run()
    if err != nil {
        return errors.New(stderr.String())
    }

    return nil
}

func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, configPath string, endpoint *portainer.Endpoint) (string, []string, error) {
    // Assume Linux as a default
    command := path.Join(binaryPath, "docker")

    if runtime.GOOS == "windows" {
        command = path.Join(binaryPath, "docker.exe")
    }

    args := make([]string, 0)
    args = append(args, "--config", configPath)

    endpointURL := endpoint.URL
    if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
        tunnel, err := manager.reverseTunnelService.GetActiveTunnel(endpoint)
        if err != nil {
            return "", nil, err
        }
        endpointURL = fmt.Sprintf("tcp://127.0.0.1:%d", tunnel.Port)
    }

    args = append(args, "-H", endpointURL)

    if endpoint.TLSConfig.TLS {
        args = append(args, "--tls")

        if !endpoint.TLSConfig.TLSSkipVerify {
            args = append(args, "--tlsverify", "--tlscacert", endpoint.TLSConfig.TLSCACertPath)
        } else {
            args = append(args, "--tlscacert", "''")
        }

        if endpoint.TLSConfig.TLSCertPath != "" && endpoint.TLSConfig.TLSKeyPath != "" {
            args = append(args, "--tlscert", endpoint.TLSConfig.TLSCertPath, "--tlskey", endpoint.TLSConfig.TLSKeyPath)
        }
    }

    return command, args, nil
}

func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string) error {
    configFilePath := path.Join(configPath, "config.json")
    config, err := manager.retrieveConfigurationFromDisk(configFilePath)
    if err != nil {
        return err
    }

    signature, err := manager.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
    if err != nil {
        return err
    }

    if config["HttpHeaders"] == nil {
        config["HttpHeaders"] = make(map[string]interface{})
    }

    headersObject := config["HttpHeaders"].(map[string]interface{})
    headersObject["X-PortainerAgent-ManagerOperation"] = "1"
    headersObject["X-PortainerAgent-Signature"] = signature
    headersObject["X-PortainerAgent-PublicKey"] = manager.signatureService.EncodedPublicKey()

    return manager.fileService.WriteJSONToFile(configFilePath, config)
}

func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (map[string]interface{}, error) {
    var config map[string]interface{}

    raw, err := manager.fileService.GetFileContent(path, "")
    if err != nil {
        return make(map[string]interface{}), nil
    }

    err = json.Unmarshal(raw, &config)
    if err != nil {
        return nil, err
    }

    return config, nil
}

func (manager *SwarmStackManager) NormalizeStackName(name string) string {
    return stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "")
}

func configureFilePaths(args []string, filePaths []string) []string {
    for _, path := range filePaths {
        args = append(args, "--compose-file", path)
    }

    return args
}