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
380 lines
9.9 KiB
Go
380 lines
9.9 KiB
Go
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)
|
|
}
|