Skip to content
Next Next commit
feat(provider): pull command and get-image protocol for image distrib…
…ution

Providers opt into image distribution by declaring a pull block in
their metadata — the presence of the command block is the declaration
of support, generalizing the stop precedent. During its pull command a
provider can request the service image from the local daemon with a
get-image message (optionally platform-narrowed); compose answers on
stdin with one image-stream JSON line then the tar as HTTP/1.1 chunked
data (RFC 9112): length-prefixed blocks need no in-band delimiter in
binary data, the zero-length chunk marks a COMPLETE transfer, and any
stock chunked reader consumes it. There is deliberately no trailer nor
final CRLF after the zero chunk — a stock reader stops there without
consuming further bytes, so the next stdin answer starts clean for a
provider requesting several images. An export failure is announced
in-band through the error field so the provider is never left waiting;
a failure after the announce closes the answer channel so the
truncation is observable as EOF mid-chunk instead of a stream nobody
will finish. The stream is exclusive on stdin for its whole duration —
the framing invariant behind the lock.

get-image is scoped to the pull command: emitted during up, down or
stop it is a protocol error rather than served, so an image export can
never stall another lifecycle command.

Message dispatch moves out of executePlugin into handlePluginMessage,
which also keeps the function under the complexity threshold.

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
  • Loading branch information
ndeloof committed Oct 2, 2026
commit 0d56c068e9f5d4f70aacac471688e9194a28b7ef
146 changes: 139 additions & 7 deletions pkg/compose/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"fmt"
"io"
"net"
"net/http/httputil"
"os"
"os/exec"
"path/filepath"
Expand All @@ -33,8 +34,10 @@ import (

"github.com/compose-spec/compose-go/v2/types"
"github.com/containerd/errdefs"
"github.com/containerd/platforms"
"github.com/docker/cli/cli-plugins/manager"
"github.com/docker/cli/cli/config"
"github.com/moby/moby/client"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"

Expand All @@ -44,6 +47,9 @@ import (
type JsonMessage struct {
Type string `json:"type"`
Message string `json:"message,omitempty"`
// Platform optionally narrows a get-image request to one platform of a
// multi-platform image (e.g. "linux/arm64").
Platform string `json:"platform,omitempty"`
}

const (
Expand All @@ -70,6 +76,17 @@ const (
// resource has no use for it.
GetRelayInfoType = "get-relay-info"

// GetImageType is a message the provider sends during its pull command
// to receive the service image from the local daemon: compose answers on
// the provider's stdin with one ImageStreamType JSON line, then — unless
// that line carries an error — the image tar encoded as an HTTP/1.1
// chunked body (see streamImageTo).
GetImageType = "get-image"

// ImageStreamType is the type of the JSON line answering a get-image
// request.
ImageStreamType = "image-stream"

// ComposeProviderMessagesEnv announces to the provider process, as a
// comma-separated list, every message type this compose accepts on the
// provider's stdout — so a provider can adapt to the compose it runs
Expand All @@ -91,8 +108,19 @@ var providerMessageTypes = strings.Join([]string{
PublishEndpointType,
GetServiceConfigType,
GetRelayInfoType,
GetImageType,
}, ",")

// imageStreamAnswer is the stdin answer to a get-image request. On success
// Encoding and MediaType describe the byte stream that follows the JSON line;
// on failure Error carries the reason and no stream follows.
type imageStreamAnswer struct {
Type string `json:"type"`
Encoding string `json:"encoding,omitempty"`
MediaType string `json:"media-type,omitempty"`
Error string `json:"error,omitempty"`
}

type pluginVariables struct {
prefixed types.Mapping
raw types.Mapping
Expand All @@ -105,15 +133,15 @@ type pluginVariables struct {

var mux sync.Mutex

func (s *composeService) runPlugin(ctx context.Context, project *types.Project, service types.ServiceConfig, command string) error {
func (s *composeService) runPlugin(ctx context.Context, project *types.Project, service types.ServiceConfig, command string, extraArgs ...string) error {
provider := *service.Provider

plugin, err := s.getPluginBinaryPath(provider.Type)
if err != nil {
return err
}

cmd, err := s.setupPluginCommand(ctx, project, service, plugin, command)
cmd, err := s.setupPluginCommand(ctx, project, service, plugin, command, extraArgs...)
if err != nil {
return err
}
Expand All @@ -126,7 +154,7 @@ func (s *composeService) runPlugin(ctx context.Context, project *types.Project,
return err
}

if command == "stop" {
if command == "stop" || command == "pull" {
return nil
}

Expand Down Expand Up @@ -203,6 +231,9 @@ func (s *composeService) executePlugin(ctx context.Context, project *types.Proje
case "stop":
s.events.On(stoppingEvent(service.Name))
action = "stop"
case "pull":
s.events.On(newEvent(service.Name, api.Working, "Pulling"))
action = "pull"
default:
return pluginVariables{}, fmt.Errorf("unsupported plugin command: %s", command)
}
Expand Down Expand Up @@ -275,7 +306,7 @@ func (s *composeService) executePlugin(ctx context.Context, project *types.Proje
if err != nil {
return pluginVariables{}, err
}
if err := s.handlePluginMessage(ctx, project, service, msg, &variables, stdin, &stdinMu, &answers); err != nil {
if err := s.handlePluginMessage(ctx, project, service, msg, command, &variables, stdin, &stdinMu, &answers); err != nil {
return pluginVariables{}, err
}
}
Expand All @@ -293,17 +324,77 @@ func (s *composeService) executePlugin(ctx context.Context, project *types.Proje
s.events.On(removedEvent(service.Name))
case "stop":
s.events.On(stoppedEvent(service.Name))
case "pull":
s.events.On(newEvent(service.Name, api.Done, "Pulled"))
}
return variables, nil
}

// imageStreamChunkSize is the fixed buffer compose fills from the image
// export before flushing it as one chunk of the stream.
const imageStreamChunkSize = 1024 * 1024

// streamImageTo answers a get-image request on the provider's stdin: one
// ImageStreamType JSON line announcing the transfer, then the image tar
// encoded as HTTP/1.1 chunked data (RFC 9112 §7.1) — every block of data
// prefixed by its length, the zero-length chunk marking a COMPLETE
// transfer. Length-prefixed framing needs no in-band delimiter (any
// byte value can appear inside a tar) and is consumable with any language's
// chunked-body reader. A stream that ends without the terminating zero chunk
// was aborted: the provider must discard it.
// When the image cannot be exported, the announce line carries an error
// instead and no stream follows.
func (s *composeService) streamImageTo(ctx context.Context, w io.Writer, ref, platform string) error {
announce := func(a imageStreamAnswer) error {
payload, err := json.Marshal(a)
if err != nil {
return err
}
_, err = w.Write(append(payload, '\n'))
return err
}

var opts []client.ImageSaveOption
if platform != "" {
p, err := platforms.Parse(platform)
if err != nil {
_ = announce(imageStreamAnswer{Type: ImageStreamType, Error: fmt.Sprintf("invalid platform %q: %s", platform, err)})
Comment thread
ndeloof marked this conversation as resolved.
Outdated
return err
}
opts = append(opts, client.ImageSaveWithPlatforms(p))
}
tar, err := s.apiClient().ImageSave(ctx, []string{ref}, opts...)
if err != nil {
_ = announce(imageStreamAnswer{Type: ImageStreamType, Error: err.Error()})
return err
}
defer func() { _ = tar.Close() }()

if err := announce(imageStreamAnswer{Type: ImageStreamType, Encoding: "chunked", MediaType: "application/x-tar"}); err != nil {
return err
Comment thread
ndeloof marked this conversation as resolved.
Outdated
}
cw := httputil.NewChunkedWriter(w)
if _, err := io.CopyBuffer(cw, struct{ io.Reader }{tar}, make([]byte, imageStreamChunkSize)); err != nil {
return err
}
// ChunkedWriter.Close writes the zero-length chunk, which ends the
// stream. Unlike an HTTP message there is deliberately no trailer
// section nor final CRLF: a stock chunked reader stops at the zero
// chunk without consuming either, and leftover bytes would corrupt the
// next JSON answer a provider requesting several images reads.
return cw.Close()
}

// handlePluginMessage processes one provider message, mutating variables in
// place; a returned error is terminal for the run (the provider is killed
// and the command fails). An unknown message type IS such an error: a
// provider requiring a message this compose does not support must fail
// loudly, not degrade silently — providers adapt through the
// COMPOSE_PROVIDER_MESSAGES announcement instead.
func (s *composeService) handlePluginMessage(ctx context.Context, project *types.Project, service types.ServiceConfig, msg JsonMessage,
// COMPOSE_PROVIDER_MESSAGES announcement instead. Answer-bearing requests
// (get-service-config, get-relay-info, get-image) are served from their own
// goroutine — see the answers/stdinMu contract in executePlugin.
func (s *composeService) handlePluginMessage(
ctx context.Context, project *types.Project, service types.ServiceConfig, msg JsonMessage, command string,
variables *pluginVariables, stdin io.WriteCloser, stdinMu *sync.Mutex, answers *sync.WaitGroup,
) error {
switch msg.Type {
Expand Down Expand Up @@ -339,6 +430,36 @@ func (s *composeService) handlePluginMessage(ctx context.Context, project *types
return fmt.Errorf("failed to answer get-relay-info: %w", err)
}
answerProvider(stdin, stdinMu, answers, payload)
case GetImageType:
// image distribution belongs to the pull command: answering it
// elsewhere would let an image export stall a down or a stop
if command != "pull" {
return fmt.Errorf("invalid message from plugin: %s is only supported during the pull command", GetImageType)
}
ref, platform := msg.Message, msg.Platform
answers.Add(1)
go func() {
defer answers.Done()
// stdinMu is deliberately held for the whole transfer: the
// chunked body must be contiguous on stdin, any concurrent
// answer interleaved into it would corrupt the framing. A
// provider must therefore drain the announced stream before
// expecting any other answer (documented contract); compose
// cannot hang forever on a provider that stops reading — the
// error path kills the process, which EPIPEs the write.
stdinMu.Lock()
defer stdinMu.Unlock()
Comment thread
ndeloof marked this conversation as resolved.
if err := s.streamImageTo(ctx, stdin, ref, platform); err != nil {
Comment thread
ndeloof marked this conversation as resolved.
logrus.Warnf("provider %q: get-image %q: %v", service.Name, ref, err)
Comment thread
ndeloof marked this conversation as resolved.
// A failure after the success announce leaves the stream
// without its terminating chunk, and the channel cannot be
// resynchronized (any byte would read as chunk data).
// Closing stdin makes the truncation observable — the
// provider gets EOF mid-chunk and discards, instead of
// blocking forever on a stream nobody will finish.
_ = stdin.Close()
}
}()
case PublishEndpointType:
port, upstream, err := parseEndpointMessage(msg.Message)
if err != nil {
Expand Down Expand Up @@ -388,7 +509,7 @@ func (s *composeService) getPluginBinaryPath(provider string) (path string, err
return path, err
}

func (s *composeService) setupPluginCommand(ctx context.Context, project *types.Project, service types.ServiceConfig, path, command string) (*exec.Cmd, error) {
func (s *composeService) setupPluginCommand(ctx context.Context, project *types.Project, service types.ServiceConfig, path, command string, extraArgs ...string) (*exec.Cmd, error) {
cmdOptionsMetadata := s.getPluginMetadata(path, service.Provider.Type, project)
var currentCommandMetadata CommandMetadata
switch command {
Expand All @@ -401,6 +522,13 @@ func (s *composeService) setupPluginCommand(ctx context.Context, project *types.
return nil, nil
}
currentCommandMetadata = *cmdOptionsMetadata.Stop
case "pull":
// image distribution is opt-in, declared like stop by the presence
// of the command block in the provider metadata
if cmdOptionsMetadata.Pull == nil {
return nil, nil
}
currentCommandMetadata = *cmdOptionsMetadata.Pull
}

provider := *service.Provider
Expand All @@ -417,6 +545,7 @@ func (s *composeService) setupPluginCommand(ctx context.Context, project *types.
}
}
}
args = append(args, extraArgs...)
args = append(args, service.Name)

cmd := exec.CommandContext(ctx, path, args...)
Expand Down Expand Up @@ -469,6 +598,9 @@ type ProviderMetadata struct {
Up CommandMetadata `json:"up"`
Down CommandMetadata `json:"down"`
Stop *CommandMetadata `json:"stop,omitempty"`
// Pull declares support for the image-distribution command; like Stop,
// the presence of the block is what opts the provider in.
Pull *CommandMetadata `json:"pull,omitempty"`
}

func (p ProviderMetadata) IsEmpty() bool {
Expand Down
19 changes: 19 additions & 0 deletions pkg/compose/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,25 @@ func TestProviderMetadata_StopAbsent(t *testing.T) {
assert.Assert(t, metadata.Stop == nil, "Stop should be nil when absent from JSON")
}

func TestProviderMetadata_PullAbsent(t *testing.T) {
raw := `{"description":"x","up":{"parameters":[]},"down":{"parameters":[]}}`

var metadata ProviderMetadata
err := json.Unmarshal([]byte(raw), &metadata)
assert.NilError(t, err)
assert.Assert(t, metadata.Pull == nil, "Pull should be nil when absent from JSON")
}

func TestProviderMetadata_PullPresent(t *testing.T) {
raw := `{"pull":{"parameters":[{"name":"registry"}]}}`

var metadata ProviderMetadata
err := json.Unmarshal([]byte(raw), &metadata)
assert.NilError(t, err)
assert.Assert(t, metadata.Pull != nil, "Pull should be non-nil when key present")
assert.Equal(t, metadata.Pull.Parameters[0].Name, "registry")
}

func TestProviderMetadata_StopAdvertisedWithoutParameters(t *testing.T) {
raw := `{"stop":{"parameters":null}}`

Expand Down