This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/backup.go
Jeffrey Smith f0dd43144e rebrand: Switchboard Core → Armature
- Rename Go module switchboard-core → armature (155+ files)
- Rename Docker image → gobha/armature
- Rename K8s resources, secrets, deployments
- Rename Prometheus metrics switchboard_* → armature_*
- Rename env vars SWITCHBOARD_ADMIN_* → ARMATURE_ADMIN_*
- Rename DB names switchboard_core* → armature*
- Update all frontend branding, notification templates, docs
- Update CI scripts, e2e tests, Keycloak realm, nginx conf
- Rename scripts/switchboard-ca.sh → scripts/armature-ca.sh
- Rename k8s/switchboard.yaml → k8s/armature.yaml
- Rename chart alerting/dashboard files
- Fix: DockerHub push uses env: binding for secret injection
- Helm chart updated (name, labels, template functions, dashboard, alerting)
- Replace favicon/icon assets with Armature brand

No functional changes. Pure mechanical rename + CI fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 21:39:58 +00:00

524 lines
14 KiB
Go

package handlers
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"armature/database"
"armature/store"
)
// BackupHandler provides backup/restore endpoints for admin users.
type BackupHandler struct {
stores store.Stores
packagesDir string
storagePath string
}
// NewBackupHandler creates a new BackupHandler.
func NewBackupHandler(stores store.Stores, packagesDir, storagePath string) *BackupHandler {
return &BackupHandler{
stores: stores,
packagesDir: packagesDir,
storagePath: storagePath,
}
}
// backupMeta is written as meta.json inside the .swb archive.
type backupMeta struct {
Version string `json:"version"`
Timestamp time.Time `json:"timestamp"`
SchemaVersion string `json:"schema_version"`
Dialect string `json:"dialect"`
CoreTables []string `json:"core_tables"`
ExtTables []string `json:"ext_tables,omitempty"`
}
func (h *BackupHandler) backupsDir() string {
if h.storagePath == "" {
return ""
}
return filepath.Join(h.storagePath, "backups")
}
// ── CreateBackup ─────────────────────────────
// POST /api/v1/admin/backup
// Streams a .swb ZIP archive containing all core + ext_data tables as JSONL,
// plus package asset files from disk.
func (h *BackupHandler) CreateBackup(c *gin.Context) {
ctx := c.Request.Context()
db := database.DB
// Determine schema version
schemaVersion := ""
row := db.QueryRowContext(ctx, "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1")
row.Scan(&schemaVersion)
dialect := "sqlite"
if database.IsPostgres() {
dialect = "postgres"
}
// Enumerate ext_data tables
extEntries, err := listExtDataTables(ctx, db)
if err != nil {
log.Printf("[backup] warning: list ext_data tables: %v", err)
}
extTableNames := make([]string, len(extEntries))
for i, e := range extEntries {
extTableNames[i] = e.PhysicalName
}
meta := backupMeta{
Version: readVersionFile(),
Timestamp: time.Now().UTC(),
SchemaVersion: schemaVersion,
Dialect: dialect,
CoreTables: coreTableOrder,
ExtTables: extTableNames,
}
// Determine if we should store server-side or stream directly
storeSide := c.Query("store") == "true"
if storeSide {
h.createStoredBackup(c, ctx, db, meta, extEntries)
return
}
// Stream directly to client
filename := fmt.Sprintf("armature-%s-%s.swb", meta.Version, meta.Timestamp.Format("20060102-150405"))
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
zw := zip.NewWriter(c.Writer)
defer zw.Close()
h.writeBackupContents(c, zw, meta, extEntries)
}
func (h *BackupHandler) createStoredBackup(c *gin.Context, ctx interface{ Deadline() (time.Time, bool) }, db interface{}, meta backupMeta, extEntries []extDataTableEntry) {
dir := h.backupsDir()
if dir == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no storage path configured"})
return
}
os.MkdirAll(dir, 0755)
filename := fmt.Sprintf("armature-%s-%s.swb", meta.Version, meta.Timestamp.Format("20060102-150405"))
path := filepath.Join(dir, filename)
f, err := os.Create(path)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "create backup file: " + err.Error()})
return
}
defer f.Close()
zw := zip.NewWriter(f)
defer zw.Close()
h.writeBackupContents(c, zw, meta, extEntries)
c.JSON(http.StatusOK, gin.H{"data": gin.H{
"filename": filename,
"path": path,
}})
}
func (h *BackupHandler) writeBackupContents(c *gin.Context, zw *zip.Writer, meta backupMeta, extEntries []extDataTableEntry) {
ctx := c.Request.Context()
db := database.DB
// Write meta.json
mw, err := zw.Create("meta.json")
if err != nil {
log.Printf("[backup] create meta.json: %v", err)
return
}
enc := json.NewEncoder(mw)
enc.SetIndent("", " ")
enc.Encode(meta)
// Dump core tables
for _, table := range coreTableOrder {
if !tableExists(ctx, db, table) {
continue
}
fw, err := zw.Create(fmt.Sprintf("core/%s.jsonl", table))
if err != nil {
log.Printf("[backup] create core/%s.jsonl: %v", table, err)
continue
}
count, err := dumpTable(ctx, db, table, fw)
if err != nil {
log.Printf("[backup] dump %s: %v", table, err)
} else {
log.Printf("[backup] dumped %s: %d rows", table, count)
}
}
// Dump ext_data tables
for _, entry := range extEntries {
if !tableExists(ctx, db, entry.PhysicalName) {
continue
}
path := fmt.Sprintf("ext_data/%s/%s.jsonl", entry.PackageID, entry.TableName)
fw, err := zw.Create(path)
if err != nil {
log.Printf("[backup] create %s: %v", path, err)
continue
}
count, err := dumpTable(ctx, db, entry.PhysicalName, fw)
if err != nil {
log.Printf("[backup] dump %s: %v", entry.PhysicalName, err)
} else {
log.Printf("[backup] dumped %s: %d rows", entry.PhysicalName, count)
}
}
// Walk package assets
if h.packagesDir != "" {
filepath.Walk(h.packagesDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
relPath, err := filepath.Rel(h.packagesDir, path)
if err != nil {
return nil
}
relPath = filepath.ToSlash(relPath)
fw, err := zw.Create("packages/" + relPath)
if err != nil {
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
io.Copy(fw, f)
return nil
})
}
}
// ── ListBackups ──────────────────────────────
// GET /api/v1/admin/backups
func (h *BackupHandler) ListBackups(c *gin.Context) {
dir := h.backupsDir()
if dir == "" {
c.JSON(http.StatusOK, gin.H{"data": []interface{}{}})
return
}
entries, err := os.ReadDir(dir)
if err != nil {
c.JSON(http.StatusOK, gin.H{"data": []interface{}{}})
return
}
type backupEntry struct {
Name string `json:"name"`
Size int64 `json:"size"`
CreatedAt time.Time `json:"created_at"`
}
var backups []backupEntry
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".swb") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
backups = append(backups, backupEntry{
Name: e.Name(),
Size: info.Size(),
CreatedAt: info.ModTime(),
})
}
if backups == nil {
backups = []backupEntry{}
}
c.JSON(http.StatusOK, gin.H{"data": backups})
}
// ── DownloadBackup ───────────────────────────
// GET /api/v1/admin/backups/:name
func (h *BackupHandler) DownloadBackup(c *gin.Context) {
name := c.Param("name")
if !isValidBackupName(name) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup name"})
return
}
dir := h.backupsDir()
if dir == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "no storage configured"})
return
}
path := filepath.Join(dir, name)
if _, err := os.Stat(path); os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"})
return
}
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name))
c.File(path)
}
// ── DeleteBackup ─────────────────────────────
// DELETE /api/v1/admin/backups/:name
func (h *BackupHandler) DeleteBackup(c *gin.Context) {
name := c.Param("name")
if !isValidBackupName(name) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup name"})
return
}
dir := h.backupsDir()
if dir == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "no storage configured"})
return
}
path := filepath.Join(dir, name)
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "backup not found"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"deleted": name}})
}
// ── RestoreBackup ────────────────────────────
// POST /api/v1/admin/restore
// Accepts a .swb file upload, validates meta, wipes DB, restores data.
func (h *BackupHandler) RestoreBackup(c *gin.Context) {
ctx := c.Request.Context()
db := database.DB
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
return
}
defer file.Close()
// Read entire file into memory for zip.NewReader
data, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "read file: " + err.Error()})
return
}
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid archive: " + err.Error()})
return
}
// Read meta.json
meta, err := readBackupMeta(zr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid backup: " + err.Error()})
return
}
// Validate schema version — reject backups from future schema
currentSchema := ""
db.QueryRowContext(ctx, "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1").Scan(&currentSchema)
if meta.SchemaVersion > currentSchema && currentSchema != "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("backup schema %s is newer than current %s — upgrade the application first", meta.SchemaVersion, currentSchema),
})
return
}
log.Printf("[backup] restoring from %s (schema %s, %d core tables, %d ext tables)",
header.Filename, meta.SchemaVersion, len(meta.CoreTables), len(meta.ExtTables))
// Build full table list for wipe (core + ext_data)
allTables := make([]string, 0, len(coreTableOrder)+len(meta.ExtTables))
allTables = append(allTables, coreTableOrder...)
allTables = append(allTables, meta.ExtTables...)
// Wipe all data
if err := wipeTables(ctx, db, allTables); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "wipe failed: " + err.Error()})
return
}
// Restore core tables in order
restored := 0
for _, table := range coreTableOrder {
path := fmt.Sprintf("core/%s.jsonl", table)
f := findInZip(zr, path)
if f == nil {
continue
}
rc, err := f.Open()
if err != nil {
log.Printf("[backup] open %s: %v", path, err)
continue
}
count, err := restoreTable(ctx, db, table, rc)
rc.Close()
if err != nil {
log.Printf("[backup] restore %s: %v", table, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("restore %s: %v", table, err)})
return
}
log.Printf("[backup] restored %s: %d rows", table, count)
restored += count
}
// Restore ext_data tables
for _, extTable := range meta.ExtTables {
// Parse package_id and table_name from physical name: ext_{pkg}_{table}
pkgID, tblName := parseExtTableName(extTable)
if pkgID == "" {
continue
}
path := fmt.Sprintf("ext_data/%s/%s.jsonl", pkgID, tblName)
f := findInZip(zr, path)
if f == nil {
continue
}
// Ensure physical table exists before restoring
if !tableExists(ctx, db, extTable) {
log.Printf("[backup] skip ext table %s (not in schema)", extTable)
continue
}
rc, err := f.Open()
if err != nil {
log.Printf("[backup] open %s: %v", path, err)
continue
}
count, err := restoreTable(ctx, db, extTable, rc)
rc.Close()
if err != nil {
log.Printf("[backup] restore ext %s: %v", extTable, err)
} else {
log.Printf("[backup] restored ext %s: %d rows", extTable, count)
restored += count
}
}
// Restore package files
if h.packagesDir != "" {
for _, f := range zr.File {
if !strings.HasPrefix(f.Name, "packages/") || f.FileInfo().IsDir() {
continue
}
relPath := strings.TrimPrefix(f.Name, "packages/")
destPath := filepath.Join(h.packagesDir, filepath.FromSlash(relPath))
os.MkdirAll(filepath.Dir(destPath), 0755)
rc, err := f.Open()
if err != nil {
continue
}
out, err := os.Create(destPath)
if err != nil {
rc.Close()
continue
}
io.Copy(out, rc)
out.Close()
rc.Close()
}
}
log.Printf("[backup] restore complete: %d total rows", restored)
c.JSON(http.StatusOK, gin.H{"data": gin.H{
"rows_restored": restored,
"schema": meta.SchemaVersion,
}})
}
// ── Helpers ──────────────────────────────────
func readBackupMeta(zr *zip.Reader) (*backupMeta, error) {
f := findInZip(zr, "meta.json")
if f == nil {
return nil, fmt.Errorf("meta.json not found in archive")
}
rc, err := f.Open()
if err != nil {
return nil, err
}
defer rc.Close()
var meta backupMeta
if err := json.NewDecoder(rc).Decode(&meta); err != nil {
return nil, fmt.Errorf("decode meta.json: %w", err)
}
return &meta, nil
}
func findInZip(zr *zip.Reader, name string) *zip.File {
for _, f := range zr.File {
if f.Name == name {
return f
}
}
return nil
}
// parseExtTableName parses "ext_{pkg}_{table}" into (pkg, table).
func parseExtTableName(physical string) (string, string) {
if !strings.HasPrefix(physical, "ext_") {
return "", ""
}
rest := physical[4:]
idx := strings.Index(rest, "_")
if idx < 0 {
return "", ""
}
return rest[:idx], rest[idx+1:]
}
var validBackupNameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]+\.swb$`)
func isValidBackupName(name string) bool {
return validBackupNameRe.MatchString(name) && !strings.Contains(name, "..")
}
// readVersionFile reads the VERSION file from the project root.
func readVersionFile() string {
// Try common locations
for _, path := range []string{"VERSION", "/app/VERSION", "../VERSION"} {
data, err := os.ReadFile(path)
if err == nil {
return strings.TrimSpace(string(data))
}
}
return "unknown"
}