package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sys/unix"
containerdclient "github.com/openeuler/Conch/internal/adapters/containerd/client"
containerdhost "github.com/openeuler/Conch/internal/adapters/containerd/host"
"github.com/openeuler/Conch/internal/cleanupdiag"
"github.com/openeuler/Conch/internal/conchruntime"
"github.com/openeuler/Conch/internal/config"
conchimage "github.com/openeuler/Conch/internal/image"
"github.com/openeuler/Conch/internal/netstack"
"github.com/openeuler/Conch/internal/runtimeapi"
conchsandbox "github.com/openeuler/Conch/internal/sandbox"
conchsnapshot "github.com/openeuler/Conch/internal/snapshot"
"github.com/openeuler/Conch/internal/util"
"github.com/openeuler/Conch/internal/volume"
"github.com/openeuler/Conch/internal/webhook"
"github.com/openeuler/Conch/pkg/ulog"
)
const (
shutdownTimeout = 30 * time.Second
minimumSandboxRAMMB = 128
maxJSONBodyBytes = 1 << 20
maxTemplateFileBytes = 64 << 20
maxTemplateMultipartBodyBytes = 2*maxTemplateFileBytes + maxJSONBodyBytes
serverReadHeaderTimeout = 10 * time.Second
serverIdleTimeout = 60 * time.Second
serverMaxHeaderBytes = 64 << 10
)
type Daemon struct {
router *http.ServeMux
containerdHost *containerdhost.Host
sandboxStore conchsandbox.Store
runtimeService *conchruntime.Service
webhookDispatcher *webhook.Dispatcher
volumeManager *volume.Manager
daemonClient *containerdclient.Client
httpServer *http.Server
cleanupOnce sync.Once
}
func handleSignals(ctx context.Context, cancel context.CancelFunc, s *Daemon) {
go func() {
var sig os.Signal
var handledSignals = []os.Signal{
unix.SIGTERM,
unix.SIGINT,
}
signal.Ignore(unix.SIGPIPE)
signalChannel := make(chan os.Signal, 1)
signal.Notify(signalChannel, handledSignals...)
for {
select {
case <-ctx.Done():
ulog.Warn("Context done",
ulog.F("error", ctx.Err()),
)
case sig = <-signalChannel:
ulog.Info("Interrupted by signal, process exiting",
ulog.F("signal", sig),
)
cancel()
s.Cleanup()
return
}
}
}()
return
}
func New(cfg *config.Config) (*Daemon, error) {
if err := ensureProcessTempDir(); err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
s := &Daemon{
router: http.NewServeMux(),
}
s.routes()
s.httpServer = newHTTPServer(s.router)
logger := ulog.GetLogger()
var err error
s.volumeManager, err = volume.NewManager(volume.Config{
MaxMounts: cfg.Volume.MaxMounts,
Backend: cfg.Volume.Backend,
Virtiofs: volume.VirtiofsConfig{
Binary: cfg.Volume.Virtiofs.Binary,
RuntimeDir: cfg.VirtiofsRuntimeDir(),
},
})
if err != nil {
cancel()
return nil, fmt.Errorf("init volume manager: %w", err)
}
host, err := containerdhost.Start(ctx, containerdhost.Config{
RootDir: cfg.ContainerdRootDir(),
StateDir: cfg.ContainerdStateDir(),
Snapshot: containerdhost.SnapshotConfig{
WorkDir: cfg.Server.WorkDir,
},
Sandbox: &conchsandbox.Config{
Network: netstack.PoolConfig{
WarmPoolSize: cfg.Network.WarmPoolSize,
RefillThreshold: cfg.Network.RefillThreshold,
CNI: cfg.Network.CNI,
},
VMMBinaries: cfg.Sandbox.BinaryPaths(),
VsockSignalRetry: cfg.Sandbox.VsockSignalRetry,
VsockSignalTimeout: cfg.Sandbox.VsockSignalTimeout,
RequestTimeout: cfg.Sandbox.RequestTimeout,
MemoryOvercommitRatio: cfg.Sandbox.MemoryOvercommitRatio,
MemoryLimitMB: cfg.Sandbox.MemoryLimitMB,
VolumeManager: s.volumeManager,
},
})
if err != nil {
cancel()
logger.Error("Failed to init embedded containerd host", ulog.F("error", err))
return nil, fmt.Errorf("failed to init embedded containerd host: %w", err)
}
s.containerdHost = host
s.sandboxStore = host.SandboxStore()
daemonClient := host.Client()
s.daemonClient = daemonClient
s.runtimeService = conchruntime.New(host.SandboxManager(), host.Client())
s.webhookDispatcher = webhook.NewDispatcher()
host.SandboxManager().WebhookDispatcher = s.webhookDispatcher
s.runtimeService.Snapshot = host.SnapshotServer()
s.runtimeService.Templates = host.TemplateStore()
s.runtimeService.SetSandboxDefaults(runtimeapi.SandboxDefaults{
TemplateName: cfg.Sandbox.DefaultSpec.TemplateName,
TemplateID: cfg.Sandbox.DefaultSpec.TemplateID,
VMMName: cfg.Sandbox.Backend,
VCPUNum: cfg.Sandbox.DefaultSpec.VCPUNum,
VCPUMax: cfg.Sandbox.DefaultSpec.VCPUMax,
RamMB: cfg.Sandbox.DefaultSpec.RamMB,
})
manager := host.SandboxManager()
if manager != nil {
records, err := s.sandboxStore.List(ctx, conchsandbox.Filter{})
if err != nil {
cleanupErr := host.Close()
cancel()
return nil, errors.Join(fmt.Errorf("list stale sandboxes during startup: %w", err), cleanupErr)
}
sandboxIDs := make([]string, 0, len(records))
vmmPIDs := make([]int, 0, len(records))
hasCreatingSandbox := false
for _, record := range records {
sandboxIDs = append(sandboxIDs, record.ID)
if record.State == conchsandbox.StateCreating {
hasCreatingSandbox = true
}
if record.VMMPID > 0 {
vmmPIDs = append(vmmPIDs, record.VMMPID)
}
}
logger.Info("cleaning up resources from abnormal sandbox exits")
if err := manager.RecoverStaleResources(ctx, sandboxIDs, vmmPIDs, hasCreatingSandbox); err != nil {
cleanupErr := host.Close()
cancel()
return nil, errors.Join(fmt.Errorf("recover stale sandbox resources during startup: %w", err), cleanupErr)
}
if err := manager.Start(ctx); err != nil {
cleanupErr := host.Close()
cancel()
return nil, errors.Join(fmt.Errorf("start network pool during startup: %w", err), cleanupErr)
}
}
handleSignals(ctx, cancel, s)
logger.Info("Server initialized successfully")
return s, nil
}
func ensureProcessTempDir() error {
tmpDir := os.TempDir()
if err := os.MkdirAll(tmpDir, 0o700); err != nil {
return fmt.Errorf("create process temp directory %s: %w", tmpDir, err)
}
return nil
}
func (s *Daemon) routes() {
s.router.HandleFunc("POST /api/v1/events/webhooks", s.handleCreateWebhook)
s.router.HandleFunc("GET /api/v1/events/webhooks", s.handleListWebhooks)
s.router.HandleFunc("DELETE /api/v1/events/webhooks/{webhookID}", s.handleDeleteWebhook)
s.router.HandleFunc("GET /api/v1/sandboxes", s.handleListSandbox)
s.router.HandleFunc("POST /api/v1/sandboxes", s.handleCreateSandbox)
s.router.HandleFunc("GET /api/v1/sandboxes/{sandboxID}", s.handleGetSandbox)
s.router.HandleFunc("DELETE /api/v1/sandboxes/{sandboxID}", s.handleDeleteSandbox)
s.router.HandleFunc("PUT /api/v1/sandboxes/{sandboxID}/network", s.handleUpdateSandboxNetwork)
s.router.HandleFunc("/health", s.handleHealth)
s.router.HandleFunc("/api/sandbox/suspend", s.handleSuspendSandbox)
s.router.HandleFunc("/api/sandbox/resume", s.handleResumeSandbox)
s.router.HandleFunc("/api/sandbox/checkpoint", s.handleCheckpointSandbox)
s.router.HandleFunc("/api/template/create", s.handleCreateTemplate)
s.router.HandleFunc("/api/template/pull", s.handlePullTemplate)
s.router.HandleFunc("/api/template/push", s.handlePushTemplate)
s.router.HandleFunc("/api/template/unpack", s.handleUnpackTemplate)
s.router.HandleFunc("/api/template/list", s.handleListTemplate)
s.router.HandleFunc("/api/template/inspect", s.handleInspectTemplate)
s.router.HandleFunc("/api/template/remove", s.handleRemoveTemplate)
s.router.HandleFunc("/api/snapshot/list", s.handleListSnapshot)
s.router.HandleFunc("/api/snapshot/remove", s.handleRemoveSnapshot)
s.router.HandleFunc("/api/snapshot/info", s.handleSnapshotInfo)
s.router.HandleFunc("/api/image/pull", s.handlePullImage)
s.router.HandleFunc("/api/image/push", s.handlePushImage)
s.router.HandleFunc("/api/image/list", s.handleListImage)
s.router.HandleFunc("/api/image/remove", s.handleRemoveImage)
}
func newHTTPServer(h http.Handler) *http.Server {
return &http.Server{
Handler: h,
ReadHeaderTimeout: serverReadHeaderTimeout,
IdleTimeout: serverIdleTimeout,
MaxHeaderBytes: serverMaxHeaderBytes,
}
}
func (s *Daemon) Start(unixSocket string) error {
logger := ulog.GetLogger()
if unixSocket == "" {
return errors.New("unix socket path is required")
}
if err := os.MkdirAll(filepath.Dir(unixSocket), 0o750); err != nil {
return fmt.Errorf("failed to create unix socket directory: %w", err)
}
if err := os.Remove(unixSocket); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to remove stale unix socket: %w", err)
}
ln, err := net.Listen("unix", unixSocket)
if err != nil {
return fmt.Errorf("failed to listen on unix socket %s: %w", unixSocket, err)
}
if err := os.Chmod(unixSocket, 0o660); err != nil {
_ = ln.Close()
return fmt.Errorf("failed to set unix socket permissions: %w", err)
}
logger.Info("Starting HTTP server", ulog.F("network", "unix"), ulog.F("socket", unixSocket))
util.NotifyReady()
err = s.httpServer.Serve(ln)
if err == http.ErrServerClosed {
logger.Info("Main server gracefully stopped")
err = nil
}
return err
}
func (s *Daemon) Cleanup() {
s.Shutdown()
}
func (s *Daemon) Shutdown() {
logger := ulog.GetLogger()
s.cleanupOnce.Do(func() {
finishShutdown := cleanupdiag.Start("daemon.shutdown")
defer finishShutdown(nil)
util.NotifyStopping()
if s.httpServer != nil {
finish := cleanupdiag.Start("daemon.http.shutdown", ulog.F("timeout", shutdownTimeout.String()))
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
err := s.httpServer.Shutdown(shutdownCtx)
shutdownCancel()
finish(err)
if err != nil {
logger.Error("HTTP server shutdown error", ulog.F("error", err))
} else {
logger.Info("HTTP server gracefully stopped")
}
}
if s.runtimeService != nil && s.sandboxStore != nil {
finish := cleanupdiag.Start("daemon.sandboxes.remove_all")
err := s.removeAllSandboxes()
finish(err)
if err != nil {
logger.Error("Sandbox cleanup error", ulog.F("error", err))
}
}
if s.containerdHost != nil {
finish := cleanupdiag.Start("daemon.containerd_host.close")
err := s.containerdHost.Close()
finish(err)
if err != nil {
logger.Error("Containerd cleanup error", ulog.F("error", err))
}
} else if s.daemonClient != nil {
finish := cleanupdiag.Start("daemon.containerd_client.close")
err := s.daemonClient.Close()
finish(err)
if err != nil {
logger.Error("Containerd cleanup error", ulog.F("error", err))
}
}
logger.Info("Cleanup completed")
})
}
func (s *Daemon) removeAllSandboxes() error {
records, err := s.sandboxStore.List(context.Background(), conchsandbox.Filter{})
if err != nil {
return fmt.Errorf("list sandboxes for shutdown: %w", err)
}
var errs []error
for _, record := range records {
if err := s.runtimeService.RemoveSandbox(context.Background(), record.ID); err != nil {
errs = append(errs, fmt.Errorf("remove sandbox %s: %w", record.ID, err))
}
}
return errors.Join(errs...)
}
func (s *Daemon) handleHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeMethodNotAllowed(w)
return
}
if !s.controlPlaneReady() {
writeAPIError(w, errServiceUnavailable.New())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Daemon) controlPlaneReady() bool {
return s != nil &&
s.sandboxStore != nil &&
s.containerdHost != nil &&
s.daemonClient != nil &&
s.runtimeService != nil &&
s.runtimeService.Sandbox != nil
}
func (s *Daemon) handleCreateSandbox(w http.ResponseWriter, r *http.Request) {
logger := ulog.GetLogger()
logger.Debug("Handling create sandbox request")
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
var req sandboxCreateRequest
if !decodeJSONBody(w, r, &req) {
return
}
defer ulog.TraceCost(ulog.TraceStart(), req.SandboxID, "handleCreateSandbox()")
if req.RAMMB != 0 && req.RAMMB < minimumSandboxRAMMB {
message := fmt.Sprintf("ram_mb must be at least %d, got %d", minimumSandboxRAMMB, req.RAMMB)
writeAPIError(w, conchsandbox.ErrInvalidArgument.WrapMessage(errors.New(message), message))
return
}
if s.runtimeService == nil || s.runtimeService.Sandbox == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
result, err := s.runtimeService.CreateSandbox(r.Context(), runtimeapi.SandboxCreateOptions{
SandboxID: req.SandboxID,
TemplateName: req.TemplateName,
TemplateID: req.TemplateID,
VMMName: req.VMMName,
VCPUNum: req.VCPUNum,
VCPUMax: req.VCPUMax,
RamMB: req.RAMMB,
VolumeMounts: req.VolumeMounts,
Env: req.Env,
Network: req.Network,
})
if err != nil {
writeAPIError(w, err, ulog.F("operation", "sandbox.create"), ulog.F("sandbox_id", req.SandboxID))
return
}
logger.Info("Sandbox created successfully",
ulog.F("sandbox_id", result.SandboxID),
ulog.F("peer_ip", result.IP),
)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(sandboxResponseFromCreate(result))
}
func (s *Daemon) handleCreateWebhook(w http.ResponseWriter, r *http.Request) {
logger := ulog.GetLogger()
logger.Debug("Handling create webhook request")
if s.webhookDispatcher == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
var req webhookCreateRequest
if !decodeJSONBody(w, r, &req) {
return
}
hook, err := s.webhookDispatcher.Create(runtimeapi.WebhookCreateOptions{
Name: req.Name, URL: req.URL, Events: req.Events,
})
if err != nil {
writeAPIError(w, err)
return
}
logger.Info("Webhook created successfully", ulog.F("webhook_id", hook.WebhookID), ulog.F("webhook_name", hook.Name))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(webhookResponseFromRecord(hook))
}
func (s *Daemon) handleListWebhooks(w http.ResponseWriter, r *http.Request) {
logger := ulog.GetLogger()
logger.Debug("Handling list webhooks request")
if s.webhookDispatcher == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
w.Header().Set("Content-Type", "application/json")
records := s.webhookDispatcher.List()
hooks := make([]webhookResponse, 0, len(records))
for _, record := range records {
hooks = append(hooks, webhookResponseFromRecord(record))
}
logger.Debug("Webhooks listed successfully", ulog.F("webhook_count", len(hooks)))
_ = json.NewEncoder(w).Encode(listWebhooksResponse{Webhooks: hooks})
}
func webhookResponseFromRecord(record runtimeapi.WebhookRecord) webhookResponse {
return webhookResponse{
WebhookID: record.WebhookID, Name: record.Name, URL: record.URL,
Events: append([]string(nil), record.Events...),
CreatedAt: record.CreatedAt.UTC().Format(time.RFC3339),
}
}
func (s *Daemon) handleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
logger := ulog.GetLogger()
webhookID := strings.TrimSpace(r.PathValue("webhookID"))
logger.Debug("Handling delete webhook request", ulog.F("webhook_id", webhookID))
if s.webhookDispatcher == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
if webhookID == "" || !s.webhookDispatcher.Delete(webhookID) {
writeAPIError(w, webhook.ErrNotFound.New())
return
}
logger.Info("Webhook deleted successfully", ulog.F("webhook_id", webhookID))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(deleteWebhookResponse{WebhookID: webhookID, Status: "deleted"})
}
func (s *Daemon) handleUpdateSandboxNetwork(w http.ResponseWriter, r *http.Request) {
sandboxID := r.PathValue("sandboxID")
if sandboxID == "" {
writeAPIError(w, conchsandbox.ErrInvalidArgument.Wrap(errors.New("sandbox id is required")))
return
}
var req runtimeapi.SandboxNetworkConfig
if !decodeJSONBody(w, r, &req) {
return
}
if s.sandboxStore == nil || s.runtimeService == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
record, err := s.findSandboxRecord(r.Context(), sandboxID)
if err != nil {
writeAPIError(w, err)
return
}
if record == nil {
writeAPIError(w, conchsandbox.ErrNotFound.New())
return
}
if err := s.runtimeService.UpdateSandboxNetworkConfig(r.Context(), runtimeapi.SandboxNetworkUpdateOptions{
SandboxID: record.ID,
Network: &req,
}); err != nil {
writeAPIError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Daemon) handleDeleteSandbox(w http.ResponseWriter, r *http.Request) {
sandboxID := r.PathValue("sandboxID")
logger := ulog.GetLogger()
logger.Debug("Handling delete sandbox request", ulog.F("sandbox_id", sandboxID))
if r.Method != http.MethodDelete {
writeMethodNotAllowed(w)
return
}
if sandboxID == "" {
writeAPIError(w, conchsandbox.ErrInvalidArgument.Wrap(errors.New("sandbox id is required")))
return
}
if s.sandboxStore == nil || s.runtimeService == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
record, err := s.findSandboxRecord(r.Context(), sandboxID)
if err != nil {
writeAPIError(w, err, ulog.F("operation", "sandbox.delete"), ulog.F("sandbox_id", sandboxID))
return
}
if record == nil {
writeAPIError(w, conchsandbox.ErrNotFound.New())
return
}
if err := s.runtimeService.RemoveSandbox(r.Context(), record.ID); err != nil {
writeAPIError(w, err, ulog.F("operation", "sandbox.delete"), ulog.F("sandbox_id", sandboxID))
return
}
logger.Info("Sandbox deleted successfully", ulog.F("sandbox_id", sandboxID))
w.WriteHeader(http.StatusNoContent)
}
func (s *Daemon) handleListSandbox(w http.ResponseWriter, r *http.Request) {
if s.sandboxStore == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
states, err := parseSandboxStates(r.URL.Query()["state"])
if err != nil {
writeAPIError(w, conchsandbox.ErrInvalidArgument.WrapMessage(err, "invalid sandbox state filter"))
return
}
limit, err := parseSandboxListLimit(r.URL.Query().Get("limit"))
if err != nil {
writeAPIError(w, conchsandbox.ErrInvalidArgument.WrapMessage(err, "invalid sandbox list limit"))
return
}
records, err := s.sandboxStore.List(r.Context(), conchsandbox.Filter{})
if err != nil {
writeAPIError(w, err)
return
}
sandboxes := make([]sandboxInspectResponse, 0, len(records))
for _, record := range records {
if matchesSandboxState(record, states) {
sandboxes = append(sandboxes, sandboxResponseFromRecord(record, false))
}
}
if len(sandboxes) > limit {
sandboxes = sandboxes[:limit]
}
writeJSON(w, sandboxes)
}
func (s *Daemon) handleGetSandbox(w http.ResponseWriter, r *http.Request) {
sandboxID := r.PathValue("sandboxID")
if s.sandboxStore == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
record, err := s.findSandboxRecord(r.Context(), sandboxID)
if err != nil {
writeAPIError(w, err)
return
}
if record == nil || record.State == conchsandbox.StateUnknown {
writeAPIError(w, conchsandbox.ErrNotFound.New())
return
}
writeJSON(w, sandboxResponseFromRecord(*record, true))
}
func (s *Daemon) findSandboxRecord(ctx context.Context, sandboxID string) (*conchsandbox.Record, error) {
record, err := s.sandboxStore.Get(ctx, sandboxID)
if errors.Is(err, conchsandbox.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &record, nil
}
func parseSandboxStates(values []string) (map[string]bool, error) {
if len(values) == 0 {
return nil, nil
}
states := make(map[string]bool, len(values))
for _, value := range values {
if value != "running" && value != "paused" {
return nil, fmt.Errorf("unsupported state %q", value)
}
states[value] = true
}
return states, nil
}
func parseSandboxListLimit(raw string) (int, error) {
if raw == "" {
return 100, nil
}
limit, err := strconv.Atoi(raw)
if err != nil || limit < 1 || limit > 5000 {
return 0, fmt.Errorf("limit must be between 1 and 5000")
}
return limit, nil
}
func matchesSandboxState(record conchsandbox.Record, states map[string]bool) bool {
running := record.State == conchsandbox.StateReady
paused := record.State == conchsandbox.StateSuspended
if len(states) == 0 {
return running || paused
}
return states["running"] && running || states["paused"] && paused
}
func sandboxResponseFromRecord(record conchsandbox.Record, detailed bool) sandboxInspectResponse {
response := sandboxInspectResponse{
TemplateName: record.SourceTemplateName,
TemplateID: record.SourceTemplateID,
ImageName: "",
SnapshotID: "",
SandboxID: record.ID,
StartedAt: formatUnixNanoRFC3339(record.CreatedAt),
CPUCount: record.VCPUNum,
MemoryMB: record.RamMB,
Alias: "",
Metadata: map[string]string{},
VolumeMounts: []sandboxVolumeMountResponse{},
}
if response.Metadata == nil {
response.Metadata = map[string]string{}
}
if detailed {
domain := record.IP
response.Domain = &domain
response.Lifecycle = &sandboxLifecycleResponse{}
response.Network = record.Network
}
return response
}
func sandboxResponseFromCreate(result runtimeapi.SandboxCreateResult) createSandboxResponse {
return createSandboxResponse{
TemplateName: result.TemplateName,
TemplateID: result.TemplateID,
SandboxID: result.SandboxID,
ConchInitAccessToken: result.AgentToken,
Domain: result.IP,
}
}
func formatUnixNanoRFC3339(timestamp int64) string {
if timestamp <= 0 {
return ""
}
return time.Unix(0, timestamp).UTC().Format(time.RFC3339)
}
func (s *Daemon) handleSuspendSandbox(w http.ResponseWriter, r *http.Request) {
logger := ulog.GetLogger()
logger.Debug("Handling suspend sandbox request")
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
var req sandboxLifecycleRequest
if !decodeJSONBody(w, r, &req) {
return
}
if strings.TrimSpace(req.SandboxID) == "" {
writeAPIError(w, conchsandbox.ErrInvalidArgument.Wrap(errors.New("sandbox_id is required")))
return
}
if s.sandboxStore == nil || s.runtimeService == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
record, err := s.findSandboxRecord(r.Context(), req.SandboxID)
if err != nil {
writeAPIError(w, err)
return
}
if record == nil {
writeAPIError(w, conchsandbox.ErrNotFound.New())
return
}
err = s.runtimeService.SuspendSandbox(r.Context(), record.ID)
if err != nil {
writeAPIError(w, err, ulog.F("operation", "sandbox.suspend"), ulog.F("sandbox_id", req.SandboxID))
return
}
logger.Info("Sandbox suspended successfully", ulog.F("sandbox_id", req.SandboxID))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Daemon) handleResumeSandbox(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
var req sandboxLifecycleRequest
if !decodeJSONBody(w, r, &req) {
return
}
if strings.TrimSpace(req.SandboxID) == "" {
writeAPIError(w, conchsandbox.ErrInvalidArgument.Wrap(errors.New("sandbox_id is required")))
return
}
if s.sandboxStore == nil || s.runtimeService == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
record, err := s.findSandboxRecord(r.Context(), req.SandboxID)
if err != nil {
writeAPIError(w, err)
return
}
if record == nil {
writeAPIError(w, conchsandbox.ErrNotFound.New())
return
}
if err := s.runtimeService.ResumeSandbox(r.Context(), record.ID); err != nil {
writeAPIError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Daemon) handleCheckpointSandbox(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
var req sandboxCheckpointRequest
if !decodeJSONBody(w, r, &req) {
return
}
if strings.TrimSpace(req.SandboxID) == "" {
writeAPIError(w, conchsandbox.ErrInvalidArgument.Wrap(errors.New("sandbox_id is required")))
return
}
if s.runtimeService == nil || s.runtimeService.Sandbox == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
result, err := s.runtimeService.CheckpointSandbox(r.Context(), runtimeapi.SandboxCheckpointOptions{
SandboxID: req.SandboxID,
TemplateName: req.TemplateName,
Labels: req.Labels,
})
if err != nil {
writeAPIError(w, err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
"template_id": result.TemplateID,
})
}
func (s *Daemon) handleCreateTemplate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxTemplateMultipartBodyBytes)
if err := r.ParseMultipartForm(32 << 20); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeAPIError(w, errRequestBodyTooLarge.Wrap(err))
return
}
writeAPIError(w, errRequestInvalidMultipart.Wrap(err))
return
}
defer r.MultipartForm.RemoveAll()
var req templateCreateRequest
if err := decodeStrictJSON(strings.NewReader(r.FormValue("metadata")), &req); err != nil {
writeAPIError(w, errRequestInvalidMultipart.WrapMessage(err, "invalid multipart metadata: "+err.Error()))
return
}
tmpDir, err := os.MkdirTemp("", "conch-template-api-*")
if err != nil {
writeAPIError(w, err)
return
}
defer os.RemoveAll(tmpDir)
kernelPath, err := saveMultipartFile(r, "kernel", tmpDir, "kernel")
if err != nil {
writeAPIError(w, err)
return
}
initrdPath, err := saveMultipartFile(r, "initrd", tmpDir, "initrd")
if err != nil {
writeAPIError(w, err)
return
}
if s.runtimeService == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
result, err := s.createTemplate(r.Context(), req, kernelPath, initrdPath)
if err != nil {
writeAPIError(w, err)
return
}
writeJSON(w, map[string]string{
"status": "ok",
"template_name": result.Name,
"template_id": result.TemplateID,
})
}
func (s *Daemon) createTemplate(ctx context.Context, req templateCreateRequest, kernelPath, initrdPath string) (runtimeapi.TemplateCreateResult, error) {
return s.runtimeService.CreateTemplate(ctx, runtimeapi.TemplateCreateOptions{
Name: req.Name,
Source: req.Source,
KernelPath: kernelPath,
InitrdPath: initrdPath,
PlainHTTP: req.PlainHTTP,
Username: req.Username,
Password: req.Password,
Labels: req.Labels,
})
}
func (s *Daemon) handlePullTemplate(w http.ResponseWriter, r *http.Request) {
var req templatePullRequest
if !decodePostJSON(w, r, &req) {
return
}
if s.runtimeService == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
result, err := s.runtimeService.PullTemplate(r.Context(), runtimeapi.TemplatePullOptions{
Reference: req.Reference,
PlainHTTP: req.PlainHTTP,
Username: req.Username,
Password: req.Password,
Labels: req.Labels,
})
if err != nil {
writeAPIError(w, err)
return
}
writeJSON(w, map[string]string{
"status": "ok",
"template_name": result.Name,
"template_id": result.TemplateID,
})
}
func (s *Daemon) handlePushTemplate(w http.ResponseWriter, r *http.Request) {
var req templatePushRequest
if !decodePostJSON(w, r, &req) {
return
}
if s.runtimeService == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
if err := s.runtimeService.PushTemplate(r.Context(), runtimeapi.TemplatePushOptions{
Name: req.Name,
RemoteReference: req.RemoteReference,
PlainHTTP: req.PlainHTTP,
Username: req.Username,
Password: req.Password,
}); err != nil {
writeAPIError(w, err)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Daemon) handleUnpackTemplate(w http.ResponseWriter, r *http.Request) {
var req templateUnpackRequest
if !decodePostJSON(w, r, &req) {
return
}
if s.runtimeService == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
if err := s.runtimeService.UnpackTemplate(r.Context(), runtimeapi.TemplateUnpackOptions{
Name: req.Name,
}); err != nil {
writeAPIError(w, err)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Daemon) handleListTemplate(w http.ResponseWriter, r *http.Request) {
var req templateListRequest
if !decodePostJSON(w, r, &req) {
return
}
if s.runtimeService == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
items, err := s.runtimeService.ListTemplates(r.Context(), runtimeapi.TemplateListOptions{
Origin: req.Origin,
BootMode: req.BootMode,
})
if err != nil {
writeAPIError(w, err)
return
}
writeJSON(w, templateListResponse{Items: items})
}
func (s *Daemon) handleInspectTemplate(w http.ResponseWriter, r *http.Request) {
var req templateNameRequest
if !decodePostJSON(w, r, &req) {
return
}
if s.runtimeService == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
item, err := s.runtimeService.GetTemplate(r.Context(), req.Name)
if err != nil {
writeAPIError(w, err)
return
}
writeJSON(w, item)
}
func (s *Daemon) handleRemoveTemplate(w http.ResponseWriter, r *http.Request) {
var req templateNameRequest
if !decodePostJSON(w, r, &req) {
return
}
if s.runtimeService == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
if err := s.runtimeService.RemoveTemplate(r.Context(), req.Name); err != nil {
writeAPIError(w, err)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Daemon) handlePullImage(w http.ResponseWriter, r *http.Request) {
logger := ulog.GetLogger()
logger.Debug("Handling pull image request")
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
if s.daemonClient == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
var req pullImageRequest
if !decodeJSONBody(w, r, &req) {
return
}
opts := runtimeapi.PullImageOptions{
ImageName: req.ImageName,
PlainHTTP: req.PlainHTTP,
Username: req.Username,
Password: req.Password,
}
if err := conchimage.Pull(r.Context(), s.daemonClient, opts); err != nil {
writeAPIError(w, err, ulog.F("operation", "image.pull"), ulog.F("image_name", opts.ImageName))
return
}
logger.Info("Image pulled successfully", ulog.F("image_name", opts.ImageName))
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Daemon) handlePushImage(w http.ResponseWriter, r *http.Request) {
logger := ulog.GetLogger()
logger.Debug("Handling push image request")
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
if s.daemonClient == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
var req pushImageRequest
if !decodeJSONBody(w, r, &req) {
return
}
opts := runtimeapi.PushImageOptions{
LocalImage: req.LocalImage,
RemoteImage: req.RemoteImage,
PlainHTTP: req.PlainHTTP,
Username: req.Username,
Password: req.Password,
}
if err := conchimage.Push(r.Context(), s.daemonClient, opts); err != nil {
writeAPIError(w, err,
ulog.F("operation", "image.push"),
ulog.F("local_image", opts.LocalImage),
ulog.F("remote_image", opts.RemoteImage),
)
return
}
logger.Info("Image pushed successfully",
ulog.F("local_image", opts.LocalImage),
ulog.F("remote_image", opts.RemoteImage),
)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func (s *Daemon) handleListImage(w http.ResponseWriter, r *http.Request) {
logger := ulog.GetLogger()
logger.Debug("Handling list image request")
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
if s.daemonClient == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
var req listImageRequest
if !decodeJSONBody(w, r, &req) {
return
}
images, err := conchimage.List(r.Context(), s.daemonClient, runtimeapi.ListImagesOptions{
Filters: req.Filters,
})
if err != nil {
writeAPIError(w, err, ulog.F("operation", "image.list"))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(listImageResponse{Images: images})
}
func (s *Daemon) handleRemoveImage(w http.ResponseWriter, r *http.Request) {
logger := ulog.GetLogger()
logger.Debug("Handling remove image request")
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
if s.daemonClient == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
var req removeImageRequest
if !decodeJSONBody(w, r, &req) {
return
}
opts := runtimeapi.RemoveImageOptions{
ImageName: req.ImageName,
Synchronous: req.Synchronous,
}
if err := conchimage.Remove(r.Context(), s.daemonClient, opts); err != nil {
writeAPIError(w, err, ulog.F("operation", "image.remove"), ulog.F("image_name", opts.ImageName))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func saveMultipartFile(r *http.Request, field, dir, name string) (string, error) {
file, _, err := r.FormFile(field)
if err != nil {
return "", errRequestInvalidMultipart.WrapMessage(err, "invalid multipart field: "+field)
}
defer file.Close()
path := filepath.Join(dir, name)
if err := writeMultipartFile(path, file); err != nil {
return "", fmt.Errorf("save multipart field %s: %w", field, err)
}
return path, nil
}
func writeMultipartFile(path string, file multipart.File) error {
return writeLimitedFile(path, file, maxTemplateFileBytes)
}
func writeLimitedFile(path string, reader io.Reader, maxBytes int64) (retErr error) {
out, err := os.Create(path)
if err != nil {
return err
}
defer func() {
if err := out.Close(); retErr == nil && err != nil {
retErr = err
}
if retErr != nil {
_ = os.Remove(path)
}
}()
written, err := io.Copy(out, io.LimitReader(reader, maxBytes+1))
if err != nil {
return err
}
if written > maxBytes {
return errRequestBodyTooLarge.WrapMessage(
fmt.Errorf("multipart file exceeds maximum size %d bytes", maxBytes),
fmt.Sprintf("multipart file exceeds maximum size %d bytes", maxBytes),
)
}
return nil
}
func decodePostJSON(w http.ResponseWriter, r *http.Request, out any) bool {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return false
}
return decodeJSONBody(w, r, out)
}
func decodeJSONBody(w http.ResponseWriter, r *http.Request, out any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodyBytes)
if err := decodeStrictJSON(r.Body, out); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
writeAPIError(w, errRequestBodyTooLarge.Wrap(err))
return false
}
writeAPIError(w, errRequestInvalidBody.WrapMessage(err, "invalid request body: "+err.Error()))
return false
}
return true
}
func decodeStrictJSON(reader io.Reader, out any) error {
decoder := json.NewDecoder(reader)
decoder.DisallowUnknownFields()
if err := decoder.Decode(out); err != nil {
return err
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("request body must contain a single JSON value")
}
return fmt.Errorf("invalid trailing data: %w", err)
}
return nil
}
func writeJSON(w http.ResponseWriter, value any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(value)
}
func (s *Daemon) handleSnapshotInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
var req snapshotInfoRequest
if !decodeJSONBody(w, r, &req) {
return
}
if req.Key == "" {
writeAPIError(w, conchsnapshot.ErrInvalidArgument.WrapMessage(errors.New("key is required"), "key is required"))
return
}
if s.runtimeService == nil || s.runtimeService.Snapshot == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
info, err := s.runtimeService.SnapshotInfo(r.Context(), runtimeapi.SnapshotInfoOptions{
Key: req.Key,
})
if err != nil {
writeAPIError(w, err, ulog.F("operation", "snapshot.info"))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(info)
}
func (s *Daemon) handleListSnapshot(w http.ResponseWriter, r *http.Request) {
logger := ulog.GetLogger()
logger.Debug("Handling list snapshot request")
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
if s.runtimeService == nil || s.runtimeService.Snapshot == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
var req listSnapshotRequest
if !decodeJSONBody(w, r, &req) {
return
}
snapshots, err := s.runtimeService.ListSnapshots(r.Context(), runtimeapi.ListSnapshotsOptions{
Filters: req.Filters,
})
if err != nil {
writeAPIError(w, err, ulog.F("operation", "snapshot.list"))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(listSnapshotResponse{Snapshots: snapshots})
}
func (s *Daemon) handleRemoveSnapshot(w http.ResponseWriter, r *http.Request) {
logger := ulog.GetLogger()
logger.Debug("Handling remove snapshot request")
if r.Method != http.MethodPost {
writeMethodNotAllowed(w)
return
}
if s.runtimeService == nil || s.runtimeService.Snapshot == nil {
writeAPIError(w, errServiceUnavailable.New())
return
}
var req removeSnapshotRequest
if !decodeJSONBody(w, r, &req) {
return
}
if req.Key == "" {
writeAPIError(w, conchsnapshot.ErrInvalidArgument.WrapMessage(errors.New("key is required"), "key is required"))
return
}
opts := runtimeapi.RemoveSnapshotOptions{
Key: req.Key,
}
if err := s.runtimeService.RemoveSnapshot(r.Context(), opts); err != nil {
writeAPIError(w, err, ulog.F("operation", "snapshot.remove"), ulog.F("key", opts.Key))
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(removeSnapshotResponse{Status: "ok"})
}