dbd2d06659
Issue #34 reports container Recreate / native auto-update (#28, #10) failing on modern Docker Engines via the agent with "starting container with non-empty request body ... removed in v1.24". The proposed fix: make API-version resolution robust in all three docker client constructors. - api/docker/client/client.go: add a shared negotiateWithFloor() used by createLocalClient, createTCPClient, createAgentClient. It eagerly NegotiateAPIVersion (10s bound) instead of relying on lazy first-call negotiation, and enforces a modern floor: if the negotiated version is empty or < 1.24 (versions.LessThan), rebuild pinned at 1.44 (WithVersion, which the SDK requires INSTEAD of negotiation — WithVersion sets manualOverride, disabling NegotiateAPIVersion). Negotiation stays primary; the floor is only a safety net. Effective API version is now guaranteed >= 1.24. Custom transport / TLS / agent headers untouched. - Tests (api/docker/client/client_test.go): modern daemon -> empty start body; below-floor daemon -> pinned to floor; unreachable daemon -> stays >= 1.24. IMPORTANT FINDING (needs maintainer confirmation before this is called a complete fix for #34): on the pinned SDK (github.com/docker/docker v28.5.2), ContainerStart posts a nil body UNCONDITIONALLY (client/container_start.go: cli.post(ctx, ".../start", query, nil, nil) — no version branch), and NewClientWithOpts defaults to version 1.51 (negotiation only downgrades). So on develop, the server-side path the issue names (autoupdate.go -> Recreate -> cli.ContainerStart) cannot emit a request body regardless of API version — this change hardens the client path but is unlikely to be the actual source of the reported symptom. The "non-empty body" more likely originates from a DIFFERENT layer (an OLDER docker SDK in the failing deployment, where ContainerStart did attach HostConfig at API < 1.24; or the docker PROXY forwarding a raw browser request). Recommend confirming the failing environment's build before merging as the #34 fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
283 lines
9.0 KiB
Go
283 lines
9.0 KiB
Go
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
portainer "github.com/portainer/portainer/api"
|
|
"github.com/portainer/portainer/api/crypto"
|
|
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"github.com/docker/docker/api/types/image"
|
|
"github.com/docker/docker/api/types/versions"
|
|
"github.com/docker/docker/client"
|
|
"github.com/segmentio/encoding/json"
|
|
)
|
|
|
|
var errUnsupportedEnvironmentType = errors.New("environment not supported")
|
|
|
|
const (
|
|
defaultDockerRequestTimeout = 60 * time.Second
|
|
dockerClientVersion = "1.37"
|
|
|
|
// negotiateTimeout bounds the eager API-version negotiation performed at
|
|
// client construction so an unreachable daemon cannot block indefinitely.
|
|
negotiateTimeout = 10 * time.Second
|
|
|
|
// minSupportedAPIVersion is the hard invariant every Docker client must
|
|
// satisfy. Below API v1.24 the daemon rejects requests that the SDK still
|
|
// shapes with legacy semantics (e.g. a non-empty body on
|
|
// "POST /containers/{id}/start"), which breaks container recreate.
|
|
minSupportedAPIVersion = "1.24"
|
|
|
|
// modernAPIVersionFloor is the version the client is pinned to when API
|
|
// negotiation cannot determine a concrete daemon version (daemon
|
|
// unreachable at construction, tunnel hiccup, ...). It keeps the effective
|
|
// API version well above minSupportedAPIVersion instead of falling back to
|
|
// the SDK legacy default.
|
|
modernAPIVersionFloor = "1.44"
|
|
)
|
|
|
|
type NodeNamesCtxKey struct{}
|
|
|
|
// ClientFactory is used to create Docker clients
|
|
type ClientFactory struct {
|
|
signatureService portainer.DigitalSignatureService
|
|
reverseTunnelService portainer.ReverseTunnelService
|
|
}
|
|
|
|
// NewClientFactory returns a new instance of a ClientFactory
|
|
func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *ClientFactory {
|
|
return &ClientFactory{
|
|
signatureService: signatureService,
|
|
reverseTunnelService: reverseTunnelService,
|
|
}
|
|
}
|
|
|
|
// CreateClient is a generic function to create a Docker client based on
|
|
// a specific environment(endpoint) configuration. The nodeName parameter can be used
|
|
// with an agent enabled environment(endpoint) to target a specific node in an agent cluster.
|
|
// The underlying http client timeout may be specified, a default value is used otherwise.
|
|
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
|
switch endpoint.Type {
|
|
case portainer.AzureEnvironment:
|
|
return nil, errUnsupportedEnvironmentType
|
|
case portainer.AgentOnDockerEnvironment:
|
|
return createAgentClient(endpoint, endpoint.URL, factory.signatureService, nodeName, timeout)
|
|
case portainer.EdgeAgentOnDockerEnvironment:
|
|
tunnelAddr, err := factory.reverseTunnelService.TunnelAddr(endpoint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
endpointURL := "http://" + tunnelAddr
|
|
|
|
return createAgentClient(endpoint, endpointURL, factory.signatureService, nodeName, timeout)
|
|
}
|
|
|
|
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
|
|
return createLocalClient(endpoint)
|
|
}
|
|
|
|
return createTCPClient(endpoint, timeout)
|
|
}
|
|
|
|
// negotiateWithFloor builds a Docker client from opts (which must include
|
|
// client.WithAPIVersionNegotiation()) and eagerly negotiates the API version
|
|
// against the daemon, bounded by timeout. The custom agent/TCP transports do
|
|
// not reliably resolve a modern API version through the SDK's lazy first-call
|
|
// negotiation, so we resolve it up-front here.
|
|
//
|
|
// If negotiation cannot determine a concrete daemon version at or above
|
|
// minSupportedAPIVersion (daemon unreachable at construction, tunnel hiccup,
|
|
// or a pre-negotiation daemon), the client is rebuilt pinned at
|
|
// modernAPIVersionFloor so its effective API version never drops below the
|
|
// supported floor.
|
|
//
|
|
// SDK semantics relied upon (github.com/docker/docker/client):
|
|
// - client.WithVersion(v) sets manualOverride=true, which makes both the
|
|
// lazy checkVersion and NegotiateAPIVersion no-ops. So a pinned client
|
|
// stays at the pinned version. This is why the floor is applied by
|
|
// rebuilding with WithVersion rather than by combining WithVersion with
|
|
// WithAPIVersionNegotiation (which would silently disable negotiation).
|
|
// - NegotiateAPIVersion leaves ClientVersion() empty when it cannot reach
|
|
// the daemon (Ping error), and otherwise sets it to the negotiated
|
|
// version (capped at the client's maximum supported version).
|
|
func negotiateWithFloor(opts []client.Opt, timeout time.Duration) (*client.Client, error) {
|
|
cli, err := client.NewClientWithOpts(opts...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
cli.NegotiateAPIVersion(ctx)
|
|
|
|
// Negotiation succeeded with a version at or above the supported floor:
|
|
// keep the negotiated version (best match for the actual daemon).
|
|
if v := cli.ClientVersion(); v != "" && !versions.LessThan(v, minSupportedAPIVersion) {
|
|
return cli, nil
|
|
}
|
|
|
|
// Negotiation failed or resolved below the supported floor: rebuild the
|
|
// client pinned at a modern version. WithVersion disables negotiation, so
|
|
// the effective API version is fixed at modernAPIVersionFloor.
|
|
if err := cli.Close(); err != nil {
|
|
log.Warn().Err(err).Msg("failed to close docker client before applying API version floor")
|
|
}
|
|
|
|
return client.NewClientWithOpts(append(opts, client.WithVersion(modernAPIVersionFloor))...)
|
|
}
|
|
|
|
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
|
opts := []client.Opt{
|
|
client.WithHost(endpoint.URL),
|
|
client.WithAPIVersionNegotiation(),
|
|
}
|
|
|
|
return negotiateWithFloor(opts, negotiateTimeout)
|
|
}
|
|
|
|
func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) {
|
|
httpCli, err := httpClient(endpoint, timeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
opts := []client.Opt{
|
|
client.WithHost(endpoint.URL),
|
|
client.WithAPIVersionNegotiation(),
|
|
client.WithHTTPClient(httpCli),
|
|
}
|
|
|
|
if endpoint.TLSConfig.TLS {
|
|
opts = append(opts, client.WithScheme("https"))
|
|
}
|
|
|
|
return negotiateWithFloor(opts, negotiateTimeout)
|
|
}
|
|
|
|
func createAgentClient(endpoint *portainer.Endpoint, endpointURL string, signatureService portainer.DigitalSignatureService, nodeName string, timeout *time.Duration) (*client.Client, error) {
|
|
httpCli, err := httpClient(endpoint, timeout)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
signature, err := signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
headers := map[string]string{
|
|
portainer.PortainerAgentPublicKeyHeader: signatureService.EncodedPublicKey(),
|
|
portainer.PortainerAgentSignatureHeader: signature,
|
|
}
|
|
|
|
if nodeName != "" {
|
|
headers[portainer.PortainerAgentTargetHeader] = nodeName
|
|
}
|
|
|
|
opts := []client.Opt{
|
|
client.WithHost(endpointURL),
|
|
client.WithAPIVersionNegotiation(),
|
|
client.WithHTTPClient(httpCli),
|
|
client.WithHTTPHeaders(headers),
|
|
}
|
|
|
|
if endpoint.TLSConfig.TLS {
|
|
opts = append(opts, client.WithScheme("https"))
|
|
}
|
|
|
|
return negotiateWithFloor(opts, negotiateTimeout)
|
|
}
|
|
|
|
type NodeNameTransport struct {
|
|
*http.Transport
|
|
}
|
|
|
|
func (t *NodeNameTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
resp, err := t.Transport.RoundTrip(req)
|
|
if err != nil ||
|
|
resp.StatusCode != http.StatusOK ||
|
|
resp.ContentLength == 0 ||
|
|
!strings.HasSuffix(req.URL.Path, "/images/json") {
|
|
return resp, err
|
|
}
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
if err := resp.Body.Close(); err != nil {
|
|
log.Warn().Err(err).Msg("failed to close response body")
|
|
}
|
|
|
|
return resp, err
|
|
}
|
|
|
|
if err := resp.Body.Close(); err != nil {
|
|
log.Warn().Err(err).Msg("failed to close response body")
|
|
}
|
|
|
|
resp.Body = io.NopCloser(bytes.NewReader(body))
|
|
|
|
var rs []struct {
|
|
image.Summary
|
|
Portainer struct {
|
|
Agent struct {
|
|
NodeName string
|
|
}
|
|
}
|
|
}
|
|
|
|
if err = json.Unmarshal(body, &rs); err != nil {
|
|
return resp, nil
|
|
}
|
|
|
|
nodeNames, ok := req.Context().Value(NodeNamesCtxKey{}).(map[string]string)
|
|
if ok {
|
|
for idx, r := range rs {
|
|
// as there is no way to differentiate the same image available in multiple nodes only by their ID
|
|
// we append the index of the image in the payload response to match the node name later
|
|
// from the image.Summary[] list returned by docker's client.ImageList()
|
|
nodeNames[fmt.Sprintf("%s-%d", r.ID, idx)] = r.Portainer.Agent.NodeName
|
|
}
|
|
}
|
|
|
|
return resp, err
|
|
}
|
|
|
|
func httpClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*http.Client, error) {
|
|
var transport *NodeNameTransport
|
|
if endpoint.TLSConfig.TLS {
|
|
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
transport = &NodeNameTransport{
|
|
Transport: ssrf.NewTransport(tlsConfig),
|
|
}
|
|
} else {
|
|
transport = &NodeNameTransport{
|
|
Transport: ssrf.NewTransport(nil),
|
|
}
|
|
}
|
|
|
|
clientTimeout := defaultDockerRequestTimeout
|
|
if timeout != nil {
|
|
clientTimeout = *timeout
|
|
}
|
|
|
|
return &http.Client{
|
|
Transport: transport,
|
|
Timeout: clientTimeout,
|
|
}, nil
|
|
}
|