Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions internal/tiger/cmd/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -736,6 +737,169 @@ func buildDbCreateCmd() *cobra.Command {
return cmd
}

func buildDbRestoreCmd() *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: "restore <file-path> [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.

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:
# Restore plain SQL dump to default service
tiger db restore backup.sql

# Restore to specific service
tiger db restore backup.sql svc-12345

# 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 restore - --format custom

# Clean existing objects before restore (DESTRUCTIVE - requires confirmation)
tiger db restore backup.sql --clean --if-exists

# Stop on first error (not recommended for cloud environments)
tiger db restore backup.sql --on-error-stop

# Verbose mode for debugging
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.`,
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(&timescaledbHooks, "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",
Expand All @@ -748,6 +912,7 @@ func buildDbCmd() *cobra.Command {
cmd.AddCommand(buildDbTestConnectionCmd())
cmd.AddCommand(buildDbSavePasswordCmd())
cmd.AddCommand(buildDbCreateCmd())
cmd.AddCommand(buildDbRestoreCmd())

return cmd
}
Expand Down
27 changes: 27 additions & 0 deletions internal/tiger/restore/confirm.go
Original file line number Diff line number Diff line change
@@ -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
}
179 changes: 179 additions & 0 deletions internal/tiger/restore/format.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading