feat: add backup/restore of app configurations
All checks were successful
Check / check (pull_request) Successful in 3m25s

Add export and import functionality for app configurations:

- Export single app or all apps as versioned JSON backup bundle
- Import from backup file with name-conflict detection (skip duplicates)
- Fresh SSH keys and webhook secrets generated on import
- Preserves env vars, labels, volumes, and port mappings
- Web UI: export button on app detail, backup/restore page on dashboard
- REST API: GET /api/v1/apps/{id}/export, GET /api/v1/backup/export,
  POST /api/v1/backup/import
- Comprehensive test coverage for service and handler layers
This commit is contained in:
user
2026-03-17 02:17:34 -07:00
parent fd110e69db
commit bb91f314c5
11 changed files with 1740 additions and 6 deletions

View File

@@ -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)
}