feat: backend container executor implement

This commit is contained in:
2026-08-15 20:58:01 +08:00
parent b4e18c6f0c
commit fbce91d797
10 changed files with 1662 additions and 1 deletions
+80
View File
@@ -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
}
+206
View File
@@ -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)
+31
View File
@@ -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)
}
}