Skip to content

Commit a4da6e1

Browse files
committed
docs(provider), e2e: image distribution documented and exercised
extension.md gains the Image distribution section: invocation contract (--image/--digest/--created/--source/--policy), the get-image request and its chunked image-stream answer (zero-chunk terminated, stdin exclusivity and drain-before-next-answer contract), the two-branch decision guidance (digest as identity, created as ordering fallback, reproducible builds caveat), and the support-by-presence metadata convention is now stated as the general rule. The example provider implements pull end to end — request, stock chunked reader, hash recorded at PROVIDER_PULL_MARKER — and the e2e scenario locks the whole path: up builds the service image and streams it to the provider under the local verdict, compose pull re-invokes it under the freshness contract. Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
1 parent fb56ffe commit a4da6e1

5 files changed

Lines changed: 187 additions & 6 deletions

File tree

‎docs/examples/provider.go‎

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,12 @@ package main
1818

1919
import (
2020
"bufio"
21+
"crypto/sha256"
2122
"encoding/json"
2223
"fmt"
24+
"io"
2325
"net"
26+
"net/http/httputil"
2427
"os"
2528
"os/exec"
2629
"strings"
@@ -85,11 +88,88 @@ func composeCommand() *cobra.Command {
8588
Args: cobra.ExactArgs(1),
8689
}
8790

88-
c.AddCommand(upCmd, downCmd, stopCmd)
89-
c.AddCommand(metadataCommand(upCmd, downCmd, stopCmd))
91+
// pull is the image-distribution command: compose invokes it during the
92+
// image phase (up) and on `docker compose pull`, passing the identity of
93+
// the service image and the state of the local daemon cache. None of its
94+
// flags is required: they are injected by compose, not declared by the
95+
// user under provider.options.
96+
pullCmd := &cobra.Command{
97+
Use: "pull",
98+
Run: pull,
99+
Args: cobra.ExactArgs(1),
100+
}
101+
pullCmd.Flags().String("image", "", "Image reference as compose resolved it")
102+
pullCmd.Flags().String("digest", "", "Image ID in the local daemon cache, when present")
103+
pullCmd.Flags().String("created", "", "Creation time of the local cache entry, when present")
104+
pullCmd.Flags().String("source", "", "Authority verdict: local (sync from the daemon) or registry (resolve upstream)")
105+
pullCmd.Flags().String("policy", "", "missing (a usable version suffices) or always (ensure freshness)")
106+
107+
c.AddCommand(upCmd, downCmd, stopCmd, pullCmd)
108+
c.AddCommand(metadataCommand(upCmd, downCmd, stopCmd, pullCmd))
90109
return c
91110
}
92111

112+
// pull demonstrates the image-distribution contract. A real provider would
113+
// compare the announced digest/created with its bookkeeping and either
114+
// resolve the reference upstream (source=registry) or request the local
115+
// bytes (source=local). This demo requests the stream whenever
116+
// PROVIDER_PULL_MARKER is set, hashes it, and records the outcome there.
117+
func pull(cmd *cobra.Command, args []string) {
118+
image, _ := cmd.Flags().GetString("image")
119+
digest, _ := cmd.Flags().GetString("digest")
120+
created, _ := cmd.Flags().GetString("created")
121+
source, _ := cmd.Flags().GetString("source")
122+
policy, _ := cmd.Flags().GetString("policy")
123+
124+
emit := func(kind, message string) {
125+
payload, _ := json.Marshal(map[string]string{"type": kind, "message": message})
126+
fmt.Println(string(payload))
127+
}
128+
emit("info", fmt.Sprintf("ensuring image %s (source=%s, policy=%s)", image, source, policy))
129+
130+
marker := os.Getenv("PROVIDER_PULL_MARKER")
131+
if marker == "" {
132+
// nothing to synchronize in the demo: a real registry-sourced provider
133+
// would pull the reference upstream here
134+
return
135+
}
136+
137+
// Request the image bytes from the local daemon. The answer is one JSON
138+
// line, then — unless it carries an error — the tar as an HTTP/1.1
139+
// chunked body (readable with any stock chunked reader), whose zero
140+
// chunk marks a COMPLETE transfer.
141+
request, _ := json.Marshal(map[string]string{"type": "get-image", "message": image})
142+
fmt.Println(string(request))
143+
144+
stdin := bufio.NewReader(os.Stdin)
145+
line, err := stdin.ReadString('\n')
146+
if err != nil {
147+
emit("error", "reading image-stream announce: "+err.Error())
148+
os.Exit(1)
149+
}
150+
var announce struct {
151+
Encoding string `json:"encoding"`
152+
Error string `json:"error"`
153+
}
154+
if err := json.Unmarshal([]byte(line), &announce); err != nil || announce.Error != "" || announce.Encoding != "chunked" {
155+
emit("error", fmt.Sprintf("image stream unavailable: %q (err %v)", line, err))
156+
os.Exit(1)
157+
}
158+
h := sha256.New()
159+
n, err := io.Copy(h, httputil.NewChunkedReader(stdin))
160+
if err != nil {
161+
emit("error", "image stream truncated: "+err.Error())
162+
os.Exit(1)
163+
}
164+
record := fmt.Sprintf("image=%s digest=%s created=%s source=%s policy=%s sha256=%x bytes=%d\n",
165+
image, digest, created, source, policy, h.Sum(nil), n)
166+
if err := os.WriteFile(marker, []byte(record), 0o600); err != nil {
167+
emit("error", "writing marker: "+err.Error())
168+
os.Exit(1)
169+
}
170+
emit("info", fmt.Sprintf("image received (%d bytes)", n))
171+
}
172+
93173
// serveDemoCommand is the detached helper process behind the
94174
// publish-endpoint demonstration: a TCP server on the given address
95175
// answering every connection with a fixed HTTP response, exiting on its own
@@ -227,23 +307,27 @@ func stop(_ *cobra.Command, _ []string) {
227307
}
228308
}
229309

230-
func metadataCommand(upCmd, downCmd, stopCmd *cobra.Command) *cobra.Command {
310+
func metadataCommand(upCmd, downCmd, stopCmd, pullCmd *cobra.Command) *cobra.Command {
231311
return &cobra.Command{
232312
Use: "metadata",
233313
Run: func(cmd *cobra.Command, _ []string) {
234-
metadata(upCmd, downCmd, stopCmd)
314+
metadata(upCmd, downCmd, stopCmd, pullCmd)
235315
},
236316
Args: cobra.NoArgs,
237317
}
238318
}
239319

240-
func metadata(upCmd, downCmd, stopCmd *cobra.Command) {
320+
func metadata(upCmd, downCmd, stopCmd, pullCmd *cobra.Command) {
241321
metadata := ProviderMetadata{}
242322
metadata.Description = "Manage services on AwesomeCloud"
243323
metadata.Up = commandParameters(upCmd)
244324
metadata.Down = commandParameters(downCmd)
245325
stopParams := commandParameters(stopCmd)
246326
metadata.Stop = &stopParams
327+
// like stop, declaring the pull block is what opts the provider into
328+
// image distribution
329+
pullParams := commandParameters(pullCmd)
330+
metadata.Pull = &pullParams
247331
jsonMetadata, err := json.Marshal(metadata)
248332
if err != nil {
249333
panic(err)
@@ -271,6 +355,7 @@ type ProviderMetadata struct {
271355
Up CommandMetadata `json:"up"`
272356
Down CommandMetadata `json:"down"`
273357
Stop *CommandMetadata `json:"stop,omitempty"`
358+
Pull *CommandMetadata `json:"pull,omitempty"`
274359
}
275360

276361
type CommandMetadata struct {

‎docs/extension.md‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ the resource(s) needed to run a service.
3030
If `provider.type` doesn't resolve into any of those, Compose will report an error and interrupt the `up` command.
3131

3232
To be a valid Compose extension, provider command *MUST* accept a `compose` command (which can be hidden)
33-
with subcommands `up` and `down`. It *MAY* additionally implement a `stop` subcommand to support `docker compose stop`.
33+
with subcommands `up` and `down`. It *MAY* additionally implement a `stop` subcommand to support `docker compose stop`,
34+
and a `pull` subcommand to take part in image distribution (see [Image distribution](#image-distribution)).
35+
Optional subcommands are declared through the provider metadata: the presence of the command block is what
36+
opts the provider in.
3437

3538
## Up lifecycle
3639

@@ -111,6 +114,64 @@ sequenceDiagram
111114
Compose-)Shell: service started
112115
```
113116

117+
## Image distribution
118+
119+
A provider-backed service can declare `build` or `image` like any other service; the image then has to reach
120+
the provider's runtime, which may be nowhere near the local daemon. Providers opt into image distribution by
121+
declaring a `pull` block in their `metadata` output — like `stop`, the presence of the block is the
122+
declaration of support. Providers without it keep managing images on their own during `up`.
123+
124+
When the provider declares `pull`, Compose invokes it during the image phase of `up` (after any build) and on
125+
`docker compose pull`:
126+
127+
```console
128+
awesomecloud compose --project-name <NAME> pull --image=<ref> --source=<verdict> --policy=<policy> [--digest=<id> --created=<time>] "database"
129+
```
130+
131+
- `--image`: the image reference as Compose resolved it (the `image` attribute, or `<project>-<service>` for a
132+
build-only service).
133+
- `--digest` / `--created`: the state of the **local daemon cache**, present only when the image exists there.
134+
They describe the cache, they are not instructions: persist them as the bookkeeping keys of what you ingest —
135+
the digest as identity test, `created` as the ordering fallback for a backend that cannot preserve digests.
136+
Beware that reproducible builds can freeze `created`, so a comparable digest always wins over it.
137+
- `--source` is the authority verdict, computed by Compose from the model and the invocation (`pull_policy`,
138+
`--build`, what the current run just built), so providers never re-implement that arbitration:
139+
- `local`: the desired state is the local daemon's image. Compare your bookkeeping with the announced
140+
digest/created; when they differ, request the bytes with `get-image`.
141+
- `registry`: resolve the reference upstream — this includes the common workflow where `build` is only the
142+
recipe CI uses to publish the image that consumers pull. The local facts are an optimization, never an
143+
obligation.
144+
- `--policy`:
145+
- `missing` (the `up` path): a usable version present in your runtime suffices;
146+
- `always` (`docker compose pull`): ensure your runtime holds the latest version of the authority.
147+
148+
### Requesting the image bytes
149+
150+
During `pull`, the provider can ask Compose for the image content with a regular JSON line on `stdout`
151+
(`platform` is optional and narrows a multi-platform image):
152+
153+
```json
154+
{ "type": "get-image", "message": "<image ref>", "platform": "linux/arm64" }
155+
```
156+
157+
Compose answers on the provider's `stdin` with one JSON line:
158+
159+
```json
160+
{ "type": "image-stream", "encoding": "chunked", "media-type": "application/x-tar" }
161+
```
162+
163+
followed — unless the line carries an `error` field instead — by the image tar encoded as HTTP/1.1 chunked
164+
data (RFC 9112 §7.1): every block of data prefixed by its length, terminated by the zero-length chunk. Unlike
165+
an HTTP message there is no trailer section nor final CRLF — the next byte after the zero chunk belongs to the
166+
next stdin answer. Length-prefixed framing needs no in-band delimiter (any byte value can appear inside a tar), and
167+
any language's stock chunked-body reader consumes it — Go providers can use `httputil.NewChunkedReader`. A
168+
stream that ends without the terminating zero chunk was aborted and must be discarded. The tar is what
169+
`docker image save` produces: feed it to `docker load` or whatever your runtime ingests.
170+
171+
The stream is exclusive on `stdin` for its whole duration — the chunked body must be contiguous, so answers to
172+
any other request emitted meanwhile are delivered after it. Drain the announced stream completely before
173+
expecting another answer.
174+
114175
## Connection to a service managed by a provider
115176

116177
A service in the Compose application can declare dependency on a service managed by an external provider:
@@ -269,6 +330,9 @@ The expected JSON output format is:
269330
"type": "string"
270331
}
271332
]
333+
},
334+
"pull": {
335+
"parameters": []
272336
}
273337
}
274338
```
@@ -277,6 +341,9 @@ The top elements are:
277341
- `up`: Object describing the parameters accepted by the `up` command
278342
- `down`: Object describing the parameters accepted by the `down` command
279343
- `stop`: Object describing the parameters accepted by the `stop` command (optional)
344+
- `pull`: Object describing the parameters accepted by the `pull` command (optional — declaring the block is
345+
what opts the provider into [image distribution](#image-distribution); the flags Compose injects need not be
346+
listed)
280347

281348
And for each command parameter, you should include the following properties:
282349
- `name`: The parameter name (without `--` prefix)

‎pkg/e2e/providers_test.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,24 @@ func TestProviderStopHook(t *testing.T) {
5656
FileExists(marker))
5757
}
5858

59+
func TestProviderImagePull(t *testing.T) {
60+
// The example provider's pull subcommand requests the image bytes with a
61+
// get-image message and records what it received (digest facts, sha256,
62+
// size) at PROVIDER_PULL_MARKER.
63+
marker := filepath.Join(t.TempDir(), "example-provider-pull-marker")
64+
s := providerScenario(t, "a provider-backed service with a build must get the built image distributed to the provider")
65+
s.Env("PROVIDER_PULL_MARKER=" + marker)
66+
s.Step("up builds the image and streams it to the provider with the local-authority verdict",
67+
ComposeCmd("up", "-d"),
68+
FileContains(marker, "source=local"),
69+
FileContains(marker, "policy=missing"),
70+
FileContains(marker, "sha256="),
71+
FileContains(marker, "digest=sha256:")).
72+
Step("pull re-invokes the provider under the freshness contract",
73+
ComposeCmd("pull"),
74+
FileContains(marker, "policy=always"))
75+
}
76+
5977
func TestDependsOnMultipleProviders(t *testing.T) {
6078
providerScenario(t, "a service depending on several providers must receive each provider's variables").
6179
Step("the service sees both providers' URLs",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
FROM alpine
2+
RUN echo provider-image-pull-fixture > /fixture.txt
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
services:
2+
db:
3+
build:
4+
context: .
5+
provider:
6+
type: example-provider
7+
options:
8+
type: mysql
9+
name: myDB

0 commit comments

Comments
 (0)