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.
+
+
+
+
+
+
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 @@
{{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)