feat(swarm): port swarm to use libstack [BE-11476] (#2486)

This commit is contained in:
Devon Steenberg
2026-05-14 10:13:19 +12:00
committed by GitHub
parent a66f114f24
commit 3b0f1eca4b
23 changed files with 2071 additions and 755 deletions
+3 -8
View File
@@ -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)
+75 -1
View File
@@ -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
}
+34 -92
View File
@@ -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
}
+1 -1
View File
@@ -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)
+61 -226
View File
@@ -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(&registry)
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)
}
-86
View File
@@ -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)
})
}
-12
View File
@@ -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)
+1 -4
View File
@@ -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
}
+5 -14
View File
@@ -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 {
-10
View File
@@ -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")
}
+14 -10
View File
@@ -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
+37 -93
View File
@@ -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=
+1 -1
View File
@@ -80,7 +80,7 @@ func TestValidateHelmRepositoryURL(t *testing.T) {
// Failure
fail = true
var failureURLs = []string{
failureURLs := []string{
"",
"!",
"oci://example.com",
+2 -1
View File
@@ -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")
})
+106 -170
View File
@@ -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...),
+11 -9
View File
@@ -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
+3 -17
View File
@@ -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 {
+86
View File
@@ -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)
}
+40
View File
@@ -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
+863
View File
@@ -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)
}
}
@@ -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 <stackName>_<serviceName> 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)
}
+112
View File
@@ -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)
}
+387
View File
@@ -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)
}
})
}
}