package exec import ( "context" "fmt" "io" "os" "path" "strings" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy/factory" "github.com/portainer/portainer/api/internal/registryutils" "github.com/portainer/portainer/api/stacks/stackutils" "github.com/portainer/portainer/pkg/libstack" "github.com/docker/cli/cli/config/types" "github.com/pkg/errors" "github.com/rs/zerolog/log" ) // ComposeStackManager is a wrapper for docker-compose binary type ComposeStackManager struct { deployer libstack.Deployer proxyManager *proxy.Manager dataStore dataservices.DataStore } // NewComposeStackManager returns a Compose stack manager func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager, dataStore dataservices.DataStore) *ComposeStackManager { return &ComposeStackManager{ deployer: deployer, proxyManager: proxyManager, dataStore: dataStore, } } // 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, options portainer.ComposeUpOptions) 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, Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries), }, ForceRecreate: options.ForceRecreate, AbortOnContainerExit: options.AbortOnContainerExit, }) return errors.Wrap(err, "failed to deploy a stack") } // Run runs a one-off command on a service. Wraps `docker-compose run` command func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, serviceName string, options portainer.ComposeRunOptions) 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.Run(ctx, filePaths, serviceName, libstack.RunOptions{ Options: libstack.Options{ WorkingDir: stack.ProjectPath, EnvFilePath: envFilePath, Host: url, ProjectName: stack.Name, Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries), }, Remove: options.Remove, Args: options.Args, Detached: options.Detached, }) 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 } else if proxy != nil { defer proxy.Close() } err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.RemoveOptions{ Options: 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, options portainer.ComposeOptions) error { url, proxy, err := manager.fetchEndpointProxy(endpoint) if err != nil { return err } else 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, Registries: portainerRegistriesToAuthConfigs(manager.dataStore, options.Registries), }) 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 envFilePath, 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 } func portainerRegistriesToAuthConfigs(tx dataservices.DataStoreTx, registries []portainer.Registry) []types.AuthConfig { var authConfigs []types.AuthConfig for _, r := range registries { ac := types.AuthConfig{ Username: r.Username, Password: r.Password, ServerAddress: r.URL, } if r.Authentication { var err error ac.Username, ac.Password, err = getEffectiveRegUsernamePassword(tx, &r) if err != nil { continue } } authConfigs = append(authConfigs, ac) } return authConfigs } func getEffectiveRegUsernamePassword(tx dataservices.DataStoreTx, registry *portainer.Registry) (string, string, error) { if err := registryutils.EnsureRegTokenValid(tx, registry); err != nil { log.Warn(). Err(err). Str("RegistryName", registry.Name). Msg("Failed to validate registry token. Skip logging with this registry.") return "", "", err } 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.") } return username, password, err }