- 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>
232 lines
6.0 KiB
Go
232 lines
6.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"strings"
|
|
|
|
"armature/database"
|
|
)
|
|
|
|
// coreTableOrder lists tables for backup in FK-safe import order.
|
|
// Ephemeral tables (node_registry, ws_tickets, rate_limit_counters,
|
|
// user_presence, oidc_auth_state, refresh_tokens) are excluded.
|
|
var coreTableOrder = []string{
|
|
"schema_migrations",
|
|
"users",
|
|
"teams",
|
|
"team_members",
|
|
"groups",
|
|
"group_members",
|
|
"platform_policies",
|
|
"global_settings",
|
|
"packages",
|
|
"package_user_settings",
|
|
"package_team_settings",
|
|
"extension_permissions",
|
|
"ext_data_tables",
|
|
"ext_connections",
|
|
"ext_dependencies",
|
|
"resource_grants",
|
|
"notifications",
|
|
"notification_preferences",
|
|
"audit_log",
|
|
"workflows",
|
|
"workflow_stages",
|
|
"workflow_versions",
|
|
"workflow_instances",
|
|
"workflow_assignments",
|
|
"workflow_signoffs",
|
|
"triggers",
|
|
"scheduled_tasks",
|
|
"trigger_logs",
|
|
}
|
|
|
|
// dumpTable writes all rows of a table as newline-delimited JSON to w.
|
|
// Columns are discovered dynamically via rows.ColumnTypes().
|
|
func dumpTable(ctx context.Context, db *sql.DB, table string, w io.Writer) (int, error) {
|
|
rows, err := db.QueryContext(ctx, fmt.Sprintf("SELECT * FROM %s", table))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("query %s: %w", table, err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
cols, err := rows.Columns()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("columns %s: %w", table, err)
|
|
}
|
|
|
|
count := 0
|
|
enc := json.NewEncoder(w)
|
|
enc.SetEscapeHTML(false)
|
|
|
|
for rows.Next() {
|
|
// Create scan destinations — all as interface{}
|
|
vals := make([]interface{}, len(cols))
|
|
ptrs := make([]interface{}, len(cols))
|
|
for i := range vals {
|
|
ptrs[i] = &vals[i]
|
|
}
|
|
if err := rows.Scan(ptrs...); err != nil {
|
|
return count, fmt.Errorf("scan %s row %d: %w", table, count, err)
|
|
}
|
|
|
|
row := make(map[string]interface{}, len(cols))
|
|
for i, col := range cols {
|
|
row[col] = normalizeValue(vals[i])
|
|
}
|
|
if err := enc.Encode(row); err != nil {
|
|
return count, fmt.Errorf("encode %s row %d: %w", table, count, err)
|
|
}
|
|
count++
|
|
}
|
|
return count, rows.Err()
|
|
}
|
|
|
|
// normalizeValue converts database-specific types to JSON-friendly values.
|
|
func normalizeValue(v interface{}) interface{} {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
switch val := v.(type) {
|
|
case []byte:
|
|
// Try to parse as JSON first (for JSONB columns)
|
|
var j interface{}
|
|
if err := json.Unmarshal(val, &j); err == nil {
|
|
return j
|
|
}
|
|
return string(val)
|
|
default:
|
|
return val
|
|
}
|
|
}
|
|
|
|
// restoreTable reads JSONL from r and inserts rows into the table.
|
|
// Column names come from the first JSON object. Uses dialect-adapted SQL.
|
|
func restoreTable(ctx context.Context, db *sql.DB, table string, r io.Reader) (int, error) {
|
|
scanner := bufio.NewScanner(r)
|
|
// Allow large lines (e.g. JSONB audit_log entries)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
|
|
|
|
count := 0
|
|
for scanner.Scan() {
|
|
line := scanner.Bytes()
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
var row map[string]interface{}
|
|
if err := json.Unmarshal(line, &row); err != nil {
|
|
return count, fmt.Errorf("unmarshal %s line %d: %w", table, count+1, err)
|
|
}
|
|
|
|
cols := make([]string, 0, len(row))
|
|
vals := make([]interface{}, 0, len(row))
|
|
for k, v := range row {
|
|
cols = append(cols, k)
|
|
vals = append(vals, marshalIfComplex(v))
|
|
}
|
|
|
|
// Build INSERT with positional placeholders
|
|
placeholders := make([]string, len(cols))
|
|
for i := range placeholders {
|
|
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
}
|
|
query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)",
|
|
table,
|
|
strings.Join(cols, ", "),
|
|
strings.Join(placeholders, ", "),
|
|
)
|
|
|
|
if _, err := database.ExecContext(ctx, query, vals...); err != nil {
|
|
return count, fmt.Errorf("insert %s row %d: %w", table, count+1, err)
|
|
}
|
|
count++
|
|
}
|
|
return count, scanner.Err()
|
|
}
|
|
|
|
// marshalIfComplex converts maps/slices back to JSON strings for DB storage.
|
|
func marshalIfComplex(v interface{}) interface{} {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
switch v.(type) {
|
|
case map[string]interface{}, []interface{}:
|
|
b, _ := json.Marshal(v)
|
|
return string(b)
|
|
default:
|
|
return v
|
|
}
|
|
}
|
|
|
|
// listExtDataTables returns all (package_id, physical_table_name) pairs
|
|
// from the ext_data_tables catalog.
|
|
func listExtDataTables(ctx context.Context, db *sql.DB) ([]extDataTableEntry, error) {
|
|
rows, err := db.QueryContext(ctx, "SELECT package_id, table_name FROM ext_data_tables ORDER BY package_id, table_name")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var entries []extDataTableEntry
|
|
for rows.Next() {
|
|
var e extDataTableEntry
|
|
if err := rows.Scan(&e.PackageID, &e.TableName); err != nil {
|
|
return nil, err
|
|
}
|
|
e.PhysicalName = fmt.Sprintf("ext_%s_%s", e.PackageID, e.TableName)
|
|
entries = append(entries, e)
|
|
}
|
|
return entries, rows.Err()
|
|
}
|
|
|
|
type extDataTableEntry struct {
|
|
PackageID string
|
|
TableName string
|
|
PhysicalName string
|
|
}
|
|
|
|
// tableExists checks if a table exists in the database.
|
|
func tableExists(ctx context.Context, db *sql.DB, table string) bool {
|
|
if database.IsPostgres() {
|
|
var exists bool
|
|
db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name = $1)", table).Scan(&exists)
|
|
return exists
|
|
}
|
|
// SQLite
|
|
var name string
|
|
err := db.QueryRowContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name)
|
|
return err == nil
|
|
}
|
|
|
|
// wipeTables deletes all data from the given tables in reverse order (FK-safe).
|
|
func wipeTables(ctx context.Context, db *sql.DB, tables []string) error {
|
|
if database.IsSQLite() {
|
|
db.ExecContext(ctx, "PRAGMA foreign_keys = OFF")
|
|
defer db.ExecContext(ctx, "PRAGMA foreign_keys = ON")
|
|
}
|
|
|
|
// Delete in reverse order to respect FK constraints
|
|
for i := len(tables) - 1; i >= 0; i-- {
|
|
table := tables[i]
|
|
if !tableExists(ctx, db, table) {
|
|
continue
|
|
}
|
|
if database.IsPostgres() {
|
|
if _, err := db.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s CASCADE", table)); err != nil {
|
|
log.Printf("[backup] warning: truncate %s: %v", table, err)
|
|
}
|
|
} else {
|
|
if _, err := db.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s", table)); err != nil {
|
|
log.Printf("[backup] warning: delete %s: %v", table, err)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|