fix(swarm): manager operations [BE-13066] (#3096)

This commit is contained in:
Devon Steenberg
2026-07-06 10:49:26 +12:00
committed by GitHub
parent 8cf0e1b849
commit fca59638d8
4 changed files with 111 additions and 3 deletions
+14
View File
@@ -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 {
+38
View File
@@ -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)
}
+18 -3
View File
@@ -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()
+41
View File
@@ -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()