diff --git a/cmd/api/api/instances_test.go b/cmd/api/api/instances_test.go index 96d25a38..3c2be35e 100644 --- a/cmd/api/api/instances_test.go +++ b/cmd/api/api/instances_test.go @@ -40,6 +40,56 @@ func TestGetInstance_NotFound(t *testing.T) { require.Error(t, err) } +func TestCreateInstance_AutoPullImage(t *testing.T) { + t.Parallel() + if _, err := os.Stat("/dev/kvm"); os.IsNotExist(err) { + t.Skip("/dev/kvm not available, skipping on this platform") + } + + svc := newTestService(t) + + // NOTE: intentionally NOT calling createAndWaitForImage here. + // The auto-pull logic in CreateInstance should handle pulling the image. + // The auto-pull has a 5s timeout — alpine:latest is small enough to + // complete within that window. + + // Ensure system files (kernel and initramfs) are available + t.Log("Ensuring system files (kernel and initramfs)...") + systemMgr := system.NewManager(paths.New(svc.Config.DataDir)) + err := systemMgr.EnsureSystemFiles(ctx()) + require.NoError(t, err) + + t.Log("Creating instance without pre-pulling image (testing auto-pull)...") + networkEnabled := false + resp, err := svc.CreateInstance(ctx(), oapi.CreateInstanceRequestObject{ + Body: &oapi.CreateInstanceRequest{ + Name: "test-auto-pull", + Image: "docker.io/library/alpine:latest", + Network: &struct { + BandwidthDownload *string `json:"bandwidth_download,omitempty"` + BandwidthUpload *string `json:"bandwidth_upload,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + }{ + Enabled: &networkEnabled, + }, + }, + }) + require.NoError(t, err) + + created, ok := resp.(oapi.CreateInstance201JSONResponse) + require.True(t, ok, "expected 201 response — auto-pull should have fetched the image") + t.Logf("Instance created via auto-pull: %s", created.Id) + + // Cleanup: delete the instance + instanceID := created.Id + t.Log("Deleting instance...") + deleteResp, err := svc.DeleteInstance(ctxWithInstance(svc, instanceID), oapi.DeleteInstanceRequestObject{Id: instanceID}) + require.NoError(t, err) + _, ok = deleteResp.(oapi.DeleteInstance204Response) + require.True(t, ok, "expected 204 response for delete") + t.Log("Instance deleted successfully") +} + func TestCreateInstance_ParsesHumanReadableSizes(t *testing.T) { t.Parallel() // Require KVM access for VM creation diff --git a/lib/instances/create.go b/lib/instances/create.go index 1cc93876..a5a2aa21 100644 --- a/lib/instances/create.go +++ b/lib/instances/create.go @@ -89,15 +89,37 @@ func (m *manager) createInstance( return nil, err } - // 2. Validate image exists and is ready + // 2. Validate image exists and is ready; auto-pull if not found log.DebugContext(ctx, "validating image", "image", req.Image) imageInfo, err := m.imageManager.GetImage(ctx, req.Image) if err != nil { - log.ErrorContext(ctx, "failed to get image", "image", req.Image, "error", err) if err == images.ErrNotFound { - return nil, fmt.Errorf("image %s: %w", req.Image, err) + // Auto-pull: image not found locally, kick off the pull in the + // background and wait up to 5 seconds for it to complete. + log.InfoContext(ctx, "image not found locally, auto-pulling", "image", req.Image) + _, pullErr := m.imageManager.CreateImage(ctx, images.CreateImageRequest{Name: req.Image}) + if pullErr != nil { + log.ErrorContext(ctx, "failed to auto-pull image", "image", req.Image, "error", pullErr) + return nil, fmt.Errorf("auto-pull image %s: %w", req.Image, pullErr) + } + // Wait with a short timeout — if the pull doesn't finish in time + // we return an error but let it continue in the background. + pullCtx, pullCancel := context.WithTimeout(ctx, 5*time.Second) + defer pullCancel() + if waitErr := m.imageManager.WaitForReady(pullCtx, req.Image); waitErr != nil { + log.InfoContext(ctx, "image pull not ready within timeout, pull continues in background", "image", req.Image, "error", waitErr) + return nil, fmt.Errorf("%w: image %s is being pulled, please try again shortly", ErrImageNotReady, req.Image) + } + // Re-fetch after successful pull + imageInfo, err = m.imageManager.GetImage(ctx, req.Image) + if err != nil { + log.ErrorContext(ctx, "failed to get image after auto-pull", "image", req.Image, "error", err) + return nil, fmt.Errorf("get image after auto-pull: %w", err) + } + } else { + log.ErrorContext(ctx, "failed to get image", "image", req.Image, "error", err) + return nil, fmt.Errorf("get image: %w", err) } - return nil, fmt.Errorf("get image: %w", err) } if imageInfo.Status != images.StatusReady {