From 6dd37a95c47afa57c0c25f92ce64e5a2530b6a2c Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 11 Apr 2026 08:32:18 +0000 Subject: [PATCH] test: add e2e tests for backend upgrade API - Test GET /api/backends/upgrades returns 200 (even with no upgrade checker) - Test POST /api/backends/upgrade/:name accepts request and returns job ID - Test full upgrade flow: trigger upgrade via API, wait for job completion, verify run.sh updated to v2 and metadata.json has version 2.0.0 - Test POST /api/backends/upgrades/check returns 200 - Fix nil check for applicationInstance in upgrade API routes --- core/http/routes/ui_api.go | 4 +- core/http/routes/ui_api_backends_test.go | 156 +++++++++++++++++++++++ 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/core/http/routes/ui_api.go b/core/http/routes/ui_api.go index 16dad72b7..a34d22401 100644 --- a/core/http/routes/ui_api.go +++ b/core/http/routes/ui_api.go @@ -1197,14 +1197,14 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model // Backend upgrade APIs app.GET("/api/backends/upgrades", func(c echo.Context) error { - if applicationInstance.UpgradeChecker() == nil { + if applicationInstance == nil || applicationInstance.UpgradeChecker() == nil { return c.JSON(200, map[string]any{}) } return c.JSON(200, applicationInstance.UpgradeChecker().GetAvailableUpgrades()) }, adminMiddleware) app.POST("/api/backends/upgrades/check", func(c echo.Context) error { - if applicationInstance.UpgradeChecker() == nil { + if applicationInstance == nil || applicationInstance.UpgradeChecker() == nil { return c.JSON(200, map[string]any{}) } applicationInstance.UpgradeChecker().TriggerCheck() diff --git a/core/http/routes/ui_api_backends_test.go b/core/http/routes/ui_api_backends_test.go index 4e4e5014d..4c46d387d 100644 --- a/core/http/routes/ui_api_backends_test.go +++ b/core/http/routes/ui_api_backends_test.go @@ -20,6 +20,7 @@ import ( "github.com/mudler/LocalAI/pkg/system" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" ) func TestRoutes(t *testing.T) { @@ -176,6 +177,161 @@ var _ = Describe("Backend API Routes", func() { Expect(response["processed"]).To(Equal(false)) }) }) + + Describe("Backend upgrade API", func() { + var ( + galleryFile string + upgradeApp *echo.Echo + upgradeGallerySvc *galleryop.GalleryService + ) + + BeforeEach(func() { + // Place gallery file inside backends dir so it passes trusted root checks + galleryFile = filepath.Join(systemState.Backend.BackendsPath, "test-gallery.yaml") + + // Create a fake "v1" backend on disk (simulates a previously installed backend) + backendDir := filepath.Join(systemState.Backend.BackendsPath, "test-upgrade-backend") + err := os.MkdirAll(backendDir, 0750) + Expect(err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(backendDir, "run.sh"), []byte("#!/bin/sh\necho v1"), 0755) + Expect(err).NotTo(HaveOccurred()) + + // Write metadata.json for the installed backend (v1) + metadata := map[string]string{ + "name": "test-upgrade-backend", + "version": "1.0.0", + "installed_at": "2024-01-01T00:00:00Z", + } + metadataBytes, err := json.MarshalIndent(metadata, "", " ") + Expect(err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(backendDir, "metadata.json"), metadataBytes, 0644) + Expect(err).NotTo(HaveOccurred()) + + // Create a "v2" source directory (the upgrade target) + // Must be inside backends path to pass trusted root checks + v2SrcDir := filepath.Join(systemState.Backend.BackendsPath, "v2-backend-src") + err = os.MkdirAll(v2SrcDir, 0750) + Expect(err).NotTo(HaveOccurred()) + err = os.WriteFile(filepath.Join(v2SrcDir, "run.sh"), []byte("#!/bin/sh\necho v2"), 0755) + Expect(err).NotTo(HaveOccurred()) + + // Write gallery YAML pointing to v2 + galleryData := []map[string]any{ + { + "name": "test-upgrade-backend", + "uri": v2SrcDir, + "version": "2.0.0", + }, + } + yamlBytes, err := yaml.Marshal(galleryData) + Expect(err).NotTo(HaveOccurred()) + err = os.WriteFile(galleryFile, yamlBytes, 0644) + Expect(err).NotTo(HaveOccurred()) + + // Configure the gallery in appConfig BEFORE creating the gallery service + // so the backend manager captures the correct galleries + appConfig.BackendGalleries = []config.Gallery{ + {Name: "test", URL: "file://" + galleryFile}, + } + + // Create a fresh gallery service with the upgrade gallery configured + upgradeGallerySvc = galleryop.NewGalleryService(appConfig, modelLoader) + err = upgradeGallerySvc.Start(context.Background(), configLoader, systemState) + Expect(err).NotTo(HaveOccurred()) + + // Register routes with the upgrade-aware gallery service + upgradeApp = echo.New() + opcache := galleryop.NewOpCache(upgradeGallerySvc) + noopMw := func(next echo.HandlerFunc) echo.HandlerFunc { return next } + routes.RegisterUIAPIRoutes(upgradeApp, configLoader, modelLoader, appConfig, upgradeGallerySvc, opcache, nil, noopMw) + }) + + Describe("GET /api/backends/upgrades", func() { + It("should return available upgrades", func() { + req := httptest.NewRequest(http.MethodGet, "/api/backends/upgrades", nil) + rec := httptest.NewRecorder() + + upgradeApp.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + + var response map[string]any + err := json.Unmarshal(rec.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + // Response is empty (upgrade checker not running in test), + // but the endpoint should not error + }) + }) + + Describe("POST /api/backends/upgrade/:name", func() { + It("should accept upgrade request and return job ID", func() { + req := httptest.NewRequest(http.MethodPost, "/api/backends/upgrade/test-upgrade-backend", nil) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + upgradeApp.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + + var response map[string]any + err := json.Unmarshal(rec.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(response["uuid"]).NotTo(BeEmpty()) + Expect(response["statusUrl"]).NotTo(BeEmpty()) + }) + + It("should upgrade the backend and update metadata", func() { + req := httptest.NewRequest(http.MethodPost, "/api/backends/upgrade/test-upgrade-backend", nil) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + upgradeApp.ServeHTTP(rec, req) + Expect(rec.Code).To(Equal(http.StatusOK)) + + var response map[string]any + err := json.Unmarshal(rec.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + jobID := response["uuid"].(string) + + // Wait for the upgrade job to complete + Eventually(func() bool { + jobReq := httptest.NewRequest(http.MethodGet, "/api/backends/job/"+jobID, nil) + jobRec := httptest.NewRecorder() + upgradeApp.ServeHTTP(jobRec, jobReq) + + var jobResp map[string]any + json.Unmarshal(jobRec.Body.Bytes(), &jobResp) + + processed, _ := jobResp["processed"].(bool) + return processed + }, "10s", "200ms").Should(BeTrue()) + + // Verify the backend was upgraded: run.sh should now contain "v2" + runContent, err := os.ReadFile(filepath.Join( + systemState.Backend.BackendsPath, "test-upgrade-backend", "run.sh")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(runContent)).To(ContainSubstring("v2")) + + // Verify metadata was updated with new version + metadataContent, err := os.ReadFile(filepath.Join( + systemState.Backend.BackendsPath, "test-upgrade-backend", "metadata.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(metadataContent)).To(ContainSubstring(`"version": "2.0.0"`)) + }) + }) + + Describe("POST /api/backends/upgrades/check", func() { + It("should trigger an upgrade check and return 200", func() { + req := httptest.NewRequest(http.MethodPost, "/api/backends/upgrades/check", nil) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + upgradeApp.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + }) + }) }) // Helper function to make POST request