diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 41937b6645..3e2432f8d4 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -52,6 +52,7 @@ import ( "github.com/portainer/portainer/pkg/fips" "github.com/portainer/portainer/pkg/libhelm" "github.com/portainer/portainer/pkg/libstack/compose" + libswarm "github.com/portainer/portainer/pkg/libstack/swarm" "github.com/portainer/portainer/pkg/validate" "github.com/google/uuid" @@ -337,7 +338,6 @@ func loadEncryptionSecretKey(keyfilename string) []byte { } func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdownTrigger context.CancelFunc) portainer.Server { - if flags.FeatureFlags != nil { featureflags.Parse(*flags.FeatureFlags, portainer.SupportedFeatureFlags) } @@ -437,16 +437,11 @@ func buildServer(flags *portainer.CLIFlags, shutdownCtx context.Context, shutdow reverseTunnelService.ProxyManager = proxyManager - dockerConfigPath := fileService.GetDockerConfigPath() - composeDeployer := compose.NewComposeDeployer() - composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager, dataStore) + composeStackManager := exec.NewComposeStackManager(composeDeployer, proxyManager) - swarmStackManager, err := exec.NewSwarmStackManager(*flags.Assets, dockerConfigPath, signatureService, fileService, reverseTunnelService, dataStore) - if err != nil { - log.Fatal().Err(err).Msg("failed initializing swarm stack manager") - } + swarmStackManager := exec.NewSwarmStackManager(libswarm.NewSwarmDeployer(), proxyManager) kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager) diff --git a/api/exec/common.go b/api/exec/common.go index 3d11c31b62..5d2b96078f 100644 --- a/api/exec/common.go +++ b/api/exec/common.go @@ -1,5 +1,79 @@ package exec -import "regexp" +import ( + "fmt" + "regexp" + "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/internal/registryutils" + + "github.com/docker/cli/cli/config/types" + "github.com/rs/zerolog/log" +) var stackNameNormalizeRegex = regexp.MustCompile("[^-_a-z0-9]+") + +func normalizeStackName(name string) string { + return stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "") +} + +// fetchEndpointProxy returns the Docker host URL for the given endpoint. +// For remote endpoints it creates a local proxy that handles TLS termination and +// Portainer agent header injection; for local unix/npipe sockets no proxy is needed. +func fetchEndpointProxy(proxyManager *proxy.Manager, endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) { + if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { + return "", nil, nil + } + + proxy, err := proxyManager.CreateAgentProxyServer(endpoint) + if err != nil { + return "", nil, err + } + + return fmt.Sprintf("tcp://127.0.0.1:%d", proxy.Port), proxy, nil +} + +// portainerRegistriesToAuthConfigs converts registries to Docker auth configs. +// Callers must ensure ECR tokens are valid before calling this function (e.g. via +// registryutils.ValidateRegistriesECRTokens with a real DataStoreTx). This function +// intentionally performs no DB writes to avoid write-lock contention when called inside +// an active BoltDB write transaction. +func portainerRegistriesToAuthConfigs(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(&r) + if err != nil { + continue + } + } + + authConfigs = append(authConfigs, ac) + } + + return authConfigs +} + +func getEffectiveRegUsernamePassword(registry *portainer.Registry) (string, string, error) { + 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 +} diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go index d52e7d3705..4b133b37a0 100644 --- a/api/exec/compose_stack.go +++ b/api/exec/compose_stack.go @@ -6,35 +6,25 @@ import ( "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/logs" "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 { +func NewComposeStackManager(deployer libstack.Deployer, proxyManager *proxy.Manager) *ComposeStackManager { return &ComposeStackManager{ deployer: deployer, proxyManager: proxyManager, - dataStore: dataStore, } } @@ -45,9 +35,9 @@ func (manager *ComposeStackManager) ComposeSyntaxMaxVersion() string { // 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) + url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint) if err != nil { - return errors.Wrap(err, "failed to fetch environment proxy") + return fmt.Errorf("failed to fetch environment proxy: %w", err) } if proxy != nil { @@ -56,11 +46,11 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta envFilePath, err := createEnvFile(stack) if err != nil { - return errors.Wrap(err, "failed to create env file") + return fmt.Errorf("failed to create env file: %w", err) } filePaths := stackutils.GetStackFilePaths(stack, true) - err = manager.deployer.Deploy(ctx, filePaths, libstack.DeployOptions{ + if err = manager.deployer.Deploy(ctx, filePaths, libstack.DeployOptions{ Options: libstack.Options{ WorkingDir: stack.ProjectPath, EnvFilePath: envFilePath, @@ -71,15 +61,17 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta ForceRecreate: options.ForceRecreate, AbortOnContainerExit: options.AbortOnContainerExit, RemoveOrphans: options.Prune, - }) - return errors.Wrap(err, "failed to deploy a stack") + }); err != nil { + return fmt.Errorf("failed to deploy a stack: %w", err) + } + return nil } // 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) + url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint) if err != nil { - return errors.Wrap(err, "failed to fetch environment proxy") + return fmt.Errorf("failed to fetch environment proxy: %w", err) } if proxy != nil { @@ -88,11 +80,11 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St envFilePath, err := createEnvFile(stack) if err != nil { - return errors.Wrap(err, "failed to create env file") + return fmt.Errorf("failed to create env file: %w", err) } filePaths := stackutils.GetStackFilePaths(stack, true) - err = manager.deployer.Run(ctx, filePaths, serviceName, libstack.RunOptions{ + if err = manager.deployer.Run(ctx, filePaths, serviceName, libstack.RunOptions{ Options: libstack.Options{ WorkingDir: stack.ProjectPath, EnvFilePath: envFilePath, @@ -103,71 +95,63 @@ func (manager *ComposeStackManager) Run(ctx context.Context, stack *portainer.St Remove: options.Remove, Args: options.Args, Detached: options.Detached, - }) - return errors.Wrap(err, "failed to deploy a stack") + }); err != nil { + return fmt.Errorf("failed to deploy a stack: %w", err) + } + return nil } // 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) + url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint) if err != nil { - return err + return fmt.Errorf("failed to fetch environment proxy: %w", err) } else if proxy != nil { defer proxy.Close() } - err = manager.deployer.Remove(ctx, stack.Name, nil, libstack.RemoveOptions{ + if 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") + }); err != nil { + return fmt.Errorf("failed to remove a stack: %w", err) + } + return nil } // 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) + url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint) if err != nil { - return err + return fmt.Errorf("failed to fetch environment proxy: %w", err) } else if proxy != nil { defer proxy.Close() } envFilePath, err := createEnvFile(stack) if err != nil { - return errors.Wrap(err, "failed to create env file") + return fmt.Errorf("failed to create env file: %w", err) } filePaths := stackutils.GetStackFilePaths(stack, true) - err = manager.deployer.Pull(ctx, filePaths, libstack.Options{ + if err = manager.deployer.Pull(ctx, filePaths, libstack.Options{ WorkingDir: stack.ProjectPath, EnvFilePath: envFilePath, Host: url, ProjectName: stack.Name, Registries: portainerRegistriesToAuthConfigs(options.Registries), - }) - return errors.Wrap(err, "failed to pull images of the stack") + }); err != nil { + return fmt.Errorf("failed to pull images of the stack: %w", err) + } + return nil } // 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 + return normalizeStackName(name) } // createEnvFile creates a file that would hold both "in-place" and default environment variables. @@ -178,7 +162,7 @@ func createEnvFile(stack *portainer.Stack) (string, error) { } envFilePath := path.Join(stack.ProjectPath, "stack.env") - envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return "", err } @@ -229,45 +213,3 @@ func copyConfigEnvVars(w io.Writer, envs []portainer.Pair) error { return nil } - -// portainerRegistriesToAuthConfigs converts registries to Docker auth configs. -// Callers must ensure ECR tokens are valid before calling this function (e.g. via -// registryutils.ValidateRegistriesECRTokens with a real DataStoreTx). This function -// intentionally performs no DB writes to avoid write-lock contention when called inside -// an active BoltDB write transaction. -func portainerRegistriesToAuthConfigs(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(&r) - if err != nil { - continue - } - } - - authConfigs = append(authConfigs, ac) - } - - return authConfigs -} - -func getEffectiveRegUsernamePassword(registry *portainer.Registry) (string, string, error) { - 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 -} diff --git a/api/exec/compose_stack_integration_test.go b/api/exec/compose_stack_integration_test.go index bae30bb042..9d52edf175 100644 --- a/api/exec/compose_stack_integration_test.go +++ b/api/exec/compose_stack_integration_test.go @@ -48,7 +48,7 @@ func Test_UpAndDown(t *testing.T) { deployer := compose.NewComposeDeployer() - w := NewComposeStackManager(deployer, nil, nil) + w := NewComposeStackManager(deployer, nil) if err := w.Up(t.Context(), stack, endpoint, portainer.ComposeUpOptions{}); err != nil { t.Fatalf("Error calling docker-compose up: %s", err) diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index b99166fed1..0d0c622c4b 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -1,258 +1,93 @@ package exec import ( - "bytes" "context" - "errors" - "os" - "os/exec" - "path" - "runtime" - "strings" + "fmt" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/stacks/stackutils" - - "github.com/rs/zerolog/log" - "github.com/segmentio/encoding/json" + "github.com/portainer/portainer/pkg/libstack/swarm" ) // 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 + deployer swarm.Deployer + proxyManager *proxy.Manager } -// NewSwarmStackManager initializes a new SwarmStackManager service. -// It also updates the configuration of the Docker CLI binary. +// NewSwarmStackManager creates a new SwarmStackManager. 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, + deployer swarm.Deployer, + proxyManager *proxy.Manager, +) *SwarmStackManager { + return &SwarmStackManager{ + deployer: deployer, + proxyManager: proxyManager, } - - if err := manager.updateDockerCLIConfiguration(manager.configPath); 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(ctx context.Context, registries []portainer.Registry, endpoint *portainer.Endpoint) error { - command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint) +// Deploy creates or updates a Docker Swarm stack. +func (manager *SwarmStackManager) Deploy( + ctx context.Context, + stack *portainer.Stack, + prune bool, + pullImage bool, + endpoint *portainer.Endpoint, + registries []portainer.Registry, +) error { + url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint) if err != nil { - return err + return fmt.Errorf("failed to fetch environment proxy: %w", err) } - for _, registry := range registries { - if registry.Authentication { - username, password, err := getEffectiveRegUsernamePassword(®istry) - if err != nil { - continue - } - - registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL) - if err := runCommandAndCaptureStdErr(ctx, command, registryArgs, nil, ""); err != nil { - log.Warn(). - Err(err). - Str("RegistryName", registry.Name). - Msg("Failed to login.") - } - } + if proxy != nil { + defer proxy.Close() } - return nil -} - -// Logout executes the docker logout command. -func (manager *SwarmStackManager) Logout(ctx context.Context, 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(ctx, command, args, nil, "") -} - -// Deploy executes the docker stack deploy command. -func (manager *SwarmStackManager) Deploy(ctx context.Context, 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) + + env := make([]string, 0, len(stack.Env)) + for _, ev := range stack.Env { + env = append(env, ev.Name+"="+ev.Value) + } + + return manager.deployer.Deploy(context.TODO(), filePaths, swarm.DeployOptions{ + Options: swarm.Options{ + ProjectName: stack.Name, + Host: url, + Env: env, + WorkingDir: stack.ProjectPath, + Registries: portainerRegistriesToAuthConfigs(registries), + }, + RemoveOrphans: prune, + PullImage: pullImage, + }) +} + +// Remove deletes all resources belonging to a Swarm stack. +func (manager *SwarmStackManager) Remove( + ctx context.Context, + stack *portainer.Stack, + endpoint *portainer.Endpoint, +) error { + url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint) if err != nil { - return err + return fmt.Errorf("failed to fetch environment proxy: %w", err) } - if prune { - args = append(args, "stack", "deploy", "--prune", "--with-registry-auth") - } else { - args = append(args, "stack", "deploy", "--with-registry-auth") + if proxy != nil { + defer proxy.Close() } - 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(ctx, command, args, env, stack.ProjectPath) -} - -// Remove executes the docker stack rm command. -func (manager *SwarmStackManager) Remove(ctx context.Context, 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", "--detach=false", stack.Name) - - return runCommandAndCaptureStdErr(ctx, command, args, nil, "") -} - -func runCommandAndCaptureStdErr(ctx context.Context, command string, args []string, env []string, workingDir string) error { - var stderr bytes.Buffer - var stdout bytes.Buffer - - cmd := exec.CommandContext(ctx, command, args...) - cmd.Stderr = &stderr - cmd.Stdout = &stdout - - if workingDir != "" { - cmd.Dir = workingDir - } - - if env != nil { - cmd.Env = os.Environ() - cmd.Env = append(cmd.Env, env...) - } - - if err := cmd.Run(); err != nil { - errMsg := strings.TrimSpace(stderr.String()) - if errMsg == "" { - errMsg = strings.TrimSpace(stdout.String()) - } - if errMsg == "" { - errMsg = err.Error() - } - - return errors.New(errMsg) - } - - 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 { - tunnelAddr, err := manager.reverseTunnelService.TunnelAddr(endpoint) - if err != nil { - return "", nil, err - } - - endpointURL = "tcp://" + tunnelAddr - } - - 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 { - log.Warn().Err(err).Msg("unable to retrieve the Swarm configuration from disk, proceeding without it") - } - - signature, err := manager.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) - if err != nil { - return err - } - - if config["HttpHeaders"] == nil { - config["HttpHeaders"] = make(map[string]any) - } - - headersObject := config["HttpHeaders"].(map[string]any) - 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]any, error) { - var config map[string]any - - raw, err := manager.fileService.GetFileContent(path, "") - if err != nil { - return make(map[string]any), nil - } - - if err := json.Unmarshal(raw, &config); err != nil { - return nil, err - } - - return config, nil + return manager.deployer.Remove(context.TODO(), stack.Name, swarm.RemoveOptions{ + Options: swarm.Options{ + Host: url, + }, + }) } +// NormalizeStackName returns a new stack name with unsupported characters replaced. 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 + return normalizeStackName(name) } diff --git a/api/exec/swarm_stack_test.go b/api/exec/swarm_stack_test.go deleted file mode 100644 index 0dc723d774..0000000000 --- a/api/exec/swarm_stack_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package exec - -import ( - "context" - "testing" - - portainer "github.com/portainer/portainer/api" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestConfigFilePaths(t *testing.T) { - t.Parallel() - args := []string{"stack", "deploy", "--with-registry-auth"} - filePaths := []string{"dir/file", "dir/file-two", "dir/file-three"} - expected := []string{"stack", "deploy", "--with-registry-auth", "--compose-file", "dir/file", "--compose-file", "dir/file-two", "--compose-file", "dir/file-three"} - output := configureFilePaths(args, filePaths) - assert.ElementsMatch(t, expected, output, "wrong output file paths") -} - -func TestPrepareDockerCommandAndArgs(t *testing.T) { - t.Parallel() - binaryPath := "/test/dist" - configPath := "/test/config" - manager := &SwarmStackManager{ - binaryPath: binaryPath, - configPath: configPath, - } - - endpoint := &portainer.Endpoint{ - URL: "tcp://test:9000", - TLSConfig: portainer.TLSConfiguration{ - TLS: true, - TLSSkipVerify: true, - }, - } - - command, args, err := manager.prepareDockerCommandAndArgs(binaryPath, configPath, endpoint) - require.NoError(t, err) - - expectedCommand := "/test/dist/docker" - expectedArgs := []string{"--config", "/test/config", "-H", "tcp://test:9000", "--tls", "--tlscacert", ""} - - require.Equal(t, expectedCommand, command) - require.Equal(t, expectedArgs, args) -} - -func TestRunCommandAndCaptureStdErr(t *testing.T) { - t.Parallel() - - t.Run("should return nil on successful command", func(t *testing.T) { - err := runCommandAndCaptureStdErr(context.Background(), "echo", []string{"hello"}, nil, "") - require.NoError(t, err) - }) - - t.Run("should capture stderr on failure", func(t *testing.T) { - err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stderr error' >&2; exit 1"}, nil, "") - require.Error(t, err) - assert.Contains(t, err.Error(), "stderr error") - }) - - t.Run("should fall back to stdout when stderr is empty", func(t *testing.T) { - err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stdout error'; exit 1"}, nil, "") - require.Error(t, err) - assert.Contains(t, err.Error(), "stdout error") - }) - - t.Run("should fall back to exec error when both are empty", func(t *testing.T) { - err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "exit 1"}, nil, "") - require.Error(t, err) - assert.NotEmpty(t, err.Error()) - assert.Contains(t, err.Error(), "exit status 1") - }) - - t.Run("should prefer stderr over stdout", func(t *testing.T) { - err := runCommandAndCaptureStdErr(context.Background(), "sh", []string{"-c", "echo 'stdout msg'; echo 'stderr msg' >&2; exit 1"}, nil, "") - require.Error(t, err) - assert.Contains(t, err.Error(), "stderr msg") - assert.NotContains(t, err.Error(), "stdout msg") - }) - - t.Run("should return error for non-existent command", func(t *testing.T) { - err := runCommandAndCaptureStdErr(context.Background(), "nonexistent-cmd-12345", nil, nil, "") - require.Error(t, err) - }) -} diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index db615a6b08..1d3e0db65c 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -46,8 +46,6 @@ const ( BinaryStorePath = "bin" // EdgeJobStorePath represents the subfolder where schedule files are stored. EdgeJobStorePath = "edge_jobs" - // DockerConfigPath represents the subfolder where docker configuration is stored. - DockerConfigPath = "docker_config" // ExtensionRegistryManagementStorePath represents the subfolder where files related to the // registry management extension are stored. ExtensionRegistryManagementStorePath = "extensions" @@ -135,11 +133,6 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) { return nil, err } - err = service.createDirectoryInStore(DockerConfigPath) - if err != nil { - return nil, err - } - return service, nil } @@ -148,11 +141,6 @@ func (service *Service) GetBinaryFolder() string { return JoinPaths(service.fileStorePath, BinaryStorePath) } -// GetDockerConfigPath returns the full path to the docker config store on the filesystem -func (service *Service) GetDockerConfigPath() string { - return JoinPaths(service.fileStorePath, DockerConfigPath) -} - // RemoveDirectory removes a directory on the filesystem. func (service *Service) RemoveDirectory(directoryPath string) error { return os.RemoveAll(directoryPath) diff --git a/api/portainer.go b/api/portainer.go index c778fab8b7..ac14ad5615 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1600,7 +1600,6 @@ type ( // FileService represents a service for managing files FileService interface { - GetDockerConfigPath() string GetFileContent(trustedRootPath, filePath string) ([]byte, error) Copy(fromFilePath string, toFilePath string, deleteIfExists bool) error Rename(oldPath, newPath string) error @@ -1894,9 +1893,7 @@ type ( // SwarmStackManager represents a service to manage Swarm stacks SwarmStackManager interface { - Login(ctx context.Context, registries []Registry, endpoint *Endpoint) error - Logout(ctx context.Context, endpoint *Endpoint) error - Deploy(ctx context.Context, stack *Stack, prune bool, pullImage bool, endpoint *Endpoint) error + Deploy(ctx context.Context, stack *Stack, prune bool, pullImage bool, endpoint *Endpoint, registries []Registry) error Remove(ctx context.Context, stack *Stack, endpoint *Endpoint) error NormalizeStackName(name string) string } diff --git a/api/stacks/deployments/deployer.go b/api/stacks/deployments/deployer.go index 101992f135..3882163f38 100644 --- a/api/stacks/deployments/deployer.go +++ b/api/stacks/deployments/deployer.go @@ -4,13 +4,11 @@ import ( "context" "sync" + "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" dockerclient "github.com/portainer/portainer/api/docker/client" k "github.com/portainer/portainer/api/kubernetes" - "github.com/rs/zerolog/log" - - "github.com/pkg/errors" ) type BaseStackDeployer interface { @@ -36,7 +34,8 @@ type stackDeployer struct { // NewStackDeployer inits a stackDeployer struct with a SwarmStackManager, a ComposeStackManager and a KubernetesDeployer func NewStackDeployer(swarmStackManager portainer.SwarmStackManager, composeStackManager portainer.ComposeStackManager, - kubernetesDeployer portainer.KubernetesDeployer, clientFactory *dockerclient.ClientFactory, dataStore dataservices.DataStore) *stackDeployer { + kubernetesDeployer portainer.KubernetesDeployer, clientFactory *dockerclient.ClientFactory, dataStore dataservices.DataStore, +) *stackDeployer { return &stackDeployer{ lock: &sync.Mutex{}, swarmStackManager: swarmStackManager, @@ -46,20 +45,12 @@ func NewStackDeployer(swarmStackManager portainer.SwarmStackManager, composeStac dataStore: dataStore, } } + func (d *stackDeployer) DeploySwarmStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, pullImage bool) error { d.lock.Lock() defer d.lock.Unlock() - if err := d.swarmStackManager.Login(ctx, registries, endpoint); err != nil { - log.Warn().Err(err).Msg("unable to login to registries for swarm stack deployment") - } - defer func() { - if err := d.swarmStackManager.Logout(ctx, endpoint); err != nil { - log.Warn().Err(err).Msg("unable to logout from registries after swarm stack deployment") - } - }() - - return d.swarmStackManager.Deploy(ctx, stack, prune, pullImage, endpoint) + return d.swarmStackManager.Deploy(ctx, stack, prune, pullImage, endpoint, registries) } func (d *stackDeployer) DeployComposeStack(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint, registries []portainer.Registry, prune, forcePullImage, forceRecreate bool) error { diff --git a/api/stacks/deployments/deployer_remote.go b/api/stacks/deployments/deployer_remote.go index 2072131410..17cd9bb88b 100644 --- a/api/stacks/deployments/deployer_remote.go +++ b/api/stacks/deployments/deployer_remote.go @@ -123,15 +123,6 @@ func (d *stackDeployer) DeployRemoteSwarmStack( d.lock.Lock() defer d.lock.Unlock() - if err := d.swarmStackManager.Login(ctx, registries, endpoint); err != nil { - log.Warn().Err(err).Msg("unable to login to registries for swarm stack deployment") - } - defer func() { - if err := d.swarmStackManager.Logout(ctx, endpoint); err != nil { - log.Warn().Err(err).Msg("unable to logout from registries after swarm stack deployment") - } - }() - return d.remoteStack(ctx, stack, endpoint, OperationSwarmDeploy, unpackerCmdBuilderOptions{ pullImage: pullImage, prune: prune, @@ -223,7 +214,6 @@ func (d *stackDeployer) remoteStack(ctx context.Context, stack *portainer.Stack, fmt.Sprintf("%s:%s", targetSocketBindHost, targetSocketBindContainer), }, }, nil, nil, fmt.Sprintf("portainer-unpacker-%d-%s-%d", stack.ID, stack.Name, librand.Intn(100))) - if err != nil { return errors.Wrap(err, "unable to create unpacker container") } diff --git a/go.mod b/go.mod index cbb50b4ea8..118c6fc8b1 100644 --- a/go.mod +++ b/go.mod @@ -73,6 +73,13 @@ require ( oras.land/oras-go/v2 v2.6.0 ) +require ( + github.com/docker/cli-docs-tool v0.10.0 // indirect + github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect + github.com/miekg/pkcs11 v1.1.1 // indirect + github.com/theupdateframework/notary v0.7.0 // indirect +) + require ( cloud.google.com/go/auth v0.18.1 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect @@ -92,7 +99,6 @@ require ( github.com/Masterminds/sprig/v3 v3.3.0 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect github.com/ProtonMail/go-crypto v1.3.0 // indirect - github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d // indirect github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect github.com/andrew-d/go-termutil v0.0.0-20150726205930-009166a695a2 // indirect @@ -112,13 +118,12 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/bboreham/go-loser v0.0.0-20230920113527-fcc2c21820a3 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/bits-and-blooms/bitset v1.12.0 // indirect + github.com/bits-and-blooms/bitset v1.13.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/buger/goterm v1.0.4 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect - github.com/cloudflare/cfssl v1.6.4 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/containerd/console v1.0.5 // indirect github.com/containerd/containerd/api v1.9.0 // indirect @@ -136,11 +141,10 @@ require ( github.com/deckarep/golang-set v1.8.0 // indirect github.com/dennwc/varint v1.0.0 // indirect github.com/docker/buildx v0.29.1 // indirect - github.com/docker/cli-docs-tool v0.10.0 // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect - github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect github.com/docker/go-metrics v0.0.1 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a // indirect @@ -236,7 +240,6 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-shellwords v1.0.12 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect - github.com/miekg/pkcs11 v1.1.1 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-ps v1.0.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect @@ -248,6 +251,7 @@ require ( github.com/moby/locker v1.0.1 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/spdystream v0.5.1 // indirect + github.com/moby/swarmkit/v2 v2.1.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/sys/capability v0.4.0 // indirect github.com/moby/sys/mountinfo v0.7.2 // indirect @@ -270,7 +274,6 @@ require ( github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/opencontainers/runtime-spec v1.2.1 // indirect github.com/openshift/api v3.9.0+incompatible // indirect - github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect @@ -302,7 +305,6 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect - github.com/theupdateframework/notary v0.7.0 // indirect github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect @@ -312,11 +314,13 @@ require ( github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/xlab/treeprint v1.2.0 // indirect github.com/zclconf/go-cty v1.17.0 // indirect - github.com/zmap/zcrypto v0.0.0-20241123155728-2916694fa469 // indirect - github.com/zmap/zlint/v3 v3.6.4 // indirect + go.etcd.io/etcd/raft/v3 v3.5.6 // indirect go.mongodb.org/mongo-driver v1.17.6 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect diff --git a/go.sum b/go.sum index b29721a002..2d92c94c6c 100644 --- a/go.sum +++ b/go.sum @@ -2,7 +2,6 @@ cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= -cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8= @@ -61,14 +60,12 @@ github.com/Microsoft/hcsshim v0.13.0 h1:/BcXOiS6Qi7N9XqUcv27vkIuVOkBEcWstd2pMlWS github.com/Microsoft/hcsshim v0.13.0/go.mod h1:9KWJ/8DgU+QzYGupX4tzMhRQE8h6w90lH6HAaclpEok= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= -github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= github.com/RoaringBitmap/roaring/v2 v2.5.0 h1:TJ45qCM7D7fIEBwKd9zhoR0/S1egfnSSIzLU1e1eYLY= github.com/RoaringBitmap/roaring/v2 v2.5.0/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0= +github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d h1:hi6J4K6DKrR4/ljxn6SF6nURyu785wKMuQcjt7H3VCQ= github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= -github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs= -github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ= github.com/VictoriaMetrics/fastcache v1.12.0 h1:vnVi/y9yKDcD9akmc4NqAoqgQhJrOwUF+j9LTgn4QDE= github.com/VictoriaMetrics/fastcache v1.12.0/go.mod h1:tjiYeEfYXCqacuvYw/7UoDIeJaNxq6132xHICNP77w8= github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8= @@ -142,8 +139,9 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bitly/go-hostpool v0.1.0/go.mod h1:4gOCgp6+NZnVqlKyZ/iBZFTAJKembaVENUpMkpg42fw= github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= -github.com/bits-and-blooms/bitset v1.12.0 h1:U/q1fAF7xXRhFCrhROzIfffYnu+dlS38vCZtmFVPHmA= github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= +github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= @@ -157,11 +155,12 @@ github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b h1:otBG+dV+YK+Soembj github.com/bugsnag/osext v0.0.0-20130617224835-0dd3f918b21b/go.mod h1:obH5gd0BsqsP2LwDJ9aOkm/6J86V6lyAXCoQWGw3K50= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXer/kZD8Ri1aaunCxIEsOst1BVJswV0o= github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= -github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cbroglie/mustache v1.4.0 h1:Azg0dVhxTml5me+7PsZ7WPrQq1Gkf3WApcHMjMprYoU= github.com/cbroglie/mustache v1.4.0/go.mod h1:SS1FTIghy0sjse4DUVGV1k/40B1qE1XkD9DtDsHo9iM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/certifi/gocertifi v0.0.0-20191021191039-0944d244cd40/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= +github.com/certifi/gocertifi v0.0.0-20200922220541-2c3bb06c6054/go.mod h1:sGbDF6GwGcLpkNXPUTkMRoywsNa/ol15pxFe6ERfguA= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= @@ -170,11 +169,13 @@ github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHe github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA= github.com/cloudflare/cfssl v1.6.4 h1:NMOvfrEjFfC63K3SGXgAnFdsgkmiq4kATme5BfcqrO8= github.com/cloudflare/cfssl v1.6.4/go.mod h1:8b3CQMxfWPAeom3zBnGJ6sd+G1NkL5TXqmDXacb+1J0= -github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cockroachdb/datadriven v0.0.0-20200714090401-bf6692d28da5/go.mod h1:h6jFvWxBdQXxjopDMZyH2UVceIRfR84bdzbkoKrsWNo= +github.com/cockroachdb/errors v1.2.4/go.mod h1:rQD95gz6FARkaKkQXUksEje/d9a6wBJoCr5oaCLELYA= +github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f/go.mod h1:i/u985jwjWRlyHXQbwatDASoW0RMlZ/3i9yJHE2xLkI= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/compose-spec/compose-go/v2 v2.9.1 h1:8UwI+ujNU+9Ffkf/YgAm/qM9/eU7Jn8nHzWG721W4rs= @@ -217,6 +218,7 @@ github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01 h1:Qzk5C6cYgle github.com/containers/libtrust v0.0.0-20230121012942-c1716e8a8d01/go.mod h1:9rfv8iPl1ZP7aqh9YA68wnZv2NUDbXdcdPHVz0pFbPY= github.com/containers/ocicrypt v1.2.1 h1:0qIOTT9DoYwcKmxSt8QJt+VzMY18onl9jUXsxpVhSmM= github.com/containers/ocicrypt v1.2.1/go.mod h1:aD0AAqfMp0MtwqWgHM1bUwe1anx0VazI108CRrSKINQ= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo= github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU= @@ -331,6 +333,7 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814 h1:gWvniJ4GbFfkf700kykAImbLiEMU0Q3QN9hQ26Js1pU= github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814/go.mod h1:secRm32Ro77eD23BmPVbgLbWN+JWDw7pJszenjxI4bI= +github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-asn1-ber/asn1-ber v1.5.1 h1:pDbRAunXzIUXfx4CB2QJFv5IuPiuoW+sWvr/Us009o8= @@ -424,6 +427,7 @@ github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -450,15 +454,9 @@ github.com/google/certificate-transparency-go v1.1.4/go.mod h1:D6lvbfwckhNrbM9WV github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -583,6 +581,7 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4 github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= @@ -683,6 +682,8 @@ github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkV github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/spdystream v0.5.1 h1:9sNYeYZUcci9R6/w7KDaFWEWeV4LStVG78Mpyq/Zm/Y= github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/swarmkit/v2 v2.1.1 h1:yvTJ8MMCc3f0qTA44J6R59EZ5yZawdYopkpuLk4+ICU= +github.com/moby/swarmkit/v2 v2.1.1/go.mod h1:mTTGIAz/59OGZR5Qe+QByIe3Nxc+sSuJkrsStFhr6Lg= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk= @@ -713,8 +714,6 @@ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/ github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= -github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= -github.com/mreiferson/go-httpclient v0.0.0-20201222173833-5e475fde3a4d/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -732,15 +731,14 @@ github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.0 h1:Iw5WCbBcaAAd0fpRb1c9r5YCylv4XDoCSigm1zLevwU= github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA= github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= -github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.145.0 h1:0dYiJ7krIwaHFX6YLNDo/yawTZIu8X16tT/nwW1UTG8= github.com/open-telemetry/opentelemetry-collector-contrib/internal/exp/metrics v0.145.0/go.mod h1:mhoa9lipcEH0heeKf6+xHzGUrCuAgImQv4/Qpmu0+Fk= github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.145.0 h1:sB4yuYx45zig1ceQ+kmrEYy0xMZ+mGagwYIFtJkkU1w= @@ -759,9 +757,8 @@ github.com/opencontainers/selinux v1.13.1 h1:A8nNeceYngH9Ow++M+VVEwJVpdFmrlxsN22 github.com/opencontainers/selinux v1.13.1/go.mod h1:S10WXZ/osk2kWOYKy1x2f/eXF5ZHJoUs8UU/2caNRbg= github.com/openshift/api v3.9.0+incompatible h1:fJ/KsefYuZAjmrr3+5U9yZIZbTOpVkDDLDLFresAeYs= github.com/openshift/api v3.9.0+incompatible/go.mod h1:dh9o4Fs58gpFXGSYfnVxGR9PnV53I8TW84pQaJDdGiY= +github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= -github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/orcaman/concurrent-map v1.0.0 h1:I/2A2XPCb4IuQWcQhBhSwGfiuybl/J0ev9HDbW65HOY= github.com/orcaman/concurrent-map v1.0.0/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI= github.com/ovh/go-ovh v1.9.0 h1:6K8VoL3BYjVV3In9tPJUdT7qMx9h0GExN9EXx1r2kKE= @@ -874,10 +871,8 @@ github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= -github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= @@ -955,13 +950,18 @@ github.com/viney-shih/go-lock v1.1.1 h1:SwzDPPAiHpcwGCr5k8xD15d2gQSo8d4roRYd7TDV github.com/viney-shih/go-lock v1.1.1/go.mod h1:Yijm78Ljteb3kRiJrbLAxVntkUukGu5uzSxq/xV7OO8= github.com/vultr/govultr/v2 v2.17.2 h1:gej/rwr91Puc/tgh+j33p/BLR16UrIPnSr+AIwYWZQs= github.com/vultr/govultr/v2 v2.17.2/go.mod h1:ZFOKGWmgjytfyjeyAdhQlSWwTjh2ig+X49cAp50dzXI= -github.com/weppos/publicsuffix-go v0.13.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= -github.com/weppos/publicsuffix-go v0.40.3-0.20241121092442-c6cbe8e9f6f1 h1:xivnSZBZAOd6o3L30GFlVS5BlTx18uyR4jcEls6X7/0= -github.com/weppos/publicsuffix-go v0.40.3-0.20241121092442-c6cbe8e9f6f1/go.mod h1:vuIH6F4fhalyBwt2P2bLeqrZnRgJf5Dj4olpUlwEO2Q= +github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b h1:FsyNrX12e5BkplJq7wKOLk0+C6LZ+KGXvuEcKUYm5ss= +github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= @@ -971,19 +971,15 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0= github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= -github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= -github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= -github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is= -github.com/zmap/zcertificate v0.0.1/go.mod h1:q0dlN54Jm4NVSSuzisusQY0hqDWvu92C+TWveAxiVWk= -github.com/zmap/zcrypto v0.0.0-20201128221613-3719af1573cf/go.mod h1:aPM7r+JOkfL+9qSB4KbYjtoEzJqUK50EXkkJabeNJDQ= -github.com/zmap/zcrypto v0.0.0-20201211161100-e54a5822fb7e/go.mod h1:aPM7r+JOkfL+9qSB4KbYjtoEzJqUK50EXkkJabeNJDQ= -github.com/zmap/zcrypto v0.0.0-20241123155728-2916694fa469 h1:dML8+gao2ANCuJ2vFOChvPH0FSwKaGxPXa86U6XvMmg= -github.com/zmap/zcrypto v0.0.0-20241123155728-2916694fa469/go.mod h1:sUuKi10EbW7faJAE9weL6EkhX+/yqBhb+iommU6P4VA= -github.com/zmap/zlint/v3 v3.0.0/go.mod h1:paGwFySdHIBEMJ61YjoqT4h7Ge+fdYG4sUQhnTb1lJ8= -github.com/zmap/zlint/v3 v3.6.4 h1:r2kHfRF7mIsxW0IH4Og2iZnrlpCLTZBFjnXy1x/ZnZI= -github.com/zmap/zlint/v3 v3.6.4/go.mod h1:KQLVUquVaO5YJDl5a4k/7RPIbIW2v66+sRoBPNZusI8= +github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc h1:zkGwegkOW709y0oiAraH/3D8njopUR/pARHv4tZZ6pw= +github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc/go.mod h1:FM4U1E3NzlNMRnSUTU3P1UdukWhYGifqEsjk9fn7BCk= +github.com/zmap/zlint/v3 v3.1.0 h1:WjVytZo79m/L1+/Mlphl09WBob6YTGljN5IGWZFpAv0= +github.com/zmap/zlint/v3 v3.1.0/go.mod h1:L7t8s3sEKkb0A2BxGy1IWrxt1ZATa1R4QfJZaQOD3zU= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.etcd.io/etcd/client/pkg/v3 v3.5.6/go.mod h1:ggrwbk069qxpKPq8/FKkQ3Xq9y39kbFR4LnKszpRXeQ= +go.etcd.io/etcd/raft/v3 v3.5.6 h1:tOmx6Ym6rn2GpZOrvTGJZciJHek6RnC3U/zNInzIN50= +go.etcd.io/etcd/raft/v3 v3.5.6/go.mod h1:wL8kkRGx1Hp8FmZUuHfL3K2/OaGIDaXGr1N7i2G07J0= go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= @@ -1060,14 +1056,17 @@ go.podman.io/image/v5 v5.37.0 h1:yzgQybwuWIIeK63hu+mQqna/wOh96XD5cpVc6j8Dg5M= go.podman.io/image/v5 v5.37.0/go.mod h1:+s2Sx5dia/jVeT8tI3r2NAPrARMiDdbEq3QPIQogx3I= go.podman.io/storage v1.60.0 h1:bWNSrR58nxg39VNFDSx3m0AswbvyzPGOo5XsUfomTao= go.podman.io/storage v1.60.0/go.mod h1:NK+rsWJVuQeCM7ifv7cxD3abegWxwtW/3OkuSUJJoE4= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= @@ -1082,15 +1081,8 @@ golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= -golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= @@ -1098,35 +1090,20 @@ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aI golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1137,11 +1114,6 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1155,13 +1127,12 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210331175145-43e1dd70ce54/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1169,54 +1140,30 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools/godoc v0.1.0-deprecated h1:o+aZ1BOj6Hsx/GBdJO/s815sqftjSnrZZwyYTHODvtk= @@ -1229,7 +1176,6 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/api v0.265.0 h1:FZvfUdI8nfmuNrE34aOWFPmLC+qRBEiNm3JdivTvAAU= google.golang.org/api v0.265.0/go.mod h1:uAvfEl3SLUj/7n6k+lJutcswVojHPp2Sp08jWCu8hLY= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0= google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE= google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= @@ -1239,8 +1185,6 @@ google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= @@ -1248,7 +1192,6 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks gopkg.in/cenkalti/backoff.v2 v2.2.1 h1:eJ9UAg01/HIHG987TwxvnzK2MgxXq97YY6rYDpY9aII= gopkg.in/cenkalti/backoff.v2 v2.2.1/go.mod h1:S0QdOvT2AlerfSBkp0O+dk+bbIMaNbEmVk876gPCthU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -1273,6 +1216,7 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/libhelm/validate_repo_test.go b/pkg/libhelm/validate_repo_test.go index bfc5882191..8f1c114918 100644 --- a/pkg/libhelm/validate_repo_test.go +++ b/pkg/libhelm/validate_repo_test.go @@ -80,7 +80,7 @@ func TestValidateHelmRepositoryURL(t *testing.T) { // Failure fail = true - var failureURLs = []string{ + failureURLs := []string{ "", "!", "oci://example.com", diff --git a/pkg/libkubectl/delete_dynamic_test.go b/pkg/libkubectl/delete_dynamic_test.go index 2ffe263925..d0ef7cb6bd 100644 --- a/pkg/libkubectl/delete_dynamic_test.go +++ b/pkg/libkubectl/delete_dynamic_test.go @@ -1,6 +1,7 @@ package libkubectl import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -213,7 +214,7 @@ data: require.NoError(t, err, "Failed to create resource") t.Cleanup(func() { - _, err = client.DeleteDynamic(t.Context(), validManifest) + _, err = client.DeleteDynamic(context.Background(), validManifest) require.NoError(t, err, "Cleanup DeleteDynamic() failed") }) diff --git a/pkg/libstack/compose/composeplugin.go b/pkg/libstack/compose/composeplugin.go index ba62b334e7..437513b2bb 100644 --- a/pkg/libstack/compose/composeplugin.go +++ b/pkg/libstack/compose/composeplugin.go @@ -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...), diff --git a/pkg/libstack/compose/composeplugin_test.go b/pkg/libstack/compose/composeplugin_test.go index 7c3ca1ffa3..da2fad231e 100644 --- a/pkg/libstack/compose/composeplugin_test.go +++ b/pkg/libstack/compose/composeplugin_test.go @@ -1390,7 +1390,7 @@ func Test_CredentialsStore_Behavior(t *testing.T) { "auths": {} }` configPath := filesystem.JoinPaths(tmpDir, "config.json") - err := os.WriteFile(configPath, []byte(configJSON), 0644) + err := os.WriteFile(configPath, []byte(configJSON), 0o644) require.NoError(t, err) t.Run("withCli preserves credsStore when no registries provided", func(t *testing.T) { @@ -1400,8 +1400,8 @@ func Test_CredentialsStore_Behavior(t *testing.T) { var capturedCredsStore string var capturedAuthConfigs map[string]configtypes.AuthConfig - err = withCli(t.Context(), libstack.Options{}, func(ctx context.Context, cli *command.DockerCli) error { - // Capture the state after withCli sets up credentials + err = libstack.WithCli(t.Context(), libstack.DockerCliOptions{}, func(ctx context.Context, cli *command.DockerCli) error { + // Capture the state after WithCli sets up credentials capturedCredsStore = cli.ConfigFile().CredentialsStore capturedAuthConfigs = cli.ConfigFile().AuthConfigs return nil @@ -1432,12 +1432,14 @@ func Test_CredentialsStore_Behavior(t *testing.T) { var capturedCredsStore string var capturedAuthConfigs map[string]configtypes.AuthConfig - err = withCli(t.Context(), libstack.Options{Registries: registries}, func(ctx context.Context, cli *command.DockerCli) error { - // Capture the state after withCli sets up credentials - capturedCredsStore = cli.ConfigFile().CredentialsStore - capturedAuthConfigs = cli.ConfigFile().AuthConfigs - return nil - }) + err = libstack.WithCli(t.Context(), + libstack.DockerCliOptions{Registries: registries}, + func(ctx context.Context, cli *command.DockerCli) error { + // Capture the state after WithCli sets up credentials + capturedCredsStore = cli.ConfigFile().CredentialsStore + capturedAuthConfigs = cli.ConfigFile().AuthConfigs + return nil + }) require.NoError(t, err) // Verify the fix: credsStore should be empty when registries are provided diff --git a/pkg/libstack/compose/status.go b/pkg/libstack/compose/status.go index 49de04d1e6..c154f23699 100644 --- a/pkg/libstack/compose/status.go +++ b/pkg/libstack/compose/status.go @@ -93,7 +93,7 @@ func getServiceStatus(ctx context.Context, service service) (libstack.Status, st func getContainerLogsTail(ctx context.Context, service service) (string, error) { var combinedOutput bytes.Buffer - if err := withCli(ctx, libstack.Options{ProjectName: service.Project}, func(ctx context.Context, cli *command.DockerCli) error { + if err := libstack.WithCli(ctx, libstack.DockerCliOptions{}, func(ctx context.Context, cli *command.DockerCli) error { out, err := cli.Client().ContainerLogs(ctx, service.Name, container.LogsOptions{ ShowStdout: true, ShowStderr: true, @@ -144,25 +144,11 @@ func aggregateStatuses(ctx context.Context, services []service) (libstack.Status Str("errorMessage", errorMessage). Msg("check_status") - switch { - case errorMessage != "": + if errorMessage != "" { return libstack.StatusError, errorMessage - case statusCounts[libstack.StatusStarting] > 0: - return libstack.StatusStarting, "" - case statusCounts[libstack.StatusRemoving] > 0: - return libstack.StatusRemoving, "" - case statusCounts[libstack.StatusCompleted] == servicesCount: - return libstack.StatusCompleted, "" - case statusCounts[libstack.StatusRunning]+statusCounts[libstack.StatusCompleted] == servicesCount: - return libstack.StatusRunning, "" - case statusCounts[libstack.StatusStopped] == servicesCount: - return libstack.StatusStopped, "" - case statusCounts[libstack.StatusRemoved] == servicesCount: - return libstack.StatusRemoved, "" - default: - return libstack.StatusUnknown, "" } + return libstack.AggregateStatusCounts(statusCounts, servicesCount), "" } func (c *ComposeDeployer) WaitForStatus(ctx context.Context, name string, status libstack.Status) libstack.WaitResult { diff --git a/pkg/libstack/dockercli.go b/pkg/libstack/dockercli.go new file mode 100644 index 0000000000..3f7866b9bc --- /dev/null +++ b/pkg/libstack/dockercli.go @@ -0,0 +1,86 @@ +package libstack + +import ( + "context" + "fmt" + "sync" + + "github.com/portainer/portainer/api/logs" + + "github.com/docker/cli/cli/command" + configtypes "github.com/docker/cli/cli/config/types" + "github.com/docker/cli/cli/flags" + "github.com/docker/docker/registry" + "github.com/rs/zerolog/log" +) + +// DockerCliOptions holds the settings required to initialise a DockerCli. +type DockerCliOptions struct { + Host string + Registries []configtypes.AuthConfig +} + +// mu serialises calls to cli.Initialize across all deployer types (compose and +// swarm) to prevent concurrent initialisation of the Docker client config. +var mu sync.Mutex + +// WithCli creates and initialises a DockerCli, injects registry credentials, +// and calls cliFn with the ready client. The client is closed after cliFn returns. +func WithCli( + ctx context.Context, //nolint:staticcheck + options DockerCliOptions, + 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) +} diff --git a/pkg/libstack/libstack.go b/pkg/libstack/libstack.go index ea22123b0e..73c3350c21 100644 --- a/pkg/libstack/libstack.go +++ b/pkg/libstack/libstack.go @@ -2,12 +2,52 @@ package libstack import ( "context" + "os" + "strings" portainer "github.com/portainer/portainer/api" configtypes "github.com/docker/cli/cli/config/types" ) +const PortainerEnvVarsPrefix = "PORTAINER_" + +// PortainerEnvVars returns all environment variables from os.Environ() that +// start with the PORTAINER_ prefix. +func PortainerEnvVars() []string { + var vars []string + for _, e := range os.Environ() { + if strings.HasPrefix(e, PortainerEnvVarsPrefix) { + vars = append(vars, e) + } + } + return vars +} + +// AggregateStatusCounts derives a single stack Status from a map of per-status +// counts and the total number of services. The priority order matches the +// behaviour of both the compose and swarm deployers. +func AggregateStatusCounts(statusCounts map[Status]int, total int) Status { + switch { + case statusCounts[StatusError] > 0: + return StatusError + case statusCounts[StatusStarting] > 0: + return StatusStarting + case statusCounts[StatusRemoving] > 0: + return StatusRemoving + case statusCounts[StatusCompleted] == total: + return StatusCompleted + case statusCounts[StatusRunning]+statusCounts[StatusCompleted] == total: + return StatusRunning + case statusCounts[StatusStopped] == total: + return StatusStopped + case statusCounts[StatusRemoved] == total: + return StatusRemoved + default: + return StatusUnknown + } +} + type Deployer interface { Deploy(ctx context.Context, filePaths []string, options DeployOptions) error // Remove stops and removes containers diff --git a/pkg/libstack/swarm/swarm.go b/pkg/libstack/swarm/swarm.go new file mode 100644 index 0000000000..801068edcd --- /dev/null +++ b/pkg/libstack/swarm/swarm.go @@ -0,0 +1,863 @@ +package swarm + +import ( + "context" + "errors" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/portainer/portainer/pkg/libstack" + + "github.com/containerd/errdefs" + "github.com/distribution/reference" + "github.com/docker/cli/cli/command" + + "github.com/docker/cli/cli/compose/convert" + composeloader "github.com/docker/cli/cli/compose/loader" + "github.com/docker/cli/cli/compose/schema" + composetypes "github.com/docker/cli/cli/compose/types" + configtypes "github.com/docker/cli/cli/config/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/network" + registrytypes "github.com/docker/docker/api/types/registry" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/client" + dockerregistry "github.com/docker/docker/registry" + "github.com/rs/zerolog/log" +) + +// Options holds connection and credential settings for swarm operations. +type Options struct { + ProjectName string + Host string + Env []string + WorkingDir string + Registries []configtypes.AuthConfig +} + +// DeployOptions extends Options with deployment-specific settings. +type DeployOptions struct { + Options + RemoveOrphans bool + // PullImage controls how image digests are resolved on deploy: + // true - query the registry (ResolveImageAlways) + // false - never contact the registry; reuse the existing digest (ResolveImageNever) + PullImage bool +} + +// RemoveOptions extends Options with removal settings. +type RemoveOptions struct { + Options +} + +// Deployer is the interface for in-process Docker Swarm stack management. +type Deployer interface { + Deploy(ctx context.Context, filePaths []string, options DeployOptions) error + Remove(ctx context.Context, projectName string, options RemoveOptions) error + Validate(ctx context.Context, filePaths []string, options Options) error + WaitForStatus(ctx context.Context, projectName string, options Options, status libstack.Status) libstack.WaitResult +} + +// SwarmDeployer implements Deployer using the Docker API in-process. +type SwarmDeployer struct{} + +// NewSwarmDeployer creates a new SwarmDeployer. +func NewSwarmDeployer() *SwarmDeployer { return &SwarmDeployer{} } + +// Deploy creates or updates a Docker Swarm stack from the given compose files. +func (d *SwarmDeployer) Deploy(ctx context.Context, filePaths []string, options DeployOptions) error { + return libstack.WithCli( + ctx, + libstack.DockerCliOptions{Host: options.Host, Registries: options.Registries}, + func(ctx context.Context, dockerCLI *command.DockerCli) error { + return deployStack(ctx, dockerCLI, filePaths, options) + }) +} + +// Validate loads and parses the compose file(s), returning an error if they are invalid. +func (d *SwarmDeployer) Validate(_ context.Context, filePaths []string, options Options) error { + _, err := getConfig(filePaths, options.WorkingDir, options.Env) + return err +} + +// Remove deletes all resources belonging to a Swarm stack and waits for tasks to terminate. +func (d *SwarmDeployer) Remove(ctx context.Context, projectName string, options RemoveOptions) error { + return libstack.WithCli( + ctx, + libstack.DockerCliOptions{Host: options.Host, Registries: options.Registries}, + func(ctx context.Context, dockerCLI *command.DockerCli) error { + apiClient := dockerCLI.Client() + + services, err := getStackServices(ctx, apiClient, projectName) + if err != nil { + return err + } + + secrets, err := getStackSecrets(ctx, apiClient, projectName) + if err != nil { + return err + } + + configs, err := getStackConfigs(ctx, apiClient, projectName) + if err != nil { + return err + } + + networks, err := getStackNetworks(ctx, apiClient, projectName) + if err != nil { + return err + } + + if len(services)+len(secrets)+len(configs)+len(networks) == 0 { + log.Info().Str("stack", projectName).Msg("nothing found in stack") + return nil + } + + var errs []error + + if err := removeServices(ctx, apiClient, services); err != nil { + errs = append(errs, err) + } + + if err := removeSecrets(ctx, apiClient, secrets); err != nil { + errs = append(errs, err) + } + + if err := removeConfigs(ctx, apiClient, configs); err != nil { + errs = append(errs, err) + } + + if err := removeNetworks(ctx, apiClient, networks); err != nil { + errs = append(errs, err) + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + // Wait for all tasks to reach a terminal state before returning, mirroring + // the behaviour of `docker stack rm --detach=false`. + return waitOnTasks(ctx, apiClient, projectName) + }) +} + +// deployStack is the core stack deployment logic. +// It reimplements `docker stack deploy` in-process using the docker/cli compose loader and +// convert packages. Reference: https://github.com/docker/cli/blob/v28.5.2/cli/command/stack/swarm/deploy_composefile.go +func deployStack(ctx context.Context, dockerCLI *command.DockerCli, filePaths []string, options DeployOptions) error { + info, err := dockerCLI.Client().Info(ctx) + if err != nil { + return fmt.Errorf("failed to get docker info: %w", err) + } + + if !info.Swarm.ControlAvailable { + return errors.New(`this node is not a swarm manager. Use "docker swarm init" or "docker swarm join" to connect this node to swarm and try again`) + } + + config, err := getConfig(filePaths, options.WorkingDir, options.Env) + if err != nil { + return fmt.Errorf("failed to load compose file: %w", err) + } + + namespace := convert.NewNamespace(options.ProjectName) + + // Prune orphan services before deploying to avoid name conflicts during rolling updates. + if options.RemoveOrphans { + incoming := make(map[string]struct{}, len(config.Services)) + for _, svc := range config.Services { + incoming[svc.Name] = struct{}{} + } + + err := pruneServices(ctx, dockerCLI.Client(), namespace, incoming) + if err != nil { + return err + } + } + + serviceNetworks := getServicesDeclaredNetworks(config.Services) + networks, externalNetworks := convert.Networks(namespace, config.Networks, serviceNetworks) + if err := validateExternalNetworks(ctx, dockerCLI.Client(), externalNetworks); err != nil { + return err + } + + if err := createNetworks(ctx, dockerCLI.Client(), namespace, networks); err != nil { + return err + } + + secrets, err := convert.Secrets(namespace, config.Secrets) + if err != nil { + return err + } + + if err := createSecrets(ctx, dockerCLI.Client(), secrets); err != nil { + return err + } + + configs, err := convert.Configs(namespace, config.Configs) + if err != nil { + return err + } + + if err := createConfigs(ctx, dockerCLI.Client(), configs); err != nil { + return err + } + + services, err := convert.Services(ctx, namespace, config, dockerCLI.Client()) + if err != nil { + return err + } + + return deployServices( + ctx, + dockerCLI.Client(), + options.Registries, + services, + namespace, + options.PullImage, + ) +} + +func getStackFilter(namespace string) filters.Args { + f := filters.NewArgs() + f.Add("label", convert.LabelNamespace+"="+namespace) + + return f +} + +func getStackServices(ctx context.Context, apiClient client.APIClient, namespace string) ([]swarm.Service, error) { + return apiClient.ServiceList(ctx, swarm.ServiceListOptions{Filters: getStackFilter(namespace)}) +} + +func getStackNetworks(ctx context.Context, apiClient client.APIClient, namespace string) ([]network.Summary, error) { + return apiClient.NetworkList(ctx, network.ListOptions{Filters: getStackFilter(namespace)}) +} + +func getStackSecrets(ctx context.Context, apiClient client.APIClient, namespace string) ([]swarm.Secret, error) { + return apiClient.SecretList(ctx, swarm.SecretListOptions{Filters: getStackFilter(namespace)}) +} + +func getStackConfigs(ctx context.Context, apiClient client.APIClient, namespace string) ([]swarm.Config, error) { + return apiClient.ConfigList(ctx, swarm.ConfigListOptions{Filters: getStackFilter(namespace)}) +} + +func getStackTasks(ctx context.Context, apiClient client.APIClient, namespace string) ([]swarm.Task, error) { + return apiClient.TaskList(ctx, swarm.TaskListOptions{Filters: getStackFilter(namespace)}) +} + +func getServicesDeclaredNetworks(services []composetypes.ServiceConfig) map[string]struct{} { + serviceNetworks := make(map[string]struct{}) + + for _, svc := range services { + if len(svc.Networks) == 0 { + serviceNetworks["default"] = struct{}{} + continue + } + for nw := range svc.Networks { + serviceNetworks[nw] = struct{}{} + } + } + + return serviceNetworks +} + +func validateExternalNetworks(ctx context.Context, apiClient client.NetworkAPIClient, externalNetworks []string) error { + for _, name := range externalNetworks { + if !container.NetworkMode(name).IsUserDefined() { + // Networks that are not user defined always exist on all nodes as + // local-scoped networks, so there's no need to inspect them. + continue + } + + nw, err := apiClient.NetworkInspect(ctx, name, network.InspectOptions{}) + switch { + case errdefs.IsNotFound(err): + return fmt.Errorf("network %q is declared as external, but could not be found. You need to create a swarm-scoped network before the stack is deployed", name) + case err != nil: + return err + case nw.Scope != "swarm": + return fmt.Errorf("network %q is declared as external, but it is not in the right scope: %q instead of \"swarm\"", name, nw.Scope) + } + } + + return nil +} + +func createNetworks( + ctx context.Context, + apiClient client.APIClient, + namespace convert.Namespace, + networks map[string]network.CreateOptions, +) error { + existingNetworks, err := getStackNetworks(ctx, apiClient, namespace.Name()) + if err != nil { + return err + } + + existingNetworkMap := make(map[string]network.Summary, len(existingNetworks)) + for _, nw := range existingNetworks { + existingNetworkMap[nw.Name] = nw + } + + for name, createOpts := range networks { + if _, exists := existingNetworkMap[name]; exists { + continue + } + + if createOpts.Driver == "" { + createOpts.Driver = "overlay" + } + + log.Info().Str("network", name).Msg("creating network") + + if _, err := apiClient.NetworkCreate(ctx, name, createOpts); err != nil { + return fmt.Errorf("failed to create network %s: %w", name, err) + } + } + + return nil +} + +func createSecrets(ctx context.Context, apiClient client.APIClient, secrets []swarm.SecretSpec) error { + for _, secretSpec := range secrets { + existing, _, err := apiClient.SecretInspectWithRaw(ctx, secretSpec.Name) + + switch { + case err == nil: + if err := apiClient.SecretUpdate(ctx, existing.ID, existing.Version, secretSpec); err != nil { + return fmt.Errorf("failed to update secret %s: %w", secretSpec.Name, err) + } + case errdefs.IsNotFound(err): + log.Info().Str("secret", secretSpec.Name).Msg("creating secret") + + if _, err := apiClient.SecretCreate(ctx, secretSpec); err != nil { + return fmt.Errorf("failed to create secret %s: %w", secretSpec.Name, err) + } + default: + return err + } + } + + return nil +} + +func createConfigs(ctx context.Context, apiClient client.APIClient, configs []swarm.ConfigSpec) error { + for _, configSpec := range configs { + existing, _, err := apiClient.ConfigInspectWithRaw(ctx, configSpec.Name) + + switch { + case err == nil: + if err := apiClient.ConfigUpdate(ctx, existing.ID, existing.Version, configSpec); err != nil { + return fmt.Errorf("failed to update config %s: %w", configSpec.Name, err) + } + case errdefs.IsNotFound(err): + log.Info().Str("config", configSpec.Name).Msg("creating config") + + if _, err := apiClient.ConfigCreate(ctx, configSpec); err != nil { + return fmt.Errorf("failed to create config %s: %w", configSpec.Name, err) + } + default: + return err + } + } + + return nil +} + +// encodeRegistryAuth finds the registry credentials for the given image and returns +// the base64-encoded auth string expected by the Docker service API. +// Returns an empty string (no error) when no matching credentials are found. +func encodeRegistryAuth(image string, registries []configtypes.AuthConfig) (string, error) { + named, err := reference.ParseNormalizedNamed(image) + if err != nil { + return "", fmt.Errorf("failed to parse image reference %q: %w", image, err) + } + + domain := reference.Domain(named) + if domain == "docker.io" { + domain = dockerregistry.IndexServer + } + + for _, r := range registries { + if r.ServerAddress == domain { + encoded, err := registrytypes.EncodeAuthConfig(registrytypes.AuthConfig{ + Username: r.Username, + Password: r.Password, + ServerAddress: r.ServerAddress, + Auth: r.Auth, + IdentityToken: r.IdentityToken, + RegistryToken: r.RegistryToken, + }) + if err != nil { + return "", fmt.Errorf("failed to encode auth for registry %s: %w", domain, err) + } + return encoded, nil + } + } + + return "", nil +} + +func deployServices( + ctx context.Context, + apiClient client.APIClient, + registries []configtypes.AuthConfig, + services map[string]swarm.ServiceSpec, + namespace convert.Namespace, + pullImage bool, +) error { + existingServices, err := getStackServices(ctx, apiClient, namespace.Name()) + if err != nil { + return err + } + + existingServiceMap := make(map[string]swarm.Service, len(existingServices)) + for _, svc := range existingServices { + existingServiceMap[svc.Spec.Name] = svc + } + + for internalName, serviceSpec := range services { + name := namespace.Scope(internalName) + image := serviceSpec.TaskTemplate.ContainerSpec.Image + + encodedAuth, err := encodeRegistryAuth(image, registries) + if err != nil { + return fmt.Errorf("failed to encode registry auth for image %s: %w", image, err) + } + + if existing, exists := existingServiceMap[name]; exists { + log.Info().Str("service", name).Str("id", existing.ID).Msg("updating service") + + updateOpts := swarm.ServiceUpdateOptions{EncodedRegistryAuth: encodedAuth} + + if pullImage { + // pullImage=true → ResolveImageAlways: always query the registry during + // updates so redeploys can repull images even when the tag is unchanged. + updateOpts.QueryRegistry = true + } else { + // pullImage=false → ResolveImageNever: always reuse the existing digest. + if image == existing.Spec.Labels[convert.LabelImage] { + serviceSpec.TaskTemplate.ContainerSpec.Image = existing.Spec.TaskTemplate.ContainerSpec.Image + } + } + + // Preserve ForceUpdate so that tasks are not re-deployed if nothing changed. + serviceSpec.TaskTemplate.ForceUpdate = existing.Spec.TaskTemplate.ForceUpdate + + response, err := apiClient.ServiceUpdate(ctx, existing.ID, existing.Version, serviceSpec, updateOpts) + if err != nil { + return fmt.Errorf("failed to update service %s: %w", name, err) + } + + for _, warning := range response.Warnings { + log.Warn().Str("service", name).Msg(warning) + } + } else { + log.Info().Str("service", name).Msg("creating service") + + createOpts := swarm.ServiceCreateOptions{EncodedRegistryAuth: encodedAuth} + + if pullImage { + createOpts.QueryRegistry = true + } + + if _, err := apiClient.ServiceCreate(ctx, serviceSpec, createOpts); err != nil { + return fmt.Errorf("failed to create service %s: %w", name, err) + } + } + } + + return nil +} + +// pruneServices removes services that are present in the existing stack but absent from +// the incoming config. Must be called before deploying the new config to avoid name conflicts. +func pruneServices( + ctx context.Context, + apiClient client.APIClient, + namespace convert.Namespace, + incoming map[string]struct{}, +) error { + existingServices, err := getStackServices(ctx, apiClient, namespace.Name()) + if err != nil { + return fmt.Errorf("failed to list services for pruning: %w", err) + } + + toRemove := make([]swarm.Service, 0, len(existingServices)) + for _, svc := range existingServices { + if _, exists := incoming[namespace.Descope(svc.Spec.Name)]; !exists { + toRemove = append(toRemove, svc) + } + } + + err = removeServices(ctx, apiClient, toRemove) + if err != nil { + return fmt.Errorf("failed to prune orphan services: %w", err) + } + + return nil +} + +func removeServices(ctx context.Context, apiClient client.APIClient, services []swarm.Service) error { + sort.Slice(services, func(i, j int) bool { + return services[i].Spec.Name < services[j].Spec.Name + }) + + var errs []error + + for _, svc := range services { + log.Info().Str("service", svc.Spec.Name).Msg("removing service") + + if err := apiClient.ServiceRemove(ctx, svc.ID); err != nil { + errs = append(errs, fmt.Errorf("failed to remove service %s: %w", svc.Spec.Name, err)) + } + } + + return errors.Join(errs...) +} + +func removeNetworks(ctx context.Context, apiClient client.APIClient, networks []network.Summary) error { + var errs []error + + for _, nw := range networks { + log.Info().Str("network", nw.Name).Msg("removing network") + + if err := apiClient.NetworkRemove(ctx, nw.ID); err != nil { + errs = append(errs, fmt.Errorf("failed to remove network %s: %w", nw.Name, err)) + } + } + + return errors.Join(errs...) +} + +func removeSecrets(ctx context.Context, apiClient client.APIClient, secrets []swarm.Secret) error { + var errs []error + + for _, secret := range secrets { + log.Info().Str("secret", secret.Spec.Name).Msg("removing secret") + + if err := apiClient.SecretRemove(ctx, secret.ID); err != nil { + errs = append(errs, fmt.Errorf("failed to remove secret %s: %w", secret.Spec.Name, err)) + } + } + + return errors.Join(errs...) +} + +func removeConfigs(ctx context.Context, apiClient client.APIClient, configs []swarm.Config) error { + var errs []error + + for _, cfg := range configs { + log.Info().Str("config", cfg.Spec.Name).Msg("removing config") + + if err := apiClient.ConfigRemove(ctx, cfg.ID); err != nil { + errs = append(errs, fmt.Errorf("failed to remove config %s: %w", cfg.Spec.Name, err)) + } + } + + return errors.Join(errs...) +} + +// taskStateOrdinal mirrors docker/cli's unexported numberedStates map (cli/command/stack/swarm/remove.go). +// The Docker SDK does not export terminal-state checking utilities, so we duplicate it here. +var taskStateOrdinal = map[swarm.TaskState]int{ + swarm.TaskStateNew: 1, + swarm.TaskStateAllocated: 2, + swarm.TaskStatePending: 3, + swarm.TaskStateAssigned: 4, + swarm.TaskStateAccepted: 5, + swarm.TaskStatePreparing: 6, + swarm.TaskStateReady: 7, + swarm.TaskStateStarting: 8, + swarm.TaskStateRunning: 9, + swarm.TaskStateComplete: 10, + swarm.TaskStateShutdown: 11, + swarm.TaskStateFailed: 12, + swarm.TaskStateRejected: 13, +} + +func isTerminalState(state swarm.TaskState) bool { + return taskStateOrdinal[state] > taskStateOrdinal[swarm.TaskStateRunning] +} + +func getConfig(filePaths []string, workingDir string, env []string) (*composetypes.Config, error) { + // Load and parse the compose file(s). + configDetails, err := getConfigDetails(filePaths, workingDir, env) + if err != nil { + return nil, fmt.Errorf("failed to load compose file: %w", err) + } + + // Collect raw config dicts for unsupported/deprecated property checks. + dicts := make([]map[string]any, 0, len(configDetails.ConfigFiles)) + for _, cf := range configDetails.ConfigFiles { + dicts = append(dicts, cf.Config) + } + + config, err := composeloader.Load(configDetails) + if err != nil { + if fpe, ok := errors.AsType[*composeloader.ForbiddenPropertiesError](err); ok { + return nil, fmt.Errorf("compose file contains unsupported options: %v", fpe.Properties) + } + + return nil, fmt.Errorf("failed to parse compose file: %w", err) + } + + if unsupported := composeloader.GetUnsupportedProperties(dicts...); len(unsupported) > 0 { + log.Warn().Strs("properties", unsupported).Msg("ignoring unsupported compose properties") + } + + if deprecated := composeloader.GetDeprecatedProperties(dicts...); len(deprecated) > 0 { + log.Warn().Interface("properties", deprecated).Msg("ignoring deprecated compose properties") + } + + for _, svc := range config.Services { + if svc.Image == "" { + return nil, fmt.Errorf("invalid image reference for service %s: no image specified", svc.Name) + } + if _, err := reference.ParseAnyReference(svc.Image); err != nil { + return nil, fmt.Errorf("invalid image reference for service %s: %w", svc.Name, err) + } + } + + return config, nil +} + +func getConfigDetails(filePaths []string, workingDir string, env []string) (composetypes.ConfigDetails, error) { + var details composetypes.ConfigDetails + + if len(filePaths) == 0 { + return details, errors.New("at least one compose file must be specified") + } + + details.WorkingDir = workingDir + + details.ConfigFiles = make([]composetypes.ConfigFile, 0, len(filePaths)) + for _, fp := range filePaths { + bytes, err := os.ReadFile(fp) + if err != nil { + return details, err + } + + config, err := composeloader.ParseYAML(bytes) + if err != nil { + return details, err + } + + details.ConfigFiles = append(details.ConfigFiles, composetypes.ConfigFile{ + Filename: fp, + Config: config, + }) + } + + // Take the first file version (2 files can't have different version) + details.Version = schema.Version(details.ConfigFiles[0].Config) + + details.Environment = make(map[string]string) + + addEnvVarFn := func(e string) { + k, v, _ := strings.Cut(e, "=") + if k != "" { + details.Environment[k] = v + } + } + + for _, e := range libstack.PortainerEnvVars() { + addEnvVarFn(e) + } + + for _, e := range env { + addEnvVarFn(e) + } + + return details, nil +} + +// WaitForStatus blocks until all services in the stack reach the requested status, +// or the context is cancelled. It polls the Docker API every second. +func (d *SwarmDeployer) WaitForStatus( + ctx context.Context, + projectName string, + options Options, + status libstack.Status, +) libstack.WaitResult { + waitResult := libstack.WaitResult{Status: status} + + // WithCli replaces the context with Background internally, so we capture the + // caller's context here to preserve cancellation. + callerCtx := ctx + + err := libstack.WithCli( + ctx, + libstack.DockerCliOptions{Host: options.Host, Registries: options.Registries}, + func(_ context.Context, dockerCLI *command.DockerCli) error { + apiClient := dockerCLI.Client() + + for { + if callerCtx.Err() != nil { + waitResult.ErrorMsg = "failed to wait for status: " + callerCtx.Err().Error() + return nil + } + + time.Sleep(time.Second) + + services, err := getStackServices(callerCtx, apiClient, projectName) + if err != nil { + log.Warn().Str("project_name", projectName).Err(err).Msg("failed to list stack services") + continue + } + + if len(services) == 0 && status == libstack.StatusRemoved { + return nil + } + + var serviceStatuses []libstack.Status + + for _, svc := range services { + svcStatus, errorMessage, err := getServiceStatus(callerCtx, apiClient, svc) + if err != nil { + log.Warn(). + Str("project_name", projectName). + Str("service_name", svc.Spec.Name). + Err(err). + Msg("failed to get service status") + continue + } + + if errorMessage != "" { + waitResult.ErrorMsg = errorMessage + return nil + } + + serviceStatuses = append(serviceStatuses, svcStatus) + } + + if aggregateStatus(serviceStatuses) == status { + return nil + } + + log.Debug(). + Str("project_name", projectName). + Str("required_status", string(status)). + Str("status", string(aggregateStatus(serviceStatuses))). + Msg("waiting for status") + } + }) + + if err != nil && waitResult.ErrorMsg == "" { + waitResult.Status = libstack.StatusError + waitResult.ErrorMsg = err.Error() + } + + return waitResult +} + +func aggregateStatus(statuses []libstack.Status) libstack.Status { + if len(statuses) == 0 { + return libstack.StatusRemoved + } + + statusCounts := make(map[libstack.Status]int) + for _, status := range statuses { + statusCounts[status]++ + } + + log.Debug().Interface("statusCounts", statusCounts).Msg("check_status") + + return libstack.AggregateStatusCounts(statusCounts, len(statuses)) +} + +func getServiceStatus( + ctx context.Context, + apiClient client.APIClient, + svc swarm.Service, +) (libstack.Status, string, error) { + tasks, err := apiClient.TaskList(ctx, swarm.TaskListOptions{ + Filters: filters.NewArgs(filters.KeyValuePair{Key: "service", Value: svc.ID}), + }) + if err != nil { + return "", "", fmt.Errorf("failed to list tasks for service %s: %w", svc.Spec.Name, err) + } + + expectedRunningTaskCount := 0 + + if svc.Spec.Mode.Replicated != nil { + expectedRunningTaskCount = int(*svc.Spec.Mode.Replicated.Replicas) + } + + if svc.Spec.Mode.Global != nil { + nodes, err := apiClient.NodeList(ctx, swarm.NodeListOptions{}) + if err != nil { + return "", "", fmt.Errorf("failed to list nodes: %w", err) + } + + expectedRunningTaskCount = len(nodes) + } + + if expectedRunningTaskCount != 0 { + runningTaskCount := 0 + + for _, task := range tasks { + if task.Status.State == swarm.TaskStateRunning { + runningTaskCount++ + } + } + + if runningTaskCount == expectedRunningTaskCount { + return libstack.StatusRunning, "", nil + } + } + + for _, task := range tasks { + switch task.Status.State { + case swarm.TaskStateRunning: + return libstack.StatusRunning, "", nil + case swarm.TaskStatePending, swarm.TaskStateStarting: + return libstack.StatusStarting, "", nil + case swarm.TaskStateRemove: + return libstack.StatusRemoving, "", nil + case swarm.TaskStateFailed: + return libstack.StatusError, task.Status.Err, nil + default: + return libstack.StatusUnknown, "", nil + } + } + + return libstack.StatusUnknown, "", nil +} + +// waitOnTasks polls until all tasks belonging to the namespace reach a terminal state. +func waitOnTasks(ctx context.Context, apiClient client.APIClient, namespace string) error { + for { + if ctx.Err() != nil { + return ctx.Err() + } + + tasks, err := getStackTasks(ctx, apiClient, namespace) + if err != nil { + return fmt.Errorf("failed to get tasks: %w", err) + } + + if len(tasks) == 0 { + return nil + } + + allTerminal := true + + for _, task := range tasks { + if !isTerminalState(task.Status.State) { + allTerminal = false + + break + } + } + + if allTerminal { + return nil + } + + time.Sleep(time.Second) + } +} diff --git a/pkg/libstack/swarm/swarm_integration_test.go b/pkg/libstack/swarm/swarm_integration_test.go new file mode 100644 index 0000000000..ed1460736c --- /dev/null +++ b/pkg/libstack/swarm/swarm_integration_test.go @@ -0,0 +1,229 @@ +package swarm + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/client" + "github.com/portainer/portainer/pkg/libstack" + "github.com/stretchr/testify/require" +) + +// ensureSwarmMode ensures the Docker daemon is a swarm manager for the duration +// of the test. If the daemon is inactive, it initialises a single-node swarm and +// registers a cleanup to leave it afterwards. If the daemon is already a manager +// it does nothing. If the daemon is a worker it fails the test immediately. +func ensureSwarmMode(t *testing.T) *client.Client { + t.Helper() + + apiClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, apiClient.Close()) }) + + info, err := apiClient.Info(t.Context()) + require.NoError(t, err) + + switch info.Swarm.LocalNodeState { + case swarm.LocalNodeStateInactive: + _, err = apiClient.SwarmInit(t.Context(), swarm.InitRequest{ + ListenAddr: "0.0.0.0:2377", + AdvertiseAddr: "127.0.0.1", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, apiClient.SwarmLeave(context.Background(), true)) }) + case swarm.LocalNodeStateActive: + if !info.Swarm.ControlAvailable { + t.Fatal("docker daemon is a swarm worker, not a manager: cannot run swarm stack tests") + } + // already a manager - don't tear down, don't disrupt + default: + t.Fatalf("unexpected swarm node state: %s", info.Swarm.LocalNodeState) + } + + return apiClient +} + +// serviceExists reports whether a service named _ exists. +func serviceExists(t *testing.T, apiClient client.APIClient, stackName, serviceName string) bool { + fullName := stackName + "_" + serviceName + + services, err := apiClient.ServiceList(t.Context(), swarm.ServiceListOptions{ + Filters: filters.NewArgs(filters.KeyValuePair{Key: "name", Value: fullName}), + }) + require.NoError(t, err) + + for _, svc := range services { + if svc.Spec.Name == fullName { + return true + } + } + + return false +} + +func createComposeFile(t *testing.T, dir, name, content string) string { + t.Helper() + + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + + return path +} + +func TestSwarmValidate(t *testing.T) { + ensureIntegrationTest(t) + + deployer := NewSwarmDeployer() + dir := t.TempDir() + + testCases := []struct { + name string + composeFile string + expectedError string + }{ + { + name: "valid compose file", + composeFile: `version: '3' +services: + web: + image: nginx:latest`, + expectedError: "", + }, + { + name: "invalid YAML returns error", + composeFile: "not valid yaml content", + expectedError: "failed to load compose file: top-level object must be a mapping", + }, + { + name: "missing image returns error", + composeFile: `version: '3' +services: + web: + command: echo hello`, + expectedError: "invalid image reference for service web: no image specified", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + path := createComposeFile(t, dir, "docker-compose.yml", testCase.composeFile) + err := deployer.Validate(t.Context(), []string{path}, Options{}) + var gotError string + if err != nil { + gotError = err.Error() + } + + if gotError != "" && testCase.expectedError == "" { + t.Fatalf("expected no error but got: %v", err) + } + require.Contains(t, gotError, testCase.expectedError) + }) + } +} + +func TestSwarmDeployWithRemoveOrphans(t *testing.T) { + ensureIntegrationTest(t) + + apiClient := ensureSwarmMode(t) + + const projectName = "swarm_orphan_test" + + const twoServiceContent = `version: '3' +services: + service-1: + image: alpine:latest + command: ["sh", "-c", "while true; do sleep 3600; done"] + service-2: + image: alpine:latest + command: ["sh", "-c", "while true; do sleep 3600; done"]` + + const oneServiceContent = `version: '3' +services: + service-2: + image: alpine:latest + command: ["sh", "-c", "while true; do sleep 3600; done"]` + + deployer := NewSwarmDeployer() + dir := t.TempDir() + + twoServicePath := createComposeFile(t, dir, "two-services.yml", twoServiceContent) + oneServicePath := createComposeFile(t, dir, "one-service.yml", oneServiceContent) + + err := deployer.Deploy( + t.Context(), + []string{twoServicePath}, + DeployOptions{Options: Options{ProjectName: projectName}}, + ) + require.NoError(t, err) + + t.Cleanup(func() { + err := deployer.Remove(context.Background(), projectName, RemoveOptions{}) + require.NoError(t, err) + }) + + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + t.Cleanup(func() { cancel() }) + + result := deployer.WaitForStatus(ctx, projectName, Options{}, libstack.StatusRunning) + require.Empty(t, result.ErrorMsg) + require.Equal(t, libstack.StatusRunning, result.Status) + + require.True(t, serviceExists(t, apiClient, projectName, "service-1")) + require.True(t, serviceExists(t, apiClient, projectName, "service-2")) + + err = deployer.Deploy(ctx, []string{oneServicePath}, DeployOptions{ + Options: Options{ProjectName: projectName}, + RemoveOrphans: true, + }) + require.NoError(t, err) + + result = deployer.WaitForStatus(ctx, projectName, Options{}, libstack.StatusRunning) + require.Empty(t, result.ErrorMsg) + require.Equal(t, libstack.StatusRunning, result.Status) + + require.False(t, serviceExists(t, apiClient, projectName, "service-1")) + require.True(t, serviceExists(t, apiClient, projectName, "service-2")) +} + +func TestSwarmDeployWithEnvVars(t *testing.T) { + ensureIntegrationTest(t) + ensureSwarmMode(t) + + const projectName = "swarm_envvar_test" + + const composeContent = `version: '3' +services: + web: + image: alpine:${TAG} + command: ["sh", "-c", "while true; do sleep 3600; done"]` + + deployer := NewSwarmDeployer() + dir := t.TempDir() + + path := createComposeFile(t, dir, "envvar.yml", composeContent) + + err := deployer.Deploy(t.Context(), []string{path}, DeployOptions{ + Options: Options{ + ProjectName: projectName, + Env: []string{"TAG=latest"}, + }, + }) + require.NoError(t, err) + + t.Cleanup(func() { + err := deployer.Remove(context.Background(), projectName, RemoveOptions{}) + require.NoError(t, err) + }) + + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + t.Cleanup(func() { cancel() }) + + result := deployer.WaitForStatus(ctx, projectName, Options{}, libstack.StatusRunning) + require.Empty(t, result.ErrorMsg) + require.Equal(t, libstack.StatusRunning, result.Status) +} diff --git a/pkg/libstack/swarm/swarm_status_test.go b/pkg/libstack/swarm/swarm_status_test.go new file mode 100644 index 0000000000..5595fbd378 --- /dev/null +++ b/pkg/libstack/swarm/swarm_status_test.go @@ -0,0 +1,112 @@ +package swarm + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + libstack "github.com/portainer/portainer/pkg/libstack" + "github.com/stretchr/testify/require" +) + +func ensureIntegrationTest(t *testing.T) { + t.Helper() + + if _, ok := os.LookupEnv("INTEGRATION_TEST"); !ok { + t.Skip("skip an integration test") + } +} + +func TestSwarmProjectStatus(t *testing.T) { + ensureIntegrationTest(t) + + testCases := []struct { + TestName string + FileContent string + ExpectedStatus libstack.Status + ExpectedStatusMessage string + }{ + { + TestName: "running", + FileContent: `version: '3' +services: + web: + image: nginx:latest`, + ExpectedStatus: libstack.StatusRunning, + }, + { + TestName: "failed", + FileContent: `version: '3' +services: + failing: + image: alpine:latest + command: ["sh", "-c", "exit 1"]`, + ExpectedStatus: libstack.StatusError, + ExpectedStatusMessage: "task: non-zero exit (1)", + }, + } + + ensureSwarmMode(t) + + deployer := NewSwarmDeployer() + dir := t.TempDir() + + for _, testCase := range testCases { + t.Run(testCase.TestName, func(t *testing.T) { + projectName := testCase.TestName + + composeFileName := fmt.Sprintf("docker-compose-%s.yml", projectName) + composeFilePath := filepath.Join(dir, composeFileName) + + f, err := os.Create(composeFilePath) + require.NoError(t, err, "failed to create compose file") + + _, err = f.WriteString(testCase.FileContent) + require.NoError(t, err, "failed to write compose file") + + err = deployer.Deploy( + t.Context(), + []string{composeFilePath}, + DeployOptions{Options: Options{ProjectName: projectName}}, + ) + require.NoError(t, err, "failed to deploy stack") + t.Cleanup(func() { + err := deployer.Remove(context.Background(), projectName, RemoveOptions{}) + require.NoError(t, err, "failed to remove stack") + + result := waitForSwarmStatus(t, deployer, projectName, libstack.StatusRemoved) + + require.Equal( + t, + libstack.StatusRemoved, + result.Status, + "expected stack to be removed, got %s (err: %s)", + result.Status, + result.ErrorMsg, + ) + }) + + result := waitForSwarmStatus(t, deployer, projectName, testCase.ExpectedStatus) + + require.Equal(t, testCase.ExpectedStatus, result.Status, "unexpected status. Error message: %v", result.ErrorMsg) + require.Equal(t, testCase.ExpectedStatusMessage, result.ErrorMsg) + }) + } +} + +func waitForSwarmStatus( + t *testing.T, + deployer *SwarmDeployer, + projectName string, + status libstack.Status, +) libstack.WaitResult { + t.Helper() + + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + + return deployer.WaitForStatus(ctx, projectName, Options{}, status) +} diff --git a/pkg/libstack/swarm/swarm_unit_test.go b/pkg/libstack/swarm/swarm_unit_test.go new file mode 100644 index 0000000000..d0f0cf75d3 --- /dev/null +++ b/pkg/libstack/swarm/swarm_unit_test.go @@ -0,0 +1,387 @@ +package swarm + +import ( + "os" + "path/filepath" + "slices" + "testing" + + composetypes "github.com/docker/cli/cli/compose/types" + configtypes "github.com/docker/cli/cli/config/types" + "github.com/docker/docker/api/types/swarm" + dockerregistry "github.com/docker/docker/registry" + "github.com/portainer/portainer/pkg/libstack" + "github.com/stretchr/testify/require" +) + +func Test_aggregateStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statuses []libstack.Status + expectedStatus libstack.Status + }{ + { + name: "empty returns removed", + statuses: []libstack.Status{}, + expectedStatus: libstack.StatusRemoved, + }, + { + name: "all running", + statuses: []libstack.Status{libstack.StatusRunning, libstack.StatusRunning}, + expectedStatus: libstack.StatusRunning, + }, + { + name: "all completed", + statuses: []libstack.Status{libstack.StatusCompleted, libstack.StatusCompleted}, + expectedStatus: libstack.StatusCompleted, + }, + { + name: "mix of running and completed", + statuses: []libstack.Status{libstack.StatusRunning, libstack.StatusCompleted}, + expectedStatus: libstack.StatusRunning, + }, + { + name: "error takes priority", + statuses: []libstack.Status{libstack.StatusRunning, libstack.StatusError}, + expectedStatus: libstack.StatusError, + }, + { + name: "starting takes priority over running", + statuses: []libstack.Status{libstack.StatusRunning, libstack.StatusStarting}, + expectedStatus: libstack.StatusStarting, + }, + { + name: "removing", + statuses: []libstack.Status{libstack.StatusRemoving, libstack.StatusRunning}, + expectedStatus: libstack.StatusRemoving, + }, + { + name: "all stopped", + statuses: []libstack.Status{libstack.StatusStopped, libstack.StatusStopped}, + expectedStatus: libstack.StatusStopped, + }, + { + name: "all removed", + statuses: []libstack.Status{libstack.StatusRemoved, libstack.StatusRemoved}, + expectedStatus: libstack.StatusRemoved, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.expectedStatus, aggregateStatus(tt.statuses)) + }) + } +} + +func Test_isTerminalState(t *testing.T) { + t.Parallel() + + tests := []struct { + state swarm.TaskState + terminal bool + }{ + {swarm.TaskStateNew, false}, + {swarm.TaskStateAllocated, false}, + {swarm.TaskStatePending, false}, + {swarm.TaskStateAssigned, false}, + {swarm.TaskStateAccepted, false}, + {swarm.TaskStatePreparing, false}, + {swarm.TaskStateReady, false}, + {swarm.TaskStateStarting, false}, + {swarm.TaskStateRunning, false}, + {swarm.TaskStateComplete, true}, + {swarm.TaskStateShutdown, true}, + {swarm.TaskStateFailed, true}, + {swarm.TaskStateRejected, true}, + } + + for _, tt := range tests { + t.Run(string(tt.state), func(t *testing.T) { + require.Equal(t, tt.terminal, isTerminalState(tt.state)) + }) + } +} + +func Test_getServicesDeclaredNetworks(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + services []composetypes.ServiceConfig + expectedNetworks map[string]struct{} + }{ + { + name: "service with no networks gets default", + services: []composetypes.ServiceConfig{ + {Name: "web", Networks: nil}, + }, + expectedNetworks: map[string]struct{}{"default": {}}, + }, + { + name: "service with explicit network", + services: []composetypes.ServiceConfig{ + {Name: "web", Networks: map[string]*composetypes.ServiceNetworkConfig{"mynet": nil}}, + }, + expectedNetworks: map[string]struct{}{"mynet": {}}, + }, + { + name: "mix: one with networks, one without", + services: []composetypes.ServiceConfig{ + {Name: "web", Networks: map[string]*composetypes.ServiceNetworkConfig{"mynet": nil}}, + {Name: "worker", Networks: nil}, + }, + expectedNetworks: map[string]struct{}{"mynet": {}, "default": {}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getServicesDeclaredNetworks(tt.services) + require.Equal(t, tt.expectedNetworks, got) + }) + } +} + +func Test_encodeRegistryAuth(t *testing.T) { + t.Parallel() + + dockerIORegistry := configtypes.AuthConfig{ + ServerAddress: dockerregistry.IndexServer, + Username: "user", + Password: "pass", + } + + customRegistry := configtypes.AuthConfig{ + ServerAddress: "registry.example.com", + Username: "user", + Password: "pass", + } + + tests := []struct { + name string + image string + registries []configtypes.AuthConfig + expectedErr string + expectedAuth string + }{ + { + name: "docker.io image with matching credentials", + image: "nginx:latest", + registries: []configtypes.AuthConfig{dockerIORegistry}, + expectedAuth: "eyJ1c2VybmFtZSI6InVzZXIiLCJwYXNzd29yZCI6InBhc3MiLCJzZXJ2ZXJhZGRyZXNzIjoiaHR0cHM6Ly9pbmRleC5kb2NrZXIuaW8vdjEvIn0=", + }, + { + name: "docker.io image with no matching credentials", + image: "nginx:latest", + registries: []configtypes.AuthConfig{}, + }, + { + name: "custom registry with matching credentials", + image: "registry.example.com/myimage:latest", + registries: []configtypes.AuthConfig{customRegistry}, + expectedAuth: "eyJ1c2VybmFtZSI6InVzZXIiLCJwYXNzd29yZCI6InBhc3MiLCJzZXJ2ZXJhZGRyZXNzIjoicmVnaXN0cnkuZXhhbXBsZS5jb20ifQ==", + }, + { + name: "custom registry image with unrelated credentials", + image: "registry.example.com/myimage:latest", + registries: []configtypes.AuthConfig{dockerIORegistry}, + }, + { + name: "invalid image reference returns error", + image: "@@invalid@@", + expectedErr: "failed to parse image reference \"@@invalid@@\": invalid reference format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := encodeRegistryAuth(tt.image, tt.registries) + if err != nil { + if tt.expectedErr == "" { + t.Fatalf("expected no error but got: %v", err) + } + require.Contains(t, err.Error(), tt.expectedErr) + } else { + require.Equal(t, tt.expectedAuth, got) + } + }) + } +} + +func Test_getConfig(t *testing.T) { + dir := t.TempDir() + + writeFile := func(name, content string) string { + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + return path + } + + tests := []struct { + name string + files map[string]string + workingDir string + env []string + osEnv map[string]string + expectedCfg *composetypes.Config + expectedErr string + }{ + { + name: "valid compose file", + files: map[string]string{ + "valid.yml": `version: '3' +services: + web: + image: nginx:latest`, + }, + expectedCfg: &composetypes.Config{ + Filename: dir + "/valid.yml", + Version: "3.13", + Services: composetypes.Services{ + composetypes.ServiceConfig{ + Name: "web", + Environment: composetypes.MappingWithEquals{}, + Image: "nginx:latest", + }, + }, + Networks: map[string]composetypes.NetworkConfig{}, + Volumes: map[string]composetypes.VolumeConfig{}, + Secrets: map[string]composetypes.SecretConfig{}, + Configs: map[string]composetypes.ConfigObjConfig{}, + }, + }, + { + name: "invalid YAML returns error", + files: map[string]string{ + "invalid.yml": `not: valid: yaml: content`, + }, + expectedErr: "failed to load compose file: yaml: mapping values are not allowed in this context", + }, + { + name: "no file paths returns error", + expectedErr: "failed to load compose file: at least one compose file must be specified", + }, + { + name: "service missing image returns error", + files: map[string]string{ + "noimage.yml": `version: '3' +services: + web: + command: echo hello`, + }, + expectedErr: "invalid image reference for service web: no image specified", + }, + { + name: "two compose files are merged", + files: map[string]string{ + "base.yml": `version: '3' +services: + web: + image: nginx:latest`, + "override.yml": `version: '3' +services: + worker: + image: alpine:latest`, + }, + expectedCfg: &composetypes.Config{ + Filename: dir + "/base.yml", + Version: "3.13", + Services: composetypes.Services{ + composetypes.ServiceConfig{ + Name: "web", + Environment: composetypes.MappingWithEquals{}, + Image: "nginx:latest", + }, + composetypes.ServiceConfig{ + Name: "worker", + Environment: composetypes.MappingWithEquals{}, + Image: "alpine:latest", + }, + }, + Networks: map[string]composetypes.NetworkConfig{}, + Volumes: map[string]composetypes.VolumeConfig{}, + Secrets: map[string]composetypes.SecretConfig{}, + Configs: map[string]composetypes.ConfigObjConfig{}, + }, + }, + { + name: "env var in image resolved from options env", + files: map[string]string{ + "envvar.yml": `version: '3' +services: + web: + image: nginx:${TAG}`, + }, + env: []string{"TAG=1.25"}, + expectedCfg: &composetypes.Config{ + Filename: dir + "/envvar.yml", + Version: "3.13", + Services: composetypes.Services{ + composetypes.ServiceConfig{ + Name: "web", + Environment: composetypes.MappingWithEquals{}, + Image: "nginx:1.25", + }, + }, + Networks: map[string]composetypes.NetworkConfig{}, + Volumes: map[string]composetypes.VolumeConfig{}, + Secrets: map[string]composetypes.SecretConfig{}, + Configs: map[string]composetypes.ConfigObjConfig{}, + }, + }, + { + name: "PORTAINER_ prefixed env var from os.Environ is resolved", + files: map[string]string{ + "portainerenv.yml": `version: '3' +services: + web: + image: nginx:${PORTAINER_TAG}`, + }, + osEnv: map[string]string{ + libstack.PortainerEnvVarsPrefix + "TAG": "1.25", + }, + expectedCfg: &composetypes.Config{ + Filename: dir + "/portainerenv.yml", + Version: "3.13", + Services: composetypes.Services{ + composetypes.ServiceConfig{ + Name: "web", + Environment: composetypes.MappingWithEquals{}, + Image: "nginx:1.25", + }, + }, + Networks: map[string]composetypes.NetworkConfig{}, + Volumes: map[string]composetypes.VolumeConfig{}, + Secrets: map[string]composetypes.SecretConfig{}, + Configs: map[string]composetypes.ConfigObjConfig{}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filePaths := make([]string, 0, len(tt.files)) + for filename, content := range tt.files { + filePaths = append(filePaths, writeFile(filename, content)) + } + slices.Sort(filePaths) + + for k, v := range tt.osEnv { + t.Setenv(k, v) + } + + cfg, err := getConfig(filePaths, tt.workingDir, tt.env) + if err != nil { + if tt.expectedErr == "" { + t.Fatalf("expected no error but got: %v", err) + } + require.Contains(t, err.Error(), tt.expectedErr) + } else { + require.Equal(t, tt.expectedCfg, cfg) + } + }) + } +}