From 6cb0c67ee166ca9e8f9ef10c0de321d05306c3f1 Mon Sep 17 00:00:00 2001 From: Vineeth Pothulapati Date: Fri, 7 Nov 2025 16:12:48 +0530 Subject: [PATCH 1/7] Add upload support PG Dump files --- internal/tiger/cmd/db.go | 165 +++++++++++++++ internal/tiger/restore/confirm.go | 27 +++ internal/tiger/restore/format.go | 179 ++++++++++++++++ internal/tiger/restore/pg_restore.go | 109 ++++++++++ internal/tiger/restore/preflight.go | 225 +++++++++++++++++++++ internal/tiger/restore/psql.go | 280 ++++++++++++++++++++++++++ internal/tiger/restore/psql_test.go | 227 +++++++++++++++++++++ internal/tiger/restore/restore.go | 150 ++++++++++++++ internal/tiger/restore/timescaledb.go | 50 +++++ internal/tiger/restore/util.go | 64 ++++++ 10 files changed, 1476 insertions(+) create mode 100644 internal/tiger/restore/confirm.go create mode 100644 internal/tiger/restore/format.go create mode 100644 internal/tiger/restore/pg_restore.go create mode 100644 internal/tiger/restore/preflight.go create mode 100644 internal/tiger/restore/psql.go create mode 100644 internal/tiger/restore/psql_test.go create mode 100644 internal/tiger/restore/restore.go create mode 100644 internal/tiger/restore/timescaledb.go create mode 100644 internal/tiger/restore/util.go diff --git a/internal/tiger/cmd/db.go b/internal/tiger/cmd/db.go index 37d409f7..fad687d6 100644 --- a/internal/tiger/cmd/db.go +++ b/internal/tiger/cmd/db.go @@ -19,6 +19,7 @@ import ( "github.com/timescale/tiger-cli/internal/tiger/api" "github.com/timescale/tiger-cli/internal/tiger/config" "github.com/timescale/tiger-cli/internal/tiger/password" + "github.com/timescale/tiger-cli/internal/tiger/restore" "github.com/timescale/tiger-cli/internal/tiger/util" ) @@ -736,6 +737,169 @@ func buildDbCreateCmd() *cobra.Command { return cmd } +func buildDbUploadCmd() *cobra.Command { + var ( + database string + role string + format string + clean bool + ifExists bool + noOwner bool + noPrivileges bool + timescaledbHooks bool + noTimescaledbHooks bool + confirm bool + onErrorStop bool + singleTransaction bool + quiet bool + verbose bool + jobs int + ) + + cmd := &cobra.Command{ + Use: "upload [service-id]", + Short: "Upload and restore database dumps", + Long: `Upload and restore PostgreSQL dumps to a database service. + +The service ID can be provided as an argument or will use the default service +from your configuration. + +Supports multiple dump formats: + - Plain SQL (.sql, .sql.gz) - Restored using psql + - Custom format (.dump, .custom) - Restored using pg_restore + - Tar format (.tar) - Restored using pg_restore + - Directory format - Restored using pg_restore + +For TimescaleDB databases, automatically detects and runs pre_restore() and +post_restore() hooks for optimal hypertable restoration. + +Cloud-Friendly Defaults: + - Continues past benign errors (like "already exists") by default + - Uses --no-owner and --no-privileges for custom/tar/directory formats + - Performs preflight checks before starting restore + +Examples: + # Upload plain SQL dump to default service + tiger db upload backup.sql + + # Upload to specific service + tiger db upload backup.sql svc-12345 + + # Upload custom format dump with 4 parallel jobs (faster) + tiger db upload backup.dump --jobs 4 + + # Read from stdin + pg_dump -Fc mydb | tiger db upload - --format custom + + # Clean existing objects before restore (DESTRUCTIVE - requires confirmation) + tiger db upload backup.sql --clean --if-exists + + # Stop on first error (not recommended for cloud environments) + tiger db upload backup.sql --on-error-stop + + # Verbose mode for debugging + tiger db upload backup.sql --verbose + +Note for AI agents: This command may be destructive when used with --clean. +Always confirm with the user before executing, especially with --clean flag.`, + Args: cobra.RangeArgs(1, 2), + ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // First arg is file path + if len(args) == 0 { + return nil, cobra.ShellCompDirectiveFilterFileExt + } + // Second arg is service ID + return serviceIDCompletion(cmd, args, toComplete) + }, + RunE: func(cmd *cobra.Command, args []string) error { + // Validate args - first arg is file path, optional second arg is service ID + if len(args) == 0 { + return fmt.Errorf("file path is required (use '-' for stdin)") + } + filePath := args[0] + + // Determine service ID from args or config + var serviceArgs []string + if len(args) > 1 { + serviceArgs = args[1:] + } + + cmd.SilenceUsage = true + + // Load config + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Get service ID using standard helper + serviceID, err := getServiceID(cfg, serviceArgs) + if err != nil { + return err + } + + // Build restore options + opts := &restore.Options{ + ServiceID: serviceID, + Database: database, + Role: role, + FilePath: filePath, + Format: format, + Clean: clean, + IfExists: ifExists, + NoOwner: noOwner, + NoPrivileges: noPrivileges, + TimescaleDBHooks: timescaledbHooks, + NoTimescaleDBHooks: noTimescaledbHooks, + Confirm: confirm, + OnErrorStop: onErrorStop, + SingleTransaction: singleTransaction, + Quiet: quiet, + Verbose: verbose, + Jobs: jobs, + Output: cmd.OutOrStdout(), + Errors: cmd.ErrOrStderr(), + } + + // Execute restore + restorer := restore.NewRestorer(cfg, opts) + return restorer.Execute(cmd.Context()) + }, + } + + // Connection flags + cmd.Flags().StringVarP(&database, "database", "d", "", "Target database name (defaults to tsdb)") + cmd.Flags().StringVar(&role, "role", "tsdbadmin", "Database role/username") + + // Format flags + cmd.Flags().StringVar(&format, "format", "", "Dump format: plain, custom, directory, tar (auto-detected if not specified)") + + // Restore options + cmd.Flags().BoolVar(&clean, "clean", false, "Drop database objects before recreating (DESTRUCTIVE)") + cmd.Flags().BoolVar(&ifExists, "if-exists", false, "Use IF EXISTS when dropping objects (use with --clean)") + cmd.Flags().BoolVar(&noOwner, "no-owner", true, "Skip restoration of object ownership (recommended for cloud)") + cmd.Flags().BoolVar(&noPrivileges, "no-privileges", true, "Skip restoration of access privileges (recommended for cloud)") + + // TimescaleDB hooks + cmd.Flags().BoolVar(×caledbHooks, "timescaledb-hooks", false, "Force enable TimescaleDB pre/post restore hooks") + cmd.Flags().BoolVar(&noTimescaledbHooks, "no-timescaledb-hooks", false, "Disable TimescaleDB hooks (even if detected)") + cmd.MarkFlagsMutuallyExclusive("timescaledb-hooks", "no-timescaledb-hooks") + + // Safety + cmd.Flags().BoolVar(&confirm, "confirm", false, "Skip confirmation prompts (for automation)") + cmd.Flags().BoolVar(&onErrorStop, "on-error-stop", false, "Stop on first error (default: continue past errors like 'already exists')") + cmd.Flags().BoolVar(&singleTransaction, "single-transaction", false, "Wrap restore in a single transaction (slower but safer)") + + // Output + cmd.Flags().BoolVar(&quiet, "quiet", false, "Suppress progress output") + cmd.Flags().BoolVar(&verbose, "verbose", false, "Show detailed restore process") + + // Performance + cmd.Flags().IntVarP(&jobs, "jobs", "j", 1, "Parallel restore jobs (for custom/directory/tar formats only)") + + return cmd +} + func buildDbCmd() *cobra.Command { cmd := &cobra.Command{ Use: "db", @@ -748,6 +912,7 @@ func buildDbCmd() *cobra.Command { cmd.AddCommand(buildDbTestConnectionCmd()) cmd.AddCommand(buildDbSavePasswordCmd()) cmd.AddCommand(buildDbCreateCmd()) + cmd.AddCommand(buildDbUploadCmd()) return cmd } diff --git a/internal/tiger/restore/confirm.go b/internal/tiger/restore/confirm.go new file mode 100644 index 00000000..5594aa27 --- /dev/null +++ b/internal/tiger/restore/confirm.go @@ -0,0 +1,27 @@ +package restore + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// confirmDestructive prompts the user to confirm destructive operations +func (r *Restorer) confirmDestructive() error { + fmt.Fprintf(r.options.Errors, "\n⚠️ WARNING: This will drop existing database objects before restore.\n") + fmt.Fprintf(r.options.Errors, "Type 'yes' to confirm: ") + + // Read user input from stdin + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return fmt.Errorf("failed to read confirmation") + } + + confirmation := strings.TrimSpace(scanner.Text()) + if strings.ToLower(confirmation) != "yes" { + return fmt.Errorf("restore cancelled") + } + + return nil +} diff --git a/internal/tiger/restore/format.go b/internal/tiger/restore/format.go new file mode 100644 index 00000000..c254b6f7 --- /dev/null +++ b/internal/tiger/restore/format.go @@ -0,0 +1,179 @@ +package restore + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// DumpFormat represents the format of a database dump file +type DumpFormat string + +const ( + FormatPlain DumpFormat = "plain" + FormatPlainGzip DumpFormat = "plain.gz" + FormatCustom DumpFormat = "custom" + FormatTar DumpFormat = "tar" + FormatDirectory DumpFormat = "directory" + FormatUnknown DumpFormat = "unknown" +) + +// detectFileFormat detects the format of a dump file based on extension and content +func detectFileFormat(filePath string, format string) (DumpFormat, error) { + // If format explicitly specified, use it + if format != "" { + switch strings.ToLower(format) { + case "plain": + return FormatPlain, nil + case "custom": + return FormatCustom, nil + case "tar": + return FormatTar, nil + case "directory": + return FormatDirectory, nil + default: + return FormatUnknown, fmt.Errorf("unsupported format: %s", format) + } + } + + // Handle stdin + if filePath == "-" { + // For stdin, assume plain SQL (can't detect format without reading) + return FormatPlain, nil + } + + // Check if it's a directory + fileInfo, err := os.Stat(filePath) + if err != nil { + return FormatUnknown, fmt.Errorf("failed to stat file: %w", err) + } + + if fileInfo.IsDir() { + // Check if it's a pg_dump directory format + if isDirectoryFormat(filePath) { + return FormatDirectory, nil + } + return FormatUnknown, fmt.Errorf("directory is not a valid pg_dump directory format") + } + + // Auto-detect based on extension + ext := strings.ToLower(filepath.Ext(filePath)) + basename := strings.TrimSuffix(filepath.Base(filePath), ext) + + // Check for .sql.gz + if ext == ".gz" && strings.HasSuffix(strings.ToLower(basename), ".sql") { + return FormatPlainGzip, nil + } + + // Check other extensions + switch ext { + case ".sql": + return FormatPlain, nil + case ".tar": + return FormatTar, nil + case ".dump", ".custom": + return FormatCustom, nil + } + + // Try to detect format by reading file header + return detectFormatByContent(filePath) +} + +// detectFormatByContent detects format by reading the file header +func detectFormatByContent(filePath string) (DumpFormat, error) { + file, err := os.Open(filePath) + if err != nil { + return FormatUnknown, fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + // Read first few bytes + header := make([]byte, 512) + n, err := file.Read(header) + if err != nil && err != io.EOF { + return FormatUnknown, fmt.Errorf("failed to read file header: %w", err) + } + header = header[:n] + + // Check for gzip magic number + if len(header) >= 2 && header[0] == 0x1f && header[1] == 0x8b { + // It's gzipped - check if it's SQL + gzReader, err := gzip.NewReader(bytes.NewReader(header)) + if err == nil { + defer gzReader.Close() + return FormatPlainGzip, nil + } + return FormatUnknown, fmt.Errorf("gzipped file but not valid gzip format") + } + + // Check for pg_dump custom format magic string + // Custom format starts with "PGDMP" (PostgreSQL Dump) + if len(header) >= 5 && string(header[:5]) == "PGDMP" { + return FormatCustom, nil + } + + // Check for tar format + // Tar files have a specific structure at offset 257 + if len(header) >= 262 && string(header[257:262]) == "ustar" { + return FormatTar, nil + } + + // Check if it looks like plain SQL (heuristic) + if looksLikePlainSQL(header) { + return FormatPlain, nil + } + + return FormatUnknown, fmt.Errorf("unable to detect file format") +} + +// isDirectoryFormat checks if a directory is a pg_dump directory format +func isDirectoryFormat(dirPath string) bool { + // pg_dump directory format has a toc.dat file + tocPath := filepath.Join(dirPath, "toc.dat") + if _, err := os.Stat(tocPath); err == nil { + return true + } + return false +} + +// looksLikePlainSQL uses heuristics to check if content looks like SQL +func looksLikePlainSQL(content []byte) bool { + // Convert to string for easier checking + text := strings.ToLower(string(content)) + + // Common SQL keywords at the beginning of dumps + sqlKeywords := []string{ + "--", // SQL comment + "/*", // SQL block comment + "set ", + "select ", + "create ", + "insert ", + "drop ", + "alter ", + "begin", + } + + // Check if any keyword appears near the start + for _, keyword := range sqlKeywords { + if strings.Contains(text, keyword) { + return true + } + } + + return false +} + +// String returns the string representation of DumpFormat +func (f DumpFormat) String() string { + return string(f) +} + +// RequiresPgRestore returns true if the format requires pg_restore tool +func (f DumpFormat) RequiresPgRestore() bool { + return f == FormatCustom || f == FormatTar || f == FormatDirectory +} diff --git a/internal/tiger/restore/pg_restore.go b/internal/tiger/restore/pg_restore.go new file mode 100644 index 00000000..4eaffd96 --- /dev/null +++ b/internal/tiger/restore/pg_restore.go @@ -0,0 +1,109 @@ +package restore + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" +) + +// restoreWithPgRestore restores using the pg_restore tool for custom/tar/directory formats +func (r *Restorer) restoreWithPgRestore(ctx context.Context, connStr string, preflight *PreflightResult) error { + // Build pg_restore command arguments + args := r.buildPgRestoreArgs(connStr) + + // Create command + cmd := exec.CommandContext(ctx, preflight.PgRestorePath, args...) + + // Capture output + var stderr bytes.Buffer + cmd.Stdout = r.options.Output + cmd.Stderr = &stderr + + if r.options.Verbose { + // In verbose mode, show command being executed + fmt.Fprintf(r.options.Errors, "\nExecuting: pg_restore %s\n", strings.Join(args, " ")) + } + + // Execute pg_restore + err := cmd.Run() + if err != nil { + // pg_restore returns non-zero for warnings too, check stderr + stderrStr := stderr.String() + if stderrStr != "" { + return &RestoreError{ + Phase: "restore", + PostgresError: stderrStr, + } + } + return fmt.Errorf("pg_restore failed: %w", err) + } + + // Show warnings if any + if stderr.Len() > 0 && r.options.Verbose { + fmt.Fprintf(r.options.Errors, "\nWarnings:\n%s\n", stderr.String()) + } + + return nil +} + +// buildPgRestoreArgs builds the command-line arguments for pg_restore +func (r *Restorer) buildPgRestoreArgs(connStr string) []string { + args := []string{ + "--dbname=" + connStr, + } + + // Add format flag if explicitly specified + if r.options.Format != "" { + switch r.options.Format { + case "custom": + args = append(args, "--format=custom") + case "tar": + args = append(args, "--format=tar") + case "directory": + args = append(args, "--format=directory") + } + } + + // Restore options + if r.options.Clean { + args = append(args, "--clean") + } + + if r.options.IfExists { + args = append(args, "--if-exists") + } + + if r.options.NoOwner { + args = append(args, "--no-owner") + } + + if r.options.NoPrivileges { + args = append(args, "--no-privileges", "--no-acl") + } + + if r.options.SingleTransaction { + args = append(args, "--single-transaction") + } + + // Parallel restore + if r.options.Jobs > 1 { + args = append(args, fmt.Sprintf("--jobs=%d", r.options.Jobs)) + } + + // Error handling + if r.options.OnErrorStop { + args = append(args, "--exit-on-error") + } + + // Verbose output + if r.options.Verbose { + args = append(args, "--verbose") + } + + // Add file path (last argument) + args = append(args, r.options.FilePath) + + return args +} diff --git a/internal/tiger/restore/preflight.go b/internal/tiger/restore/preflight.go new file mode 100644 index 00000000..c637321a --- /dev/null +++ b/internal/tiger/restore/preflight.go @@ -0,0 +1,225 @@ +package restore + +import ( + "context" + "fmt" + "os" + "os/exec" + + "github.com/jackc/pgx/v5" + "github.com/timescale/tiger-cli/internal/tiger/api" + "github.com/timescale/tiger-cli/internal/tiger/config" + "github.com/timescale/tiger-cli/internal/tiger/password" +) + +// PreflightResult contains the results of pre-flight validation checks +type PreflightResult struct { + FileExists bool + FileReadable bool + FileSize int64 + FileFormat DumpFormat + ServiceExists bool + ServiceAccessible bool + DatabaseExists bool + HasTimescaleDB bool + TimescaleDBVersion string + PostgreSQLVersion string + PgRestoreAvailable bool + PgRestorePath string +} + +// runPreflight performs pre-flight validation checks +func (r *Restorer) runPreflight(ctx context.Context) (*PreflightResult, error) { + result := &PreflightResult{} + + // 1. Check file exists and is readable (unless stdin) + if r.options.FilePath != "-" { + fileInfo, err := os.Stat(r.options.FilePath) + if err != nil { + return result, fmt.Errorf("file not found: %w", err) + } + result.FileExists = true + + // Check if we can open the file + file, err := os.Open(r.options.FilePath) + if err != nil { + return result, fmt.Errorf("file not readable: %w", err) + } + file.Close() + result.FileReadable = true + + // Get file size (0 for directories) + if !fileInfo.IsDir() { + result.FileSize = fileInfo.Size() + } + } else { + // stdin - assume readable + result.FileExists = true + result.FileReadable = true + result.FileSize = 0 // unknown size for stdin + } + + // 2. Detect file format + format, err := detectFileFormat(r.options.FilePath, r.options.Format) + if err != nil { + return result, fmt.Errorf("failed to detect file format: %w", err) + } + result.FileFormat = format + + // 3. Check if pg_restore or psql is available (depending on format) + if format.RequiresPgRestore() { + pgRestorePath, err := exec.LookPath("pg_restore") + if err != nil { + return result, fmt.Errorf("pg_restore not found. Please install PostgreSQL client tools") + } + result.PgRestoreAvailable = true + result.PgRestorePath = pgRestorePath + } else if format == FormatPlain || format == FormatPlainGzip { + // Plain SQL requires psql + psqlPath, err := exec.LookPath("psql") + if err != nil { + return result, fmt.Errorf("psql not found. Please install PostgreSQL client tools") + } + result.PgRestoreAvailable = true // Reuse this field for psql availability + result.PgRestorePath = psqlPath + } + + // 4. Get service details and test connectivity + service, err := r.getService(ctx) + if err != nil { + return result, fmt.Errorf("failed to get service details: %w", err) + } + r.service = service + result.ServiceExists = true + + // 5. Test database connectivity and gather metadata + connStr, err := r.getConnectionString(ctx) + if err != nil { + return result, fmt.Errorf("failed to build connection string: %w", err) + } + + conn, err := pgx.Connect(ctx, connStr) + if err != nil { + return result, fmt.Errorf("failed to connect to database: %w", err) + } + defer conn.Close(ctx) + result.ServiceAccessible = true + result.DatabaseExists = true + + // 6. Check PostgreSQL version + var pgVersion string + err = conn.QueryRow(ctx, "SELECT version()").Scan(&pgVersion) + if err != nil { + return result, fmt.Errorf("failed to get PostgreSQL version: %w", err) + } + result.PostgreSQLVersion = pgVersion + + // 7. Check for TimescaleDB extension + var timescaleVersion string + err = conn.QueryRow(ctx, "SELECT extversion FROM pg_extension WHERE extname = 'timescaledb'").Scan(×caleVersion) + if err == nil { + result.HasTimescaleDB = true + result.TimescaleDBVersion = timescaleVersion + } + // Not an error if TimescaleDB is not installed + + return result, nil +} + +// getService fetches service details from the API +func (r *Restorer) getService(ctx context.Context) (api.Service, error) { + // Get credentials + apiKey, projectID, err := config.GetCredentials() + if err != nil { + return api.Service{}, fmt.Errorf("authentication required: %w. Please run 'tiger auth login'", err) + } + + // Create API client + client, err := api.NewTigerClient(r.config, apiKey) + if err != nil { + return api.Service{}, fmt.Errorf("failed to create API client: %w", err) + } + + // Fetch service details + resp, err := client.GetProjectsProjectIdServicesServiceIdWithResponse(ctx, projectID, r.options.ServiceID) + if err != nil { + return api.Service{}, fmt.Errorf("failed to fetch service details: %w", err) + } + + if resp.StatusCode() != 200 { + return api.Service{}, fmt.Errorf("API error: %d", resp.StatusCode()) + } + + if resp.JSON200 == nil { + return api.Service{}, fmt.Errorf("empty response from API") + } + + return *resp.JSON200, nil +} + +// getConnectionString builds a connection string for the service +func (r *Restorer) getConnectionString(ctx context.Context) (string, error) { + role := r.options.Role + if role == "" { + role = "tsdbadmin" + } + + details, err := password.GetConnectionDetails(r.service, password.ConnectionDetailsOptions{ + Pooled: false, // Never use pooler for restore operations + Role: role, + WithPassword: true, + }) + if err != nil { + return "", fmt.Errorf("failed to build connection string: %w", err) + } + + return details.String(), nil +} + +// printPreflightResults prints the pre-flight validation results +func (r *Restorer) printPreflightResults(result *PreflightResult) { + fmt.Fprintln(r.options.Errors, "\nPre-flight validation:") + + // File info + if r.options.FilePath == "-" { + fmt.Fprintln(r.options.Errors, "✓ File: stdin (plain SQL)") + } else { + sizeStr := formatBytes(result.FileSize) + if result.FileSize == 0 { + sizeStr = "directory" + } + fmt.Fprintf(r.options.Errors, "✓ File: %s (%s, %s)\n", + r.options.FilePath, sizeStr, result.FileFormat) + } + + // Service info + if r.service.ServiceId != nil { + fmt.Fprintf(r.options.Errors, "✓ Service: %s (accessible)\n", *r.service.ServiceId) + } + + // Database info + fmt.Fprintf(r.options.Errors, "✓ Database: %s (PostgreSQL)\n", + getTargetDatabase(r.options.Database)) + + // TimescaleDB info + if result.HasTimescaleDB { + fmt.Fprintf(r.options.Errors, "✓ TimescaleDB: %s detected\n", result.TimescaleDBVersion) + } + + // pg_restore/psql info (if needed) + if result.FileFormat.RequiresPgRestore() { + fmt.Fprintf(r.options.Errors, "✓ pg_restore: found at %s\n", result.PgRestorePath) + } else if result.FileFormat == FormatPlain || result.FileFormat == FormatPlainGzip { + fmt.Fprintf(r.options.Errors, "✓ psql: found at %s\n", result.PgRestorePath) + } + + fmt.Fprintln(r.options.Errors, "\nReady to restore.") +} + +// getTargetDatabase returns the target database name +func getTargetDatabase(database string) string { + if database != "" { + return database + } + return "tsdb" // default database +} diff --git a/internal/tiger/restore/psql.go b/internal/tiger/restore/psql.go new file mode 100644 index 00000000..2469c45b --- /dev/null +++ b/internal/tiger/restore/psql.go @@ -0,0 +1,280 @@ +package restore + +import ( + "context" + "fmt" + "os" + "os/exec" + "time" + + "github.com/jackc/pgx/v5" + "github.com/timescale/tiger-cli/internal/tiger/password" +) + +// restorePlainSQLWithPsql restores a plain SQL dump using psql +func (r *Restorer) restorePlainSQLWithPsql(ctx context.Context, connStr string, preflight *PreflightResult) error { + startTime := getNow() + // Check if psql is available + psqlPath, err := exec.LookPath("psql") + if err != nil { + return fmt.Errorf("psql client not found. Please install PostgreSQL client tools") + } + + // Build psql command arguments + args := []string{connStr} + + // Add options based on restore settings + if r.options.OnErrorStop { + args = append(args, "--set", "ON_ERROR_STOP=1") + } else { + // When not stopping on errors, add helpful psql variable settings + // ON_ERROR_ROLLBACK=interactive rolls back failed statements but continues + // This is useful for cloud environments where some objects may already exist + args = append(args, "--set", "ON_ERROR_ROLLBACK=on") + } + + if r.options.SingleTransaction { + args = append(args, "--single-transaction") + } + + // Control output verbosity + if r.options.Quiet { + args = append(args, "--quiet") + // Also suppress NOTICE messages + args = append(args, "--set", "VERBOSITY=terse") + } else if !r.options.Verbose { + // Default: show only errors, suppress successful command output + // --quiet suppresses meta-commands and table results + args = append(args, "--quiet") + // VERBOSITY=terse makes error messages more concise + args = append(args, "--set", "VERBOSITY=terse") + } else { + // Verbose mode: show everything including commands being executed + args = append(args, "--echo-all") + } + + // Add file input (or stdin) + if r.options.FilePath == "-" { + // Read from stdin - psql will automatically read from stdin if no file specified + args = append(args, "--file", "-") + } else { + args = append(args, "--file", r.options.FilePath) + } + + // Create command + cmd := exec.CommandContext(ctx, psqlPath, args...) + + // Set up I/O + if r.options.Verbose { + // Verbose mode: show everything + cmd.Stdout = r.options.Output + cmd.Stderr = r.options.Errors + } else if r.options.Quiet { + // Quiet mode: suppress everything + cmd.Stdout = nil + cmd.Stderr = nil + } else { + // Default mode: suppress all output (both stdout and stderr) + // We're using --on-error-stop=false, so errors are expected and tolerated + // Only show final success/failure summary + cmd.Stdout = nil + cmd.Stderr = nil + } + + if r.options.FilePath == "-" { + cmd.Stdin = os.Stdin + } + + // Set PGPASSWORD environment variable if using keyring storage + storage := password.GetPasswordStorage() + if _, isKeyring := storage.(*password.KeyringStorage); isKeyring { + if pwd, err := storage.Get(r.service, r.options.Role); err == nil && pwd != "" { + cmd.Env = append(os.Environ(), "PGPASSWORD="+pwd) + } + } + + // Execute psql + if err := cmd.Run(); err != nil { + return fmt.Errorf("psql execution failed: %w", err) + } + + // Calculate duration + duration := getNow().Sub(startTime) + + // Show restore summary unless quiet mode + if !r.options.Quiet { + fmt.Fprintln(r.options.Errors) // blank line + if err := r.printRestoreSummary(ctx, connStr, duration); err != nil { + // Don't fail the restore if we can't get summary stats + fmt.Fprintf(r.options.Errors, "⚠️ Warning: Could not retrieve restore summary: %v\n", err) + } + } + + return nil +} + +// RestoreSummary contains statistics about what was restored +type RestoreSummary struct { + Tables int + Views int + Functions int + Sequences int + Indexes int + Hypertables int + TotalRows int64 + HasTimescale bool +} + +// printRestoreSummary queries the database and prints a summary of restored objects +func (r *Restorer) printRestoreSummary(ctx context.Context, connStr string, duration time.Duration) error { + conn, err := pgx.Connect(ctx, connStr) + if err != nil { + return err + } + defer conn.Close(ctx) + + summary := &RestoreSummary{} + + // Count tables in public schema + err = conn.QueryRow(ctx, ` + SELECT COUNT(*) + FROM pg_tables + WHERE schemaname = 'public' + `).Scan(&summary.Tables) + if err != nil { + return err + } + + // Count views in public schema + err = conn.QueryRow(ctx, ` + SELECT COUNT(*) + FROM pg_views + WHERE schemaname = 'public' + `).Scan(&summary.Views) + if err != nil { + return err + } + + // Count functions in public schema + err = conn.QueryRow(ctx, ` + SELECT COUNT(*) + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE n.nspname = 'public' + `).Scan(&summary.Functions) + if err != nil { + return err + } + + // Count sequences in public schema + err = conn.QueryRow(ctx, ` + SELECT COUNT(*) + FROM pg_sequences + WHERE schemaname = 'public' + `).Scan(&summary.Sequences) + if err != nil { + return err + } + + // Count indexes in public schema + err = conn.QueryRow(ctx, ` + SELECT COUNT(*) + FROM pg_indexes + WHERE schemaname = 'public' + `).Scan(&summary.Indexes) + if err != nil { + return err + } + + // Check for TimescaleDB and count hypertables + var hasTimescale bool + err = conn.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 FROM pg_extension WHERE extname = 'timescaledb' + ) + `).Scan(&hasTimescale) + if err == nil && hasTimescale { + summary.HasTimescale = true + err = conn.QueryRow(ctx, ` + SELECT COUNT(*) + FROM timescaledb_information.hypertables + WHERE hypertable_schema = 'public' + `).Scan(&summary.Hypertables) + if err != nil { + return err + } + } + + // Estimate total rows across all tables in public schema + err = conn.QueryRow(ctx, ` + SELECT COALESCE(SUM(n_live_tup), 0) + FROM pg_stat_user_tables + WHERE schemaname = 'public' + `).Scan(&summary.TotalRows) + if err != nil { + return err + } + + // Print sleek, minimal summary + fmt.Fprintf(r.options.Errors, "✓ Restore completed in %s\n\n", formatDuration(duration)) + + // Show compact stats on one or two lines + var stats []string + if summary.Tables > 0 { + stats = append(stats, fmt.Sprintf("%d table%s", summary.Tables, pluralize(summary.Tables))) + } + if summary.Views > 0 { + stats = append(stats, fmt.Sprintf("%d view%s", summary.Views, pluralize(summary.Views))) + } + if summary.Functions > 0 { + stats = append(stats, fmt.Sprintf("%d function%s", summary.Functions, pluralize(summary.Functions))) + } + if summary.Sequences > 0 { + stats = append(stats, fmt.Sprintf("%d sequence%s", summary.Sequences, pluralize(summary.Sequences))) + } + if summary.Indexes > 0 { + stats = append(stats, fmt.Sprintf("%d index%s", summary.Indexes, pluralize(summary.Indexes))) + } + if summary.HasTimescale && summary.Hypertables > 0 { + stats = append(stats, fmt.Sprintf("%d hypertable%s", summary.Hypertables, pluralize(summary.Hypertables))) + } + + if len(stats) > 0 { + fmt.Fprintf(r.options.Errors, "📊 ") + for i, stat := range stats { + if i > 0 { + fmt.Fprintf(r.options.Errors, " • ") + } + fmt.Fprintf(r.options.Errors, "%s", stat) + } + fmt.Fprintln(r.options.Errors) + } + + if summary.TotalRows > 0 { + fmt.Fprintf(r.options.Errors, "📈 %s rows\n", formatRowCount(summary.TotalRows)) + } + + return nil +} + +// pluralize returns "s" if count != 1, otherwise empty string +func pluralize(count int) string { + if count == 1 { + return "" + } + return "s" +} + +// formatRowCount formats row counts with thousand separators +func formatRowCount(count int64) string { + if count < 1000 { + return fmt.Sprintf("%d", count) + } + if count < 1000000 { + return fmt.Sprintf("%.1fK", float64(count)/1000) + } + if count < 1000000000 { + return fmt.Sprintf("%.1fM", float64(count)/1000000) + } + return fmt.Sprintf("%.1fB", float64(count)/1000000000) +} diff --git a/internal/tiger/restore/psql_test.go b/internal/tiger/restore/psql_test.go new file mode 100644 index 00000000..5719f10b --- /dev/null +++ b/internal/tiger/restore/psql_test.go @@ -0,0 +1,227 @@ +package restore + +import ( + "bytes" + "fmt" + "testing" + "time" + + "github.com/timescale/tiger-cli/internal/tiger/config" +) + +func TestFormatRowCount(t *testing.T) { + tests := []struct { + name string + count int64 + expected string + }{ + {"less than 1000", 999, "999"}, + {"exactly 1000", 1000, "1.0K"}, + {"thousands", 5500, "5.5K"}, + {"exactly 1 million", 1000000, "1.0M"}, + {"millions", 2500000, "2.5M"}, + {"exactly 1 billion", 1000000000, "1.0B"}, + {"billions", 3500000000, "3.5B"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatRowCount(tt.count) + if result != tt.expected { + t.Errorf("formatRowCount(%d) = %s; want %s", tt.count, result, tt.expected) + } + }) + } +} + +func TestPluralize(t *testing.T) { + tests := []struct { + name string + count int + expected string + }{ + {"zero", 0, "s"}, + {"one", 1, ""}, + {"two", 2, "s"}, + {"many", 100, "s"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := pluralize(tt.count) + if result != tt.expected { + t.Errorf("pluralize(%d) = %s; want %s", tt.count, result, tt.expected) + } + }) + } +} + +func TestRestorePlainSQLWithPsql_OutputModes(t *testing.T) { + // Save original getNow and restore after test + originalGetNow := getNow + defer func() { getNow = originalGetNow }() + + // Mock time for consistent duration + mockTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + getNow = func() time.Time { return mockTime } + + tests := []struct { + name string + quiet bool + verbose bool + wantOut string + }{ + { + name: "default mode", + quiet: false, + verbose: false, + wantOut: "", // Should suppress all psql output + }, + { + name: "verbose mode", + quiet: false, + verbose: true, + wantOut: "", // Would show psql output if psql was actually run + }, + { + name: "quiet mode", + quiet: true, + verbose: false, + wantOut: "", // Should suppress everything + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create test restorer + cfg := &config.Config{} + opts := &Options{ + ServiceID: "test-service", + FilePath: "test.sql", + Quiet: tt.quiet, + Verbose: tt.verbose, + Role: "tsdbadmin", + } + + var outBuf, errBuf bytes.Buffer + opts.Output = &outBuf + opts.Errors = &errBuf + + _ = NewRestorer(cfg, opts) + + // Note: This test doesn't actually run psql, just tests the setup logic + // Full integration tests would require a real database + }) + } +} + +func TestRestoreSummaryFormatting(t *testing.T) { + tests := []struct { + name string + summary *RestoreSummary + duration time.Duration + wantText []string // Strings that should appear in output + }{ + { + name: "single table", + summary: &RestoreSummary{ + Tables: 1, + TotalRows: 1000, + }, + duration: 1500 * time.Millisecond, + wantText: []string{"1 table", "1.0K rows"}, + }, + { + name: "multiple tables and indexes", + summary: &RestoreSummary{ + Tables: 5, + Indexes: 10, + TotalRows: 1500000, + }, + duration: 3 * time.Second, + wantText: []string{"5 tables", "10 indexes", "1.5M rows"}, + }, + { + name: "hypertables", + summary: &RestoreSummary{ + Tables: 3, + Hypertables: 2, + HasTimescale: true, + TotalRows: 50000, + }, + duration: 2 * time.Second, + wantText: []string{"3 tables", "2 hypertables", "50.0K rows"}, + }, + { + name: "all object types", + summary: &RestoreSummary{ + Tables: 10, + Views: 3, + Functions: 5, + Sequences: 2, + Indexes: 15, + Hypertables: 4, + HasTimescale: true, + TotalRows: 2000000000, + }, + duration: 30 * time.Second, + wantText: []string{ + "10 tables", + "3 views", + "5 functions", + "2 sequences", + "15 indexes", + "4 hypertables", + "2.0B rows", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // We test the formatting logic directly without creating a restorer + // since we can't mock database connections in unit tests + + // Test row count formatting + if tt.summary.TotalRows > 0 { + formatted := formatRowCount(tt.summary.TotalRows) + found := false + for _, want := range tt.wantText { + if want == formatted+" rows" { + found = true + break + } + } + if !found { + t.Errorf("formatRowCount(%d) = %s; expected to match one of %v", + tt.summary.TotalRows, formatted, tt.wantText) + } + } + + // Test pluralization + if tt.summary.Tables > 0 { + _ = pluralize(tt.summary.Tables) + found := false + for _, want := range tt.wantText { + if want == formatStatCount(tt.summary.Tables, "table") { + found = true + break + } + } + if !found { + t.Errorf("Expected pluralization for %d tables", tt.summary.Tables) + } + } + }) + } +} + +// Helper function to format stat counts consistently +func formatStatCount(count int, singular string) string { + return fmt.Sprintf("%d %s%s", count, singular, pluralize(count)) +} + +// Helper to create string pointers +func stringPtr(s string) *string { + return &s +} diff --git a/internal/tiger/restore/restore.go b/internal/tiger/restore/restore.go new file mode 100644 index 00000000..a6a09fca --- /dev/null +++ b/internal/tiger/restore/restore.go @@ -0,0 +1,150 @@ +package restore + +import ( + "context" + "fmt" + "io" + + "github.com/timescale/tiger-cli/internal/tiger/api" + "github.com/timescale/tiger-cli/internal/tiger/config" + "go.uber.org/zap" +) + +// Options contains configuration for database restore operations +type Options struct { + ServiceID string + Database string + Role string + FilePath string // "-" for stdin + Format string // plain, custom, directory, tar (empty = auto-detect) + Clean bool + IfExists bool + NoOwner bool + NoPrivileges bool + TimescaleDBHooks bool + NoTimescaleDBHooks bool + Confirm bool + OnErrorStop bool + SingleTransaction bool + Quiet bool + Verbose bool + Jobs int // parallel restore jobs for custom/directory/tar formats + Output io.Writer + Errors io.Writer +} + +// Restorer orchestrates database restore operations +type Restorer struct { + config *config.Config + options *Options + logger *zap.Logger + service api.Service +} + +// NewRestorer creates a new Restorer instance +func NewRestorer(cfg *config.Config, opts *Options) *Restorer { + return &Restorer{ + config: cfg, + options: opts, + logger: zap.L(), + } +} + +// Execute performs the complete restore operation +func (r *Restorer) Execute(ctx context.Context) error { + // 1. Pre-flight validation + if !r.options.Quiet { + fmt.Fprintf(r.options.Errors, "⚙️ Preparing restore...\n") + } + + preflight, err := r.runPreflight(ctx) + if err != nil { + return fmt.Errorf("pre-flight validation failed: %w", err) + } + + if !r.options.Quiet && r.options.Verbose { + // Only show detailed preflight in verbose mode + r.printPreflightResults(preflight) + } + + // 2. Confirmation for destructive operations + if r.options.Clean && !r.options.Confirm { + if err := r.confirmDestructive(); err != nil { + return err + } + } + + // 3. Get connection string + connStr, err := r.getConnectionString(ctx) + if err != nil { + return err + } + + // 4. Run TimescaleDB pre-restore hook + shouldUseHooks := r.shouldUseTimescaleDBHooks(preflight) + if shouldUseHooks { + if !r.options.Quiet && r.options.Verbose { + fmt.Fprintln(r.options.Errors, "Running TimescaleDB pre-restore hooks...") + } + if err := r.runTimescaleDBPreRestore(ctx, connStr); err != nil { + return fmt.Errorf("timescaledb pre-restore failed: %w", err) + } + if !r.options.Quiet && r.options.Verbose { + fmt.Fprintln(r.options.Errors, "✓ TimescaleDB pre-restore hooks executed") + } + } + + // 5. Execute restore with progress tracking + if !r.options.Quiet { + fmt.Fprintf(r.options.Errors, "📦 Restoring database...\n") + } + + if err := r.executeRestore(ctx, connStr, preflight); err != nil { + return fmt.Errorf("restore failed: %w", err) + } + + // 6. Run TimescaleDB post-restore hook + if shouldUseHooks { + if !r.options.Quiet && r.options.Verbose { + fmt.Fprintln(r.options.Errors, "\nRunning TimescaleDB post-restore hooks...") + } + if err := r.runTimescaleDBPostRestore(ctx, connStr); err != nil { + return fmt.Errorf("timescaledb post-restore failed: %w", err) + } + if !r.options.Quiet && r.options.Verbose { + fmt.Fprintln(r.options.Errors, "✓ TimescaleDB post-restore hooks executed") + } + } + + // 7. Print summary - moved to psql.go to combine with restore stats + + return nil +} + +// executeRestore dispatches to the appropriate restore method based on format +func (r *Restorer) executeRestore(ctx context.Context, connStr string, preflight *PreflightResult) error { + format := preflight.FileFormat + + switch format { + case FormatPlain, FormatPlainGzip: + return r.restorePlainSQLWithPsql(ctx, connStr, preflight) + case FormatCustom, FormatTar, FormatDirectory: + return r.restoreWithPgRestore(ctx, connStr, preflight) + default: + return fmt.Errorf("unsupported format: %s", format) + } +} + +// shouldUseTimescaleDBHooks determines if TimescaleDB hooks should be run +func (r *Restorer) shouldUseTimescaleDBHooks(preflight *PreflightResult) bool { + // Explicit flags take precedence + if r.options.NoTimescaleDBHooks { + return false + } + if r.options.TimescaleDBHooks { + return true + } + + // Auto-detect: use hooks if TimescaleDB is installed + return preflight.HasTimescaleDB +} diff --git a/internal/tiger/restore/timescaledb.go b/internal/tiger/restore/timescaledb.go new file mode 100644 index 00000000..129dfeb2 --- /dev/null +++ b/internal/tiger/restore/timescaledb.go @@ -0,0 +1,50 @@ +package restore + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" +) + +// runTimescaleDBPreRestore executes the timescaledb_pre_restore() function +// This function prepares the database for restore by: +// - Disabling compression policies +// - Pausing background jobs +// - Setting up the database for optimal restore performance +func (r *Restorer) runTimescaleDBPreRestore(ctx context.Context, connStr string) error { + conn, err := pgx.Connect(ctx, connStr) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer conn.Close(ctx) + + // Execute timescaledb_pre_restore() + _, err = conn.Exec(ctx, "SELECT public.timescaledb_pre_restore()") + if err != nil { + return fmt.Errorf("failed to execute timescaledb_pre_restore(): %w", err) + } + + return nil +} + +// runTimescaleDBPostRestore executes the timescaledb_post_restore() function +// This function cleans up after restore by: +// - Re-enabling compression policies +// - Resuming background jobs +// - Rebuilding necessary metadata +func (r *Restorer) runTimescaleDBPostRestore(ctx context.Context, connStr string) error { + conn, err := pgx.Connect(ctx, connStr) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + defer conn.Close(ctx) + + // Execute timescaledb_post_restore() + _, err = conn.Exec(ctx, "SELECT public.timescaledb_post_restore()") + if err != nil { + return fmt.Errorf("failed to execute timescaledb_post_restore(): %w", err) + } + + return nil +} diff --git a/internal/tiger/restore/util.go b/internal/tiger/restore/util.go new file mode 100644 index 00000000..bae2dd67 --- /dev/null +++ b/internal/tiger/restore/util.go @@ -0,0 +1,64 @@ +package restore + +import ( + "fmt" + "time" +) + +// formatBytes formats bytes into human-readable format +func formatBytes(bytes int64) string { + const ( + KB = 1024 + MB = 1024 * KB + GB = 1024 * MB + ) + + switch { + case bytes >= GB: + return fmt.Sprintf("%.1f GB", float64(bytes)/float64(GB)) + case bytes >= MB: + return fmt.Sprintf("%.1f MB", float64(bytes)/float64(MB)) + case bytes >= KB: + return fmt.Sprintf("%.1f KB", float64(bytes)/float64(KB)) + default: + return fmt.Sprintf("%d B", bytes) + } +} + +// formatDuration formats a duration into human-readable format +func formatDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", d.Milliseconds()) + } + if d < time.Minute { + return fmt.Sprintf("%.1fs", d.Seconds()) + } + if d < time.Hour { + minutes := int(d.Minutes()) + seconds := int(d.Seconds()) % 60 + return fmt.Sprintf("%dm%ds", minutes, seconds) + } + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + return fmt.Sprintf("%dh%dm", hours, minutes) +} + +// getNow returns the current time (can be mocked in tests) +var getNow = time.Now + +// RestoreError represents an error during restore +type RestoreError struct { + Phase string // "preflight", "pre_restore", "restore", "post_restore" + Statement string + LineNumber int + PostgresError string + Recoverable bool +} + +// Error implements the error interface +func (e *RestoreError) Error() string { + if e.LineNumber > 0 { + return fmt.Sprintf("%s failed at line %d: %s", e.Phase, e.LineNumber, e.PostgresError) + } + return fmt.Sprintf("%s failed: %s", e.Phase, e.PostgresError) +} From 31d2aac9acb72456a1d7e9d78edc33719d11e0d2 Mon Sep 17 00:00:00 2001 From: Vineeth Pothulapati Date: Fri, 7 Nov 2025 17:06:19 +0530 Subject: [PATCH 2/7] rename from upload to restore --- internal/tiger/cmd/db.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/internal/tiger/cmd/db.go b/internal/tiger/cmd/db.go index fad687d6..8735369c 100644 --- a/internal/tiger/cmd/db.go +++ b/internal/tiger/cmd/db.go @@ -737,7 +737,7 @@ func buildDbCreateCmd() *cobra.Command { return cmd } -func buildDbUploadCmd() *cobra.Command { +func buildDbRestoreCmd() *cobra.Command { var ( database string role string @@ -757,9 +757,9 @@ func buildDbUploadCmd() *cobra.Command { ) cmd := &cobra.Command{ - Use: "upload [service-id]", - Short: "Upload and restore database dumps", - Long: `Upload and restore PostgreSQL dumps to a database service. + Use: "restore [service-id]", + Short: "Restore database from dump file", + Long: `Restore a PostgreSQL database from a dump file to a database service. The service ID can be provided as an argument or will use the default service from your configuration. @@ -779,26 +779,26 @@ Cloud-Friendly Defaults: - Performs preflight checks before starting restore Examples: - # Upload plain SQL dump to default service - tiger db upload backup.sql + # Restore plain SQL dump to default service + tiger db restore backup.sql - # Upload to specific service - tiger db upload backup.sql svc-12345 + # Restore to specific service + tiger db restore backup.sql svc-12345 - # Upload custom format dump with 4 parallel jobs (faster) - tiger db upload backup.dump --jobs 4 + # Restore custom format dump with 4 parallel jobs (faster) + tiger db restore backup.dump --jobs 4 # Read from stdin - pg_dump -Fc mydb | tiger db upload - --format custom + pg_dump -Fc mydb | tiger db restore - --format custom # Clean existing objects before restore (DESTRUCTIVE - requires confirmation) - tiger db upload backup.sql --clean --if-exists + tiger db restore backup.sql --clean --if-exists # Stop on first error (not recommended for cloud environments) - tiger db upload backup.sql --on-error-stop + tiger db restore backup.sql --on-error-stop # Verbose mode for debugging - tiger db upload backup.sql --verbose + tiger db restore backup.sql --verbose Note for AI agents: This command may be destructive when used with --clean. Always confirm with the user before executing, especially with --clean flag.`, @@ -912,7 +912,7 @@ func buildDbCmd() *cobra.Command { cmd.AddCommand(buildDbTestConnectionCmd()) cmd.AddCommand(buildDbSavePasswordCmd()) cmd.AddCommand(buildDbCreateCmd()) - cmd.AddCommand(buildDbUploadCmd()) + cmd.AddCommand(buildDbRestoreCmd()) return cmd } From e101eaf656fd99f845355f42b5bf62d4503cf9d8 Mon Sep 17 00:00:00 2001 From: Vineeth Pothulapati Date: Sat, 8 Nov 2025 16:57:36 +0530 Subject: [PATCH 3/7] Update internal/tiger/restore/psql.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Vineeth Pothulapati --- internal/tiger/restore/psql.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/tiger/restore/psql.go b/internal/tiger/restore/psql.go index 2469c45b..dfcd2048 100644 --- a/internal/tiger/restore/psql.go +++ b/internal/tiger/restore/psql.go @@ -88,6 +88,9 @@ func (r *Restorer) restorePlainSQLWithPsql(ctx context.Context, connStr string, // Set PGPASSWORD environment variable if using keyring storage storage := password.GetPasswordStorage() if _, isKeyring := storage.(*password.KeyringStorage); isKeyring { + if r.service == nil { + return fmt.Errorf("service is not initialized; cannot retrieve password") + } if pwd, err := storage.Get(r.service, r.options.Role); err == nil && pwd != "" { cmd.Env = append(os.Environ(), "PGPASSWORD="+pwd) } From 5eb35282dccafac8e834f3ad4a1f3ae039821971 Mon Sep 17 00:00:00 2001 From: Vineeth Pothulapati Date: Sat, 8 Nov 2025 16:57:46 +0530 Subject: [PATCH 4/7] Update internal/tiger/restore/psql.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Vineeth Pothulapati --- internal/tiger/restore/psql.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tiger/restore/psql.go b/internal/tiger/restore/psql.go index dfcd2048..9e38f765 100644 --- a/internal/tiger/restore/psql.go +++ b/internal/tiger/restore/psql.go @@ -75,7 +75,7 @@ func (r *Restorer) restorePlainSQLWithPsql(ctx context.Context, connStr string, cmd.Stderr = nil } else { // Default mode: suppress all output (both stdout and stderr) - // We're using --on-error-stop=false, so errors are expected and tolerated + // Errors are expected and tolerated unless ON_ERROR_STOP=1 is set in the environment // Only show final success/failure summary cmd.Stdout = nil cmd.Stderr = nil From 623ff052b78c01929b2badd5a85b3245c1de0f39 Mon Sep 17 00:00:00 2001 From: Vineeth Pothulapati Date: Sat, 8 Nov 2025 16:58:05 +0530 Subject: [PATCH 5/7] Update internal/tiger/restore/pg_restore.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Vineeth Pothulapati --- internal/tiger/restore/pg_restore.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tiger/restore/pg_restore.go b/internal/tiger/restore/pg_restore.go index 4eaffd96..25ae2fc0 100644 --- a/internal/tiger/restore/pg_restore.go +++ b/internal/tiger/restore/pg_restore.go @@ -80,7 +80,7 @@ func (r *Restorer) buildPgRestoreArgs(connStr string) []string { } if r.options.NoPrivileges { - args = append(args, "--no-privileges", "--no-acl") + args = append(args, "--no-privileges") } if r.options.SingleTransaction { From 8f43446652566f11ff6d30d5047ae8038f5d0831 Mon Sep 17 00:00:00 2001 From: Vineeth Pothulapati Date: Sat, 8 Nov 2025 16:58:13 +0530 Subject: [PATCH 6/7] Update internal/tiger/restore/psql.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Vineeth Pothulapati --- internal/tiger/restore/psql.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tiger/restore/psql.go b/internal/tiger/restore/psql.go index 9e38f765..8e6332a7 100644 --- a/internal/tiger/restore/psql.go +++ b/internal/tiger/restore/psql.go @@ -28,7 +28,7 @@ func (r *Restorer) restorePlainSQLWithPsql(ctx context.Context, connStr string, args = append(args, "--set", "ON_ERROR_STOP=1") } else { // When not stopping on errors, add helpful psql variable settings - // ON_ERROR_ROLLBACK=interactive rolls back failed statements but continues + // ON_ERROR_ROLLBACK=on always rolls back failed statements but continues // This is useful for cloud environments where some objects may already exist args = append(args, "--set", "ON_ERROR_ROLLBACK=on") } From 6df2795525a7ec66f8d5c04511fe8d8c3aad7833 Mon Sep 17 00:00:00 2001 From: Vineeth Pothulapati Date: Sat, 8 Nov 2025 17:12:28 +0530 Subject: [PATCH 7/7] fix nit --- internal/tiger/restore/psql.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/tiger/restore/psql.go b/internal/tiger/restore/psql.go index 8e6332a7..c759336e 100644 --- a/internal/tiger/restore/psql.go +++ b/internal/tiger/restore/psql.go @@ -88,7 +88,7 @@ func (r *Restorer) restorePlainSQLWithPsql(ctx context.Context, connStr string, // Set PGPASSWORD environment variable if using keyring storage storage := password.GetPasswordStorage() if _, isKeyring := storage.(*password.KeyringStorage); isKeyring { - if r.service == nil { + if r.service.ServiceId == nil || *r.service.ServiceId == "" { return fmt.Errorf("service is not initialized; cannot retrieve password") } if pwd, err := storage.Get(r.service, r.options.Role); err == nil && pwd != "" {