mirror of
https://github.com/portainer/portainer.git
synced 2026-08-07 11:24:48 +00:00
fix(swarm): manager operations [BE-13066] (#3096)
This commit is contained in:
@@ -3,6 +3,7 @@ package libstack
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"sync"
|
||||
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
type DockerCliOptions struct {
|
||||
Host string
|
||||
Registries []configtypes.AuthConfig
|
||||
// Headers are injected as custom HTTP headers on every request the client makes.
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// mu serialises calls to cli.Initialize across all deployer types (compose and
|
||||
@@ -49,6 +52,17 @@ func WithCli(
|
||||
return fmt.Errorf("unable to initialize the Docker client: %w", err)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// Inject custom headers before the first Client() call, which is where the
|
||||
// Docker client is lazily created and reads ConfigFile().HTTPHeaders.
|
||||
if len(options.Headers) > 0 {
|
||||
cfg := cli.ConfigFile()
|
||||
if cfg.HTTPHeaders == nil {
|
||||
cfg.HTTPHeaders = make(map[string]string)
|
||||
}
|
||||
maps.Copy(cfg.HTTPHeaders, options.Headers)
|
||||
}
|
||||
|
||||
defer logs.CloseAndLogErr(cli.Client())
|
||||
|
||||
for _, r := range options.Registries {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package libstack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/cli/cli/command"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWithCli_InjectsHeaders(t *testing.T) {
|
||||
const headerName = "X-PortainerAgent-ManagerOperation"
|
||||
|
||||
err := WithCli(
|
||||
t.Context(),
|
||||
DockerCliOptions{
|
||||
Host: "tcp://127.0.0.1:1",
|
||||
Headers: map[string]string{headerName: "1"},
|
||||
},
|
||||
func(_ context.Context, cli *command.DockerCli) error {
|
||||
require.Equal(t, "1", cli.ConfigFile().HTTPHeaders[headerName])
|
||||
return nil
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestWithCli_NoHeaders(t *testing.T) {
|
||||
err := WithCli(
|
||||
context.Background(),
|
||||
DockerCliOptions{Host: "tcp://127.0.0.1:1"},
|
||||
func(_ context.Context, cli *command.DockerCli) error {
|
||||
require.Empty(t, cli.ConfigFile().HTTPHeaders)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -32,6 +32,21 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// managerOperationHeader forces the Portainer agent to route requests to a swarm
|
||||
// manager node. Swarm-scoped operations (e.g. network create/remove) fail on worker
|
||||
// nodes without it. This constant is originally defined in the agent, see package/agent/README.md.
|
||||
const managerOperationHeader = "X-PortainerAgent-ManagerOperation"
|
||||
|
||||
// cliOptions builds the Docker CLI options for swarm operations, always setting the
|
||||
// manager-operation header so requests proxied through an agent target a manager node.
|
||||
func cliOptions(host string, registries []configtypes.AuthConfig) libstack.DockerCliOptions {
|
||||
return libstack.DockerCliOptions{
|
||||
Host: host,
|
||||
Registries: registries,
|
||||
Headers: map[string]string{managerOperationHeader: "1"},
|
||||
}
|
||||
}
|
||||
|
||||
// Options holds connection and credential settings for swarm operations.
|
||||
type Options struct {
|
||||
ProjectName string
|
||||
@@ -81,7 +96,7 @@ func (d *SwarmDeployer) Deploy(ctx context.Context, filePaths []string, options
|
||||
|
||||
return libstack.WithCli(
|
||||
ctx,
|
||||
libstack.DockerCliOptions{Host: options.Host, Registries: options.Registries},
|
||||
cliOptions(options.Host, options.Registries),
|
||||
func(_ context.Context, dockerCLI *command.DockerCli) error {
|
||||
return deployStack(callerCtx, dockerCLI, filePaths, options)
|
||||
})
|
||||
@@ -101,7 +116,7 @@ func (d *SwarmDeployer) Remove(ctx context.Context, projectName string, options
|
||||
|
||||
return libstack.WithCli(
|
||||
ctx,
|
||||
libstack.DockerCliOptions{Host: options.Host, Registries: options.Registries},
|
||||
cliOptions(options.Host, options.Registries),
|
||||
func(_ context.Context, dockerCLI *command.DockerCli) error {
|
||||
apiClient := dockerCLI.Client()
|
||||
|
||||
@@ -741,7 +756,7 @@ func (d *SwarmDeployer) WaitForStatus(
|
||||
|
||||
err := libstack.WithCli(
|
||||
ctx,
|
||||
libstack.DockerCliOptions{Host: options.Host, Registries: options.Registries},
|
||||
cliOptions(options.Host, options.Registries),
|
||||
func(_ context.Context, dockerCLI *command.DockerCli) error {
|
||||
apiClient := dockerCLI.Client()
|
||||
|
||||
|
||||
@@ -79,6 +79,47 @@ func Test_aggregateStatus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_cliOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
registries := []configtypes.AuthConfig{
|
||||
{ServerAddress: "registry.example.com", Username: "user", Password: "pass"},
|
||||
{ServerAddress: dockerregistry.IndexServer, Username: "other"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
registries []configtypes.AuthConfig
|
||||
expected libstack.DockerCliOptions
|
||||
}{
|
||||
{
|
||||
name: "sets manager-operation header and passes through host and registries",
|
||||
host: "tcp://127.0.0.1:2377",
|
||||
registries: registries,
|
||||
expected: libstack.DockerCliOptions{
|
||||
Host: "tcp://127.0.0.1:2377",
|
||||
Registries: registries,
|
||||
Headers: map[string]string{managerOperationHeader: "1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty host and nil registries still set the header",
|
||||
expected: libstack.DockerCliOptions{
|
||||
Headers: map[string]string{managerOperationHeader: "1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.Equal(t, tt.expected, cliOptions(tt.host, tt.registries))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_isTerminalState(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user