Skip to content

Commit 18a7bdc

Browse files
chore(devtools): ods db operations (#6661)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
1 parent c658fd4 commit 18a7bdc

File tree

12 files changed

+1317
-22
lines changed

12 files changed

+1317
-22
lines changed

tools/ods/cmd/cherry-pick.go

Lines changed: 3 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package cmd
22

33
import (
4-
"bufio"
54
"fmt"
65
"os"
76
"os/exec"
@@ -10,6 +9,8 @@ import (
109

1110
log "github.com/sirupsen/logrus"
1211
"github.com/spf13/cobra"
12+
13+
"github.com/onyx-dot-app/onyx/tools/ods/internal/prompt"
1314
)
1415

1516
// CherryPickOptions holds options for the cherry-pick command
@@ -110,7 +111,7 @@ func runCherryPick(cmd *cobra.Command, args []string, opts *CherryPickOptions) {
110111

111112
// Prompt user for confirmation
112113
if !opts.Yes {
113-
if !promptYesNo(fmt.Sprintf("Auto-detected release version: %s. Continue? (yes/no): ", version)) {
114+
if !prompt.Confirm(fmt.Sprintf("Auto-detected release version: %s. Continue? (yes/no): ", version)) {
114115
log.Info("If you want to cherry-pick to a different release, use the --release flag. Exiting...")
115116
return
116117
}
@@ -223,26 +224,6 @@ func cherryPickToRelease(commitSHAs []string, branchSuffix, version, prTitle str
223224
return prURL, nil
224225
}
225226

226-
// promptYesNo prompts the user with a yes/no question and returns true for yes, false for no
227-
func promptYesNo(prompt string) bool {
228-
reader := bufio.NewReader(os.Stdin)
229-
for {
230-
fmt.Print(prompt)
231-
response, err := reader.ReadString('\n')
232-
if err != nil {
233-
log.Fatalf("Failed to read input: %v", err)
234-
}
235-
response = strings.TrimSpace(strings.ToLower(response))
236-
if response == "yes" || response == "y" || response == "" {
237-
return true
238-
}
239-
if response == "no" || response == "n" {
240-
return false
241-
}
242-
fmt.Println("Please enter 'yes' or 'no'")
243-
}
244-
}
245-
246227
// getCurrentBranch returns the name of the current git branch
247228
func getCurrentBranch() (string, error) {
248229
cmd := exec.Command("git", "branch", "--show-current")

tools/ods/cmd/db.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package cmd
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
)
6+
7+
// NewDBCommand creates the parent db command.
8+
func NewDBCommand() *cobra.Command {
9+
cmd := &cobra.Command{
10+
Use: "db",
11+
Short: "Database administration commands",
12+
Long: `Database administration commands for managing PostgreSQL and Alembic migrations.
13+
14+
Commands include dropping/recreating databases, creating and restoring snapshots,
15+
and managing Alembic migrations (upgrade, downgrade, current, history).`,
16+
}
17+
18+
// Add subcommands
19+
cmd.AddCommand(NewDBDropCommand())
20+
cmd.AddCommand(NewDBDumpCommand())
21+
cmd.AddCommand(NewDBRestoreCommand())
22+
cmd.AddCommand(NewDBUpgradeCommand())
23+
cmd.AddCommand(NewDBDowngradeCommand())
24+
cmd.AddCommand(NewDBCurrentCommand())
25+
cmd.AddCommand(NewDBHistoryCommand())
26+
27+
return cmd
28+
}

tools/ods/cmd/db_drop.go

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
7+
log "github.com/sirupsen/logrus"
8+
"github.com/spf13/cobra"
9+
10+
"github.com/onyx-dot-app/onyx/tools/ods/internal/docker"
11+
"github.com/onyx-dot-app/onyx/tools/ods/internal/postgres"
12+
"github.com/onyx-dot-app/onyx/tools/ods/internal/prompt"
13+
)
14+
15+
// validIdentifier matches valid PostgreSQL identifiers (letters, digits, underscores, starting with letter/underscore)
16+
var validIdentifier = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
17+
18+
// DBDropOptions holds options for the db drop command.
19+
type DBDropOptions struct {
20+
Yes bool
21+
Schema string
22+
}
23+
24+
// NewDBDropCommand creates the db drop command.
25+
func NewDBDropCommand() *cobra.Command {
26+
opts := &DBDropOptions{}
27+
28+
cmd := &cobra.Command{
29+
Use: "drop",
30+
Short: "Drop and recreate the database",
31+
Long: `Drop and recreate the PostgreSQL database.
32+
33+
This command will:
34+
1. Find the running PostgreSQL container
35+
2. Drop all connections to the database
36+
3. Drop the database (or schema if --schema is specified)
37+
4. Recreate the database (or schema)
38+
39+
WARNING: This is a destructive operation. All data will be lost.`,
40+
Run: func(cmd *cobra.Command, args []string) {
41+
runDBDrop(opts)
42+
},
43+
}
44+
45+
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "Skip confirmation prompt")
46+
cmd.Flags().StringVar(&opts.Schema, "schema", "", "Drop a specific schema instead of the entire database")
47+
48+
return cmd
49+
}
50+
51+
func runDBDrop(opts *DBDropOptions) {
52+
// Find PostgreSQL container
53+
container, err := docker.FindPostgresContainer()
54+
if err != nil {
55+
log.Fatalf("Failed to find PostgreSQL container: %v", err)
56+
}
57+
log.Infof("Found PostgreSQL container: %s", container)
58+
59+
config := postgres.NewConfigFromEnv()
60+
61+
// Confirmation prompt
62+
if !opts.Yes {
63+
var msg string
64+
if opts.Schema != "" {
65+
msg = fmt.Sprintf("This will DROP the schema '%s' in database '%s'. All data will be lost. Continue? (yes/no): ",
66+
opts.Schema, config.Database)
67+
} else {
68+
msg = fmt.Sprintf("This will DROP and RECREATE the database '%s'. All data will be lost. Continue? (yes/no): ",
69+
config.Database)
70+
}
71+
72+
if !prompt.Confirm(msg) {
73+
log.Info("Aborted.")
74+
return
75+
}
76+
}
77+
78+
env := config.Env()
79+
80+
if opts.Schema != "" {
81+
// Validate schema name to prevent SQL injection
82+
if !validIdentifier.MatchString(opts.Schema) {
83+
log.Fatalf("Invalid schema name: %s", opts.Schema)
84+
}
85+
86+
// Drop and recreate schema
87+
log.Infof("Dropping schema: %s", opts.Schema)
88+
dropSchemaSQL := fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE;", opts.Schema)
89+
createSchemaSQL := fmt.Sprintf("CREATE SCHEMA %s;", opts.Schema)
90+
91+
args := append(config.PsqlArgs(), "-c", dropSchemaSQL)
92+
if err := docker.ExecWithEnv(container, env, append([]string{"psql"}, args...)...); err != nil {
93+
log.Fatalf("Failed to drop schema: %v", err)
94+
}
95+
96+
args = append(config.PsqlArgs(), "-c", createSchemaSQL)
97+
if err := docker.ExecWithEnv(container, env, append([]string{"psql"}, args...)...); err != nil {
98+
log.Fatalf("Failed to create schema: %v", err)
99+
}
100+
101+
log.Infof("Schema '%s' dropped and recreated successfully", opts.Schema)
102+
} else {
103+
// Drop and recreate entire database
104+
log.Infof("Dropping database: %s", config.Database)
105+
106+
// Use template1 as maintenance database (can't drop a DB while connected to it)
107+
maintenanceDB := "template1"
108+
109+
// Terminate existing connections
110+
// Validate database name to prevent SQL injection
111+
if !validIdentifier.MatchString(config.Database) {
112+
log.Fatalf("Invalid database name: %s", config.Database)
113+
}
114+
115+
// Terminate existing connections
116+
terminateSQL := fmt.Sprintf(
117+
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '%s' AND pid <> pg_backend_pid();",
118+
config.Database)
119+
120+
args := []string{"psql", "-U", config.User, "-d", maintenanceDB, "-c", terminateSQL}
121+
if err := docker.ExecWithEnv(container, env, args...); err != nil {
122+
log.Warnf("Failed to terminate connections (this may be okay): %v", err)
123+
}
124+
125+
// Drop database
126+
dropSQL := fmt.Sprintf("DROP DATABASE IF EXISTS %s;", config.Database)
127+
args = []string{"psql", "-U", config.User, "-d", maintenanceDB, "-c", dropSQL}
128+
if err := docker.ExecWithEnv(container, env, args...); err != nil {
129+
log.Fatalf("Failed to drop database: %v", err)
130+
}
131+
132+
// Create database
133+
createSQL := fmt.Sprintf("CREATE DATABASE %s;", config.Database)
134+
args = []string{"psql", "-U", config.User, "-d", maintenanceDB, "-c", createSQL}
135+
if err := docker.ExecWithEnv(container, env, args...); err != nil {
136+
log.Fatalf("Failed to create database: %v", err)
137+
}
138+
139+
log.Infof("Database '%s' dropped and recreated successfully", config.Database)
140+
log.Info("Run 'ods db upgrade' to apply migrations")
141+
}
142+
}

tools/ods/cmd/db_dump.go

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"time"
8+
9+
log "github.com/sirupsen/logrus"
10+
"github.com/spf13/cobra"
11+
12+
"github.com/onyx-dot-app/onyx/tools/ods/internal/docker"
13+
"github.com/onyx-dot-app/onyx/tools/ods/internal/paths"
14+
"github.com/onyx-dot-app/onyx/tools/ods/internal/postgres"
15+
)
16+
17+
// DBDumpOptions holds options for the db dump command.
18+
type DBDumpOptions struct {
19+
Format string
20+
Schema string
21+
Output string
22+
}
23+
24+
// NewDBDumpCommand creates the db dump command.
25+
func NewDBDumpCommand() *cobra.Command {
26+
opts := &DBDumpOptions{}
27+
28+
cmd := &cobra.Command{
29+
Use: "dump [output-file]",
30+
Short: "Create a database snapshot",
31+
Long: `Create a database snapshot using pg_dump.
32+
33+
The snapshot is saved to the specified output file, or to the default
34+
snapshots directory (~/.local/share/onyx-dev/snapshots/) if no file is specified.
35+
36+
Examples:
37+
ods db dump # Creates onyx_<timestamp>.dump in snapshots dir
38+
ods db dump mybackup.dump # Creates mybackup.dump in snapshots dir
39+
ods db dump /path/to/backup.sql # Creates backup.sql at specified path
40+
ods db dump --format sql # Creates SQL format instead of custom format`,
41+
Args: cobra.MaximumNArgs(1),
42+
Run: func(cmd *cobra.Command, args []string) {
43+
if len(args) > 0 {
44+
opts.Output = args[0]
45+
}
46+
runDBDump(opts)
47+
},
48+
}
49+
50+
cmd.Flags().StringVar(&opts.Format, "format", "custom", "Output format: 'custom' (pg_dump -Fc) or 'sql' (plain SQL)")
51+
cmd.Flags().StringVar(&opts.Schema, "schema", "", "Dump only a specific schema")
52+
53+
return cmd
54+
}
55+
56+
func runDBDump(opts *DBDumpOptions) {
57+
// Find PostgreSQL container
58+
container, err := docker.FindPostgresContainer()
59+
if err != nil {
60+
log.Fatalf("Failed to find PostgreSQL container: %v", err)
61+
}
62+
log.Infof("Found PostgreSQL container: %s", container)
63+
64+
config := postgres.NewConfigFromEnv()
65+
66+
// Determine output file path
67+
outputPath := determineOutputPath(opts.Output, opts.Format)
68+
69+
// Ensure output directory exists
70+
outputDir := filepath.Dir(outputPath)
71+
if err := os.MkdirAll(outputDir, 0755); err != nil {
72+
log.Fatalf("Failed to create output directory: %v", err)
73+
}
74+
75+
log.Infof("Dumping database '%s' to: %s", config.Database, outputPath)
76+
77+
// Build pg_dump arguments
78+
args := config.PgDumpArgs(opts.Format)
79+
if opts.Schema != "" {
80+
args = append(args, "-n", opts.Schema)
81+
}
82+
83+
// Create a temporary file in the container
84+
containerTmpFile := "/tmp/onyx_dump_tmp"
85+
args = append(args, "-f", containerTmpFile)
86+
87+
// Run pg_dump in container
88+
env := config.Env()
89+
pgDumpArgs := append([]string{"pg_dump"}, args...)
90+
if err := docker.ExecWithEnv(container, env, pgDumpArgs...); err != nil {
91+
log.Fatalf("Failed to run pg_dump: %v", err)
92+
}
93+
94+
// Copy the dump file from container to host
95+
if err := docker.CopyFromContainer(container, containerTmpFile, outputPath); err != nil {
96+
log.Fatalf("Failed to copy dump file: %v", err)
97+
}
98+
99+
// Clean up temporary file in container
100+
_ = docker.Exec(container, "rm", "-f", containerTmpFile)
101+
102+
// Get file size for info
103+
if info, err := os.Stat(outputPath); err == nil {
104+
log.Infof("Dump completed successfully (%s)", humanizeBytes(info.Size()))
105+
} else {
106+
log.Info("Dump completed successfully")
107+
}
108+
}
109+
110+
// determineOutputPath determines the output file path based on options.
111+
func determineOutputPath(output string, format string) string {
112+
ext := ".dump"
113+
if format == "sql" {
114+
ext = ".sql"
115+
}
116+
117+
if output == "" {
118+
// Generate default filename with timestamp
119+
timestamp := time.Now().Format("20060102_150405")
120+
filename := fmt.Sprintf("onyx_%s%s", timestamp, ext)
121+
return filepath.Join(paths.SnapshotsDir(), filename)
122+
}
123+
124+
// Check if output is just a filename (no directory)
125+
if filepath.Dir(output) == "." {
126+
return filepath.Join(paths.SnapshotsDir(), output)
127+
}
128+
129+
return output
130+
}
131+
132+
// humanizeBytes converts bytes to a human-readable string.
133+
func humanizeBytes(bytes int64) string {
134+
const unit = 1024
135+
if bytes < unit {
136+
return fmt.Sprintf("%d B", bytes)
137+
}
138+
div, exp := int64(unit), 0
139+
for n := bytes / unit; n >= unit; n /= unit {
140+
div *= unit
141+
exp++
142+
}
143+
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
144+
}

0 commit comments

Comments
 (0)