Files
Memoh/internal/containerd/service_apple.go
T
Acbox e4aca0db13 feat(container): add current container metrics view
Expose a dedicated container metrics endpoint and surface current CPU, memory, and root filesystem usage in the bot container view. This gives operators a quick health snapshot while degrading cleanly on unsupported backends.
2026-04-24 15:10:47 +08:00

479 lines
13 KiB
Go

package containerd
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"github.com/memohai/acgo"
"github.com/memohai/acgo/socktainer"
)
// ---------------------------------------------------------------------------
// Service & lifecycle
// ---------------------------------------------------------------------------
type AppleService struct {
client *acgo.Client
manager *socktainer.Manager
managerOpts []socktainer.Option
socketPath string
logger *slog.Logger
mu sync.Mutex
}
type AppleServiceConfig struct {
SocketPath string
BinaryPath string
}
func NewAppleService(ctx context.Context, log *slog.Logger, cfg AppleServiceConfig) (*AppleService, error) {
var managerOpts []socktainer.Option
if cfg.BinaryPath != "" {
managerOpts = append(managerOpts, socktainer.WithBinary(cfg.BinaryPath))
}
if cfg.SocketPath != "" {
managerOpts = append(managerOpts, socktainer.WithSocket(expandHome(cfg.SocketPath)))
}
svc := &AppleService{
managerOpts: managerOpts,
logger: log.With(slog.String("service", "apple-container")),
}
if err := svc.startSocktainer(ctx); err != nil {
return nil, err
}
return svc, nil
}
func (s *AppleService) startSocktainer(ctx context.Context) error {
mgr := socktainer.NewManager(s.managerOpts...)
if err := mgr.Start(ctx); err != nil {
return fmt.Errorf("start socktainer: %w", err)
}
client, err := acgo.New(acgo.WithSocketPath(mgr.SocketPath()))
if err != nil {
_ = mgr.Stop()
return fmt.Errorf("create acgo client: %w", err)
}
s.manager = mgr
s.client = client
s.socketPath = mgr.SocketPath()
return nil
}
func (s *AppleService) ensureHealthy(ctx context.Context) error {
if ok, _ := s.client.IsServing(ctx); ok {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
if ok, _ := s.client.IsServing(ctx); ok {
return nil
}
s.logger.Warn("socktainer not responding, restarting")
_ = s.client.Close()
_ = s.manager.Stop()
_ = os.Remove(s.socketPath)
if err := s.startSocktainer(ctx); err != nil {
s.logger.Error("socktainer restart failed", slog.Any("error", err))
return err
}
s.logger.Info("socktainer restarted successfully")
return nil
}
func (s *AppleService) Close() error {
_ = s.client.Close()
return s.manager.Stop()
}
// ---------------------------------------------------------------------------
// Images
// ---------------------------------------------------------------------------
func (s *AppleService) PullImage(ctx context.Context, ref string, _ *PullImageOptions) (ImageInfo, error) {
if ref == "" {
return ImageInfo{}, ErrInvalidArgument
}
if err := s.ensureHealthy(ctx); err != nil {
return ImageInfo{}, err
}
img, err := s.client.Pull(ctx, ref)
if err != nil {
return ImageInfo{}, err
}
return toAcgoImageInfo(img), nil
}
func (s *AppleService) GetImage(ctx context.Context, ref string) (ImageInfo, error) {
if ref == "" {
return ImageInfo{}, ErrInvalidArgument
}
if err := s.ensureHealthy(ctx); err != nil {
return ImageInfo{}, err
}
img, err := s.client.GetImage(ctx, ref)
if err != nil {
return ImageInfo{}, err
}
return toAcgoImageInfo(img), nil
}
func (s *AppleService) ListImages(ctx context.Context) ([]ImageInfo, error) {
if err := s.ensureHealthy(ctx); err != nil {
return nil, err
}
imgs, err := s.client.ListImages(ctx)
if err != nil {
return nil, err
}
out := make([]ImageInfo, len(imgs))
for i, img := range imgs {
out[i] = toAcgoImageInfo(img)
}
return out, nil
}
func (*AppleService) ResolveRemoteDigest(_ context.Context, _ string) (string, error) {
return "", ErrNotSupported
}
func (s *AppleService) DeleteImage(ctx context.Context, ref string, _ *DeleteImageOptions) error {
if ref == "" {
return ErrInvalidArgument
}
if err := s.ensureHealthy(ctx); err != nil {
return err
}
return s.client.DeleteImage(ctx, ref)
}
// ---------------------------------------------------------------------------
// Containers
// ---------------------------------------------------------------------------
func (s *AppleService) CreateContainer(ctx context.Context, req CreateContainerRequest) (ContainerInfo, error) {
if req.ID == "" || req.ImageRef == "" {
return ContainerInfo{}, ErrInvalidArgument
}
if len(req.Spec.CDIDevices) > 0 {
return ContainerInfo{}, ErrNotSupported
}
if err := s.ensureHealthy(ctx); err != nil {
return ContainerInfo{}, err
}
if _, err := s.client.GetImage(ctx, req.ImageRef); err != nil {
s.logger.Info("image not found locally, pulling", slog.String("image", req.ImageRef))
if _, pullErr := s.client.Pull(ctx, req.ImageRef); pullErr != nil {
return ContainerInfo{}, fmt.Errorf("pull image %s: %w", req.ImageRef, pullErr)
}
}
ctr, err := s.client.NewContainer(ctx, req.ID, specToCreateOpts(req)...)
if err != nil {
return ContainerInfo{}, err
}
return acgoContainerToInfo(ctx, ctr)
}
func (s *AppleService) GetContainer(ctx context.Context, id string) (ContainerInfo, error) {
if id == "" {
return ContainerInfo{}, ErrInvalidArgument
}
if err := s.ensureHealthy(ctx); err != nil {
return ContainerInfo{}, err
}
ctr, err := s.client.LoadContainer(ctx, id)
if err != nil {
return ContainerInfo{}, err
}
return acgoContainerToInfo(ctx, ctr)
}
func (s *AppleService) ListContainers(ctx context.Context) ([]ContainerInfo, error) {
if err := s.ensureHealthy(ctx); err != nil {
return nil, err
}
ctrs, err := s.client.Containers(ctx, acgo.WithListAll())
if err != nil {
return nil, err
}
out := make([]ContainerInfo, 0, len(ctrs))
for _, c := range ctrs {
info, err := acgoContainerToInfo(ctx, c)
if err != nil {
return nil, err
}
out = append(out, info)
}
return out, nil
}
func (s *AppleService) DeleteContainer(ctx context.Context, id string, opts *DeleteContainerOptions) error {
if id == "" {
return ErrInvalidArgument
}
if err := s.ensureHealthy(ctx); err != nil {
return err
}
ctr, err := s.client.LoadContainer(ctx, id)
if err != nil {
return err
}
var deleteOpts []acgo.DeleteOpt
if opts != nil && opts.CleanupSnapshot {
deleteOpts = append(deleteOpts, acgo.WithRemoveVolumes())
}
deleteOpts = append(deleteOpts, acgo.WithForceDelete())
return ctr.Delete(ctx, deleteOpts...)
}
func (s *AppleService) ListContainersByLabel(ctx context.Context, key, value string) ([]ContainerInfo, error) {
if key == "" {
return nil, ErrInvalidArgument
}
if err := s.ensureHealthy(ctx); err != nil {
return nil, err
}
filtersJSON := fmt.Sprintf(`{"label":["%s=%s"]}`, key, value)
ctrs, err := s.client.Containers(ctx, acgo.WithListAll(), acgo.WithListFilters(filtersJSON))
if err != nil {
return nil, err
}
var out []ContainerInfo
for _, c := range ctrs {
info, err := acgoContainerToInfo(ctx, c)
if err != nil {
return nil, err
}
if v, ok := info.Labels[key]; ok && (value == "" || v == value) {
out = append(out, info)
}
}
return out, nil
}
// ---------------------------------------------------------------------------
// Task / process lifecycle
// ---------------------------------------------------------------------------
func (s *AppleService) StartContainer(ctx context.Context, containerID string, _ *StartTaskOptions) error {
if containerID == "" {
return ErrInvalidArgument
}
if err := s.ensureHealthy(ctx); err != nil {
return err
}
ctr, err := s.client.LoadContainer(ctx, containerID)
if err != nil {
return err
}
return ctr.Start(ctx)
}
func (s *AppleService) StopContainer(ctx context.Context, containerID string, opts *StopTaskOptions) error {
if containerID == "" {
return ErrInvalidArgument
}
if err := s.ensureHealthy(ctx); err != nil {
return err
}
ctr, err := s.client.LoadContainer(ctx, containerID)
if err != nil {
return err
}
timeout := 10
if opts != nil && opts.Timeout > 0 {
timeout = int(opts.Timeout.Seconds())
}
var stopOpts []acgo.StopOpt
stopOpts = append(stopOpts, acgo.WithStopTimeout(timeout))
if opts != nil && opts.Signal != 0 {
stopOpts = append(stopOpts, acgo.WithStopSignal(opts.Signal.String()))
}
if err := ctr.Stop(ctx, stopOpts...); err != nil && opts != nil && opts.Force {
return ctr.Kill(ctx)
}
return nil
}
func (*AppleService) DeleteTask(context.Context, string, *DeleteTaskOptions) error {
return nil
}
func (s *AppleService) GetTaskInfo(ctx context.Context, containerID string) (TaskInfo, error) {
if containerID == "" {
return TaskInfo{}, ErrInvalidArgument
}
if err := s.ensureHealthy(ctx); err != nil {
return TaskInfo{}, err
}
ctr, err := s.client.LoadContainer(ctx, containerID)
if err != nil {
return TaskInfo{}, err
}
info, err := ctr.Info(ctx)
if err != nil {
return TaskInfo{}, err
}
return TaskInfo{
ContainerID: containerID,
ID: containerID,
Status: containerStateToTaskStatus(info.State),
}, nil
}
func (*AppleService) GetContainerMetrics(context.Context, string) (ContainerMetrics, error) {
return ContainerMetrics{}, ErrNotSupported
}
func (s *AppleService) ListTasks(ctx context.Context, opts *ListTasksOptions) ([]TaskInfo, error) {
if err := s.ensureHealthy(ctx); err != nil {
return nil, err
}
ctrs, err := s.client.Containers(ctx, acgo.WithListAll())
if err != nil {
return nil, err
}
var out []TaskInfo
for _, c := range ctrs {
info, err := c.Info(ctx)
if err != nil {
continue
}
if opts != nil && opts.Filter != "" {
if strings.Contains(opts.Filter, "container.id==") {
if strings.TrimPrefix(opts.Filter, "container.id==") != info.ID {
continue
}
}
}
out = append(out, TaskInfo{
ContainerID: info.ID,
ID: info.ID,
Status: containerStateToTaskStatus(info.State),
})
}
return out, nil
}
// ---------------------------------------------------------------------------
// Network (no-op — Apple Container handles networking natively)
// ---------------------------------------------------------------------------
func (*AppleService) SetupNetwork(context.Context, NetworkSetupRequest) (NetworkResult, error) {
return NetworkResult{}, nil
}
func (*AppleService) RemoveNetwork(context.Context, NetworkSetupRequest) error { return nil }
// ---------------------------------------------------------------------------
// Snapshots (not supported on Apple Container)
// ---------------------------------------------------------------------------
func (*AppleService) CommitSnapshot(context.Context, string, string, string) error {
return ErrNotSupported
}
func (*AppleService) ListSnapshots(context.Context, string) ([]SnapshotInfo, error) {
return nil, ErrNotSupported
}
func (*AppleService) PrepareSnapshot(context.Context, string, string, string) error {
return ErrNotSupported
}
func (*AppleService) CreateContainerFromSnapshot(context.Context, CreateContainerRequest) (ContainerInfo, error) {
return ContainerInfo{}, ErrNotSupported
}
func (*AppleService) SnapshotMounts(context.Context, string, string) ([]MountInfo, error) {
return nil, ErrNotSupported
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func specToCreateOpts(req CreateContainerRequest) []acgo.CreateOpt {
var opts []acgo.CreateOpt
opts = append(opts, acgo.WithImage(req.ImageRef))
if len(req.Spec.Cmd) > 0 {
opts = append(opts, acgo.WithEntrypoint(req.Spec.Cmd[0]))
if len(req.Spec.Cmd) > 1 {
opts = append(opts, acgo.WithCmd(req.Spec.Cmd[1:]...))
}
}
if req.Spec.WorkDir != "" {
opts = append(opts, acgo.WithWorkdir(req.Spec.WorkDir))
}
if req.Spec.User != "" {
opts = append(opts, acgo.WithUser(req.Spec.User))
}
if req.Spec.TTY {
opts = append(opts, acgo.WithTTY())
}
for _, env := range req.Spec.Env {
if k, v, ok := strings.Cut(env, "="); ok {
opts = append(opts, acgo.WithEnv(k, v))
}
}
for _, m := range req.Spec.Mounts {
opts = append(opts, acgo.WithVolume(m.Source, m.Destination))
}
for _, dns := range req.Spec.DNS {
opts = append(opts, acgo.WithDNS(dns))
}
for k, v := range req.Labels {
opts = append(opts, acgo.WithLabel(k, v))
}
return opts
}
func toAcgoImageInfo(img acgo.Image) ImageInfo {
return ImageInfo{Name: img.Name(), ID: img.ID(), Tags: img.RepoTags()}
}
func acgoContainerToInfo(ctx context.Context, c acgo.Container) (ContainerInfo, error) {
info, err := c.Info(ctx)
if err != nil {
return ContainerInfo{}, err
}
return ContainerInfo{
ID: info.ID,
Image: info.Image,
Labels: info.Labels,
Runtime: RuntimeInfo{Name: "apple-container"},
CreatedAt: info.CreatedAt,
UpdatedAt: info.CreatedAt,
}, nil
}
func containerStateToTaskStatus(state string) TaskStatus {
switch state {
case "running":
return TaskStatusRunning
case "created":
return TaskStatusCreated
case "exited", "dead":
return TaskStatusStopped
case "paused":
return TaskStatusPaused
default:
return TaskStatusUnknown
}
}
func expandHome(path string) string {
if !strings.HasPrefix(path, "~/") {
return path
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, path[2:])
}
return path
}