Files
badgerbeesandViet Tran f5917b0e46 fix(backup): harden tenant restore preview and lookup handling (#920)
* fix(backup): harden tenant restore preview and lookup handling

* test(pg): fix hook test migration path

* test(ci): stabilize race coverage tests

* fix(hooks): scope GetByID and allow loopback tests

* fix(backup): harden tenant restore replace/new contracts + race-safe SSRF flag

- Replace mode no longer deletes the tenants row (FK safe vs excluded
  diagnostic tables: traces, activity_logs, usage_snapshots, spans,
  embedding_cache, pairing_requests, paired_devices,
  channel_pending_messages, cron_run_logs). Metadata is preserved in place.
- shouldRestoreTable now excludes tenants for both new and replace modes.
- CLI: add validateTenantRestoreFlags guardrail. mode=new requires
  --new-tenant-slug and rejects --tenant/--tenant-id; upsert/replace warn
  on stray --new-tenant-slug; invalid --mode values rejected. TAB in help
  text fixed; flag descriptions clarified.
- HTTP: resolveRestoreTarget rejects tenant_id for mode=new regardless
  of tenant_slug (matches CLI contract). New i18n key
  MsgRestoreNewModeRejectsTenantID (en/vi/zh).
- security/ssrf: allowLoopbackForTest switched to atomic.Bool so
  concurrent reads from outbound dialers are race-safe.
- Polish: vi backup.json key order matches en/zh; TenantRestoreOptions.Mode
  doc comment documents upsert/replace/new semantics including clone
  behavior for new.
- Tests: unit coverage for validator (12 cases), HTTP guardrails
  (3 cases), shouldRestoreTable replace branch. Integration test
  tests/integration/tenant_restore_replace_test.go regression-guards
  the FK fix using activity_logs seed + DeleteTenantDataForTest helper.

---------

Co-authored-by: Viet Tran <viettranx@gmail.com>
2026-04-16 15:49:50 +07:00

272 lines
8.5 KiB
Go

package backup
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/upgrade"
)
// TenantRestoreOptions configures a tenant-scoped restore run.
type TenantRestoreOptions struct {
DB *sql.DB
ArchivePath string
TenantID uuid.UUID // target tenant; zero = create new (mode "new")
TenantSlug string // target tenant slug; required for mode "new"
DataDir string
WorkspacePath string
// Mode selects restore strategy:
// - "upsert" (default): INSERT ... ON CONFLICT DO NOTHING. Non-destructive.
// Tenant metadata (name/status/settings) is NOT updated
// if the tenants row already exists.
// - "replace": DELETE all tenant-scoped data except the tenants row (FK-safe
// with respect to excluded diagnostic tables), then INSERT from
// archive. Metadata on the tenants row is preserved in place.
// - "new": Create a new tenant from archive metadata (name/status/settings)
// with the provided TenantSlug. All tenant-scoped rows are cloned
// with tenant_id remapped to the new tenant. This includes
// tenant_users, api_keys, llm_providers, config_secrets, etc. —
// effectively a tenant clone under a new slug.
Mode string
Force bool
DryRun bool
ProgressFn func(phase, detail string)
}
// TenantRestoreResult describes the outcome of a tenant restore.
type TenantRestoreResult struct {
TenantID string `json:"tenant_id"`
TablesRestored map[string]int `json:"tables_restored"`
FilesExtracted int `json:"files_extracted"`
Warnings []string `json:"warnings,omitempty"`
}
// TenantRestore reads a tenant archive and restores DB rows + filesystem.
// Modes:
// - "upsert" (default): INSERT … ON CONFLICT DO NOTHING — non-destructive
// - "replace": DELETE existing tenant data (reverse tier order), then INSERT
// - "new": Create a new tenant record, remap tenant_id in all rows before INSERT
func TenantRestore(ctx context.Context, opts TenantRestoreOptions) (*TenantRestoreResult, error) {
progress := func(phase, detail string) {
if opts.ProgressFn != nil {
opts.ProgressFn(phase, detail)
}
}
mode := opts.Mode
if mode == "" {
mode = "upsert"
}
progress("init", "opening archive")
tableData, wsEntries, dataEntries, manifest, err := readTenantArchive(opts.ArchivePath)
if err != nil {
return nil, err
}
result := &TenantRestoreResult{
TenantID: opts.TenantID.String(),
TablesRestored: make(map[string]int),
}
// Schema version check.
currentSchema := int(upgrade.RequiredSchemaVersion)
if manifest.SchemaVersion > currentSchema {
return nil, fmt.Errorf("backup schema version %d is newer than current %d; upgrade GoClaw first",
manifest.SchemaVersion, currentSchema)
}
if manifest.SchemaVersion < currentSchema {
result.Warnings = append(result.Warnings,
fmt.Sprintf("backup schema version %d is older than current %d; run 'goclaw migrate up' after restore",
manifest.SchemaVersion, currentSchema))
}
tables := TenantTables()
targetTenantID := opts.TenantID
var sourceTenant *tenantRestoreRow
if mode == "new" && strings.TrimSpace(opts.TenantSlug) == "" {
return nil, fmt.Errorf("target tenant slug is required for new restore")
}
if mode == "new" {
tenantData, ok := tableData["tenants"]
if !ok || len(tenantData) == 0 {
return nil, fmt.Errorf("archive missing tenants table")
}
tenantRow, err := loadTenantRestoreRow(tenantData)
if err != nil {
return nil, fmt.Errorf("load tenants row: %w", err)
}
sourceTenant = tenantRow
}
if mode == "new" && opts.DryRun {
if err := ensureTenantSlugAvailable(ctx, opts.TenantSlug, func(ctx context.Context, slug string) (uuid.UUID, error) {
return lookupTenantSlugID(ctx, opts.DB, slug)
}); err != nil {
return nil, fmt.Errorf("validate target tenant slug: %w", err)
}
}
if opts.DryRun {
progress("dry-run", fmt.Sprintf("manifest ok: schema=%d, tables=%d", manifest.SchemaVersion, len(tableData)))
return result, nil
}
switch mode {
case "new":
newID, err := createNewTenant(ctx, opts.DB, sourceTenant, opts.TenantSlug)
if err != nil {
return nil, fmt.Errorf("create new tenant: %w", err)
}
targetTenantID = newID
result.TenantID = newID.String()
result.TablesRestored["tenants"] = 1
progress("database", fmt.Sprintf("created new tenant %s", newID))
case "replace":
if err := deleteTenantData(ctx, opts.DB, opts.TenantID, tables); err != nil {
return nil, fmt.Errorf("delete existing tenant data: %w", err)
}
progress("database", "existing tenant data deleted")
}
// Restore DB tables in forward tier order.
for _, table := range tables {
if !shouldRestoreTable(mode, table) {
continue
}
data, ok := tableData[table.Name]
if !ok || len(data) == 0 {
continue
}
progress("database", fmt.Sprintf("restoring %s", table.Name))
importData := data
if mode == "new" && table.HasTenantID {
rewritten, err := rewriteTenantIDInJSONL(data, targetTenantID)
if err != nil {
result.Warnings = append(result.Warnings,
fmt.Sprintf("rewrite tenant_id in %s: %v", table.Name, err))
} else {
importData = rewritten
}
}
count, err := ImportTableRows(ctx, opts.DB, table.Name, bytes.NewReader(importData))
if err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("import %s: %v", table.Name, err))
continue
}
result.TablesRestored[table.Name] = count
progress("database", fmt.Sprintf("%s: %d rows", table.Name, count))
}
// Restore filesystem entries.
if opts.WorkspacePath != "" && len(wsEntries) > 0 {
progress("filesystem", "extracting workspace")
n, _, err := extractEntries(wsEntries, opts.WorkspacePath)
if err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("extract workspace: %v", err))
} else {
result.FilesExtracted += n
progress("filesystem", fmt.Sprintf("workspace done (%d files)", n))
}
}
if opts.DataDir != "" && len(dataEntries) > 0 {
progress("filesystem", "extracting data dir")
n, _, err := extractEntries(dataEntries, opts.DataDir)
if err != nil {
result.Warnings = append(result.Warnings, fmt.Sprintf("extract data dir: %v", err))
} else {
result.FilesExtracted += n
progress("filesystem", fmt.Sprintf("data dir done (%d files)", n))
}
}
progress("done", fmt.Sprintf("tenant restore complete: tables=%d, files=%d",
len(result.TablesRestored), result.FilesExtracted))
return result, nil
}
// readTenantArchive opens a tenant backup archive and separates its contents.
func readTenantArchive(archivePath string) (
tableData map[string][]byte,
wsEntries map[string][]byte,
dataEntries map[string][]byte,
manifest *TenantBackupManifest,
err error,
) {
tableData = map[string][]byte{}
wsEntries = map[string][]byte{}
dataEntries = map[string][]byte{}
f, err := os.Open(archivePath)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("open archive: %w", err)
}
defer f.Close()
gr, err := gzip.NewReader(f)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("decompress archive: %w", err)
}
defer gr.Close()
tr := tar.NewReader(gr)
var manifestData []byte
for {
hdr, nextErr := tr.Next()
if nextErr == io.EOF {
break
}
if nextErr != nil {
return nil, nil, nil, nil, fmt.Errorf("read tar: %w", nextErr)
}
if hdr.Typeflag != tar.TypeReg {
continue
}
data, readErr := io.ReadAll(tr)
if readErr != nil {
return nil, nil, nil, nil, fmt.Errorf("read entry %s: %w", hdr.Name, readErr)
}
switch {
case hdr.Name == "manifest.json":
manifestData = data
case strings.HasPrefix(hdr.Name, "tables/") && strings.HasSuffix(hdr.Name, ".jsonl"):
name := strings.TrimSuffix(strings.TrimPrefix(hdr.Name, "tables/"), ".jsonl")
tableData[name] = data
case strings.HasPrefix(hdr.Name, "workspace/"):
wsEntries[strings.TrimPrefix(hdr.Name, "workspace/")] = data
case strings.HasPrefix(hdr.Name, "data/"):
dataEntries[strings.TrimPrefix(hdr.Name, "data/")] = data
}
}
if manifestData == nil {
return nil, nil, nil, nil, fmt.Errorf("manifest.json not found in archive")
}
var m TenantBackupManifest
if err := json.Unmarshal(manifestData, &m); err != nil {
return nil, nil, nil, nil, fmt.Errorf("parse manifest: %w", err)
}
if m.Format != "goclaw-tenant-backup" {
return nil, nil, nil, nil, fmt.Errorf("unsupported archive format: %q", m.Format)
}
return tableData, wsEntries, dataEntries, &m, nil
}