Files
goclaw/internal/backup/db_restore.go
T
thotam 25eaa0166f fix(backup): repair tenant backup/restore (config_secrets order, hooks registry, restore conn check) (#1357)
Closes #1076, #1338.

Fix A — tenant backup aborted with SQLSTATE 42703 "column id does not
exist" because exportQuery() hardcoded ORDER BY id. Several tenant-scoped
tables have composite PKs and no id column. Add a TableDef.OrderBy field,
honor it in exportQuery(), and set it for every id-less registry table:
config_secrets, agent_team_members, tenant_hook_budget, system_configs,
builtin_tool_tenant_configs, skill_tenant_configs, user_agent_profiles.

Fix B — hooks, tenant_hook_budget and webhook config were missing from
the backup registry, silently dropping their data on backup/restore. Add
hooks, tenant_hook_budget, webhooks (preserve) plus the hook_agents junction
(via ParentJoin through hooks, composite PK), and mark hook_executions and
webhook_calls as ephemeral in the skipped list.

Fix C — restoring on a fresh server failed with "N active DB connection(s)
detected" because the gateway's own pool connections were counted as active
clients. Tag pool connections with application_name='goclaw' (pg.OpenDB) and
exclude them in CheckActiveConnections; genuine external clients still block.

Adds unit + integration regression tests, including an export-over-every-
registered-table test that surfaced the additional id-less tables.
2026-07-05 11:56:36 +07:00

93 lines
2.4 KiB
Go

//go:build !sqliteonly
package backup
import (
"bytes"
"context"
"database/sql"
"fmt"
"io"
"os"
"os/exec"
"strings"
_ "github.com/jackc/pgx/v5/stdlib"
)
// RestoreDatabase restores a PostgreSQL database from a plain-SQL dump reader.
// Uses a temporary .pgpass file (0600) to pass credentials securely.
// The child psql process receives only PGPASSFILE, PATH, HOME, LC_ALL=C.
func RestoreDatabase(ctx context.Context, dsn string, dumpReader io.Reader) error {
creds, err := ParseDSN(dsn)
if err != nil {
return fmt.Errorf("parse DSN: %w", err)
}
psql, err := exec.LookPath("psql")
if err != nil {
return fmt.Errorf("psql not found on PATH: %w", err)
}
tempDir, pgpassPath, err := WritePgpass(creds)
if err != nil {
return err
}
defer os.RemoveAll(tempDir)
args := []string{
"--host", creds.Host,
"--port", creds.Port,
"--username", creds.User,
"--dbname", creds.DBName,
"--no-password",
}
cmd := exec.CommandContext(ctx, psql, args...)
cmd.Env = CleanEnv(pgpassPath)
cmd.Stdin = dumpReader
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" {
errMsg = err.Error()
}
// Truncate very long psql error output.
if len(errMsg) > 512 {
errMsg = errMsg[:512] + "..."
}
return fmt.Errorf("psql restore failed: %s", errMsg)
}
return nil
}
// CheckActiveConnections returns the number of active backend connections to the
// database (excluding the current connection). Used as a pre-restore safety check.
func CheckActiveConnections(ctx context.Context, dsn string) (int, error) {
creds, err := ParseDSN(dsn)
if err != nil {
return 0, fmt.Errorf("parse DSN: %w", err)
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return 0, fmt.Errorf("open db: %w", err)
}
defer db.Close()
var count int
// Exclude the gateway's own pool connections, tagged application_name='goclaw'
// (see pg.PoolApplicationName). Otherwise a running gateway blocks its own
// restore on a fresh server (issue #1338). External clients are still counted.
query := `SELECT COUNT(*) FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()
AND application_name <> 'goclaw'`
if err := db.QueryRowContext(ctx, query, creds.DBName).Scan(&count); err != nil {
return 0, fmt.Errorf("query pg_stat_activity: %w", err)
}
return count, nil
}