diff --git a/cmd/pgctl/main.go b/cmd/pgctl/main.go index 6970d86..0262798 100644 --- a/cmd/pgctl/main.go +++ b/cmd/pgctl/main.go @@ -93,35 +93,10 @@ func run(args []string) error { return usage() } - fs := flag.NewFlagSet(cmd, flag.ContinueOnError) - env := fs.String("env", "", "target database (a target named in the config)") - cfgPath := fs.String("config", "", "config file (default: $"+config.EnvVar+", else ./"+config.Default+")") - sshUser := fs.String("ssh-user", "", "ssh to the host as this user (default: the target's ssh_user, else you)") - to := fs.String("to", "", "restore: recover to this point in time (e.g. '2026-07-12 03:00:00')") - pgdata := fs.String("pgdata", defaultPGData, "restore: directory to restore into") - // In the recovery image, everything -- barman, postgres, psql -- is already - // here, and the host we would otherwise ssh to may be exactly what we lost. - local := fs.Bool("local", false, "run commands here instead of over ssh (set inside the recovery image)") - if err := fs.Parse(rest); err != nil { - return err - } - - // Load and validate before anything reaches a database: a bad config must - // fail here, not halfway through a backup. - cfg, _, err := config.Load(*cfgPath) - if err != nil { - return err - } - target, err := cfg.LookupTarget(*env) + opts, err := parseFlags(cmd, rest) if err != nil { return err } - // --ssh-user beats the target's ssh_user: who you are is a property of you, - // not of the database. Empty leaves it to ssh, which uses the local user. - user := cfg.SSHUser(*env) - if *sshUser != "" { - user = *sshUser - } // Ctrl-C and CI cancellation cancel the context rather than killing the // process, so the drill's teardown gets a chance to run. A leaked drill @@ -129,10 +104,8 @@ func run(args []string) error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - var runner pg.Runner = pg.SSHRunner{Host: target.Host, User: user} - if *local || os.Getenv("PGCTL_LOCAL") == "1" { - runner = pg.LocalRunner{} - } + target := opts.target + runner := opts.runner logger := log.Default() switch cmd { @@ -153,12 +126,62 @@ func run(args []string) error { ctx, cancel := context.WithTimeout(ctx, restoreTimeout) defer cancel() logger.Printf("pgctl %s (%s), restoring %s", version, commit, target.Server) - return pg.Restore(ctx, runner, target, logger, *pgdata, *to, time.Now()) + return pg.Restore(ctx, runner, target, logger, opts.pgdata, opts.to, time.Now()) default: return usage() } } +// opts is everything a command needs once flags are parsed and the config is +// resolved: the target, the runner to reach it, and the restore-only flags. +type opts struct { + target pg.Target + runner pg.Runner + to string + pgdata string +} + +// parseFlags parses the shared flag set, loads and validates the config, and +// resolves the target and runner. A bad config fails here, before anything +// reaches a database. +func parseFlags(cmd string, rest []string) (opts, error) { + fs := flag.NewFlagSet(cmd, flag.ContinueOnError) + env := fs.String("env", "", "target database (a target named in the config)") + cfgPath := fs.String("config", "", "config file (default: $"+config.EnvVar+", else ./"+config.Default+")") + sshUser := fs.String("ssh-user", "", "ssh to the host as this user (default: the target's ssh_user, else you)") + to := fs.String("to", "", "restore: recover to this point in time (e.g. '2026-07-12 03:00:00')") + pgdata := fs.String("pgdata", defaultPGData, "restore: directory to restore into") + // In the recovery image, everything -- barman, postgres, psql -- is already + // here, and the host we would otherwise ssh to may be exactly what we lost. + local := fs.Bool("local", false, "run commands here instead of over ssh (set inside the recovery image)") + if err := fs.Parse(rest); err != nil { + return opts{}, err + } + + cfg, _, err := config.Load(*cfgPath) + if err != nil { + return opts{}, err + } + target, err := cfg.LookupTarget(*env) + if err != nil { + return opts{}, err + } + + // --ssh-user beats the target's ssh_user: who you are is a property of you, + // not of the database. Empty leaves it to ssh, which uses the local user. + user := cfg.SSHUser(*env) + if *sshUser != "" { + user = *sshUser + } + + var runner pg.Runner = pg.SSHRunner{Host: target.Host, User: user} + if *local || os.Getenv("PGCTL_LOCAL") == "1" { + runner = pg.LocalRunner{} + } + + return opts{target: target, runner: runner, to: *to, pgdata: *pgdata}, nil +} + func listBackups(ctx context.Context, runner pg.Runner, target pg.Target) error { ctx, cancel := context.WithTimeout(ctx, listTimeout) defer cancel() diff --git a/internal/config/config.go b/internal/config/config.go index b49c391..a230fd1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -119,7 +119,7 @@ func Load(flagPath string) (Config, string, error) { return Config{}, "", err } - data, err := os.ReadFile(path) //nolint:gosec // the path is the operator's, by definition + data, err := os.ReadFile(path) if err != nil { return Config{}, path, fmt.Errorf("reading %s: %w", path, err) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 07c645f..39a227e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -34,17 +34,27 @@ func write(t *testing.T, dir, name, body string) string { } // chdir moves into a temp dir so ./pgctl.yaml resolution is testable without -// touching the repo. +// touching the repo. t.Chdir restores the previous directory automatically. func chdir(t *testing.T, dir string) { t.Helper() - old, err := os.Getwd() + t.Chdir(dir) +} + +// loadFromPath asserts Load reads exactly wantPath and returns a config that +// still defines example-production. Keeps each resolution-order subtest to +// setup plus one call. +func loadFromPath(t *testing.T, flag, wantPath string) { + t.Helper() + cfg, path, err := Load(flag) if err != nil { t.Fatal(err) } - if err := os.Chdir(dir); err != nil { - t.Fatal(err) + if path != wantPath { + t.Errorf("read %s, want %s", path, wantPath) + } + if _, ok := cfg.Targets["example-production"]; !ok { + t.Errorf("got targets %v, want the one from %s", cfg.targetNames(), wantPath) } - t.Cleanup(func() { _ = os.Chdir(old) }) } func TestResolutionOrder(t *testing.T) { @@ -56,16 +66,7 @@ func TestResolutionOrder(t *testing.T) { flagPath := write(t, dir, "flag.yaml", validConfig) t.Setenv(EnvVar, envPath) - cfg, path, err := Load(flagPath) - if err != nil { - t.Fatal(err) - } - if path != flagPath { - t.Errorf("read %s, want %s", path, flagPath) - } - if _, ok := cfg.Targets["example-production"]; !ok { - t.Errorf("got targets %v, want the --config one", cfg.targetNames()) - } + loadFromPath(t, flagPath, flagPath) }) t.Run("PGCTL_CONFIG wins over ./pgctl.yaml", func(t *testing.T) { @@ -75,16 +76,7 @@ func TestResolutionOrder(t *testing.T) { envPath := write(t, dir, "env.yaml", validConfig) t.Setenv(EnvVar, envPath) - cfg, path, err := Load("") - if err != nil { - t.Fatal(err) - } - if path != envPath { - t.Errorf("read %s, want %s", path, envPath) - } - if _, ok := cfg.Targets["example-production"]; !ok { - t.Errorf("got targets %v, want the $%s one", cfg.targetNames(), EnvVar) - } + loadFromPath(t, "", envPath) }) t.Run("./pgctl.yaml is the last resort", func(t *testing.T) { @@ -92,13 +84,7 @@ func TestResolutionOrder(t *testing.T) { chdir(t, dir) write(t, dir, "pgctl.yaml", validConfig) - _, path, err := Load("") - if err != nil { - t.Fatal(err) - } - if path != Default { - t.Errorf("read %s, want ./%s", path, Default) - } + loadFromPath(t, "", Default) }) t.Run("nothing anywhere names every path tried", func(t *testing.T) {