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 }