feat(swarm): port swarm to use libstack [BE-11476] (#2486)
This commit is contained in:
@@ -11,7 +11,6 @@ import (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/distribution/reference"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -24,7 +23,6 @@ import (
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/docker/cli/cli/command"
|
||||
configtypes "github.com/docker/cli/cli/config/types"
|
||||
"github.com/docker/cli/cli/flags"
|
||||
cmdcompose "github.com/docker/compose/v2/cmd/compose"
|
||||
"github.com/docker/compose/v2/pkg/api"
|
||||
"github.com/docker/compose/v2/pkg/compose"
|
||||
@@ -40,10 +38,6 @@ import (
|
||||
|
||||
const PortainerEdgeStackLabel = "io.portainer.edge_stack_id"
|
||||
|
||||
const portainerEnvVarsPrefix = "PORTAINER_"
|
||||
|
||||
var mu sync.Mutex
|
||||
|
||||
func init() {
|
||||
logrus.SetOutput(LogrusToZerologWriter{})
|
||||
logrus.SetFormatter(&logrus.TextFormatter{
|
||||
@@ -51,98 +45,40 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
func withCli(
|
||||
ctx context.Context, //nolint:staticcheck
|
||||
options libstack.Options,
|
||||
cliFn func(context.Context, *command.DockerCli) error,
|
||||
) error {
|
||||
ctx = context.Background() //nolint:staticcheck
|
||||
|
||||
cli, err := command.NewDockerCli(command.WithCombinedStreams(log.Logger))
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create a Docker client: %w", err)
|
||||
}
|
||||
|
||||
opts := flags.NewClientOptions()
|
||||
|
||||
if options.Host != "" {
|
||||
opts.Hosts = []string{options.Host}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
if err := cli.Initialize(opts); err != nil {
|
||||
mu.Unlock()
|
||||
return fmt.Errorf("unable to initialize the Docker client: %w", err)
|
||||
}
|
||||
mu.Unlock()
|
||||
defer logs.CloseAndLogErr(cli.Client())
|
||||
|
||||
for _, r := range options.Registries {
|
||||
if r.ServerAddress == "" || r.ServerAddress == registry.DefaultNamespace {
|
||||
r.ServerAddress = registry.IndexServer
|
||||
}
|
||||
|
||||
cli.ConfigFile().AuthConfigs[r.ServerAddress] = r
|
||||
}
|
||||
|
||||
// Docker resolves credentials in the following priority:
|
||||
// 1. credHelpers – per-registry credential helpers
|
||||
// 2. credsStore – global credential store used for all registries
|
||||
// 3. auths – inline credentials defined in config.json
|
||||
//
|
||||
// Many Docker Desktop users (Windows/macOS) have a global credsStore configured
|
||||
// by default (e.g. "desktop.exe" on Windows or "osxkeychain" on macOS). These
|
||||
// global stores often do not include credentials for the custom registries
|
||||
// defined in Portainer stacks, leading to authentication failures.
|
||||
//
|
||||
// To avoid this, when inline credentials are provided for one or more registries,
|
||||
// we intentionally clear the global credsStore. This ensures Docker uses the
|
||||
// credentials configured in Portainer instead of falling back to an empty global
|
||||
// store.
|
||||
//
|
||||
// If no inline credentials are configured in Portainer, we keep the credsStore
|
||||
// so Docker can still use it as a fallback.
|
||||
// credHelpers are not affected as they are external services managed by the user.
|
||||
// @ref: https://linear.app/portainer/issue/BE-12237
|
||||
if len(options.Registries) > 0 {
|
||||
cli.ConfigFile().CredentialsStore = ""
|
||||
}
|
||||
|
||||
return cliFn(ctx, cli)
|
||||
}
|
||||
|
||||
func (c *ComposeDeployer) withComposeService(
|
||||
ctx context.Context,
|
||||
filePaths []string,
|
||||
options libstack.Options,
|
||||
composeFn func(api.Compose, *types.Project) error,
|
||||
) error {
|
||||
return withCli(ctx, options, func(ctx context.Context, cli *command.DockerCli) error {
|
||||
composeService := c.createComposeServiceFn(cli)
|
||||
return libstack.WithCli(ctx,
|
||||
libstack.DockerCliOptions{Host: options.Host, Registries: options.Registries},
|
||||
func(ctx context.Context, cli *command.DockerCli) error {
|
||||
composeService := c.createComposeServiceFn(cli)
|
||||
|
||||
if len(filePaths) == 0 {
|
||||
return composeFn(composeService, nil)
|
||||
}
|
||||
|
||||
project, err := createProject(ctx, filePaths, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create compose project: %w", err)
|
||||
}
|
||||
|
||||
parallel := 0
|
||||
if v, ok := project.Environment[cmdcompose.ComposeParallelLimit]; ok {
|
||||
i, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s must be an integer (found: %q)", cmdcompose.ComposeParallelLimit, v)
|
||||
if len(filePaths) == 0 {
|
||||
return composeFn(composeService, nil)
|
||||
}
|
||||
parallel = i
|
||||
}
|
||||
if parallel > 0 {
|
||||
composeService.MaxConcurrency(parallel)
|
||||
}
|
||||
|
||||
return composeFn(composeService, project)
|
||||
})
|
||||
project, err := createProject(ctx, filePaths, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create compose project: %w", err)
|
||||
}
|
||||
|
||||
parallel := 0
|
||||
if v, ok := project.Environment[cmdcompose.ComposeParallelLimit]; ok {
|
||||
i, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s must be an integer (found: %q)", cmdcompose.ComposeParallelLimit, v)
|
||||
}
|
||||
parallel = i
|
||||
}
|
||||
if parallel > 0 {
|
||||
composeService.MaxConcurrency(parallel)
|
||||
}
|
||||
|
||||
return composeFn(composeService, project)
|
||||
})
|
||||
}
|
||||
|
||||
// Deploy creates and starts containers
|
||||
@@ -226,11 +162,13 @@ func (c *ComposeDeployer) Run(ctx context.Context, filePaths []string, serviceNa
|
||||
|
||||
// Remove stops and removes containers
|
||||
func (c *ComposeDeployer) Remove(ctx context.Context, projectName string, filePaths []string, options libstack.RemoveOptions) error {
|
||||
if err := withCli(ctx, options.Options, func(ctx context.Context, cli *command.DockerCli) error {
|
||||
composeService := compose.NewComposeService(cli)
|
||||
if err := libstack.WithCli(ctx,
|
||||
libstack.DockerCliOptions{Host: options.Host, Registries: options.Registries},
|
||||
func(ctx context.Context, cli *command.DockerCli) error {
|
||||
composeService := compose.NewComposeService(cli)
|
||||
|
||||
return composeService.Down(ctx, projectName, api.DownOptions{RemoveOrphans: true, Volumes: options.Volumes})
|
||||
}); err != nil {
|
||||
return composeService.Down(ctx, projectName, api.DownOptions{RemoveOrphans: true, Volumes: options.Volumes})
|
||||
}); err != nil {
|
||||
return fmt.Errorf("compose down operation failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -287,92 +225,95 @@ func encodeRegistryAuth(image string, registries []configtypes.AuthConfig) (stri
|
||||
|
||||
// Pull pulls images
|
||||
func (c *ComposeDeployer) Pull(ctx context.Context, filePaths []string, options libstack.Options) error {
|
||||
if err := withCli(ctx, options, func(ctx context.Context, cli *command.DockerCli) error {
|
||||
project, err := createProject(ctx, filePaths, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create compose project: %w", err)
|
||||
}
|
||||
|
||||
for _, s := range project.Services {
|
||||
imageName := getImageNameOrDefault(s, project.Name)
|
||||
encodedAuth, err := encodeRegistryAuth(imageName, options.Registries)
|
||||
if err := libstack.WithCli(
|
||||
ctx,
|
||||
libstack.DockerCliOptions{Host: options.Host, Registries: options.Registries},
|
||||
func(ctx context.Context, cli *command.DockerCli) error {
|
||||
project, err := createProject(ctx, filePaths, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode registry auth: %w", err)
|
||||
return fmt.Errorf("failed to create compose project: %w", err)
|
||||
}
|
||||
|
||||
_, err = retry.RetryWithWarnings("Pull image: "+imageName, retry.Default, func() (string, error) {
|
||||
_, err := cli.Client().ImageInspect(ctx, imageName)
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
reader, err := cli.Client().ImagePull(ctx, imageName, image.PullOptions{
|
||||
Platform: s.Platform,
|
||||
RegistryAuth: encodedAuth,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to pull image: %w", err)
|
||||
}
|
||||
for _, s := range project.Services {
|
||||
imageName := getImageNameOrDefault(s, project.Name)
|
||||
encodedAuth, err := encodeRegistryAuth(imageName, options.Registries)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode registry auth: %w", err)
|
||||
}
|
||||
|
||||
defer logs.CloseAndLogErr(reader)
|
||||
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
message := scanner.Text()
|
||||
log.Debug().
|
||||
Str("ProjectName", options.ProjectName).
|
||||
Str("Host", options.Host).
|
||||
Str("Image", imageName).
|
||||
Msg(message)
|
||||
|
||||
var m jsonmessage.JSONMessage
|
||||
err := json.Unmarshal([]byte(message), &m)
|
||||
_, err = retry.RetryWithWarnings("Pull image: "+imageName, retry.Default, func() (string, error) {
|
||||
_, err := cli.Client().ImageInspect(ctx, imageName)
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
reader, err := cli.Client().ImagePull(ctx, imageName, image.PullOptions{
|
||||
Platform: s.Platform,
|
||||
RegistryAuth: encodedAuth,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to pull image: %w", err)
|
||||
}
|
||||
|
||||
defer logs.CloseAndLogErr(reader)
|
||||
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
message := scanner.Text()
|
||||
log.Debug().
|
||||
Str("ProjectName", options.ProjectName).
|
||||
Str("Host", options.Host).
|
||||
Str("Image", imageName).
|
||||
Msg(message)
|
||||
|
||||
var m jsonmessage.JSONMessage
|
||||
err := json.Unmarshal([]byte(message), &m)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("ProjectName", options.ProjectName).
|
||||
Str("Host", options.Host).
|
||||
Str("Image", imageName).
|
||||
Msg("ComposeDeployer.Pull: failed to json Unmarshal image pull message.")
|
||||
return "", fmt.Errorf("failed to json Unmarshal image pull message: %w", err)
|
||||
}
|
||||
|
||||
if m.Error != nil {
|
||||
log.Error().
|
||||
Err(m.Error).
|
||||
Str("ProjectName", options.ProjectName).
|
||||
Str("Host", options.Host).
|
||||
Str("Image", imageName).
|
||||
Msg("ComposeDeployer.Pull: error pulling image")
|
||||
return "", fmt.Errorf("error pulling image: %w", m.Error)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("ProjectName", options.ProjectName).
|
||||
Str("Host", options.Host).
|
||||
Str("Image", imageName).
|
||||
Msg("ComposeDeployer.Pull: failed to json Unmarshal image pull message.")
|
||||
return "", fmt.Errorf("failed to json Unmarshal image pull message: %w", err)
|
||||
Msg("ComposeDeployer.Pull: error reading from pull reader")
|
||||
return "", fmt.Errorf("error reading from pull reader: %w", err)
|
||||
}
|
||||
|
||||
if m.Error != nil {
|
||||
log.Error().
|
||||
Err(m.Error).
|
||||
Str("ProjectName", options.ProjectName).
|
||||
Str("Host", options.Host).
|
||||
Str("Image", imageName).
|
||||
Msg("ComposeDeployer.Pull: error pulling image")
|
||||
return "", fmt.Errorf("error pulling image: %w", m.Error)
|
||||
}
|
||||
return "", nil
|
||||
} else if err != nil {
|
||||
return "", fmt.Errorf("failed to inspect image: %w", err)
|
||||
} else {
|
||||
return "", nil
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("ProjectName", options.ProjectName).
|
||||
Str("Host", options.Host).
|
||||
Str("Image", imageName).
|
||||
Msg("ComposeDeployer.Pull: error reading from pull reader")
|
||||
return "", fmt.Errorf("error reading from pull reader: %w", err)
|
||||
}
|
||||
|
||||
return "", nil
|
||||
} else if err != nil {
|
||||
return "", fmt.Errorf("failed to inspect image: %w", err)
|
||||
} else {
|
||||
return "", nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("ProjectName", options.ProjectName).
|
||||
Str("Host", options.Host).
|
||||
Str("Image", imageName).
|
||||
Msg("ComposeDeployer.Pull: failed to pull image")
|
||||
return fmt.Errorf("failed to pull image: %w", err)
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("ProjectName", options.ProjectName).
|
||||
Str("Host", options.Host).
|
||||
Str("Image", imageName).
|
||||
Msg("ComposeDeployer.Pull: failed to pull image")
|
||||
return fmt.Errorf("failed to pull image: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("compose pull operation failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -492,13 +433,8 @@ func createProject(ctx context.Context, configFilepaths []string, options libsta
|
||||
envFiles = append(envFiles, options.EnvFilePath)
|
||||
}
|
||||
|
||||
var osPortainerEnvVars []string
|
||||
var composeEnvVars []string
|
||||
for _, ev := range os.Environ() {
|
||||
if strings.HasPrefix(ev, portainerEnvVarsPrefix) {
|
||||
osPortainerEnvVars = append(osPortainerEnvVars, ev)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(ev, "COMPOSE_") {
|
||||
composeEnvVars = append(composeEnvVars, ev)
|
||||
}
|
||||
@@ -509,7 +445,7 @@ func createProject(ctx context.Context, configFilepaths []string, options libsta
|
||||
cli.WithName(options.ProjectName),
|
||||
cli.WithoutEnvironmentResolution,
|
||||
cli.WithResolvedPaths(!slices.Contains(options.ConfigOptions, "--no-path-resolution")),
|
||||
cli.WithEnv(osPortainerEnvVars),
|
||||
cli.WithEnv(libstack.PortainerEnvVars()),
|
||||
cli.WithEnv(composeEnvVars),
|
||||
cli.WithEnv(options.Env),
|
||||
cli.WithEnvFiles(envFiles...),
|
||||
|
||||
Reference in New Issue
Block a user