Files
goclaw/cmd/tenant_backup_cli_helpers.go
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

73 lines
1.9 KiB
Go

package cmd
import (
"database/sql"
"fmt"
"os"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/spf13/cobra"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store/pg"
)
func openTenantBackupDB(cfg *config.Config) (*sql.DB, error) {
dsn := cfg.Database.PostgresDSN
if dsn == "" {
return nil, fmt.Errorf("GOCLAW_POSTGRES_DSN not configured")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
return db, nil
}
// resolveTenantForCLI opens the DB, looks up the tenant by slug or UUID,
// and returns the resolved tenant ID, slug, and an open *sql.DB (caller must close).
func resolveTenantForCLI(cmd *cobra.Command, cfg *config.Config, rawID, slug string) (uuid.UUID, string, *sql.DB, error) {
if rawID == "" && slug == "" {
return uuid.Nil, "", nil, fmt.Errorf("--tenant <slug> or --tenant-id <uuid> is required")
}
db, err := openTenantBackupDB(cfg)
if err != nil {
return uuid.Nil, "", nil, err
}
ts := pg.NewPGTenantStore(db)
if rawID != "" {
tid, err := uuid.Parse(rawID)
if err != nil {
db.Close()
return uuid.Nil, "", nil, fmt.Errorf("invalid tenant-id: %w", err)
}
tenant, err := ts.GetTenant(cmd.Context(), tid)
if err != nil {
db.Close()
return uuid.Nil, "", nil, fmt.Errorf("tenant not found: %w", err)
}
return tenant.ID, tenant.Slug, db, nil
}
tenant, err := ts.GetTenantBySlug(cmd.Context(), slug)
if err != nil {
db.Close()
return uuid.Nil, "", nil, fmt.Errorf("tenant %q not found: %w", slug, err)
}
return tenant.ID, tenant.Slug, db, nil
}
// tenantS3Upload uploads a local archive to S3 using the system S3 config.
func tenantS3Upload(cmd *cobra.Command, cfg *config.Config, archivePath string) error {
if err := uploadBackupToS3(cmd.Context(), cfg, archivePath, Version); err != nil {
fmt.Fprintf(os.Stderr, "\nS3 upload failed: %v\n", err)
return err
}
return nil
}