mirror of
https://github.com/portainer/portainer.git
synced 2026-06-23 04:10:29 +00:00
feat(swarm): port swarm to use libstack [BE-11476] (#2486)
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,258 +1,93 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"runtime"
|
||||
"strings"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/portainer/portainer/pkg/libstack/swarm"
|
||||
)
|
||||
|
||||
// SwarmStackManager represents a service for managing stacks.
|
||||
type SwarmStackManager struct {
|
||||
binaryPath string
|
||||
configPath string
|
||||
signatureService portainer.DigitalSignatureService
|
||||
fileService portainer.FileService
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
dataStore dataservices.DataStore
|
||||
deployer swarm.Deployer
|
||||
proxyManager *proxy.Manager
|
||||
}
|
||||
|
||||
// NewSwarmStackManager initializes a new SwarmStackManager service.
|
||||
// It also updates the configuration of the Docker CLI binary.
|
||||
// NewSwarmStackManager creates a new SwarmStackManager.
|
||||
func NewSwarmStackManager(
|
||||
binaryPath, configPath string,
|
||||
signatureService portainer.DigitalSignatureService,
|
||||
fileService portainer.FileService,
|
||||
reverseTunnelService portainer.ReverseTunnelService,
|
||||
datastore dataservices.DataStore,
|
||||
) (*SwarmStackManager, error) {
|
||||
manager := &SwarmStackManager{
|
||||
binaryPath: binaryPath,
|
||||
configPath: configPath,
|
||||
signatureService: signatureService,
|
||||
fileService: fileService,
|
||||
reverseTunnelService: reverseTunnelService,
|
||||
dataStore: datastore,
|
||||
deployer swarm.Deployer,
|
||||
proxyManager *proxy.Manager,
|
||||
) *SwarmStackManager {
|
||||
return &SwarmStackManager{
|
||||
deployer: deployer,
|
||||
proxyManager: proxyManager,
|
||||
}
|
||||
|
||||
if err := manager.updateDockerCLIConfiguration(manager.configPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// Login executes the docker login command against a list of registries (including DockerHub).
|
||||
func (manager *SwarmStackManager) Login(ctx context.Context, registries []portainer.Registry, endpoint *portainer.Endpoint) error {
|
||||
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
|
||||
// Deploy creates or updates a Docker Swarm stack.
|
||||
func (manager *SwarmStackManager) Deploy(
|
||||
ctx context.Context,
|
||||
stack *portainer.Stack,
|
||||
prune bool,
|
||||
pullImage bool,
|
||||
endpoint *portainer.Endpoint,
|
||||
registries []portainer.Registry,
|
||||
) error {
|
||||
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to fetch environment proxy: %w", err)
|
||||
}
|
||||
|
||||
for _, registry := range registries {
|
||||
if registry.Authentication {
|
||||
username, password, err := getEffectiveRegUsernamePassword(®istry)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
|
||||
if err := runCommandAndCaptureStdErr(ctx, command, registryArgs, nil, ""); err != nil {
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("RegistryName", registry.Name).
|
||||
Msg("Failed to login.")
|
||||
}
|
||||
}
|
||||
if proxy != nil {
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout executes the docker logout command.
|
||||
func (manager *SwarmStackManager) Logout(ctx context.Context, endpoint *portainer.Endpoint) error {
|
||||
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args = append(args, "logout")
|
||||
|
||||
return runCommandAndCaptureStdErr(ctx, command, args, nil, "")
|
||||
}
|
||||
|
||||
// Deploy executes the docker stack deploy command.
|
||||
func (manager *SwarmStackManager) Deploy(ctx context.Context, stack *portainer.Stack, prune bool, pullImage bool, endpoint *portainer.Endpoint) error {
|
||||
filePaths := stackutils.GetStackFilePaths(stack, true)
|
||||
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
|
||||
|
||||
env := make([]string, 0, len(stack.Env))
|
||||
for _, ev := range stack.Env {
|
||||
env = append(env, ev.Name+"="+ev.Value)
|
||||
}
|
||||
|
||||
return manager.deployer.Deploy(context.TODO(), filePaths, swarm.DeployOptions{
|
||||
Options: swarm.Options{
|
||||
ProjectName: stack.Name,
|
||||
Host: url,
|
||||
Env: env,
|
||||
WorkingDir: stack.ProjectPath,
|
||||
Registries: portainerRegistriesToAuthConfigs(registries),
|
||||
},
|
||||
RemoveOrphans: prune,
|
||||
PullImage: pullImage,
|
||||
})
|
||||
}
|
||||
|
||||
// Remove deletes all resources belonging to a Swarm stack.
|
||||
func (manager *SwarmStackManager) Remove(
|
||||
ctx context.Context,
|
||||
stack *portainer.Stack,
|
||||
endpoint *portainer.Endpoint,
|
||||
) error {
|
||||
url, proxy, err := fetchEndpointProxy(manager.proxyManager, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to fetch environment proxy: %w", err)
|
||||
}
|
||||
|
||||
if prune {
|
||||
args = append(args, "stack", "deploy", "--prune", "--with-registry-auth")
|
||||
} else {
|
||||
args = append(args, "stack", "deploy", "--with-registry-auth")
|
||||
if proxy != nil {
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
if !pullImage {
|
||||
args = append(args, "--resolve-image=never")
|
||||
}
|
||||
|
||||
args = configureFilePaths(args, filePaths)
|
||||
args = append(args, stack.Name)
|
||||
|
||||
env := make([]string, 0)
|
||||
for _, envvar := range stack.Env {
|
||||
env = append(env, envvar.Name+"="+envvar.Value)
|
||||
}
|
||||
|
||||
return runCommandAndCaptureStdErr(ctx, command, args, env, stack.ProjectPath)
|
||||
}
|
||||
|
||||
// Remove executes the docker stack rm command.
|
||||
func (manager *SwarmStackManager) Remove(ctx context.Context, stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
args = append(args, "stack", "rm", "--detach=false", stack.Name)
|
||||
|
||||
return runCommandAndCaptureStdErr(ctx, command, args, nil, "")
|
||||
}
|
||||
|
||||
func runCommandAndCaptureStdErr(ctx context.Context, command string, args []string, env []string, workingDir string) error {
|
||||
var stderr bytes.Buffer
|
||||
var stdout bytes.Buffer
|
||||
|
||||
cmd := exec.CommandContext(ctx, command, args...)
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdout = &stdout
|
||||
|
||||
if workingDir != "" {
|
||||
cmd.Dir = workingDir
|
||||
}
|
||||
|
||||
if env != nil {
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Env = append(cmd.Env, env...)
|
||||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg == "" {
|
||||
errMsg = strings.TrimSpace(stdout.String())
|
||||
}
|
||||
if errMsg == "" {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
|
||||
return errors.New(errMsg)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, configPath string, endpoint *portainer.Endpoint) (string, []string, error) {
|
||||
// Assume Linux as a default
|
||||
command := path.Join(binaryPath, "docker")
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
command = path.Join(binaryPath, "docker.exe")
|
||||
}
|
||||
|
||||
args := make([]string, 0)
|
||||
args = append(args, "--config", configPath)
|
||||
|
||||
endpointURL := endpoint.URL
|
||||
if endpoint.Type == portainer.EdgeAgentOnDockerEnvironment {
|
||||
tunnelAddr, err := manager.reverseTunnelService.TunnelAddr(endpoint)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
endpointURL = "tcp://" + tunnelAddr
|
||||
}
|
||||
|
||||
args = append(args, "-H", endpointURL)
|
||||
|
||||
if endpoint.TLSConfig.TLS {
|
||||
args = append(args, "--tls")
|
||||
|
||||
if !endpoint.TLSConfig.TLSSkipVerify {
|
||||
args = append(args, "--tlsverify", "--tlscacert", endpoint.TLSConfig.TLSCACertPath)
|
||||
} else {
|
||||
args = append(args, "--tlscacert", "")
|
||||
}
|
||||
|
||||
if endpoint.TLSConfig.TLSCertPath != "" && endpoint.TLSConfig.TLSKeyPath != "" {
|
||||
args = append(args, "--tlscert", endpoint.TLSConfig.TLSCertPath, "--tlskey", endpoint.TLSConfig.TLSKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
return command, args, nil
|
||||
}
|
||||
|
||||
func (manager *SwarmStackManager) updateDockerCLIConfiguration(configPath string) error {
|
||||
configFilePath := path.Join(configPath, "config.json")
|
||||
|
||||
config, err := manager.retrieveConfigurationFromDisk(configFilePath)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("unable to retrieve the Swarm configuration from disk, proceeding without it")
|
||||
}
|
||||
|
||||
signature, err := manager.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config["HttpHeaders"] == nil {
|
||||
config["HttpHeaders"] = make(map[string]any)
|
||||
}
|
||||
|
||||
headersObject := config["HttpHeaders"].(map[string]any)
|
||||
headersObject["X-PortainerAgent-ManagerOperation"] = "1"
|
||||
headersObject["X-PortainerAgent-Signature"] = signature
|
||||
headersObject["X-PortainerAgent-PublicKey"] = manager.signatureService.EncodedPublicKey()
|
||||
|
||||
return manager.fileService.WriteJSONToFile(configFilePath, config)
|
||||
}
|
||||
|
||||
func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (map[string]any, error) {
|
||||
var config map[string]any
|
||||
|
||||
raw, err := manager.fileService.GetFileContent(path, "")
|
||||
if err != nil {
|
||||
return make(map[string]any), nil
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(raw, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
return manager.deployer.Remove(context.TODO(), stack.Name, swarm.RemoveOptions{
|
||||
Options: swarm.Options{
|
||||
Host: url,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// NormalizeStackName returns a new stack name with unsupported characters replaced.
|
||||
func (manager *SwarmStackManager) NormalizeStackName(name string) string {
|
||||
return stackNameNormalizeRegex.ReplaceAllString(strings.ToLower(name), "")
|
||||
}
|
||||
|
||||
func configureFilePaths(args []string, filePaths []string) []string {
|
||||
for _, path := range filePaths {
|
||||
args = append(args, "--compose-file", path)
|
||||
}
|
||||
|
||||
return args
|
||||
return normalizeStackName(name)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -80,7 +80,7 @@ func TestValidateHelmRepositoryURL(t *testing.T) {
|
||||
// Failure
|
||||
fail = true
|
||||
|
||||
var failureURLs = []string{
|
||||
failureURLs := []string{
|
||||
"",
|
||||
"!",
|
||||
"oci://example.com",
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
|
||||
@@ -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...),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user