feat(images): gRPC client and server for ListImages

This commit is contained in:
Pasha Sviderski
2025-10-03 14:23:18 +10:00
parent 458d282357
commit cdff036935
7 changed files with 355 additions and 161 deletions
+20 -20
View File
@@ -25,16 +25,18 @@ import (
)
// Client is a gRPC client for the Docker service that provides a similar interface to the Docker HTTP client.
// TODO: it doesn't seem there is much value in having this intermediate Docker client.
// Consider merging it into the main pkg/client.
type Client struct {
conn *grpc.ClientConn
grpcClient pb.DockerClient
GRPCClient pb.DockerClient
}
// NewClient creates a new Docker gRPC client with the provided gRPC connection.
func NewClient(conn *grpc.ClientConn) *Client {
return &Client{
conn: conn,
grpcClient: pb.NewDockerClient(conn),
GRPCClient: pb.NewDockerClient(conn),
}
}
@@ -71,7 +73,7 @@ func (c *Client) CreateContainer(
return resp, fmt.Errorf("marshal platform: %w", err)
}
grpcResp, err := c.grpcClient.CreateContainer(ctx, &pb.CreateContainerRequest{
grpcResp, err := c.GRPCClient.CreateContainer(ctx, &pb.CreateContainerRequest{
Config: configBytes,
HostConfig: hostConfigBytes,
NetworkConfig: networkingConfigBytes,
@@ -95,7 +97,7 @@ func (c *Client) CreateContainer(
func (c *Client) InspectContainer(ctx context.Context, id string) (types.ContainerJSON, error) {
var resp types.ContainerJSON
grpcResp, err := c.grpcClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
grpcResp, err := c.GRPCClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
if err != nil {
if status.Convert(err).Code() == codes.NotFound {
return resp, errdefs.NotFound(err)
@@ -116,7 +118,7 @@ func (c *Client) StartContainer(ctx context.Context, id string, opts container.S
return fmt.Errorf("marshal options: %w", err)
}
_, err = c.grpcClient.StartContainer(ctx, &pb.StartContainerRequest{
_, err = c.GRPCClient.StartContainer(ctx, &pb.StartContainerRequest{
Id: id,
Options: optsBytes,
})
@@ -135,7 +137,7 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St
return fmt.Errorf("marshal options: %w", err)
}
_, err = c.grpcClient.StopContainer(ctx, &pb.StopContainerRequest{
_, err = c.GRPCClient.StopContainer(ctx, &pb.StopContainerRequest{
Id: id,
Options: optsBytes,
})
@@ -158,7 +160,7 @@ func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions)
return nil, fmt.Errorf("marshal options: %w", err)
}
resp, err := c.grpcClient.ListContainers(ctx, &pb.ListContainersRequest{Options: optsBytes})
resp, err := c.GRPCClient.ListContainers(ctx, &pb.ListContainersRequest{Options: optsBytes})
if err != nil {
return nil, err
}
@@ -185,7 +187,7 @@ func (c *Client) RemoveContainer(ctx context.Context, id string, opts container.
return fmt.Errorf("marshal options: %w", err)
}
_, err = c.grpcClient.RemoveContainer(ctx, &pb.RemoveContainerRequest{
_, err = c.GRPCClient.RemoveContainer(ctx, &pb.RemoveContainerRequest{
Id: id,
Options: optsBytes,
})
@@ -212,7 +214,7 @@ func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions)
return nil, fmt.Errorf("marshal options: %w", err)
}
stream, err := c.grpcClient.PullImage(ctx, &pb.PullImageRequest{Image: image, Options: optsBytes})
stream, err := c.GRPCClient.PullImage(ctx, &pb.PullImageRequest{Image: image, Options: optsBytes})
if err != nil {
return nil, err
}
@@ -246,7 +248,7 @@ func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions)
// InspectImage returns the image information for the given image ID. The request may be sent to multiple machines.
func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
resp, err := c.grpcClient.InspectImage(ctx, &pb.InspectImageRequest{Id: id})
resp, err := c.GRPCClient.InspectImage(ctx, &pb.InspectImageRequest{Id: id})
if err != nil {
// If the request was sent to only one machine, err is an actual error from the machine.
if status.Convert(err).Code() == codes.NotFound {
@@ -284,7 +286,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
// credentials if necessary. If the response from a machine doesn't contain an error, the api.RemoteImage will either
// contain an IndexManifest or an ImageManifest.
func (c *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
resp, err := c.grpcClient.InspectRemoteImage(ctx, &pb.InspectRemoteImageRequest{Id: id})
resp, err := c.GRPCClient.InspectRemoteImage(ctx, &pb.InspectRemoteImageRequest{Id: id})
if err != nil {
return nil, err
}
@@ -361,7 +363,7 @@ func (c *Client) CreateVolume(ctx context.Context, opts volume.CreateOptions) (v
return vol, fmt.Errorf("marshal options: %w", err)
}
resp, err := c.grpcClient.CreateVolume(ctx, &pb.CreateVolumeRequest{Options: optsBytes})
resp, err := c.GRPCClient.CreateVolume(ctx, &pb.CreateVolumeRequest{Options: optsBytes})
if err != nil {
return vol, err
}
@@ -373,20 +375,18 @@ func (c *Client) CreateVolume(ctx context.Context, opts volume.CreateOptions) (v
return vol, nil
}
// MachineVolumes represents a volume list response from a machine.
type MachineVolumes struct {
Metadata *pb.Metadata
Response volume.ListResponse
}
// ListVolumes returns a list of all volumes matching the filter.
func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]MachineVolumes, error) {
optsBytes, err := json.Marshal(opts)
if err != nil {
return nil, fmt.Errorf("marshal options: %w", err)
}
resp, err := c.grpcClient.ListVolumes(ctx, &pb.ListVolumesRequest{Options: optsBytes})
resp, err := c.GRPCClient.ListVolumes(ctx, &pb.ListVolumesRequest{Options: optsBytes})
if err != nil {
return nil, err
}
@@ -408,7 +408,7 @@ func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]Ma
// RemoveVolume removes a volume with the given ID.
func (c *Client) RemoveVolume(ctx context.Context, id string, force bool) error {
_, err := c.grpcClient.RemoveVolume(ctx, &pb.RemoveVolumeRequest{
_, err := c.GRPCClient.RemoveVolume(ctx, &pb.RemoveVolumeRequest{
Id: id,
Force: force,
})
@@ -431,7 +431,7 @@ func (c *Client) CreateServiceContainer(
if err != nil {
return resp, fmt.Errorf("marshal service spec: %w", err)
}
grpcResp, err := c.grpcClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
grpcResp, err := c.GRPCClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
ServiceId: serviceID,
ServiceSpec: specBytes,
ContainerName: containerName,
@@ -454,7 +454,7 @@ func (c *Client) CreateServiceContainer(
func (c *Client) InspectServiceContainer(ctx context.Context, id string) (api.ServiceContainer, error) {
var resp api.ServiceContainer
grpcResp, err := c.grpcClient.InspectServiceContainer(ctx, &pb.InspectContainerRequest{Id: id})
grpcResp, err := c.GRPCClient.InspectServiceContainer(ctx, &pb.InspectContainerRequest{Id: id})
if err != nil {
if status.Convert(err).Code() == codes.NotFound {
return resp, errdefs.NotFound(err)
@@ -487,7 +487,7 @@ func (c *Client) ListServiceContainers(
return nil, fmt.Errorf("marshal options: %w", err)
}
resp, err := c.grpcClient.ListServiceContainers(ctx, &pb.ListServiceContainersRequest{
resp, err := c.GRPCClient.ListServiceContainers(ctx, &pb.ListServiceContainersRequest{
ServiceId: serviceNameOrID,
Options: optsBytes,
})
@@ -526,7 +526,7 @@ func (c *Client) RemoveServiceContainer(ctx context.Context, id string, opts con
return fmt.Errorf("marshal options: %w", err)
}
_, err = c.grpcClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
_, err = c.GRPCClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
Id: id,
Options: optsBytes,
})
+49
View File
@@ -36,10 +36,12 @@ import (
"github.com/jmoiron/sqlx"
"github.com/opencontainers/go-digest"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/psviderski/uncloud/internal/containerd"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/internal/machine/dns"
"github.com/psviderski/uncloud/internal/secret"
"github.com/psviderski/uncloud/pkg/api"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@@ -447,6 +449,53 @@ func (s *Server) ListVolumes(ctx context.Context, req *pb.ListVolumesRequest) (*
}, nil
}
// ListImages lists images present in the Docker and containerd image stores.
func (s *Server) ListImages(ctx context.Context, req *pb.ListImagesRequest) (*pb.ListImagesResponse, error) {
var opts image.ListOptions
if len(req.Options) > 0 {
if err := json.Unmarshal(req.Options, &opts); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
}
// Handle filters separately because they implement custom JSON unmarshalling.
var raw map[string]json.RawMessage
if err := json.Unmarshal(req.Options, &raw); err != nil {
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options to raw map: %v", err)
}
if filtersBytes, ok := raw["Filters"]; ok {
args, err := filters.FromJSON(string(filtersBytes))
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
}
opts.Filters = args
}
}
images, err := s.service.ListImages(ctx, opts)
if err != nil {
return nil, status.Errorf(codes.Internal, err.Error())
}
machineImages := pb.MachineImages{
DockerContainerdStore: images.DockerContainerdStore,
}
if len(images.DockerImages) > 0 {
if machineImages.DockerImages, err = json.Marshal(images.DockerImages); err != nil {
return nil, status.Errorf(codes.Internal, "marshal Docker images: %v", err)
}
}
if len(images.ContainerdImages) > 0 {
if machineImages.ContainerdImages, err = json.Marshal(images.ContainerdImages); err != nil {
return nil, status.Errorf(codes.Internal, "marshal containerd images: %v", err)
}
}
return &pb.ListImagesResponse{
Messages: []*pb.MachineImages{&machineImages},
}, nil
}
// RemoveVolume removes a volume with the given ID.
func (s *Server) RemoveVolume(ctx context.Context, req *pb.RemoveVolumeRequest) (*emptypb.Empty, error) {
if err := s.client.VolumeRemove(ctx, req.Id, req.Force); err != nil {
+62
View File
@@ -7,13 +7,18 @@ import (
"errors"
"fmt"
"log/slog"
"strings"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
"github.com/jmoiron/sqlx"
"github.com/psviderski/uncloud/internal/containerd"
"github.com/psviderski/uncloud/internal/machine/api/pb"
"github.com/psviderski/uncloud/pkg/api"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Service provides higher-level Docker operations that extends Docker API with Uncloud-specific data
@@ -107,3 +112,60 @@ func (s *Service) ListServiceContainers(
return containers, nil
}
// IsContainerdImageStoreEnabled checks if Docker is configured to use the containerd image store:
// https://docs.docker.com/engine/storage/containerd/
func (s *Service) IsContainerdImageStoreEnabled(ctx context.Context) (bool, error) {
info, err := s.Client.Info(ctx)
if err != nil {
return false, fmt.Errorf("get Docker info: %w", err)
}
return strings.Contains(fmt.Sprintf("%s", info.DriverStatus), "containerd.snapshotter"), nil
}
type Images struct {
// DockerImages lists images from the Docker internal image store.
// It may be empty if Docker uses the containerd image store.
DockerImages []image.Summary
// ContainerdImages lists images from the containerd image store.
ContainerdImages []image.Summary
// DockerContainerdStore indicates whether Docker is using the containerd image store.
DockerContainerdStore bool
}
// ListImages lists images present in the Docker and containerd image stores.
// If Docker uses the containerd image store, only images from containerd are listed, as the internal Docker image
// store is not accessible in this case. Otherwise, images from both stores are listed.
func (s *Service) ListImages(ctx context.Context, opts image.ListOptions) (Images, error) {
var images Images
// Always include the image manifests in the response.
opts.Manifests = true
// List images from Docker.
dockerImages, err := s.Client.ImageList(ctx, opts)
if err != nil {
return images, status.Errorf(codes.Internal, "list Docker images: %v", err)
}
isContainerdStore, err := s.IsContainerdImageStoreEnabled(ctx)
if err != nil {
return images, status.Errorf(codes.Internal, "check if Docker uses containerd image store: %v", err)
}
if isContainerdStore {
// Docker uses the containerd image store, hence the images listed from Docker are just references to images
// in containerd. The internal Docker image store is not accessible in this case.
images = Images{
ContainerdImages: dockerImages,
DockerContainerdStore: true,
}
} else {
images = Images{
DockerImages: dockerImages,
// TODO: List images from containerd directly. ContainerdImages: containerdImages,
}
}
return images, nil
}