feat: backend container executor implement
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
// Package containerengine defines the container runtime boundary used by update executors.
|
||||
package containerengine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("container engine object not found")
|
||||
|
||||
// Platform is an explicit OCI operating system and CPU platform.
|
||||
type Platform struct {
|
||||
OS string
|
||||
Architecture string
|
||||
Variant string
|
||||
}
|
||||
|
||||
// Image is the immutable image information returned by the engine.
|
||||
type Image struct {
|
||||
ID string
|
||||
RepoDigests []string
|
||||
DescriptorDigest string
|
||||
Platform Platform
|
||||
}
|
||||
|
||||
// RestartPolicy is passed to the engine without an implicit default.
|
||||
type RestartPolicy struct {
|
||||
Name string
|
||||
MaximumRetryCount int
|
||||
}
|
||||
|
||||
// Mount is one explicit container mount.
|
||||
type Mount struct {
|
||||
Type string
|
||||
Source string
|
||||
Target string
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// ContainerSpec contains every property controlled by the backend executor.
|
||||
type ContainerSpec struct {
|
||||
Name string
|
||||
ImageReference string
|
||||
Platform Platform
|
||||
Environment []string
|
||||
Labels map[string]string
|
||||
NetworkMode string
|
||||
RestartPolicy RestartPolicy
|
||||
Mounts []Mount
|
||||
}
|
||||
|
||||
// Container is the runtime state required for idempotent inspection.
|
||||
type Container struct {
|
||||
ID string
|
||||
Name string
|
||||
ImageID string
|
||||
ImageReference string
|
||||
Platform string
|
||||
Running bool
|
||||
Dead bool
|
||||
Status string
|
||||
Environment []string
|
||||
Labels map[string]string
|
||||
NetworkMode string
|
||||
RestartPolicy RestartPolicy
|
||||
Mounts []Mount
|
||||
}
|
||||
|
||||
// Engine is the smallest container runtime API required by an update executor.
|
||||
type Engine interface {
|
||||
Ping(context.Context) error
|
||||
LoadImage(context.Context, io.Reader) error
|
||||
InspectImage(context.Context, string) (Image, error)
|
||||
CreateContainer(context.Context, ContainerSpec) (Container, error)
|
||||
StartContainer(context.Context, string) error
|
||||
InspectContainer(context.Context, string) (Container, error)
|
||||
RemoveContainer(context.Context, string, bool) error
|
||||
Close() error
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package containerengine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
cerrdefs "github.com/containerd/errdefs"
|
||||
"github.com/moby/moby/api/types/container"
|
||||
"github.com/moby/moby/api/types/jsonstream"
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
"github.com/moby/moby/client"
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
// MobyEngine adapts the official Docker Engine Go client.
|
||||
type MobyEngine struct {
|
||||
client *client.Client
|
||||
}
|
||||
|
||||
// NewMobyEngine creates a client from Docker's documented environment variables.
|
||||
// API negotiation remains enabled, including when DOCKER_HOST selects a non-default socket.
|
||||
func NewMobyEngine() (*MobyEngine, error) {
|
||||
apiClient, err := client.New(client.FromEnv)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create Docker Engine client: %w", err)
|
||||
}
|
||||
return &MobyEngine{client: apiClient}, nil
|
||||
}
|
||||
|
||||
func (e *MobyEngine) Ping(ctx context.Context) error {
|
||||
if _, err := e.client.Ping(ctx, client.PingOptions{NegotiateAPIVersion: true}); err != nil {
|
||||
return fmt.Errorf("ping Docker Engine: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *MobyEngine) LoadImage(ctx context.Context, input io.Reader) error {
|
||||
if input == nil {
|
||||
return errors.New("image archive reader is required")
|
||||
}
|
||||
response, err := e.client.ImageLoad(ctx, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load image archive: %w", err)
|
||||
}
|
||||
defer response.Close()
|
||||
if err := decodeImageLoadResponse(response); err != nil {
|
||||
return fmt.Errorf("load image archive response: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeImageLoadResponse(input io.Reader) error {
|
||||
decoder := json.NewDecoder(input)
|
||||
for {
|
||||
var message jsonstream.Message
|
||||
if err := decoder.Decode(&message); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("decode Docker JSON stream: %w", err)
|
||||
}
|
||||
if message.Error != nil {
|
||||
return message.Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *MobyEngine) InspectImage(ctx context.Context, reference string) (Image, error) {
|
||||
response, err := e.client.ImageInspect(ctx, reference)
|
||||
if err != nil {
|
||||
return Image{}, engineError("inspect image", err)
|
||||
}
|
||||
descriptorDigest := ""
|
||||
if response.Descriptor != nil {
|
||||
descriptorDigest = response.Descriptor.Digest.String()
|
||||
}
|
||||
return Image{
|
||||
ID: response.ID,
|
||||
RepoDigests: append([]string(nil), response.RepoDigests...),
|
||||
DescriptorDigest: descriptorDigest,
|
||||
Platform: Platform{
|
||||
OS: response.Os,
|
||||
Architecture: response.Architecture,
|
||||
Variant: response.Variant,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (e *MobyEngine) CreateContainer(ctx context.Context, spec ContainerSpec) (Container, error) {
|
||||
apiMounts := make([]mount.Mount, 0, len(spec.Mounts))
|
||||
for _, item := range spec.Mounts {
|
||||
apiMounts = append(apiMounts, mount.Mount{
|
||||
Type: mount.Type(item.Type),
|
||||
Source: item.Source,
|
||||
Target: item.Target,
|
||||
ReadOnly: item.ReadOnly,
|
||||
})
|
||||
}
|
||||
result, err := e.client.ContainerCreate(ctx, client.ContainerCreateOptions{
|
||||
Config: &container.Config{
|
||||
Env: append([]string(nil), spec.Environment...),
|
||||
Labels: cloneMap(spec.Labels),
|
||||
},
|
||||
HostConfig: &container.HostConfig{
|
||||
NetworkMode: container.NetworkMode(spec.NetworkMode),
|
||||
RestartPolicy: container.RestartPolicy{
|
||||
Name: container.RestartPolicyMode(spec.RestartPolicy.Name),
|
||||
MaximumRetryCount: spec.RestartPolicy.MaximumRetryCount,
|
||||
},
|
||||
Mounts: apiMounts,
|
||||
},
|
||||
Platform: &ocispec.Platform{
|
||||
OS: spec.Platform.OS,
|
||||
Architecture: spec.Platform.Architecture,
|
||||
Variant: spec.Platform.Variant,
|
||||
},
|
||||
Name: spec.Name,
|
||||
Image: spec.ImageReference,
|
||||
})
|
||||
if err != nil {
|
||||
return Container{}, engineError("create container", err)
|
||||
}
|
||||
return e.InspectContainer(ctx, result.ID)
|
||||
}
|
||||
|
||||
func (e *MobyEngine) StartContainer(ctx context.Context, idOrName string) error {
|
||||
if _, err := e.client.ContainerStart(ctx, idOrName, client.ContainerStartOptions{}); err != nil {
|
||||
return engineError("start container", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *MobyEngine) InspectContainer(ctx context.Context, idOrName string) (Container, error) {
|
||||
result, err := e.client.ContainerInspect(ctx, idOrName, client.ContainerInspectOptions{})
|
||||
if err != nil {
|
||||
return Container{}, engineError("inspect container", err)
|
||||
}
|
||||
response := result.Container
|
||||
record := Container{
|
||||
ID: response.ID,
|
||||
Name: response.Name,
|
||||
ImageID: response.Image,
|
||||
Platform: response.Platform,
|
||||
}
|
||||
if response.State != nil {
|
||||
record.Running = response.State.Running
|
||||
record.Dead = response.State.Dead
|
||||
record.Status = string(response.State.Status)
|
||||
}
|
||||
if response.Config != nil {
|
||||
record.ImageReference = response.Config.Image
|
||||
record.Environment = append([]string(nil), response.Config.Env...)
|
||||
record.Labels = cloneMap(response.Config.Labels)
|
||||
}
|
||||
if response.HostConfig != nil {
|
||||
record.NetworkMode = string(response.HostConfig.NetworkMode)
|
||||
record.RestartPolicy = RestartPolicy{
|
||||
Name: string(response.HostConfig.RestartPolicy.Name),
|
||||
MaximumRetryCount: response.HostConfig.RestartPolicy.MaximumRetryCount,
|
||||
}
|
||||
}
|
||||
for _, item := range response.Mounts {
|
||||
record.Mounts = append(record.Mounts, Mount{
|
||||
Type: string(item.Type),
|
||||
Source: item.Source,
|
||||
Target: item.Destination,
|
||||
ReadOnly: !item.RW,
|
||||
})
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (e *MobyEngine) RemoveContainer(ctx context.Context, idOrName string, force bool) error {
|
||||
_, err := e.client.ContainerRemove(ctx, idOrName, client.ContainerRemoveOptions{Force: force})
|
||||
if err != nil {
|
||||
return engineError("remove container", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *MobyEngine) Close() error {
|
||||
return e.client.Close()
|
||||
}
|
||||
|
||||
func engineError(action string, err error) error {
|
||||
if cerrdefs.IsNotFound(err) {
|
||||
return fmt.Errorf("%s: %w: %v", action, ErrNotFound, err)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", action, err)
|
||||
}
|
||||
|
||||
func cloneMap(source map[string]string) map[string]string {
|
||||
if source == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]string, len(source))
|
||||
for key, value := range source {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
var _ Engine = (*MobyEngine)(nil)
|
||||
@@ -0,0 +1,31 @@
|
||||
package containerengine
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDecodeImageLoadResponseConsumesCompleteSuccessStream(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := strings.NewReader("{\"stream\":\"Loaded image: repository:20260814-093609-d7ed70f0-v1.1.8.1\\n\"}\n" +
|
||||
"{\"stream\":\"Loaded image: repository:20260814-093609-d7ed70f0\\n\"}\n")
|
||||
if err := decodeImageLoadResponse(input); err != nil {
|
||||
t.Fatalf("decode successful load response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeImageLoadResponseReturnsStreamError(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := decodeImageLoadResponse(strings.NewReader(`{"errorDetail":{"code":500,"message":"load failed"}}`))
|
||||
if err == nil || err.Error() != "load failed" {
|
||||
t.Fatalf("unexpected load error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeImageLoadResponseRejectsMalformedJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
err := decodeImageLoadResponse(strings.NewReader(`{"stream":`))
|
||||
if err == nil {
|
||||
t.Fatalf("expected malformed response error, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user