diff --git a/README.md b/README.md index 91877d7..0c5ad1a 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A simple self-hosted PaaS that auto-deploys Docker containers from Git repositor - Environment variables, labels, and volume mounts per app - Docker builds via socket access - Notifications via ntfy and Slack-compatible webhooks +- Backup/restore of app configurations (JSON export/import via UI and API) - Simple server-rendered UI with Tailwind CSS ## Non-Goals diff --git a/internal/handlers/api_test.go b/internal/handlers/api_test.go index 8efd7be..6d2f350 100644 --- a/internal/handlers/api_test.go +++ b/internal/handlers/api_test.go @@ -27,6 +27,11 @@ func apiRouter(tc *testContext) http.Handler { apiR.Get("/apps", tc.handlers.HandleAPIListApps()) apiR.Get("/apps/{id}", tc.handlers.HandleAPIGetApp()) apiR.Get("/apps/{id}/deployments", tc.handlers.HandleAPIListDeployments()) + + // Backup/Restore API + apiR.Get("/apps/{id}/export", tc.handlers.HandleAPIExportApp()) + apiR.Get("/backup/export", tc.handlers.HandleAPIExportAllApps()) + apiR.Post("/backup/import", tc.handlers.HandleAPIImportApps()) }) }) diff --git a/internal/handlers/backup.go b/internal/handlers/backup.go new file mode 100644 index 0000000..4d70a49 --- /dev/null +++ b/internal/handlers/backup.go @@ -0,0 +1,282 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + + "sneak.berlin/go/upaas/internal/models" + "sneak.berlin/go/upaas/internal/service/app" + "sneak.berlin/go/upaas/templates" +) + +// importMaxBodyBytes is the maximum allowed request body size for backup import (10 MB). +const importMaxBodyBytes = 10 << 20 + +// HandleExportApp exports a single app's configuration as a JSON download. +func (h *Handlers) HandleExportApp() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + + application, findErr := models.FindApp(request.Context(), h.db, appID) + if findErr != nil || application == nil { + http.NotFound(writer, request) + + return + } + + bundle, exportErr := h.appService.ExportApp(request.Context(), application) + if exportErr != nil { + h.log.Error("failed to export app", "error", exportErr, "app", application.Name) + http.Error(writer, "Internal Server Error", http.StatusInternalServerError) + + return + } + + filename := fmt.Sprintf("upaas-backup-%s-%s.json", + application.Name, + time.Now().UTC().Format("20060102-150405"), + ) + + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set("Content-Disposition", + `attachment; filename="`+filename+`"`) + + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + + err := encoder.Encode(bundle) + if err != nil { + h.log.Error("failed to encode backup", "error", err) + } + } +} + +// HandleExportAllApps exports all app configurations as a JSON download. +func (h *Handlers) HandleExportAllApps() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + bundle, exportErr := h.appService.ExportAllApps(request.Context()) + if exportErr != nil { + h.log.Error("failed to export all apps", "error", exportErr) + http.Error(writer, "Internal Server Error", http.StatusInternalServerError) + + return + } + + filename := fmt.Sprintf("upaas-backup-all-%s.json", + time.Now().UTC().Format("20060102-150405"), + ) + + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set("Content-Disposition", + `attachment; filename="`+filename+`"`) + + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + + err := encoder.Encode(bundle) + if err != nil { + h.log.Error("failed to encode backup", "error", err) + } + } +} + +// HandleImportPage renders the import/restore page. +func (h *Handlers) HandleImportPage() http.HandlerFunc { + tmpl := templates.GetParsed() + + return func(writer http.ResponseWriter, request *http.Request) { + data := h.addGlobals(map[string]any{ + "Success": request.URL.Query().Get("success"), + }, request) + + h.renderTemplate(writer, tmpl, "backup_import.html", data) + } +} + +// HandleImportApps processes an uploaded backup JSON file and imports apps. +func (h *Handlers) HandleImportApps() http.HandlerFunc { + tmpl := templates.GetParsed() + + return func(writer http.ResponseWriter, request *http.Request) { + bundle, parseErr := h.parseBackupUpload(request) + if parseErr != "" { + data := h.addGlobals(map[string]any{"Error": parseErr}, request) + h.renderTemplate(writer, tmpl, "backup_import.html", data) + + return + } + + imported, skipped, importErr := h.appService.ImportApps( + request.Context(), bundle, + ) + if importErr != nil { + h.log.Error("failed to import apps", "error", importErr) + + data := h.addGlobals(map[string]any{ + "Error": "Import failed: " + importErr.Error(), + }, request) + h.renderTemplate(writer, tmpl, "backup_import.html", data) + + return + } + + successMsg := fmt.Sprintf("Imported %d app(s)", len(imported)) + if len(skipped) > 0 { + successMsg += fmt.Sprintf(", skipped %d (name conflict)", len(skipped)) + } + + http.Redirect(writer, request, + "/backup/import?success="+successMsg, + http.StatusSeeOther, + ) + } +} + +// parseBackupUpload extracts and validates a BackupBundle from a multipart upload. +// Returns the bundle and an empty string on success, or nil and an error message. +func (h *Handlers) parseBackupUpload( + request *http.Request, +) (*app.BackupBundle, string) { + request.Body = http.MaxBytesReader(nil, request.Body, importMaxBodyBytes) + + parseErr := request.ParseMultipartForm(importMaxBodyBytes) + if parseErr != nil { + return nil, "Failed to parse upload: " + parseErr.Error() + } + + file, _, openErr := request.FormFile("backup_file") + if openErr != nil { + return nil, "Please select a backup file to import" + } + + defer func() { _ = file.Close() }() + + var bundle app.BackupBundle + + decodeErr := json.NewDecoder(file).Decode(&bundle) + if decodeErr != nil { + return nil, "Invalid backup file: " + decodeErr.Error() + } + + if bundle.Version != 1 { + return nil, fmt.Sprintf( + "Unsupported backup version: %d (expected 1)", bundle.Version, + ) + } + + if len(bundle.Apps) == 0 { + return nil, "Backup file contains no apps" + } + + return &bundle, "" +} + +// HandleAPIExportApp exports a single app's configuration as JSON via API. +func (h *Handlers) HandleAPIExportApp() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + appID := chi.URLParam(request, "id") + + application, err := h.appService.GetApp(request.Context(), appID) + if err != nil { + h.respondJSON(writer, request, + map[string]string{"error": "internal server error"}, + http.StatusInternalServerError) + + return + } + + if application == nil { + h.respondJSON(writer, request, + map[string]string{"error": "app not found"}, + http.StatusNotFound) + + return + } + + bundle, exportErr := h.appService.ExportApp(request.Context(), application) + if exportErr != nil { + h.log.Error("failed to export app", "error", exportErr) + h.respondJSON(writer, request, + map[string]string{"error": "failed to export app"}, + http.StatusInternalServerError) + + return + } + + h.respondJSON(writer, request, bundle, http.StatusOK) + } +} + +// HandleAPIExportAllApps exports all app configurations as JSON via API. +func (h *Handlers) HandleAPIExportAllApps() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + bundle, exportErr := h.appService.ExportAllApps(request.Context()) + if exportErr != nil { + h.log.Error("failed to export all apps", "error", exportErr) + h.respondJSON(writer, request, + map[string]string{"error": "failed to export apps"}, + http.StatusInternalServerError) + + return + } + + h.respondJSON(writer, request, bundle, http.StatusOK) + } +} + +// HandleAPIImportApps imports app configurations from a JSON request body via API. +func (h *Handlers) HandleAPIImportApps() http.HandlerFunc { + return func(writer http.ResponseWriter, request *http.Request) { + request.Body = http.MaxBytesReader(writer, request.Body, importMaxBodyBytes) + + var bundle app.BackupBundle + + decodeErr := json.NewDecoder(request.Body).Decode(&bundle) + if decodeErr != nil { + h.respondJSON(writer, request, + map[string]string{"error": "invalid request body"}, + http.StatusBadRequest) + + return + } + + if bundle.Version != 1 { + h.respondJSON(writer, request, + map[string]string{"error": fmt.Sprintf( + "unsupported backup version: %d", bundle.Version, + )}, + http.StatusBadRequest) + + return + } + + if len(bundle.Apps) == 0 { + h.respondJSON(writer, request, + map[string]string{"error": "backup contains no apps"}, + http.StatusBadRequest) + + return + } + + imported, skipped, importErr := h.appService.ImportApps( + request.Context(), &bundle, + ) + if importErr != nil { + h.log.Error("api: failed to import apps", "error", importErr) + h.respondJSON(writer, request, + map[string]string{"error": "import failed: " + importErr.Error()}, + http.StatusInternalServerError) + + return + } + + h.respondJSON(writer, request, map[string]any{ + "imported": imported, + "skipped": skipped, + }, http.StatusOK) + } +} diff --git a/internal/handlers/backup_test.go b/internal/handlers/backup_test.go new file mode 100644 index 0000000..d60abaf --- /dev/null +++ b/internal/handlers/backup_test.go @@ -0,0 +1,582 @@ +package handlers_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/upaas/internal/models" + "sneak.berlin/go/upaas/internal/service/app" +) + +// createTestAppWithConfig creates an app with env vars, labels, volumes, and ports. +func createTestAppWithConfig( + t *testing.T, + tc *testContext, + name string, +) *models.App { + t.Helper() + + createdApp := createTestApp(t, tc, name) + + // Add env vars + ev := models.NewEnvVar(tc.database) + ev.AppID = createdApp.ID + ev.Key = "DATABASE_URL" + ev.Value = "postgres://localhost/mydb" + require.NoError(t, ev.Save(context.Background())) + + // Add label + label := models.NewLabel(tc.database) + label.AppID = createdApp.ID + label.Key = "traefik.enable" + label.Value = "true" + require.NoError(t, label.Save(context.Background())) + + // Add volume + volume := models.NewVolume(tc.database) + volume.AppID = createdApp.ID + volume.HostPath = "/data/app" + volume.ContainerPath = "/app/data" + volume.ReadOnly = false + require.NoError(t, volume.Save(context.Background())) + + // Add port + port := models.NewPort(tc.database) + port.AppID = createdApp.ID + port.HostPort = 8080 + port.ContainerPort = 80 + port.Protocol = models.PortProtocolTCP + require.NoError(t, port.Save(context.Background())) + + return createdApp +} + +// createTestAppWithConfigPort creates an app with a custom host port. +func createTestAppWithConfigPort( + t *testing.T, + tc *testContext, + name string, + hostPort int, +) *models.App { + t.Helper() + + createdApp := createTestApp(t, tc, name) + + ev := models.NewEnvVar(tc.database) + ev.AppID = createdApp.ID + ev.Key = "DATABASE_URL" + ev.Value = "postgres://localhost/mydb" + require.NoError(t, ev.Save(context.Background())) + + port := models.NewPort(tc.database) + port.AppID = createdApp.ID + port.HostPort = hostPort + port.ContainerPort = 80 + port.Protocol = models.PortProtocolTCP + require.NoError(t, port.Save(context.Background())) + + return createdApp +} + +func TestHandleExportApp(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + createdApp := createTestAppWithConfig(t, testCtx, "export-test-app") + + request := httptest.NewRequest(http.MethodGet, "/apps/"+createdApp.ID+"/export", nil) + request = addChiURLParams(request, map[string]string{"id": createdApp.ID}) + + recorder := httptest.NewRecorder() + + handler := testCtx.handlers.HandleExportApp() + handler.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Contains(t, recorder.Header().Get("Content-Type"), "application/json") + assert.Contains(t, recorder.Header().Get("Content-Disposition"), "attachment") + assert.Contains(t, recorder.Header().Get("Content-Disposition"), "export-test-app") + + var bundle app.BackupBundle + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &bundle)) + + assert.Equal(t, 1, bundle.Version) + assert.NotEmpty(t, bundle.ExportedAt) + require.Len(t, bundle.Apps, 1) + + appBackup := bundle.Apps[0] + assert.Equal(t, "export-test-app", appBackup.Name) + assert.Equal(t, "main", appBackup.Branch) + assert.Len(t, appBackup.EnvVars, 1) + assert.Equal(t, "DATABASE_URL", appBackup.EnvVars[0].Key) + assert.Equal(t, "postgres://localhost/mydb", appBackup.EnvVars[0].Value) + assert.Len(t, appBackup.Labels, 1) + assert.Equal(t, "traefik.enable", appBackup.Labels[0].Key) + assert.Len(t, appBackup.Volumes, 1) + assert.Equal(t, "/data/app", appBackup.Volumes[0].HostPath) + assert.Len(t, appBackup.Ports, 1) + assert.Equal(t, 8080, appBackup.Ports[0].HostPort) +} + +func TestHandleExportAppNotFound(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + + request := httptest.NewRequest(http.MethodGet, "/apps/nonexistent/export", nil) + request = addChiURLParams(request, map[string]string{"id": "nonexistent"}) + + recorder := httptest.NewRecorder() + + handler := testCtx.handlers.HandleExportApp() + handler.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusNotFound, recorder.Code) +} + +func TestHandleExportAllApps(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + createTestAppWithConfig(t, testCtx, "export-all-app1") + createTestAppWithConfigPort(t, testCtx, "export-all-app2", 8081) + + request := httptest.NewRequest(http.MethodGet, "/backup/export", nil) + recorder := httptest.NewRecorder() + + handler := testCtx.handlers.HandleExportAllApps() + handler.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Contains(t, recorder.Header().Get("Content-Disposition"), "upaas-backup-all") + + var bundle app.BackupBundle + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &bundle)) + + assert.Equal(t, 1, bundle.Version) + assert.Len(t, bundle.Apps, 2) +} + +func TestHandleExportAllAppsEmpty(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + + request := httptest.NewRequest(http.MethodGet, "/backup/export", nil) + recorder := httptest.NewRecorder() + + handler := testCtx.handlers.HandleExportAllApps() + handler.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + + var bundle app.BackupBundle + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &bundle)) + + assert.Empty(t, bundle.Apps) +} + +// createMultipartBackupRequest builds a multipart form request with backup JSON as a file upload. +func createMultipartBackupRequest( + t *testing.T, + backupJSON string, +) *http.Request { + t.Helper() + + var body bytes.Buffer + + writer := multipart.NewWriter(&body) + + part, err := writer.CreateFormFile("backup_file", "backup.json") + require.NoError(t, err) + + _, err = io.WriteString(part, backupJSON) + require.NoError(t, err) + + require.NoError(t, writer.Close()) + + request := httptest.NewRequest(http.MethodPost, "/backup/import", &body) + request.Header.Set("Content-Type", writer.FormDataContentType()) + + return request +} + +func TestHandleImportApps(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + + backupJSON := `{ + "version": 1, + "exportedAt": "2025-01-01T00:00:00Z", + "apps": [{ + "name": "imported-app", + "repoUrl": "git@example.com:user/repo.git", + "branch": "main", + "dockerfilePath": "Dockerfile", + "envVars": [{"key": "FOO", "value": "bar"}], + "labels": [{"key": "app.name", "value": "test"}], + "volumes": [{"hostPath": "/data", "containerPath": "/app/data", "readOnly": true}], + "ports": [{"hostPort": 3000, "containerPort": 8080, "protocol": "tcp"}] + }] + }` + + request := createMultipartBackupRequest(t, backupJSON) + recorder := httptest.NewRecorder() + + handler := testCtx.handlers.HandleImportApps() + handler.ServeHTTP(recorder, request) + + // Should redirect on success + assert.Equal(t, http.StatusSeeOther, recorder.Code) + assert.Contains(t, recorder.Header().Get("Location"), "success=") + + // Verify the app was created + apps, err := models.AllApps(context.Background(), testCtx.database) + require.NoError(t, err) + require.Len(t, apps, 1) + assert.Equal(t, "imported-app", apps[0].Name) + + // Verify env vars + envVars, _ := apps[0].GetEnvVars(context.Background()) + require.Len(t, envVars, 1) + assert.Equal(t, "FOO", envVars[0].Key) + assert.Equal(t, "bar", envVars[0].Value) + + // Verify labels + labels, _ := apps[0].GetLabels(context.Background()) + require.Len(t, labels, 1) + assert.Equal(t, "app.name", labels[0].Key) + + // Verify volumes + volumes, _ := apps[0].GetVolumes(context.Background()) + require.Len(t, volumes, 1) + assert.Equal(t, "/data", volumes[0].HostPath) + assert.True(t, volumes[0].ReadOnly) + + // Verify ports + ports, _ := apps[0].GetPorts(context.Background()) + require.Len(t, ports, 1) + assert.Equal(t, 3000, ports[0].HostPort) + assert.Equal(t, 8080, ports[0].ContainerPort) +} + +func TestHandleImportAppsSkipsDuplicateNames(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + + // Create an existing app with same name + createTestApp(t, testCtx, "existing-app") + + backupJSON := `{ + "version": 1, + "exportedAt": "2025-01-01T00:00:00Z", + "apps": [ + { + "name": "existing-app", + "repoUrl": "git@example.com:user/repo.git", + "branch": "main", + "dockerfilePath": "Dockerfile", + "envVars": [], + "labels": [], + "volumes": [], + "ports": [] + }, + { + "name": "new-app", + "repoUrl": "git@example.com:user/new.git", + "branch": "main", + "dockerfilePath": "Dockerfile", + "envVars": [], + "labels": [], + "volumes": [], + "ports": [] + } + ] + }` + + request := createMultipartBackupRequest(t, backupJSON) + recorder := httptest.NewRecorder() + + handler := testCtx.handlers.HandleImportApps() + handler.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusSeeOther, recorder.Code) + assert.Contains(t, recorder.Header().Get("Location"), "skipped") + + // Should have 2 apps total (existing + new) + apps, err := models.AllApps(context.Background(), testCtx.database) + require.NoError(t, err) + assert.Len(t, apps, 2) +} + +func TestHandleImportAppsInvalidJSON(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + + request := createMultipartBackupRequest(t, "not valid json") + recorder := httptest.NewRecorder() + + handler := testCtx.handlers.HandleImportApps() + handler.ServeHTTP(recorder, request) + + // Should render the page with error, not redirect + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Contains(t, recorder.Body.String(), "Invalid backup file") +} + +func TestHandleImportAppsUnsupportedVersion(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + + backupJSON := `{"version": 99, "exportedAt": "2025-01-01T00:00:00Z", "apps": [{"name": "test"}]}` + + request := createMultipartBackupRequest(t, backupJSON) + recorder := httptest.NewRecorder() + + handler := testCtx.handlers.HandleImportApps() + handler.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Contains(t, recorder.Body.String(), "Unsupported backup version") +} + +func TestHandleImportAppsEmptyBundle(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + + backupJSON := `{"version": 1, "exportedAt": "2025-01-01T00:00:00Z", "apps": []}` + + request := createMultipartBackupRequest(t, backupJSON) + recorder := httptest.NewRecorder() + + handler := testCtx.handlers.HandleImportApps() + handler.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Contains(t, recorder.Body.String(), "contains no apps") +} + +func TestHandleImportPage(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + + request := httptest.NewRequest(http.MethodGet, "/backup/import", nil) + recorder := httptest.NewRecorder() + + handler := testCtx.handlers.HandleImportPage() + handler.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.Contains(t, recorder.Body.String(), "Import Backup") +} + +func TestExportImportRoundTrip(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + createTestAppWithConfig(t, testCtx, "roundtrip-app") + + // Export + exportReq := httptest.NewRequest(http.MethodGet, "/backup/export", nil) + exportRec := httptest.NewRecorder() + + testCtx.handlers.HandleExportAllApps().ServeHTTP(exportRec, exportReq) + + require.Equal(t, http.StatusOK, exportRec.Code) + + exportedJSON := exportRec.Body.String() + + // Delete the original app + apps, _ := models.AllApps(context.Background(), testCtx.database) + for _, a := range apps { + require.NoError(t, a.Delete(context.Background())) + } + + // Import + importReq := createMultipartBackupRequest(t, exportedJSON) + importRec := httptest.NewRecorder() + + testCtx.handlers.HandleImportApps().ServeHTTP(importRec, importReq) + + assert.Equal(t, http.StatusSeeOther, importRec.Code) + + // Verify the app was recreated with all config + restoredApps, _ := models.AllApps(context.Background(), testCtx.database) + require.Len(t, restoredApps, 1) + assert.Equal(t, "roundtrip-app", restoredApps[0].Name) + + envVars, _ := restoredApps[0].GetEnvVars(context.Background()) + assert.Len(t, envVars, 1) + + labels, _ := restoredApps[0].GetLabels(context.Background()) + assert.Len(t, labels, 1) + + volumes, _ := restoredApps[0].GetVolumes(context.Background()) + assert.Len(t, volumes, 1) + + ports, _ := restoredApps[0].GetPorts(context.Background()) + assert.Len(t, ports, 1) +} + +// TestAPIExportApp tests the API endpoint for exporting a single app. +func TestAPIExportApp(t *testing.T) { + t.Parallel() + + tc, cookies := setupAPITest(t) + + createdApp, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{ + Name: "api-export-app", + RepoURL: "git@example.com:user/repo.git", + }) + require.NoError(t, err) + + rr := apiGet(t, tc, cookies, "/api/v1/apps/"+createdApp.ID+"/export") + assert.Equal(t, http.StatusOK, rr.Code) + + var bundle app.BackupBundle + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &bundle)) + + assert.Equal(t, 1, bundle.Version) + require.Len(t, bundle.Apps, 1) + assert.Equal(t, "api-export-app", bundle.Apps[0].Name) +} + +// TestAPIExportAppNotFound tests the API endpoint for a nonexistent app. +func TestAPIExportAppNotFound(t *testing.T) { + t.Parallel() + + tc, cookies := setupAPITest(t) + + rr := apiGet(t, tc, cookies, "/api/v1/apps/nonexistent/export") + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +// TestAPIExportAllApps tests the API endpoint for exporting all apps. +func TestAPIExportAllApps(t *testing.T) { + t.Parallel() + + tc, cookies := setupAPITest(t) + + _, err := tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{ + Name: "api-export-all-1", + RepoURL: "git@example.com:user/repo1.git", + }) + require.NoError(t, err) + + _, err = tc.appSvc.CreateApp(t.Context(), app.CreateAppInput{ + Name: "api-export-all-2", + RepoURL: "git@example.com:user/repo2.git", + }) + require.NoError(t, err) + + rr := apiGet(t, tc, cookies, "/api/v1/backup/export") + assert.Equal(t, http.StatusOK, rr.Code) + + var bundle app.BackupBundle + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &bundle)) + + assert.Len(t, bundle.Apps, 2) +} + +// TestAPIImportApps tests the API import endpoint. +func TestAPIImportApps(t *testing.T) { + t.Parallel() + + tc, cookies := setupAPITest(t) + + backupJSON := `{ + "version": 1, + "exportedAt": "2025-01-01T00:00:00Z", + "apps": [{ + "name": "api-imported-app", + "repoUrl": "git@example.com:user/repo.git", + "branch": "main", + "dockerfilePath": "Dockerfile", + "envVars": [], + "labels": [], + "volumes": [], + "ports": [] + }] + }` + + r := apiRouter(tc) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", strings.NewReader(backupJSON)) + req.Header.Set("Content-Type", "application/json") + + for _, c := range cookies { + req.AddCookie(c) + } + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + + imported, ok := resp["imported"].([]any) + require.True(t, ok) + assert.Len(t, imported, 1) + assert.Equal(t, "api-imported-app", imported[0]) +} + +// TestAPIImportAppsInvalidBody tests that the API rejects bad JSON. +func TestAPIImportAppsInvalidBody(t *testing.T) { + t.Parallel() + + tc, cookies := setupAPITest(t) + + r := apiRouter(tc) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", strings.NewReader("not json")) + req.Header.Set("Content-Type", "application/json") + + for _, c := range cookies { + req.AddCookie(c) + } + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) +} + +// TestAPIImportAppsUnsupportedVersion tests that the API rejects bad versions. +func TestAPIImportAppsUnsupportedVersion(t *testing.T) { + t.Parallel() + + tc, cookies := setupAPITest(t) + + r := apiRouter(tc) + + body := `{"version": 42, "apps": [{"name": "x"}]}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + for _, c := range cookies { + req.AddCookie(c) + } + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index ebddba9..759ef7e 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -98,6 +98,12 @@ func (s *Server) SetupRoutes() { // Ports r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd()) r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete()) + + // Backup/Restore + r.Get("/apps/{id}/export", s.handlers.HandleExportApp()) + r.Get("/backup/export", s.handlers.HandleExportAllApps()) + r.Get("/backup/import", s.handlers.HandleImportPage()) + r.Post("/backup/import", s.handlers.HandleImportApps()) }) }) @@ -115,6 +121,11 @@ func (s *Server) SetupRoutes() { r.Get("/apps", s.handlers.HandleAPIListApps()) r.Get("/apps/{id}", s.handlers.HandleAPIGetApp()) r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments()) + + // Backup/Restore API + r.Get("/apps/{id}/export", s.handlers.HandleAPIExportApp()) + r.Get("/backup/export", s.handlers.HandleAPIExportAllApps()) + r.Post("/backup/import", s.handlers.HandleAPIImportApps()) }) }) diff --git a/internal/service/app/backup.go b/internal/service/app/backup.go new file mode 100644 index 0000000..e4f33e3 --- /dev/null +++ b/internal/service/app/backup.go @@ -0,0 +1,391 @@ +package app + +import ( + "context" + "fmt" + "time" + + "sneak.berlin/go/upaas/internal/models" +) + +// BackupEnvVar represents an environment variable in a backup. +type BackupEnvVar struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// BackupLabel represents a Docker label in a backup. +type BackupLabel struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// BackupVolume represents a volume mount in a backup. +type BackupVolume struct { + HostPath string `json:"hostPath"` + ContainerPath string `json:"containerPath"` + ReadOnly bool `json:"readOnly"` +} + +// BackupPort represents a port mapping in a backup. +type BackupPort struct { + HostPort int `json:"hostPort"` + ContainerPort int `json:"containerPort"` + Protocol string `json:"protocol"` +} + +// Backup represents the exported configuration of a single app. +type Backup struct { + Name string `json:"name"` + RepoURL string `json:"repoUrl"` + Branch string `json:"branch"` + DockerfilePath string `json:"dockerfilePath"` + DockerNetwork string `json:"dockerNetwork,omitempty"` + NtfyTopic string `json:"ntfyTopic,omitempty"` + SlackWebhook string `json:"slackWebhook,omitempty"` + EnvVars []BackupEnvVar `json:"envVars"` + Labels []BackupLabel `json:"labels"` + Volumes []BackupVolume `json:"volumes"` + Ports []BackupPort `json:"ports"` +} + +// BackupBundle represents a complete backup of one or more apps. +type BackupBundle struct { + Version int `json:"version"` + ExportedAt string `json:"exportedAt"` + Apps []Backup `json:"apps"` +} + +// backupVersion is the current backup format version. +const backupVersion = 1 + +// ExportApp exports a single app's configuration as a BackupBundle. +func (svc *Service) ExportApp( + ctx context.Context, + application *models.App, +) (*BackupBundle, error) { + appBackup, err := svc.buildAppBackup(ctx, application) + if err != nil { + return nil, err + } + + return &BackupBundle{ + Version: backupVersion, + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Apps: []Backup{appBackup}, + }, nil +} + +// ExportAllApps exports all app configurations as a BackupBundle. +func (svc *Service) ExportAllApps(ctx context.Context) (*BackupBundle, error) { + apps, err := models.AllApps(ctx, svc.db) + if err != nil { + return nil, fmt.Errorf("listing apps for export: %w", err) + } + + backups := make([]Backup, 0, len(apps)) + + for _, application := range apps { + appBackup, buildErr := svc.buildAppBackup(ctx, application) + if buildErr != nil { + return nil, buildErr + } + + backups = append(backups, appBackup) + } + + return &BackupBundle{ + Version: backupVersion, + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Apps: backups, + }, nil +} + +// ImportApps imports app configurations from a BackupBundle. +// It creates new apps (with fresh IDs, SSH keys, and webhook secrets) +// and populates their env vars, labels, volumes, and ports. +// Apps whose names conflict with existing apps are skipped and reported. +func (svc *Service) ImportApps( + ctx context.Context, + bundle *BackupBundle, +) ([]string, []string, error) { + // Build a set of existing app names for conflict detection + existingApps, listErr := models.AllApps(ctx, svc.db) + if listErr != nil { + return nil, nil, fmt.Errorf("listing existing apps: %w", listErr) + } + + existingNames := make(map[string]bool, len(existingApps)) + for _, a := range existingApps { + existingNames[a.Name] = true + } + + var imported, skipped []string + + for _, ab := range bundle.Apps { + if existingNames[ab.Name] { + skipped = append(skipped, ab.Name) + + continue + } + + importErr := svc.importSingleApp(ctx, ab) + if importErr != nil { + return imported, skipped, fmt.Errorf( + "importing app %q: %w", ab.Name, importErr, + ) + } + + imported = append(imported, ab.Name) + } + + return imported, skipped, nil +} + +// importSingleApp creates a single app from backup data. +func (svc *Service) importSingleApp( + ctx context.Context, + ab Backup, +) error { + createdApp, createErr := svc.CreateApp(ctx, CreateAppInput{ + Name: ab.Name, + RepoURL: ab.RepoURL, + Branch: ab.Branch, + DockerfilePath: ab.DockerfilePath, + DockerNetwork: ab.DockerNetwork, + NtfyTopic: ab.NtfyTopic, + SlackWebhook: ab.SlackWebhook, + }) + if createErr != nil { + return fmt.Errorf("creating app: %w", createErr) + } + + envErr := svc.importEnvVars(ctx, createdApp.ID, ab.EnvVars) + if envErr != nil { + return envErr + } + + labelErr := svc.importLabels(ctx, createdApp.ID, ab.Labels) + if labelErr != nil { + return labelErr + } + + volErr := svc.importVolumes(ctx, createdApp.ID, ab.Volumes) + if volErr != nil { + return volErr + } + + portErr := svc.importPorts(ctx, createdApp.ID, ab.Ports) + if portErr != nil { + return portErr + } + + svc.log.Info("app imported from backup", + "id", createdApp.ID, "name", createdApp.Name) + + return nil +} + +// importEnvVars adds env vars from backup to an app. +func (svc *Service) importEnvVars( + ctx context.Context, + appID string, + envVars []BackupEnvVar, +) error { + for _, ev := range envVars { + addErr := svc.AddEnvVar(ctx, appID, ev.Key, ev.Value) + if addErr != nil { + return fmt.Errorf("adding env var %q: %w", ev.Key, addErr) + } + } + + return nil +} + +// importLabels adds labels from backup to an app. +func (svc *Service) importLabels( + ctx context.Context, + appID string, + labels []BackupLabel, +) error { + for _, l := range labels { + addErr := svc.AddLabel(ctx, appID, l.Key, l.Value) + if addErr != nil { + return fmt.Errorf("adding label %q: %w", l.Key, addErr) + } + } + + return nil +} + +// importVolumes adds volumes from backup to an app. +func (svc *Service) importVolumes( + ctx context.Context, + appID string, + volumes []BackupVolume, +) error { + for _, v := range volumes { + addErr := svc.AddVolume(ctx, appID, v.HostPath, v.ContainerPath, v.ReadOnly) + if addErr != nil { + return fmt.Errorf("adding volume %q: %w", v.ContainerPath, addErr) + } + } + + return nil +} + +// importPorts adds ports from backup to an app. +func (svc *Service) importPorts( + ctx context.Context, + appID string, + ports []BackupPort, +) error { + for _, p := range ports { + port := models.NewPort(svc.db) + port.AppID = appID + port.HostPort = p.HostPort + port.ContainerPort = p.ContainerPort + port.Protocol = models.PortProtocol(p.Protocol) + + if port.Protocol == "" { + port.Protocol = models.PortProtocolTCP + } + + saveErr := port.Save(ctx) + if saveErr != nil { + return fmt.Errorf("adding port %d: %w", p.HostPort, saveErr) + } + } + + return nil +} + +// buildAppBackup collects all configuration for a single app into a Backup. +func (svc *Service) buildAppBackup( + ctx context.Context, + application *models.App, +) (Backup, error) { + envVars, labels, volumes, ports, err := svc.fetchAppResources(ctx, application) + if err != nil { + return Backup{}, err + } + + backup := Backup{ + Name: application.Name, + RepoURL: application.RepoURL, + Branch: application.Branch, + DockerfilePath: application.DockerfilePath, + EnvVars: convertEnvVars(envVars), + Labels: convertLabels(labels), + Volumes: convertVolumes(volumes), + Ports: convertPorts(ports), + } + + if application.DockerNetwork.Valid { + backup.DockerNetwork = application.DockerNetwork.String + } + + if application.NtfyTopic.Valid { + backup.NtfyTopic = application.NtfyTopic.String + } + + if application.SlackWebhook.Valid { + backup.SlackWebhook = application.SlackWebhook.String + } + + return backup, nil +} + +// fetchAppResources retrieves all sub-resources for an app. +func (svc *Service) fetchAppResources( + ctx context.Context, + application *models.App, +) ([]*models.EnvVar, []*models.Label, []*models.Volume, []*models.Port, error) { + envVars, err := application.GetEnvVars(ctx) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf( + "getting env vars for %q: %w", application.Name, err, + ) + } + + labels, err := application.GetLabels(ctx) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf( + "getting labels for %q: %w", application.Name, err, + ) + } + + volumes, err := application.GetVolumes(ctx) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf( + "getting volumes for %q: %w", application.Name, err, + ) + } + + ports, err := application.GetPorts(ctx) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf( + "getting ports for %q: %w", application.Name, err, + ) + } + + return envVars, labels, volumes, ports, nil +} + +// convertEnvVars converts model env vars to backup format. +func convertEnvVars(envVars []*models.EnvVar) []BackupEnvVar { + result := make([]BackupEnvVar, 0, len(envVars)) + + for _, ev := range envVars { + result = append(result, BackupEnvVar{ + Key: ev.Key, + Value: ev.Value, + }) + } + + return result +} + +// convertLabels converts model labels to backup format. +func convertLabels(labels []*models.Label) []BackupLabel { + result := make([]BackupLabel, 0, len(labels)) + + for _, l := range labels { + result = append(result, BackupLabel{ + Key: l.Key, + Value: l.Value, + }) + } + + return result +} + +// convertVolumes converts model volumes to backup format. +func convertVolumes(volumes []*models.Volume) []BackupVolume { + result := make([]BackupVolume, 0, len(volumes)) + + for _, v := range volumes { + result = append(result, BackupVolume{ + HostPath: v.HostPath, + ContainerPath: v.ContainerPath, + ReadOnly: v.ReadOnly, + }) + } + + return result +} + +// convertPorts converts model ports to backup format. +func convertPorts(ports []*models.Port) []BackupPort { + result := make([]BackupPort, 0, len(ports)) + + for _, p := range ports { + result = append(result, BackupPort{ + HostPort: p.HostPort, + ContainerPort: p.ContainerPort, + Protocol: string(p.Protocol), + }) + } + + return result +} diff --git a/internal/service/app/backup_test.go b/internal/service/app/backup_test.go new file mode 100644 index 0000000..e6bb7ab --- /dev/null +++ b/internal/service/app/backup_test.go @@ -0,0 +1,379 @@ +package app_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/fx" + + "sneak.berlin/go/upaas/internal/config" + "sneak.berlin/go/upaas/internal/database" + "sneak.berlin/go/upaas/internal/globals" + "sneak.berlin/go/upaas/internal/logger" + "sneak.berlin/go/upaas/internal/models" + "sneak.berlin/go/upaas/internal/service/app" +) + +// backupTestContext bundles test dependencies for backup tests. +type backupTestContext struct { + svc *app.Service + db *database.Database +} + +func setupBackupTest(t *testing.T) *backupTestContext { + t.Helper() + + tmpDir := t.TempDir() + + globals.SetAppname("upaas-test") + globals.SetVersion("test") + + globalsInst, err := globals.New(fx.Lifecycle(nil)) + require.NoError(t, err) + + loggerInst, err := logger.New( + fx.Lifecycle(nil), + logger.Params{Globals: globalsInst}, + ) + require.NoError(t, err) + + cfg := &config.Config{ + Port: 8080, + DataDir: tmpDir, + SessionSecret: "test-secret-key-at-least-32-chars", + } + + dbInst, err := database.New(fx.Lifecycle(nil), database.Params{ + Logger: loggerInst, + Config: cfg, + }) + require.NoError(t, err) + + svc, err := app.New(fx.Lifecycle(nil), app.ServiceParams{ + Logger: loggerInst, + Database: dbInst, + }) + require.NoError(t, err) + + return &backupTestContext{svc: svc, db: dbInst} +} + +// createAppWithFullConfig creates an app with env vars, labels, volumes, and ports. +func createAppWithFullConfig( + t *testing.T, + btc *backupTestContext, + name string, +) *models.App { + t.Helper() + + createdApp, err := btc.svc.CreateApp(context.Background(), app.CreateAppInput{ + Name: name, + RepoURL: "git@example.com:user/" + name + ".git", + Branch: "develop", + NtfyTopic: "https://ntfy.sh/" + name, + DockerNetwork: "test-network", + }) + require.NoError(t, err) + + require.NoError(t, btc.svc.AddEnvVar( + context.Background(), createdApp.ID, "DB_HOST", "localhost", + )) + require.NoError(t, btc.svc.AddEnvVar( + context.Background(), createdApp.ID, "DB_PORT", "5432", + )) + require.NoError(t, btc.svc.AddLabel( + context.Background(), createdApp.ID, "traefik.enable", "true", + )) + require.NoError(t, btc.svc.AddVolume( + context.Background(), createdApp.ID, "/data", "/app/data", false, + )) + + port := models.NewPort(btc.db) + port.AppID = createdApp.ID + port.HostPort = 9090 + port.ContainerPort = 8080 + port.Protocol = models.PortProtocolTCP + require.NoError(t, port.Save(context.Background())) + + return createdApp +} + +// createAppWithConfigPort creates an app like createAppWithFullConfig but with +// a custom host port to avoid UNIQUE constraint collisions. +func createAppWithConfigPort( + t *testing.T, + btc *backupTestContext, + name string, + hostPort int, +) *models.App { + t.Helper() + + createdApp, err := btc.svc.CreateApp(context.Background(), app.CreateAppInput{ + Name: name, + RepoURL: "git@example.com:user/" + name + ".git", + Branch: "develop", + NtfyTopic: "https://ntfy.sh/" + name, + DockerNetwork: "test-network", + }) + require.NoError(t, err) + + require.NoError(t, btc.svc.AddEnvVar( + context.Background(), createdApp.ID, "DB_HOST", "localhost", + )) + require.NoError(t, btc.svc.AddLabel( + context.Background(), createdApp.ID, "traefik.enable", "true", + )) + require.NoError(t, btc.svc.AddVolume( + context.Background(), createdApp.ID, "/data2", "/app/data2", false, + )) + + port := models.NewPort(btc.db) + port.AppID = createdApp.ID + port.HostPort = hostPort + port.ContainerPort = 8080 + port.Protocol = models.PortProtocolTCP + require.NoError(t, port.Save(context.Background())) + + return createdApp +} + +func TestExportApp(t *testing.T) { + t.Parallel() + + btc := setupBackupTest(t) + createdApp := createAppWithFullConfig(t, btc, "export-svc-test") + + bundle, err := btc.svc.ExportApp(context.Background(), createdApp) + require.NoError(t, err) + + assert.Equal(t, 1, bundle.Version) + assert.NotEmpty(t, bundle.ExportedAt) + require.Len(t, bundle.Apps, 1) + + ab := bundle.Apps[0] + assert.Equal(t, "export-svc-test", ab.Name) + assert.Equal(t, "develop", ab.Branch) + assert.Equal(t, "test-network", ab.DockerNetwork) + assert.Equal(t, "https://ntfy.sh/export-svc-test", ab.NtfyTopic) + assert.Len(t, ab.EnvVars, 2) + assert.Len(t, ab.Labels, 1) + assert.Len(t, ab.Volumes, 1) + assert.Len(t, ab.Ports, 1) + assert.Equal(t, 9090, ab.Ports[0].HostPort) + assert.Equal(t, 8080, ab.Ports[0].ContainerPort) + assert.Equal(t, "tcp", ab.Ports[0].Protocol) +} + +func TestExportAllApps(t *testing.T) { + t.Parallel() + + btc := setupBackupTest(t) + createAppWithFullConfig(t, btc, "export-all-1") + createAppWithConfigPort(t, btc, "export-all-2", 9091) + + bundle, err := btc.svc.ExportAllApps(context.Background()) + require.NoError(t, err) + + assert.Equal(t, 1, bundle.Version) + assert.Len(t, bundle.Apps, 2) +} + +func TestExportAllAppsEmpty(t *testing.T) { + t.Parallel() + + btc := setupBackupTest(t) + + bundle, err := btc.svc.ExportAllApps(context.Background()) + require.NoError(t, err) + + assert.Empty(t, bundle.Apps) +} + +func TestImportApps(t *testing.T) { + t.Parallel() + + btc := setupBackupTest(t) + + bundle := &app.BackupBundle{ + Version: 1, + ExportedAt: "2025-01-01T00:00:00Z", + Apps: []app.Backup{ + { + Name: "imported-test", + RepoURL: "git@example.com:user/imported.git", + Branch: "main", + DockerfilePath: "Dockerfile", + DockerNetwork: "my-network", + EnvVars: []app.BackupEnvVar{ + {Key: "FOO", Value: "bar"}, + }, + Labels: []app.BackupLabel{ + {Key: "app", Value: "test"}, + }, + Volumes: []app.BackupVolume{ + {HostPath: "/host", ContainerPath: "/container", ReadOnly: true}, + }, + Ports: []app.BackupPort{ + {HostPort: 3000, ContainerPort: 8080, Protocol: "tcp"}, + }, + }, + }, + } + + imported, skipped, err := btc.svc.ImportApps(context.Background(), bundle) + require.NoError(t, err) + + assert.Equal(t, []string{"imported-test"}, imported) + assert.Empty(t, skipped) + + // Verify the app was created + apps, _ := btc.svc.ListApps(context.Background()) + require.Len(t, apps, 1) + assert.Equal(t, "imported-test", apps[0].Name) + assert.True(t, apps[0].DockerNetwork.Valid) + assert.Equal(t, "my-network", apps[0].DockerNetwork.String) + + // Has fresh secrets + assert.NotEmpty(t, apps[0].WebhookSecret) + assert.NotEmpty(t, apps[0].SSHPublicKey) + + // Verify sub-resources + envVars, _ := apps[0].GetEnvVars(context.Background()) + assert.Len(t, envVars, 1) + + labels, _ := apps[0].GetLabels(context.Background()) + assert.Len(t, labels, 1) + + volumes, _ := apps[0].GetVolumes(context.Background()) + assert.Len(t, volumes, 1) + assert.True(t, volumes[0].ReadOnly) + + ports, _ := apps[0].GetPorts(context.Background()) + assert.Len(t, ports, 1) + assert.Equal(t, 3000, ports[0].HostPort) +} + +func TestImportAppsSkipsDuplicates(t *testing.T) { + t.Parallel() + + btc := setupBackupTest(t) + + // Create existing app + _, err := btc.svc.CreateApp(context.Background(), app.CreateAppInput{ + Name: "existing", + RepoURL: "git@example.com:user/existing.git", + }) + require.NoError(t, err) + + bundle := &app.BackupBundle{ + Version: 1, + ExportedAt: "2025-01-01T00:00:00Z", + Apps: []app.Backup{ + { + Name: "existing", + RepoURL: "git@example.com:user/existing.git", + Branch: "main", + DockerfilePath: "Dockerfile", + EnvVars: []app.BackupEnvVar{}, + Labels: []app.BackupLabel{}, + Volumes: []app.BackupVolume{}, + Ports: []app.BackupPort{}, + }, + { + Name: "brand-new", + RepoURL: "git@example.com:user/new.git", + Branch: "main", + DockerfilePath: "Dockerfile", + EnvVars: []app.BackupEnvVar{}, + Labels: []app.BackupLabel{}, + Volumes: []app.BackupVolume{}, + Ports: []app.BackupPort{}, + }, + }, + } + + imported, skipped, err := btc.svc.ImportApps(context.Background(), bundle) + require.NoError(t, err) + + assert.Equal(t, []string{"brand-new"}, imported) + assert.Equal(t, []string{"existing"}, skipped) +} + +func TestImportAppsPortDefaultProtocol(t *testing.T) { + t.Parallel() + + btc := setupBackupTest(t) + + bundle := &app.BackupBundle{ + Version: 1, + ExportedAt: "2025-01-01T00:00:00Z", + Apps: []app.Backup{ + { + Name: "port-default-test", + RepoURL: "git@example.com:user/repo.git", + Branch: "main", + DockerfilePath: "Dockerfile", + EnvVars: []app.BackupEnvVar{}, + Labels: []app.BackupLabel{}, + Volumes: []app.BackupVolume{}, + Ports: []app.BackupPort{ + {HostPort: 80, ContainerPort: 80, Protocol: ""}, + }, + }, + }, + } + + imported, _, err := btc.svc.ImportApps(context.Background(), bundle) + require.NoError(t, err) + assert.Len(t, imported, 1) + + apps, _ := btc.svc.ListApps(context.Background()) + ports, _ := apps[0].GetPorts(context.Background()) + require.Len(t, ports, 1) + assert.Equal(t, models.PortProtocolTCP, ports[0].Protocol) +} + +func TestExportImportRoundTripService(t *testing.T) { + t.Parallel() + + btc := setupBackupTest(t) + createAppWithFullConfig(t, btc, "roundtrip-svc") + + // Export + bundle, err := btc.svc.ExportAllApps(context.Background()) + require.NoError(t, err) + require.Len(t, bundle.Apps, 1) + + // Delete original + apps, _ := btc.svc.ListApps(context.Background()) + for _, a := range apps { + require.NoError(t, btc.svc.DeleteApp(context.Background(), a)) + } + + // Import + imported, skipped, err := btc.svc.ImportApps(context.Background(), bundle) + require.NoError(t, err) + assert.Len(t, imported, 1) + assert.Empty(t, skipped) + + // Verify round-trip fidelity + restored, _ := btc.svc.ListApps(context.Background()) + require.Len(t, restored, 1) + assert.Equal(t, "roundtrip-svc", restored[0].Name) + assert.Equal(t, "develop", restored[0].Branch) + assert.Equal(t, "test-network", restored[0].DockerNetwork.String) + + envVars, _ := restored[0].GetEnvVars(context.Background()) + assert.Len(t, envVars, 2) + + labels, _ := restored[0].GetLabels(context.Background()) + assert.Len(t, labels, 1) + + volumes, _ := restored[0].GetVolumes(context.Background()) + assert.Len(t, volumes, 1) + + ports, _ := restored[0].GetPorts(context.Background()) + assert.Len(t, ports, 1) +} diff --git a/templates/app_detail.html b/templates/app_detail.html index b80ad87..df2f308 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -432,6 +432,18 @@ + +
+

Backup

+

Export this app's configuration (settings, env vars, labels, volumes, ports) as a JSON file for backup or migration.

+ + + + + Export Config + +
+

Danger Zone

diff --git a/templates/backup_import.html b/templates/backup_import.html new file mode 100644 index 0000000..b1d3844 --- /dev/null +++ b/templates/backup_import.html @@ -0,0 +1,62 @@ +{{template "base" .}} + +{{define "title"}}Import Backup - µPaaS{{end}} + +{{define "content"}} +{{template "nav" .}} + +
+ + + {{template "alert-success" .}} + {{template "alert-error" .}} + +

Import Backup

+ +
+

Restore from Backup File

+

+ Upload a previously exported µPaaS backup file (JSON) to restore app configurations. + New apps will be created with fresh SSH keys and webhook secrets. + Apps whose names already exist will be skipped. +

+
+ {{ .CSRFField }} +
+ + +
+ +
+
+ +
+

Export All Apps

+

+ Download a backup of all app configurations. This includes app settings, + environment variables, labels, volumes, and port mappings. + Secrets (SSH keys, webhook tokens) are not included — they are regenerated on import. +

+ + + + + Export All Apps + +
+
+{{end}} diff --git a/templates/dashboard.html b/templates/dashboard.html index ff66e1c..18c071d 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -11,12 +11,20 @@

Applications

- - - - - New App - +
{{if .AppStats}} diff --git a/templates/templates.go b/templates/templates.go index ab46739..b5a8994 100644 --- a/templates/templates.go +++ b/templates/templates.go @@ -45,6 +45,7 @@ func initTemplates() { "app_edit.html", "deployments.html", "webhook_events.html", + "backup_import.html", } pageTemplates = make(map[string]*template.Template)