diff --git a/docs/examples/provider.go b/docs/examples/provider.go index 89ef92c4ad..a948c24cdf 100644 --- a/docs/examples/provider.go +++ b/docs/examples/provider.go @@ -18,9 +18,12 @@ package main import ( "bufio" + "crypto/sha256" "encoding/json" "fmt" + "io" "net" + "net/http/httputil" "os" "os/exec" "strings" @@ -85,11 +88,88 @@ func composeCommand() *cobra.Command { Args: cobra.ExactArgs(1), } - c.AddCommand(upCmd, downCmd, stopCmd) - c.AddCommand(metadataCommand(upCmd, downCmd, stopCmd)) + // pull is the image-distribution command: compose invokes it during the + // image phase (up) and on `docker compose pull`, passing the identity of + // the service image and the state of the local daemon cache. None of its + // flags is required: they are injected by compose, not declared by the + // user under provider.options. + pullCmd := &cobra.Command{ + Use: "pull", + Run: pull, + Args: cobra.ExactArgs(1), + } + pullCmd.Flags().String("image", "", "Image reference as compose resolved it") + pullCmd.Flags().String("digest", "", "Image ID in the local daemon cache, when present") + pullCmd.Flags().String("created", "", "Creation time of the local cache entry, when present") + pullCmd.Flags().String("source", "", "Authority verdict: local (sync from the daemon) or registry (resolve upstream)") + pullCmd.Flags().String("policy", "", "missing (a usable version suffices) or always (ensure freshness)") + + c.AddCommand(upCmd, downCmd, stopCmd, pullCmd) + c.AddCommand(metadataCommand(upCmd, downCmd, stopCmd, pullCmd)) return c } +// pull demonstrates the image-distribution contract. A real provider would +// compare the announced digest/created with its bookkeeping and either +// resolve the reference upstream (source=registry) or request the local +// bytes (source=local). This demo requests the stream whenever +// PROVIDER_PULL_MARKER is set, hashes it, and records the outcome there. +func pull(cmd *cobra.Command, args []string) { + image, _ := cmd.Flags().GetString("image") + digest, _ := cmd.Flags().GetString("digest") + created, _ := cmd.Flags().GetString("created") + source, _ := cmd.Flags().GetString("source") + policy, _ := cmd.Flags().GetString("policy") + + emit := func(kind, message string) { + payload, _ := json.Marshal(map[string]string{"type": kind, "message": message}) + fmt.Println(string(payload)) + } + emit("info", fmt.Sprintf("ensuring image %s (source=%s, policy=%s)", image, source, policy)) + + marker := os.Getenv("PROVIDER_PULL_MARKER") + if marker == "" { + // nothing to synchronize in the demo: a real registry-sourced provider + // would pull the reference upstream here + return + } + + // Request the image bytes from the local daemon. The answer is one JSON + // line, then — unless it carries an error — the tar as an HTTP/1.1 + // chunked body (readable with any stock chunked reader), whose zero + // chunk marks a COMPLETE transfer. + request, _ := json.Marshal(map[string]string{"type": "get-image", "message": image}) + fmt.Println(string(request)) + + stdin := bufio.NewReader(os.Stdin) + line, err := stdin.ReadString('\n') + if err != nil { + emit("error", "reading image-stream announce: "+err.Error()) + os.Exit(1) + } + var announce struct { + Encoding string `json:"encoding"` + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(line), &announce); err != nil || announce.Error != "" || announce.Encoding != "chunked" { + emit("error", fmt.Sprintf("image stream unavailable: %q (err %v)", line, err)) + os.Exit(1) + } + h := sha256.New() + n, err := io.Copy(h, httputil.NewChunkedReader(stdin)) + if err != nil { + emit("error", "image stream truncated: "+err.Error()) + os.Exit(1) + } + record := fmt.Sprintf("image=%s digest=%s created=%s source=%s policy=%s sha256=%x bytes=%d\n", + image, digest, created, source, policy, h.Sum(nil), n) + if err := os.WriteFile(marker, []byte(record), 0o600); err != nil { + emit("error", "writing marker: "+err.Error()) + os.Exit(1) + } + emit("info", fmt.Sprintf("image received (%d bytes)", n)) +} + // serveDemoCommand is the detached helper process behind the // publish-endpoint demonstration: a TCP server on the given address // answering every connection with a fixed HTTP response, exiting on its own @@ -227,23 +307,27 @@ func stop(_ *cobra.Command, _ []string) { } } -func metadataCommand(upCmd, downCmd, stopCmd *cobra.Command) *cobra.Command { +func metadataCommand(upCmd, downCmd, stopCmd, pullCmd *cobra.Command) *cobra.Command { return &cobra.Command{ Use: "metadata", Run: func(cmd *cobra.Command, _ []string) { - metadata(upCmd, downCmd, stopCmd) + metadata(upCmd, downCmd, stopCmd, pullCmd) }, Args: cobra.NoArgs, } } -func metadata(upCmd, downCmd, stopCmd *cobra.Command) { +func metadata(upCmd, downCmd, stopCmd, pullCmd *cobra.Command) { metadata := ProviderMetadata{} metadata.Description = "Manage services on AwesomeCloud" metadata.Up = commandParameters(upCmd) metadata.Down = commandParameters(downCmd) stopParams := commandParameters(stopCmd) metadata.Stop = &stopParams + // like stop, declaring the pull block is what opts the provider into + // image distribution + pullParams := commandParameters(pullCmd) + metadata.Pull = &pullParams jsonMetadata, err := json.Marshal(metadata) if err != nil { panic(err) @@ -271,6 +355,7 @@ type ProviderMetadata struct { Up CommandMetadata `json:"up"` Down CommandMetadata `json:"down"` Stop *CommandMetadata `json:"stop,omitempty"` + Pull *CommandMetadata `json:"pull,omitempty"` } type CommandMetadata struct { diff --git a/docs/extension.md b/docs/extension.md index 0a173e4436..afdf56779e 100644 --- a/docs/extension.md +++ b/docs/extension.md @@ -30,7 +30,10 @@ the resource(s) needed to run a service. If `provider.type` doesn't resolve into any of those, Compose will report an error and interrupt the `up` command. To be a valid Compose extension, provider command *MUST* accept a `compose` command (which can be hidden) -with subcommands `up` and `down`. It *MAY* additionally implement a `stop` subcommand to support `docker compose stop`. +with subcommands `up` and `down`. It *MAY* additionally implement a `stop` subcommand to support `docker compose stop`, +and a `pull` subcommand to take part in image distribution (see [Image distribution](#image-distribution)). +Optional subcommands are declared through the provider metadata: the presence of the command block is what +opts the provider in. ## Up lifecycle @@ -111,6 +114,68 @@ sequenceDiagram Compose-)Shell: service started ``` +## Image distribution + +A provider-backed service can declare `build` or `image` like any other service; the image then has to reach +the provider's runtime, which may be nowhere near the local daemon. Providers opt into image distribution by +declaring a `pull` block in their `metadata` output — like `stop`, the presence of the block is the +declaration of support. Providers without it keep managing images on their own during `up`. + +When the provider declares `pull`, Compose invokes it during the image phase of `up` (after any build) and on +`docker compose pull`: + +```console +awesomecloud compose --project-name pull --image= --source= --policy= [--digest= --created=