From 27bbbe71866f87022849cd56fe12120951b1cdb8 Mon Sep 17 00:00:00 2001 From: Pasha Sviderski Date: Thu, 25 Sep 2025 16:18:46 +1000 Subject: [PATCH] chore: internal docker package with handy PullImage and CreateContainerWithImagePull methods --- internal/docker/client.go | 4 ++ internal/docker/container.go | 44 +++++++++++++++ internal/docker/image.go | 100 +++++++++++++++++++++++++++++++++++ pkg/client/container.go | 39 ++------------ 4 files changed, 152 insertions(+), 35 deletions(-) create mode 100644 internal/docker/container.go create mode 100644 internal/docker/image.go diff --git a/internal/docker/client.go b/internal/docker/client.go index b37f2962..892594ec 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -11,6 +11,10 @@ import ( "github.com/docker/docker/client" ) +type Client struct { + *client.Client +} + // WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests. func WaitDaemonReady(ctx context.Context, cli *client.Client) error { // Retry to ping the Docker daemon until it's ready or the context is canceled. diff --git a/internal/docker/container.go b/internal/docker/container.go new file mode 100644 index 00000000..962d2486 --- /dev/null +++ b/internal/docker/container.go @@ -0,0 +1,44 @@ +package docker + +import ( + "context" + "fmt" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" +) + +// CreateContainerWithImagePull creates a Docker container. If the image is missing, it pulls the image first. +func (cli *Client) CreateContainerWithImagePull( + ctx context.Context, name string, config *container.Config, hostConfig *container.HostConfig, +) (container.CreateResponse, error) { + var resp container.CreateResponse + + _, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name) + if err == nil { + return resp, nil + } + + if !client.IsErrNotFound(err) { + return resp, fmt.Errorf("create container: %w", err) + } + + pullCh, err := cli.PullImage(ctx, config.Image, image.PullOptions{}) + if err != nil { + return resp, fmt.Errorf("pull image: %w", err) + } + + for msg := range pullCh { + if msg.Err != nil { + return resp, fmt.Errorf("pull image: %w", msg.Err) + } + } + + // Create container again after image pull. + if resp, err = cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name); err != nil { + return resp, fmt.Errorf("create container: %w", err) + } + + return resp, nil +} diff --git a/internal/docker/image.go b/internal/docker/image.go new file mode 100644 index 00000000..b85ad8c9 --- /dev/null +++ b/internal/docker/image.go @@ -0,0 +1,100 @@ +package docker + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + + dockercommand "github.com/docker/cli/cli/command" + dockerconfig "github.com/docker/cli/cli/config" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/registry" + "github.com/docker/docker/pkg/jsonmessage" +) + +type PullImageMessage struct { + Message jsonmessage.JSONMessage + Err error +} + +// PullImage pulls a Docker image and returns a channel to receive progress messages. +func (cli *Client) PullImage( + ctx context.Context, image string, opts image.PullOptions, +) (<-chan PullImageMessage, error) { + if opts.RegistryAuth == "" { + // Try to retrieve the authentication token for the image from the default local Docker config file. + if encodedAuth, err := RetrieveLocalDockerRegistryAuth(image); err == nil { + opts.RegistryAuth = encodedAuth + } + } + + respBody, err := cli.ImagePull(ctx, image, opts) + if err != nil { + return nil, err + } + defer respBody.Close() + + decoder := json.NewDecoder(respBody) + ch := make(chan PullImageMessage) + + go func() { + defer close(ch) + var jm jsonmessage.JSONMessage + + for { + if err = decoder.Decode(&jm); err != nil { + if errors.Is(err, io.EOF) { + break + } + ch <- PullImageMessage{Err: fmt.Errorf("decode image pull message: %w", err)} + break + } + + msg := PullImageMessage{Message: jm} + if jm.Error != nil { + msg.Err = errors.New(jm.Error.Message) + } + + select { + case err = <-ctx.Done(): + ch <- PullImageMessage{Err: ctx.Err()} + return + default: + ch <- msg + } + } + }() + + return ch, nil +} + +// RetrieveLocalDockerRegistryAuth retrieves the authentication token for the specified image from the local Docker +// config file. It returns the encoded authentication token if it contains any credentials, or an empty string if +// no credentials are found. +func RetrieveLocalDockerRegistryAuth(image string) (string, error) { + // Try to retrieve the authentication token for the image from the default local Docker config file. + dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr) + encodedAuth, err := dockercommand.RetrieveAuthTokenFromImage(dockerConfig, image) + if err != nil { + return "", err + } + // The encodedAuth can be a base64-encoded "{}" (empty JSON object) or include a server address but no credentials. + // Return encodedAuth only if it contains any credentials. + auth, err := registry.DecodeAuthConfig(encodedAuth) + if err != nil { + return "", fmt.Errorf("decode auth config: %w", err) + } + + if auth.Username == "" && + auth.Password == "" && + auth.Auth == "" && + auth.IdentityToken == "" && + auth.RegistryToken == "" { + return "", nil + } + + return encodedAuth, nil +} diff --git a/pkg/client/container.go b/pkg/client/container.go index c122bc2e..cebc2148 100644 --- a/pkg/client/container.go +++ b/pkg/client/container.go @@ -4,17 +4,14 @@ import ( "context" "errors" "fmt" - "os" "strings" - dockercommand "github.com/docker/cli/cli/command" - dockerconfig "github.com/docker/cli/cli/config" "github.com/docker/compose/v2/pkg/progress" "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/registry" dockerclient "github.com/docker/docker/client" "github.com/docker/docker/pkg/jsonmessage" - "github.com/psviderski/uncloud/internal/machine/docker" + "github.com/psviderski/uncloud/internal/docker" + machinedocker "github.com/psviderski/uncloud/internal/machine/docker" "github.com/psviderski/uncloud/internal/secret" "github.com/psviderski/uncloud/pkg/api" "google.golang.org/grpc/status" @@ -94,9 +91,9 @@ func (cli *Client) pullImageWithProgress(ctx context.Context, image, machineName StatusText: "Pulling", }) - opts := docker.PullOptions{} + opts := machinedocker.PullOptions{} // Try to retrieve the authentication token for the image from the default local Docker config file. - if encodedAuth, err := retrieveRegistryAuthFromDocker(image); err == nil && encodedAuth != "" { + if encodedAuth, err := docker.RetrieveLocalDockerRegistryAuth(image); err == nil { // If RegistryAuth is empty, Uncloud daemon will try to retrieve the credentials from its own Docker config. opts.RegistryAuth = encodedAuth } @@ -155,34 +152,6 @@ func (cli *Client) pullImageWithProgress(ctx context.Context, image, machineName return nil } -// retrieveRegistryAuthFromDocker retrieves the authentication token for the specified image from the local Docker -// config file. It returns the encoded authentication token if it contains any credentials, or an empty string if -// no credentials are found. -func retrieveRegistryAuthFromDocker(image string) (string, error) { - // Try to retrieve the authentication token for the image from the default local Docker config file. - dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr) - encodedAuth, err := dockercommand.RetrieveAuthTokenFromImage(dockerConfig, image) - if err != nil { - return "", err - } - // The encodedAuth can be a base64-encoded "{}" (empty JSON object) or include a server address but no credentials. - // Return encodedAuth only if it contains any credentials. - auth, err := registry.DecodeAuthConfig(encodedAuth) - if err != nil { - return "", fmt.Errorf("decode auth config: %w", err) - } - - if auth.Username == "" && - auth.Password == "" && - auth.Auth == "" && - auth.IdentityToken == "" && - auth.RegistryToken == "" { - return "", nil - } - - return encodedAuth, nil -} - // toPullProgressEvent converts a JSON progress message from the Docker API to a progress event. // It's based on toPullProgressEvent from Docker Compose. func toPullProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {