From 7e40fea6f395b769575a495a871cb6f4ee89896c Mon Sep 17 00:00:00 2001 From: Leopere Date: Tue, 3 Mar 2026 18:42:14 -0500 Subject: [PATCH] Archive customer volumes on expiry, restore on resubscribe When a subscription is deleted, all Docker volumes for the customer stack are tarred to /data/archives/{stackName}/ before the stack is removed and volumes pruned. On resubscribe or reactivation, volumes are restored from the archive before deploying the stack. Made-with: Cursor --- docker/ss-atlas/internal/config/config.go | 2 + docker/ss-atlas/internal/handlers/activate.go | 3 + .../internal/handlers/subscription.go | 3 + docker/ss-atlas/internal/handlers/webhook.go | 6 +- docker/ss-atlas/internal/swarm/archive.go | 131 ++++++++++++++++++ stack.yml | 3 + 6 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 docker/ss-atlas/internal/swarm/archive.go diff --git a/docker/ss-atlas/internal/config/config.go b/docker/ss-atlas/internal/config/config.go index f7f0ba0..59a903f 100644 --- a/docker/ss-atlas/internal/config/config.go +++ b/docker/ss-atlas/internal/config/config.go @@ -20,6 +20,7 @@ type Config struct { TraefikNetwork string TemplatePath string CustomerDomain string + ArchivePath string } func Load() *Config { @@ -41,6 +42,7 @@ func Load() *Config { TraefikNetwork: envOrDefault("TRAEFIK_NETWORK", "authelia_dev"), TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"), CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "app.a250.ca"), + ArchivePath: envOrDefault("ARCHIVE_PATH", "/archives"), } } diff --git a/docker/ss-atlas/internal/handlers/activate.go b/docker/ss-atlas/internal/handlers/activate.go index 6b5f8e3..1292f50 100644 --- a/docker/ss-atlas/internal/handlers/activate.go +++ b/docker/ss-atlas/internal/handlers/activate.go @@ -55,6 +55,9 @@ func (a *App) handleActivatePost(w http.ResponseWriter, r *http.Request) { } stackName := fmt.Sprintf("customer-%s", remoteUser) + if err := a.swarm.RestoreVolumes(stackName, a.cfg.ArchivePath); err != nil { + log.Printf("activate: volume restore failed for %s: %v", remoteUser, err) + } if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil { log.Printf("activate: stack deploy failed for %s: %v", remoteUser, err) } diff --git a/docker/ss-atlas/internal/handlers/subscription.go b/docker/ss-atlas/internal/handlers/subscription.go index f18452a..2cffa8e 100644 --- a/docker/ss-atlas/internal/handlers/subscription.go +++ b/docker/ss-atlas/internal/handlers/subscription.go @@ -112,6 +112,9 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) { stackName := fmt.Sprintf("customer-%s", result.Username) exists, _ := a.swarm.StackExists(stackName) if !exists { + if err := a.swarm.RestoreVolumes(stackName, a.cfg.ArchivePath); err != nil { + log.Printf("resubscribe: volume restore failed for %s: %v", result.Username, err) + } if err := a.swarm.DeployStack(stackName, result.Username, a.cfg.TraefikDomain); err != nil { log.Printf("resubscribe: stack deploy failed for %s: %v", result.Username, err) } diff --git a/docker/ss-atlas/internal/handlers/webhook.go b/docker/ss-atlas/internal/handlers/webhook.go index cf493a1..6e36c63 100644 --- a/docker/ss-atlas/internal/handlers/webhook.go +++ b/docker/ss-atlas/internal/handlers/webhook.go @@ -83,11 +83,15 @@ func (a *App) onSubscriptionDeleted(event stripego.Event) { } stackName := "customer-" + username + if err := a.swarm.ArchiveVolumes(stackName, a.cfg.ArchivePath); err != nil { + log.Printf("archive failed for %s: %v", stackName, err) + } + if err := a.swarm.RemoveStack(stackName); err != nil { log.Printf("stack remove failed for %s: %v", stackName, err) } - log.Printf("deprovisioned stack for customer %s (%s)", customerID, username) + log.Printf("deprovisioned stack for customer %s (%s), volumes archived", customerID, username) } func (a *App) onSubscriptionUpdated(event stripego.Event) { diff --git a/docker/ss-atlas/internal/swarm/archive.go b/docker/ss-atlas/internal/swarm/archive.go new file mode 100644 index 0000000..47ecd56 --- /dev/null +++ b/docker/ss-atlas/internal/swarm/archive.go @@ -0,0 +1,131 @@ +package swarm + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// ArchiveVolumes tars each Docker volume belonging to stackName into archiveDir +// and removes the volumes afterwards. Archive layout: +// +// {archiveDir}/{stackName}/{volumeName}.tar.gz +func (c *Client) ArchiveVolumes(stackName, archiveDir string) error { + vols, err := c.listVolumes(stackName) + if err != nil { + return fmt.Errorf("list volumes: %w", err) + } + if len(vols) == 0 { + log.Printf("archive: no volumes found for %s, nothing to archive", stackName) + return nil + } + + dir := filepath.Join(archiveDir, stackName) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create archive dir: %w", err) + } + + for _, vol := range vols { + tarName := vol + ".tar.gz" + log.Printf("archive: backing up volume %s -> %s/%s", vol, dir, tarName) + + cmd := exec.Command("docker", "run", "--rm", + "-v", vol+":/data:ro", + "-v", dir+":/backup", + "alpine", + "tar", "czf", "/backup/"+tarName, "-C", "/data", ".", + ) + cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) + + if out, err := cmd.CombinedOutput(); err != nil { + log.Printf("archive: failed to tar %s: %s: %v", vol, strings.TrimSpace(string(out)), err) + continue + } + + rmCmd := exec.Command("docker", "volume", "rm", vol) + rmCmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) + if out, err := rmCmd.CombinedOutput(); err != nil { + log.Printf("archive: failed to remove volume %s: %s: %v", vol, strings.TrimSpace(string(out)), err) + } + } + + log.Printf("archive: completed for %s (%d volumes)", stackName, len(vols)) + return nil +} + +// RestoreVolumes recreates Docker volumes from a previous archive. No-op if +// no archive directory exists for the stack (fresh customer). +func (c *Client) RestoreVolumes(stackName, archiveDir string) error { + dir := filepath.Join(archiveDir, stackName) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read archive dir: %w", err) + } + + restored := 0 + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".tar.gz") { + continue + } + vol := strings.TrimSuffix(name, ".tar.gz") + + log.Printf("restore: extracting %s -> volume %s", name, vol) + + createCmd := exec.Command("docker", "volume", "create", vol) + createCmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) + if out, err := createCmd.CombinedOutput(); err != nil { + log.Printf("restore: volume create %s failed: %s: %v", vol, strings.TrimSpace(string(out)), err) + continue + } + + cmd := exec.Command("docker", "run", "--rm", + "-v", vol+":/data", + "-v", dir+":/backup:ro", + "alpine", + "tar", "xzf", "/backup/"+name, "-C", "/data", + ) + cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) + + if out, err := cmd.CombinedOutput(); err != nil { + log.Printf("restore: extract %s failed: %s: %v", name, strings.TrimSpace(string(out)), err) + continue + } + + os.Remove(filepath.Join(dir, name)) + restored++ + } + + if restored > 0 { + os.Remove(dir) + log.Printf("restore: completed for %s (%d volumes)", stackName, restored) + } + return nil +} + +func (c *Client) listVolumes(stackName string) ([]string, error) { + cmd := exec.Command("docker", "volume", "ls", + "--filter", "name="+stackName+"_", + "--format", "{{.Name}}", + ) + cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) + + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("docker volume ls: %s: %w", strings.TrimSpace(string(out)), err) + } + + var vols []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line != "" { + vols = append(vols, line) + } + } + return vols, nil +} diff --git a/stack.yml b/stack.yml index 835794b..731fffc 100644 --- a/stack.yml +++ b/stack.yml @@ -195,8 +195,10 @@ services: - TRAEFIK_NETWORK=authelia_dev - CUSTOMER_DOMAIN=app.a250.ca - TEMPLATE_PATH=/app/templates + - ARCHIVE_PATH=/archives volumes: - /var/run/docker.sock:/var/run/docker.sock:ro + - atlas_archives:/archives networks: - authelia_dev deploy: @@ -230,3 +232,4 @@ volumes: redis_data: authelia_data: lldap_data: + atlas_archives: