feat: add backup/restore of app configurations
All checks were successful
Check / check (pull_request) Successful in 3m25s
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:
391
internal/service/app/backup.go
Normal file
391
internal/service/app/backup.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user