mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
e4aca0db13
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.
479 lines
13 KiB
Go
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
|
|
}
|