Files
Memoh/internal/workspace/metrics.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

143 lines
3.0 KiB
Go

package workspace
import (
"context"
"errors"
"io/fs"
"os"
"path/filepath"
"time"
"github.com/containerd/containerd/v2/core/mount"
"github.com/containerd/errdefs"
ctr "github.com/memohai/memoh/internal/containerd"
)
const unsupportedReasonBackend = "backend_not_supported"
func (m *Manager) GetContainerMetrics(ctx context.Context, botID string) (*ContainerMetricsResult, error) {
result := &ContainerMetricsResult{
Supported: true,
Status: ContainerMetricsStatus{
Exists: false,
},
}
containerID, err := m.ContainerID(ctx, botID)
if err != nil {
if errors.Is(err, ErrContainerNotFound) {
return result, nil
}
return nil, err
}
info, err := m.service.GetContainer(ctx, containerID)
if err != nil {
if errdefs.IsNotFound(err) {
return result, nil
}
return nil, err
}
result.Status.Exists = true
taskInfo, err := m.service.GetTaskInfo(ctx, containerID)
if err == nil {
result.Status.TaskRunning = taskInfo.Status == ctr.TaskStatusRunning
} else if !errdefs.IsNotFound(err) {
return nil, err
}
runtimeMetrics, err := m.service.GetContainerMetrics(ctx, containerID)
switch {
case err == nil:
result.CPU = runtimeMetrics.CPU
result.Memory = runtimeMetrics.Memory
result.SampledAt = runtimeMetrics.SampledAt
case errors.Is(err, ctr.ErrNotSupported):
result.Supported = false
result.UnsupportedReason = unsupportedReasonBackend
case errdefs.IsNotFound(err):
// Task is not running, so CPU and memory metrics are unavailable.
default:
return nil, err
}
if result.Supported {
storage, err := m.collectStorageMetrics(ctx, info)
if err != nil {
if errors.Is(err, ctr.ErrNotSupported) {
return result, nil
}
return nil, err
}
result.Storage = storage
if result.SampledAt.IsZero() {
result.SampledAt = time.Now()
}
}
return result, nil
}
func (m *Manager) collectStorageMetrics(ctx context.Context, info ctr.ContainerInfo) (*ContainerStorageMetrics, error) {
mounts, err := m.snapshotMounts(ctx, info)
if err != nil {
if errors.Is(err, errMountNotSupported) {
return nil, ctr.ErrNotSupported
}
return nil, err
}
var usedBytes uint64
if err := mount.WithReadonlyTempMount(ctx, mounts, func(root string) error {
if _, statErr := os.Stat(root); statErr != nil {
if os.IsNotExist(statErr) {
return nil
}
return statErr
}
size, sizeErr := dirSize(root)
if sizeErr != nil {
return sizeErr
}
usedBytes = size
return nil
}); err != nil {
return nil, err
}
return &ContainerStorageMetrics{
Path: "/",
UsedBytes: usedBytes,
}, nil
}
func dirSize(root string) (uint64, error) {
var size uint64
err := filepath.WalkDir(root, func(_ string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
info, err := entry.Info()
if err != nil {
return err
}
fileSize := info.Size()
if fileSize > 0 {
size += uint64(fileSize) //nolint:gosec // file sizes are checked to be positive before conversion
}
return nil
})
if err != nil {
return 0, err
}
return size, nil
}