package exec import ( "context" "fmt" "io" "os" "path" "strings" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy/factory" "github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/pkg/libstack" "github.com/pkg/errors" ) // ComposeStackManager is a wrapper for docker-compose binary type ComposeStackManager struct { deployer libstack.Deployer proxyManager *proxy.Manager } // NewComposeStackManager returns a docker-compose wrapper if corresponding binary present, otherwise nil func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager) (*ComposeStackManager, error) { return &ComposeStackManager{ deployer: deployer, proxyManager: proxyManager, }, nil } // ComposeSyntaxMaxVersion returns the maximum supported version of the docker compose syntax func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string { return portainer.ComposeSyntaxMaxVersion } // Up builds, (re)creates and starts containers in the background. Wraps `docker-compose up -d` command func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, forceRecreate bool) error { url, proxy, err := manager.fetchEndpointProxy(endpoint) if err != nil { return errors.Wrap(err, "failed to fetch environment proxy") } if proxy != nil { defer proxy.Close() } envFilePath, err := createEnvFile(stack) if err != nil { return errors.Wrap(err, "failed to create env file") } filePaths := stackutils.GetStackFilePaths(stack, true) err = manager.deployer.Deploy(ctx, filePaths, libstack.DeployOptions{ Options: libstack.Options{ WorkingDir: stack.ProjectPath, EnvFilePath: envFilePath, Host: url, ProjectName: stack.Name, }, ForceRecreate: forceRecreate, }) return errors.Wrap(err, "failed to deploy a stack") } // Down stops and removes containers, networks, images, and volumes func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { url, proxy, err := manager.fetchEndpointProxy(endpoint) if err != nil { return err } if proxy != nil { defer proxy.Close() } err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.Options{ WorkingDir: "", Host: url, }) return errors.Wrap(err, "failed to remove a stack") } // Pull an image associated with a service defined in a docker-compose.yml or docker-stack.yml file, // but does not start containers based on those images. func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error { url, proxy, err := manager.fetchEndpointProxy(endpoint) if err != nil { return err } if proxy != nil { defer proxy.Close() } envFilePath, err := createEnvFile(stack) if err != nil { return errors.Wrap(err, "failed to create env file") } filePaths := stackutils.GetStackFilePaths(stack, true) err = manager.deployer.Pull(ctx, filePaths, libstack.Options{ WorkingDir: stack.ProjectPath, EnvFilePath: envFilePath, Host: url, ProjectName: stack.Name, }) return errors.Wrap(err, "failed to pull images of the stack") } // NormalizeStackName returns a new stack name with unsupported characters replaced func (manager *ComposeStackManager) NormalizeStackName(name string) string { return stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "") } func (manager *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) { if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { return "", nil, nil } proxy, err := manager.proxyManager.CreateAgentProxyServer(endpoint) if err != nil { return "", nil, err } return fmt.Sprintf("tcp://127.0.0.1:%d", proxy.Port), proxy, nil } // createEnvFile creates a file that would hold both "in-place" and default environment variables. // It will return the name of the file if the stack has "in-place" env vars, otherwise empty string. func createEnvFile(stack *portainer.Stack) (string, error) { if len(stack.Env) == 0 { return "", nil } envFilePath := path.Join(stack.ProjectPath, "stack.env") envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return "", err } defer envfile.Close() // Copy from default .env file defaultEnvPath := path.Join(stack.ProjectPath, path.Dir(stack.EntryPoint), ".env") if err = copyDefaultEnvFile(envfile, defaultEnvPath); err != nil { return "", err } // Copy from stack env vars if err = copyConfigEnvVars(envfile, stack.Env); err != nil { return "", err } return "stack.env", nil } // copyDefaultEnvFile copies the default .env file if it exists to the provided writer func copyDefaultEnvFile(w io.Writer, defaultEnvFilePath string) error { defaultEnvFile, err := os.Open(defaultEnvFilePath) if err != nil { // If cannot open a default file, then don't need to copy it. // We could as well stat it and check if it exists, but this is more efficient. return nil } defer defaultEnvFile.Close() if _, err = io.Copy(w, defaultEnvFile); err == nil { if _, err = fmt.Fprintf(w, "\n"); err != nil { return fmt.Errorf("failed to copy default env file: %w", err) } } return nil // If couldn't copy the .env file, then ignore the error and try to continue } // copyConfigEnvVars write the environment variables from stack configuration to the writer func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error { for _, v := range envs { if _, err := fmt.Fprintf(w, "%s=%s\n", v.Name, v.Value); err != nil { return fmt.Errorf("failed to copy config env vars: %w", err) } } return nil }