CLI test coverage
This commit is contained in:
3
Makefile
3
Makefile
@@ -1,9 +1,10 @@
|
|||||||
.PHONY: default
|
.PHONY: default
|
||||||
default: testdata
|
default:
|
||||||
|
|
||||||
.PHONY: testdata
|
.PHONY: testdata
|
||||||
testdata:
|
testdata:
|
||||||
make -C testdata
|
make -C testdata
|
||||||
|
make -C cmd/litestream testdata
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
docker build -t litestream .
|
docker build -t litestream .
|
||||||
|
|||||||
6
cmd/litestream/Makefile
Normal file
6
cmd/litestream/Makefile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.PHONY: default
|
||||||
|
default:
|
||||||
|
|
||||||
|
.PHONY: testdata
|
||||||
|
testdata:
|
||||||
|
make -C testdata
|
||||||
@@ -4,17 +4,30 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DatabasesCommand is a command for listing managed databases.
|
// DatabasesCommand is a command for listing managed databases.
|
||||||
type DatabasesCommand struct {
|
type DatabasesCommand struct {
|
||||||
|
stdin io.Reader
|
||||||
|
stdout io.Writer
|
||||||
|
stderr io.Writer
|
||||||
|
|
||||||
configPath string
|
configPath string
|
||||||
noExpandEnv bool
|
noExpandEnv bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewDatabasesCommand returns a new instance of DatabasesCommand.
|
||||||
|
func NewDatabasesCommand(stdin io.Reader, stdout, stderr io.Writer) *DatabasesCommand {
|
||||||
|
return &DatabasesCommand{
|
||||||
|
stdin: stdin,
|
||||||
|
stdout: stdout,
|
||||||
|
stderr: stderr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Run executes the command.
|
// Run executes the command.
|
||||||
func (c *DatabasesCommand) Run(ctx context.Context, args []string) (err error) {
|
func (c *DatabasesCommand) Run(ctx context.Context, args []string) (err error) {
|
||||||
fs := flag.NewFlagSet("litestream-databases", flag.ContinueOnError)
|
fs := flag.NewFlagSet("litestream-databases", flag.ContinueOnError)
|
||||||
@@ -27,16 +40,16 @@ func (c *DatabasesCommand) Run(ctx context.Context, args []string) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load configuration.
|
// Load configuration.
|
||||||
if c.configPath == "" {
|
|
||||||
c.configPath = DefaultConfigPath()
|
|
||||||
}
|
|
||||||
config, err := ReadConfigFile(c.configPath, !c.noExpandEnv)
|
config, err := ReadConfigFile(c.configPath, !c.noExpandEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
} else if len(config.DBs) == 0 {
|
||||||
|
fmt.Fprintln(c.stdout, "No databases found in config file.")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all databases.
|
// List all databases.
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 8, 2, ' ', 0)
|
w := tabwriter.NewWriter(c.stdout, 0, 8, 2, ' ', 0)
|
||||||
defer w.Flush()
|
defer w.Flush()
|
||||||
|
|
||||||
fmt.Fprintln(w, "path\treplicas")
|
fmt.Fprintln(w, "path\treplicas")
|
||||||
@@ -62,7 +75,7 @@ func (c *DatabasesCommand) Run(ctx context.Context, args []string) (err error) {
|
|||||||
|
|
||||||
// Usage prints the help screen to STDOUT.
|
// Usage prints the help screen to STDOUT.
|
||||||
func (c *DatabasesCommand) Usage() {
|
func (c *DatabasesCommand) Usage() {
|
||||||
fmt.Printf(`
|
fmt.Fprintf(c.stdout, `
|
||||||
The databases command lists all databases in the configuration file.
|
The databases command lists all databases in the configuration file.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|||||||
66
cmd/litestream/databases_test.go
Normal file
66
cmd/litestream/databases_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/benbjohnson/litestream/internal/testingutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDatabasesCommand(t *testing.T) {
|
||||||
|
t.Run("OK", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "databases", "ok")
|
||||||
|
m, _, stdout, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"databases", "-config", filepath.Join(testDir, "litestream.yml")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stdout.String(), string(testingutil.MustReadFile(t, filepath.Join(testDir, "stdout"))); got != want {
|
||||||
|
t.Fatalf("stdout=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NoDatabases", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "databases", "no-databases")
|
||||||
|
m, _, stdout, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"databases", "-config", filepath.Join(testDir, "litestream.yml")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stdout.String(), string(testingutil.MustReadFile(t, filepath.Join(testDir, "stdout"))); got != want {
|
||||||
|
t.Fatalf("stdout=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrConfigNotFound", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "databases", "no-config")
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"databases", "-config", filepath.Join(testDir, "litestream.yml")})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `config file not found:`) {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrInvalidConfig", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "databases", "invalid-config")
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"databases", "-config", filepath.Join(testDir, "litestream.yml")})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `replica path cannot be a url`) {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrTooManyArguments", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"databases", "xyz"})
|
||||||
|
if err == nil || err.Error() != `too many arguments` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Usage", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"databases", "-h"}); err != flag.ErrHelp {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,93 +4,80 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/benbjohnson/litestream"
|
"github.com/benbjohnson/litestream"
|
||||||
|
"github.com/benbjohnson/litestream/internal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GenerationsCommand represents a command to list all generations for a database.
|
// GenerationsCommand represents a command to list all generations for a database.
|
||||||
type GenerationsCommand struct {
|
type GenerationsCommand struct {
|
||||||
|
stdin io.Reader
|
||||||
|
stdout io.Writer
|
||||||
|
stderr io.Writer
|
||||||
|
|
||||||
configPath string
|
configPath string
|
||||||
noExpandEnv bool
|
noExpandEnv bool
|
||||||
|
|
||||||
|
replicaName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGenerationsCommand returns a new instance of GenerationsCommand.
|
||||||
|
func NewGenerationsCommand(stdin io.Reader, stdout, stderr io.Writer) *GenerationsCommand {
|
||||||
|
return &GenerationsCommand{
|
||||||
|
stdin: stdin,
|
||||||
|
stdout: stdout,
|
||||||
|
stderr: stderr,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run executes the command.
|
// Run executes the command.
|
||||||
func (c *GenerationsCommand) Run(ctx context.Context, args []string) (err error) {
|
func (c *GenerationsCommand) Run(ctx context.Context, args []string) (ret error) {
|
||||||
fs := flag.NewFlagSet("litestream-generations", flag.ContinueOnError)
|
fs := flag.NewFlagSet("litestream-generations", flag.ContinueOnError)
|
||||||
registerConfigFlag(fs, &c.configPath, &c.noExpandEnv)
|
registerConfigFlag(fs, &c.configPath, &c.noExpandEnv)
|
||||||
replicaName := fs.String("replica", "", "replica name")
|
fs.StringVar(&c.replicaName, "replica", "", "replica name")
|
||||||
fs.Usage = c.Usage
|
fs.Usage = c.Usage
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if fs.NArg() == 0 || fs.Arg(0) == "" {
|
} else if fs.Arg(0) == "" {
|
||||||
return fmt.Errorf("database path or replica URL required")
|
return fmt.Errorf("database path or replica URL required")
|
||||||
} else if fs.NArg() > 1 {
|
} else if fs.NArg() > 1 {
|
||||||
return fmt.Errorf("too many arguments")
|
return fmt.Errorf("too many arguments")
|
||||||
}
|
}
|
||||||
|
|
||||||
var db *litestream.DB
|
|
||||||
var r *litestream.Replica
|
|
||||||
dbUpdatedAt := time.Now()
|
|
||||||
if isURL(fs.Arg(0)) {
|
|
||||||
if c.configPath != "" {
|
|
||||||
return fmt.Errorf("cannot specify a replica URL and the -config flag")
|
|
||||||
}
|
|
||||||
if r, err = NewReplicaFromConfig(&ReplicaConfig{URL: fs.Arg(0)}, nil); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if c.configPath == "" {
|
|
||||||
c.configPath = DefaultConfigPath()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load configuration.
|
// Load configuration.
|
||||||
config, err := ReadConfigFile(c.configPath, !c.noExpandEnv)
|
config, err := ReadConfigFile(c.configPath, !c.noExpandEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup database from configuration file by path.
|
replicas, db, err := loadReplicas(ctx, config, fs.Arg(0), c.replicaName)
|
||||||
if path, err := expand(fs.Arg(0)); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if dbc := config.DBConfig(path); dbc == nil {
|
|
||||||
return fmt.Errorf("database not found in config: %s", path)
|
|
||||||
} else if db, err = NewDBFromConfig(dbc); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by replica, if specified.
|
|
||||||
if *replicaName != "" {
|
|
||||||
if r = db.Replica(*replicaName); r == nil {
|
|
||||||
return fmt.Errorf("replica %q not found for database %q", *replicaName, db.Path())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine last time database or WAL was updated.
|
// Determine last time database or WAL was updated.
|
||||||
if dbUpdatedAt, err = db.UpdatedAt(); err != nil {
|
var dbUpdatedAt time.Time
|
||||||
|
if db != nil {
|
||||||
|
if dbUpdatedAt, err = db.UpdatedAt(); err != nil && !os.IsNotExist(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var replicas []*litestream.Replica
|
|
||||||
if r != nil {
|
|
||||||
replicas = []*litestream.Replica{r}
|
|
||||||
} else {
|
|
||||||
replicas = db.Replicas
|
|
||||||
}
|
|
||||||
|
|
||||||
// List each generation.
|
// List each generation.
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 8, 2, ' ', 0)
|
w := tabwriter.NewWriter(c.stdout, 0, 8, 2, ' ', 0)
|
||||||
defer w.Flush()
|
defer w.Flush()
|
||||||
|
|
||||||
fmt.Fprintln(w, "name\tgeneration\tlag\tstart\tend")
|
fmt.Fprintln(w, "name\tgeneration\tlag\tstart\tend")
|
||||||
|
|
||||||
for _, r := range replicas {
|
for _, r := range replicas {
|
||||||
generations, err := r.Client.Generations(ctx)
|
generations, err := r.Client.Generations(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("%s: cannot list generations: %s", r.Name(), err)
|
fmt.Fprintf(c.stderr, "%s: cannot list generations: %s", r.Name(), err)
|
||||||
|
ret = errExit // signal error return without printing message
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,26 +85,35 @@ func (c *GenerationsCommand) Run(ctx context.Context, args []string) (err error)
|
|||||||
for _, generation := range generations {
|
for _, generation := range generations {
|
||||||
createdAt, updatedAt, err := litestream.GenerationTimeBounds(ctx, r.Client, generation)
|
createdAt, updatedAt, err := litestream.GenerationTimeBounds(ctx, r.Client, generation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("%s: cannot determine generation time bounds: %s", r.Name(), err)
|
fmt.Fprintf(c.stderr, "%s: cannot determine generation time bounds: %s", r.Name(), err)
|
||||||
|
ret = errExit // signal error return without printing message
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate lag from database mod time to the replica mod time.
|
||||||
|
// This is ignored if the database mod time is unavailable such as
|
||||||
|
// when specifying the replica URL or if the database file is missing.
|
||||||
|
lag := "-"
|
||||||
|
if !dbUpdatedAt.IsZero() {
|
||||||
|
lag = internal.TruncateDuration(dbUpdatedAt.Sub(updatedAt)).String()
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||||
r.Name(),
|
r.Name(),
|
||||||
generation,
|
generation,
|
||||||
truncateDuration(dbUpdatedAt.Sub(updatedAt)).String(),
|
lag,
|
||||||
createdAt.Format(time.RFC3339),
|
createdAt.Format(time.RFC3339),
|
||||||
updatedAt.Format(time.RFC3339),
|
updatedAt.Format(time.RFC3339),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage prints the help message to STDOUT.
|
// Usage prints the help message to STDOUT.
|
||||||
func (c *GenerationsCommand) Usage() {
|
func (c *GenerationsCommand) Usage() {
|
||||||
fmt.Printf(`
|
fmt.Fprintf(c.stdout, `
|
||||||
The generations command lists all generations for a database or replica. It also
|
The generations command lists all generations for a database or replica. It also
|
||||||
lists stats about their lag behind the primary database and the time range they
|
lists stats about their lag behind the primary database and the time range they
|
||||||
cover.
|
cover.
|
||||||
@@ -144,29 +140,3 @@ Arguments:
|
|||||||
DefaultConfigPath(),
|
DefaultConfigPath(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateDuration(d time.Duration) time.Duration {
|
|
||||||
if d < 0 {
|
|
||||||
if d < -10*time.Second {
|
|
||||||
return d.Truncate(time.Second)
|
|
||||||
} else if d < -time.Second {
|
|
||||||
return d.Truncate(time.Second / 10)
|
|
||||||
} else if d < -time.Millisecond {
|
|
||||||
return d.Truncate(time.Millisecond)
|
|
||||||
} else if d < -time.Microsecond {
|
|
||||||
return d.Truncate(time.Microsecond)
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
if d > 10*time.Second {
|
|
||||||
return d.Truncate(time.Second)
|
|
||||||
} else if d > time.Second {
|
|
||||||
return d.Truncate(time.Second / 10)
|
|
||||||
} else if d > time.Millisecond {
|
|
||||||
return d.Truncate(time.Millisecond)
|
|
||||||
} else if d > time.Microsecond {
|
|
||||||
return d.Truncate(time.Microsecond)
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|||||||
140
cmd/litestream/generations_test.go
Normal file
140
cmd/litestream/generations_test.go
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/benbjohnson/litestream/internal/testingutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerationsCommand(t *testing.T) {
|
||||||
|
t.Run("OK", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "generations", "ok")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, stdout, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"generations", "-config", filepath.Join(testDir, "litestream.yml"), filepath.Join(testDir, "db")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stdout.String(), string(testingutil.MustReadFile(t, filepath.Join(testDir, "stdout"))); got != want {
|
||||||
|
t.Fatalf("stdout=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ReplicaName", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "generations", "replica-name")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, stdout, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"generations", "-config", filepath.Join(testDir, "litestream.yml"), "-replica", "replica1", filepath.Join(testDir, "db")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stdout.String(), string(testingutil.MustReadFile(t, filepath.Join(testDir, "stdout"))); got != want {
|
||||||
|
t.Fatalf("stdout=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ReplicaURL", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join(testingutil.Getwd(t), "testdata", "generations", "replica-url")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
replicaURL := "file://" + filepath.ToSlash(testDir) + "/replica"
|
||||||
|
|
||||||
|
m, _, stdout, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"generations", replicaURL}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stdout.String(), string(testingutil.MustReadFile(t, filepath.Join(testDir, "stdout"))); got != want {
|
||||||
|
t.Fatalf("stdout=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NoDatabase", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "generations", "no-database")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, stdout, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"generations", "-config", filepath.Join(testDir, "litestream.yml"), filepath.Join(testDir, "db")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stdout.String(), string(testingutil.MustReadFile(t, filepath.Join(testDir, "stdout"))); got != want {
|
||||||
|
t.Fatalf("stdout=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrDatabaseOrReplicaRequired", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"generations"})
|
||||||
|
if err == nil || err.Error() != `database path or replica URL required` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrTooManyArguments", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"generations", "abc", "123"})
|
||||||
|
if err == nil || err.Error() != `too many arguments` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrInvalidFlags", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"generations", "-no-such-flag"})
|
||||||
|
if err == nil || err.Error() != `flag provided but not defined: -no-such-flag` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrConfigFileNotFound", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"generations", "-config", "/no/such/file", "/var/lib/db"})
|
||||||
|
if err == nil || err.Error() != `config file not found: /no/such/file` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrInvalidConfig", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "generations", "invalid-config")
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"generations", "-config", filepath.Join(testDir, "litestream.yml"), "/var/lib/db"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `replica path cannot be a url`) {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrDatabaseNotFound", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "generations", "database-not-found")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"generations", "-config", filepath.Join(testDir, "litestream.yml"), "/no/such/db"})
|
||||||
|
if err == nil || err.Error() != `database not found in config: /no/such/db` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrReplicaNotFound", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join(testingutil.Getwd(t), "testdata", "generations", "replica-not-found")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"generations", "-config", filepath.Join(testDir, "litestream.yml"), "-replica", "no_such_replica", filepath.Join(testDir, "db")})
|
||||||
|
if err == nil || err.Error() != `replica "no_such_replica" not found for database "`+filepath.Join(testDir, "db")+`"` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrInvalidReplicaURL", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"generations", "xyz://xyz"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `unknown replica type in config: "xyz"`) {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Usage", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"generations", "-h"}); err != flag.ErrHelp {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -32,14 +33,14 @@ var (
|
|||||||
Version = "(development build)"
|
Version = "(development build)"
|
||||||
)
|
)
|
||||||
|
|
||||||
// errStop is a terminal error for indicating program should quit.
|
// errExit is a terminal error for indicating program should quit.
|
||||||
var errStop = errors.New("stop")
|
var errExit = errors.New("exit")
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.SetFlags(0)
|
log.SetFlags(0)
|
||||||
|
|
||||||
m := NewMain()
|
m := NewMain(os.Stdin, os.Stdout, os.Stderr)
|
||||||
if err := m.Run(context.Background(), os.Args[1:]); err == flag.ErrHelp || err == errStop {
|
if err := m.Run(context.Background(), os.Args[1:]); err == flag.ErrHelp || err == errExit {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
@@ -48,11 +49,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Main represents the main program execution.
|
// Main represents the main program execution.
|
||||||
type Main struct{}
|
type Main struct {
|
||||||
|
stdin io.Reader
|
||||||
|
stdout io.Writer
|
||||||
|
stderr io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
// NewMain returns a new instance of Main.
|
// NewMain returns a new instance of Main.
|
||||||
func NewMain() *Main {
|
func NewMain(stdin io.Reader, stdout, stderr io.Writer) *Main {
|
||||||
return &Main{}
|
return &Main{
|
||||||
|
stdin: stdin,
|
||||||
|
stdout: stdout,
|
||||||
|
stderr: stderr,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run executes the program.
|
// Run executes the program.
|
||||||
@@ -75,11 +84,11 @@ func (m *Main) Run(ctx context.Context, args []string) (err error) {
|
|||||||
|
|
||||||
switch cmd {
|
switch cmd {
|
||||||
case "databases":
|
case "databases":
|
||||||
return (&DatabasesCommand{}).Run(ctx, args)
|
return NewDatabasesCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args)
|
||||||
case "generations":
|
case "generations":
|
||||||
return (&GenerationsCommand{}).Run(ctx, args)
|
return NewGenerationsCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args)
|
||||||
case "replicate":
|
case "replicate":
|
||||||
c := NewReplicateCommand()
|
c := NewReplicateCommand(m.stdin, m.stdout, m.stderr)
|
||||||
if err := c.ParseFlags(ctx, args); err != nil {
|
if err := c.ParseFlags(ctx, args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -96,21 +105,21 @@ func (m *Main) Run(ctx context.Context, args []string) (err error) {
|
|||||||
// Wait for signal to stop program.
|
// Wait for signal to stop program.
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
fmt.Println("context done, litestream shutting down")
|
fmt.Fprintln(m.stdout, "context done, litestream shutting down")
|
||||||
case err = <-c.execCh:
|
case err = <-c.execCh:
|
||||||
cancel()
|
cancel()
|
||||||
fmt.Println("subprocess exited, litestream shutting down")
|
fmt.Fprintln(m.stdout, "subprocess exited, litestream shutting down")
|
||||||
case sig := <-signalCh:
|
case sig := <-signalCh:
|
||||||
cancel()
|
cancel()
|
||||||
fmt.Println("signal received, litestream shutting down")
|
fmt.Fprintln(m.stdout, "signal received, litestream shutting down")
|
||||||
|
|
||||||
if c.cmd != nil {
|
if c.cmd != nil {
|
||||||
fmt.Println("sending signal to exec process")
|
fmt.Fprintln(m.stdout, "sending signal to exec process")
|
||||||
if err := c.cmd.Process.Signal(sig); err != nil {
|
if err := c.cmd.Process.Signal(sig); err != nil {
|
||||||
return fmt.Errorf("cannot signal exec process: %w", err)
|
return fmt.Errorf("cannot signal exec process: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("waiting for exec process to close")
|
fmt.Fprintln(m.stdout, "waiting for exec process to close")
|
||||||
if err := <-c.execCh; err != nil && !strings.HasPrefix(err.Error(), "signal:") {
|
if err := <-c.execCh; err != nil && !strings.HasPrefix(err.Error(), "signal:") {
|
||||||
return fmt.Errorf("cannot wait for exec process: %w", err)
|
return fmt.Errorf("cannot wait for exec process: %w", err)
|
||||||
}
|
}
|
||||||
@@ -121,17 +130,17 @@ func (m *Main) Run(ctx context.Context, args []string) (err error) {
|
|||||||
if e := c.Close(); e != nil && err == nil {
|
if e := c.Close(); e != nil && err == nil {
|
||||||
err = e
|
err = e
|
||||||
}
|
}
|
||||||
fmt.Println("litestream shut down")
|
fmt.Fprintln(m.stdout, "litestream shut down")
|
||||||
return err
|
return err
|
||||||
|
|
||||||
case "restore":
|
case "restore":
|
||||||
return NewRestoreCommand().Run(ctx, args)
|
return NewRestoreCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args)
|
||||||
case "snapshots":
|
case "snapshots":
|
||||||
return (&SnapshotsCommand{}).Run(ctx, args)
|
return NewSnapshotsCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args)
|
||||||
case "version":
|
case "version":
|
||||||
return (&VersionCommand{}).Run(ctx, args)
|
return NewVersionCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args)
|
||||||
case "wal":
|
case "wal":
|
||||||
return (&WALCommand{}).Run(ctx, args)
|
return NewWALCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args)
|
||||||
default:
|
default:
|
||||||
if cmd == "" || cmd == "help" || strings.HasPrefix(cmd, "-") {
|
if cmd == "" || cmd == "help" || strings.HasPrefix(cmd, "-") {
|
||||||
m.Usage()
|
m.Usage()
|
||||||
@@ -143,7 +152,7 @@ func (m *Main) Run(ctx context.Context, args []string) (err error) {
|
|||||||
|
|
||||||
// Usage prints the help screen to STDOUT.
|
// Usage prints the help screen to STDOUT.
|
||||||
func (m *Main) Usage() {
|
func (m *Main) Usage() {
|
||||||
fmt.Println(`
|
fmt.Fprintln(m.stdout, `
|
||||||
litestream is a tool for replicating SQLite databases.
|
litestream is a tool for replicating SQLite databases.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@@ -210,9 +219,15 @@ func (c *Config) DBConfig(path string) *DBConfig {
|
|||||||
|
|
||||||
// ReadConfigFile unmarshals config from filename. Expands path if needed.
|
// ReadConfigFile unmarshals config from filename. Expands path if needed.
|
||||||
// If expandEnv is true then environment variables are expanded in the config.
|
// If expandEnv is true then environment variables are expanded in the config.
|
||||||
|
// If filename is blank then the default config path is used.
|
||||||
func ReadConfigFile(filename string, expandEnv bool) (_ Config, err error) {
|
func ReadConfigFile(filename string, expandEnv bool) (_ Config, err error) {
|
||||||
config := DefaultConfig()
|
config := DefaultConfig()
|
||||||
|
|
||||||
|
useDefaultPath := filename == ""
|
||||||
|
if useDefaultPath {
|
||||||
|
filename = DefaultConfigPath()
|
||||||
|
}
|
||||||
|
|
||||||
// Expand filename, if necessary.
|
// Expand filename, if necessary.
|
||||||
filename, err = expand(filename)
|
filename, err = expand(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -220,8 +235,12 @@ func ReadConfigFile(filename string, expandEnv bool) (_ Config, err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read configuration.
|
// Read configuration.
|
||||||
|
// Do not return an error if using default path and file is missing.
|
||||||
buf, err := ioutil.ReadFile(filename)
|
buf, err := ioutil.ReadFile(filename)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
if useDefaultPath {
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
return config, fmt.Errorf("config file not found: %s", filename)
|
return config, fmt.Errorf("config file not found: %s", filename)
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return config, err
|
return config, err
|
||||||
@@ -354,7 +373,7 @@ func NewReplicaFromConfig(c *ReplicaConfig, db *litestream.DB) (_ *litestream.Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build and set client on replica.
|
// Build and set client on replica.
|
||||||
switch c.ReplicaType() {
|
switch typ := c.ReplicaType(); typ {
|
||||||
case "file":
|
case "file":
|
||||||
if r.Client, err = newFileReplicaClientFromConfig(c, r); err != nil {
|
if r.Client, err = newFileReplicaClientFromConfig(c, r); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -376,7 +395,7 @@ func NewReplicaFromConfig(c *ReplicaConfig, db *litestream.DB) (_ *litestream.Re
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unknown replica type in config: %q", c.Type)
|
return nil, fmt.Errorf("unknown replica type in config: %q", typ)
|
||||||
}
|
}
|
||||||
|
|
||||||
return r, nil
|
return r, nil
|
||||||
@@ -714,3 +733,45 @@ func (v *indexVar) Set(s string) error {
|
|||||||
*v = indexVar(i)
|
*v = indexVar(i)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadReplicas returns a list of replicas to use based on CLI flags. Filters
|
||||||
|
// by replicaName, if not blank. The DB is returned if pathOrURL is not a replica URL.
|
||||||
|
func loadReplicas(ctx context.Context, config Config, pathOrURL, replicaName string) ([]*litestream.Replica, *litestream.DB, error) {
|
||||||
|
// Build a replica based on URL, if specified.
|
||||||
|
if isURL(pathOrURL) {
|
||||||
|
r, err := NewReplicaFromConfig(&ReplicaConfig{
|
||||||
|
URL: pathOrURL,
|
||||||
|
AccessKeyID: config.AccessKeyID,
|
||||||
|
SecretAccessKey: config.SecretAccessKey,
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return []*litestream.Replica{r}, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use replicas from the database configuration file.
|
||||||
|
path, err := expand(pathOrURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
dbc := config.DBConfig(path)
|
||||||
|
if dbc == nil {
|
||||||
|
return nil, nil, fmt.Errorf("database not found in config: %s", path)
|
||||||
|
}
|
||||||
|
db, err := NewDBFromConfig(dbc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by replica, if specified.
|
||||||
|
if replicaName != "" {
|
||||||
|
r := db.Replica(replicaName)
|
||||||
|
if r == nil {
|
||||||
|
return nil, nil, fmt.Errorf("replica %q not found for database %q", replicaName, db.Path())
|
||||||
|
}
|
||||||
|
return []*litestream.Replica{r}, db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.Replicas, db, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main_test
|
package main_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
@@ -180,3 +182,17 @@ func TestNewGCSReplicaFromConfig(t *testing.T) {
|
|||||||
t.Fatalf("Path=%s, want %s", got, want)
|
t.Fatalf("Path=%s, want %s", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newMain returns a new instance of Main and associated buffers.
|
||||||
|
func newMain() (m *main.Main, stdin, stdout, stderr *bytes.Buffer) {
|
||||||
|
stdin, stdout, stderr = &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}
|
||||||
|
|
||||||
|
// Split stdout/stderr to terminal if verbose flag set.
|
||||||
|
out, err := io.Writer(stdout), io.Writer(stderr)
|
||||||
|
if testing.Verbose() {
|
||||||
|
out = io.MultiWriter(out, os.Stdout)
|
||||||
|
err = io.MultiWriter(err, os.Stderr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return main.NewMain(stdin, out, err), stdin, stdout, stderr
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
//go:build windows
|
||||||
// +build windows
|
// +build windows
|
||||||
|
|
||||||
package main
|
package main
|
||||||
@@ -41,7 +42,7 @@ func runWindowsService(ctx context.Context) error {
|
|||||||
log.Print("Litestream service starting")
|
log.Print("Litestream service starting")
|
||||||
|
|
||||||
if err := svc.Run(serviceName, &windowsService{ctx: ctx}); err != nil {
|
if err := svc.Run(serviceName, &windowsService{ctx: ctx}); err != nil {
|
||||||
return errStop
|
return errExit
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Print("Litestream service stopped")
|
log.Print("Litestream service stopped")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -22,6 +23,10 @@ import (
|
|||||||
|
|
||||||
// ReplicateCommand represents a command that continuously replicates SQLite databases.
|
// ReplicateCommand represents a command that continuously replicates SQLite databases.
|
||||||
type ReplicateCommand struct {
|
type ReplicateCommand struct {
|
||||||
|
stdin io.Reader
|
||||||
|
stdout io.Writer
|
||||||
|
stderr io.Writer
|
||||||
|
|
||||||
configPath string
|
configPath string
|
||||||
noExpandEnv bool
|
noExpandEnv bool
|
||||||
|
|
||||||
@@ -34,8 +39,13 @@ type ReplicateCommand struct {
|
|||||||
DBs []*litestream.DB
|
DBs []*litestream.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewReplicateCommand() *ReplicateCommand {
|
// NewReplicateCommand returns a new instance of ReplicateCommand.
|
||||||
|
func NewReplicateCommand(stdin io.Reader, stdout, stderr io.Writer) *ReplicateCommand {
|
||||||
return &ReplicateCommand{
|
return &ReplicateCommand{
|
||||||
|
stdin: stdin,
|
||||||
|
stdout: stdout,
|
||||||
|
stderr: stderr,
|
||||||
|
|
||||||
execCh: make(chan error),
|
execCh: make(chan error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,7 +191,7 @@ func (c *ReplicateCommand) Close() (err error) {
|
|||||||
|
|
||||||
// Usage prints the help screen to STDOUT.
|
// Usage prints the help screen to STDOUT.
|
||||||
func (c *ReplicateCommand) Usage() {
|
func (c *ReplicateCommand) Usage() {
|
||||||
fmt.Printf(`
|
fmt.Fprintf(c.stdout, `
|
||||||
The replicate command starts a server to monitor & replicate databases.
|
The replicate command starts a server to monitor & replicate databases.
|
||||||
You can specify your database & replicas in a configuration file or you can
|
You can specify your database & replicas in a configuration file or you can
|
||||||
replicate a single database file by specifying its path and its replicas in the
|
replicate a single database file by specifying its path and its replicas in the
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
main "github.com/benbjohnson/litestream/cmd/litestream"
|
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,7 +81,8 @@ dbs:
|
|||||||
|
|
||||||
// Replicate database unless the context is canceled.
|
// Replicate database unless the context is canceled.
|
||||||
g.Go(func() error {
|
g.Go(func() error {
|
||||||
return main.NewMain().Run(mainctx, []string{"replicate", "-config", configPath})
|
m, _, _, _ := newMain()
|
||||||
|
return m.Run(mainctx, []string{"replicate", "-config", configPath})
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := g.Wait(); err != nil {
|
if err := g.Wait(); err != nil {
|
||||||
@@ -94,7 +94,8 @@ dbs:
|
|||||||
chksum0 := mustChecksum(t, dbPath)
|
chksum0 := mustChecksum(t, dbPath)
|
||||||
|
|
||||||
// Restore to another path.
|
// Restore to another path.
|
||||||
if err := main.NewMain().Run(context.Background(), []string{"restore", "-config", configPath, "-o", restorePath, dbPath}); err != nil && !errors.Is(err, context.Canceled) {
|
m, _, _, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"restore", "-config", configPath, "-o", restorePath, dbPath}); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -15,6 +16,10 @@ import (
|
|||||||
|
|
||||||
// RestoreCommand represents a command to restore a database from a backup.
|
// RestoreCommand represents a command to restore a database from a backup.
|
||||||
type RestoreCommand struct {
|
type RestoreCommand struct {
|
||||||
|
stdin io.Reader
|
||||||
|
stdout io.Writer
|
||||||
|
stderr io.Writer
|
||||||
|
|
||||||
snapshotIndex int // index of snapshot to start from
|
snapshotIndex int // index of snapshot to start from
|
||||||
|
|
||||||
// CLI options
|
// CLI options
|
||||||
@@ -29,8 +34,13 @@ type RestoreCommand struct {
|
|||||||
opt litestream.RestoreOptions
|
opt litestream.RestoreOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRestoreCommand() *RestoreCommand {
|
// NewRestoreCommand returns a new instance of RestoreCommand.
|
||||||
|
func NewRestoreCommand(stdin io.Reader, stdout, stderr io.Writer) *RestoreCommand {
|
||||||
return &RestoreCommand{
|
return &RestoreCommand{
|
||||||
|
stdin: stdin,
|
||||||
|
stdout: stdout,
|
||||||
|
stderr: stderr,
|
||||||
|
|
||||||
targetIndex: -1,
|
targetIndex: -1,
|
||||||
opt: litestream.NewRestoreOptions(),
|
opt: litestream.NewRestoreOptions(),
|
||||||
}
|
}
|
||||||
@@ -55,31 +65,39 @@ func (c *RestoreCommand) Run(ctx context.Context, args []string) (err error) {
|
|||||||
} else if fs.NArg() > 1 {
|
} else if fs.NArg() > 1 {
|
||||||
return fmt.Errorf("too many arguments")
|
return fmt.Errorf("too many arguments")
|
||||||
}
|
}
|
||||||
arg := fs.Arg(0)
|
pathOrURL := fs.Arg(0)
|
||||||
|
|
||||||
// Ensure a generation is specified if target index is specified.
|
// Ensure a generation is specified if target index is specified.
|
||||||
if c.targetIndex != -1 && c.generation == "" {
|
if c.targetIndex != -1 && c.generation == "" {
|
||||||
return fmt.Errorf("must specify -generation when using -index flag")
|
return fmt.Errorf("must specify -generation flag when using -index flag")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to original database path if output path not specified.
|
// Default to original database path if output path not specified.
|
||||||
if !isURL(arg) && c.outputPath == "" {
|
if !isURL(pathOrURL) && c.outputPath == "" {
|
||||||
c.outputPath = arg
|
c.outputPath = pathOrURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exit successfully if the output file already exists and flag is set.
|
// Exit successfully if the output file already exists and flag is set.
|
||||||
if _, err := os.Stat(c.outputPath); !os.IsNotExist(err) && c.ifDBNotExists {
|
if _, err := os.Stat(c.outputPath); os.IsNotExist(err) {
|
||||||
fmt.Println("database already exists, skipping")
|
// file doesn't exist, continue
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
} else if err == nil {
|
||||||
|
if c.ifDBNotExists {
|
||||||
|
fmt.Fprintln(c.stdout, "database already exists, skipping")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return fmt.Errorf("output file already exists: %s", c.outputPath)
|
||||||
|
}
|
||||||
|
|
||||||
// Create parent directory if it doesn't already exist.
|
// Load configuration.
|
||||||
if err := os.MkdirAll(filepath.Dir(c.outputPath), 0700); err != nil {
|
config, err := ReadConfigFile(c.configPath, !c.noExpandEnv)
|
||||||
return fmt.Errorf("cannot create parent directory: %w", err)
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build replica from either a URL or config.
|
// Build replica from either a URL or config.
|
||||||
r, err := c.loadReplica(ctx, arg)
|
r, err := c.loadReplica(ctx, config, pathOrURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -90,7 +108,7 @@ func (c *RestoreCommand) Run(ctx context.Context, args []string) (err error) {
|
|||||||
// Return an error if no matching targets found.
|
// Return an error if no matching targets found.
|
||||||
// If optional flag set, return success. Useful for automated recovery.
|
// If optional flag set, return success. Useful for automated recovery.
|
||||||
if c.ifReplicaExists {
|
if c.ifReplicaExists {
|
||||||
fmt.Println("no matching backups found")
|
fmt.Fprintln(c.stdout, "no matching backups found, skipping")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no matching backups found")
|
return fmt.Errorf("no matching backups found")
|
||||||
@@ -112,47 +130,42 @@ func (c *RestoreCommand) Run(ctx context.Context, args []string) (err error) {
|
|||||||
return fmt.Errorf("cannot find snapshot index: %w", err)
|
return fmt.Errorf("cannot find snapshot index: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.opt.Logger = log.New(os.Stderr, "", log.LstdFlags|log.Lmicroseconds)
|
// Create parent directory if it doesn't already exist.
|
||||||
|
if err := os.MkdirAll(filepath.Dir(c.outputPath), 0700); err != nil {
|
||||||
|
return fmt.Errorf("cannot create parent directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.opt.Logger = log.New(c.stdout, "", log.LstdFlags|log.Lmicroseconds)
|
||||||
|
|
||||||
return litestream.Restore(ctx, r.Client, c.outputPath, c.generation, c.snapshotIndex, c.targetIndex, c.opt)
|
return litestream.Restore(ctx, r.Client, c.outputPath, c.generation, c.snapshotIndex, c.targetIndex, c.opt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *RestoreCommand) loadReplica(ctx context.Context, arg string) (*litestream.Replica, error) {
|
func (c *RestoreCommand) loadReplica(ctx context.Context, config Config, arg string) (*litestream.Replica, error) {
|
||||||
if isURL(arg) {
|
if isURL(arg) {
|
||||||
return c.loadReplicaFromURL(ctx, arg)
|
return c.loadReplicaFromURL(ctx, config, arg)
|
||||||
}
|
}
|
||||||
return c.loadReplicaFromConfig(ctx, arg)
|
return c.loadReplicaFromConfig(ctx, config, arg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadReplicaFromURL creates a replica & updates the restore options from a replica URL.
|
// loadReplicaFromURL creates a replica & updates the restore options from a replica URL.
|
||||||
func (c *RestoreCommand) loadReplicaFromURL(ctx context.Context, replicaURL string) (*litestream.Replica, error) {
|
func (c *RestoreCommand) loadReplicaFromURL(ctx context.Context, config Config, replicaURL string) (*litestream.Replica, error) {
|
||||||
if c.configPath != "" {
|
if c.replicaName != "" {
|
||||||
return nil, fmt.Errorf("cannot specify a replica URL and the -config flag")
|
return nil, fmt.Errorf("cannot specify both the replica URL and the -replica flag")
|
||||||
} else if c.replicaName != "" {
|
|
||||||
return nil, fmt.Errorf("cannot specify a replica URL and the -replica flag")
|
|
||||||
} else if c.outputPath == "" {
|
} else if c.outputPath == "" {
|
||||||
return nil, fmt.Errorf("output path required")
|
return nil, fmt.Errorf("output path required when using a replica URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
syncInterval := litestream.DefaultSyncInterval
|
syncInterval := litestream.DefaultSyncInterval
|
||||||
return NewReplicaFromConfig(&ReplicaConfig{
|
return NewReplicaFromConfig(&ReplicaConfig{
|
||||||
URL: replicaURL,
|
URL: replicaURL,
|
||||||
|
AccessKeyID: config.AccessKeyID,
|
||||||
|
SecretAccessKey: config.SecretAccessKey,
|
||||||
SyncInterval: &syncInterval,
|
SyncInterval: &syncInterval,
|
||||||
}, nil)
|
}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadReplicaFromConfig returns replicas based on the specific config path.
|
// loadReplicaFromConfig returns replicas based on the specific config path.
|
||||||
func (c *RestoreCommand) loadReplicaFromConfig(ctx context.Context, dbPath string) (*litestream.Replica, error) {
|
func (c *RestoreCommand) loadReplicaFromConfig(ctx context.Context, config Config, dbPath string) (_ *litestream.Replica, err error) {
|
||||||
if c.configPath == "" {
|
|
||||||
c.configPath = DefaultConfigPath()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load configuration.
|
|
||||||
config, err := ReadConfigFile(c.configPath, !c.noExpandEnv)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup database from configuration file by path.
|
// Lookup database from configuration file by path.
|
||||||
if dbPath, err = expand(dbPath); err != nil {
|
if dbPath, err = expand(dbPath); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -184,7 +197,7 @@ func (c *RestoreCommand) loadReplicaFromConfig(ctx context.Context, dbPath strin
|
|||||||
|
|
||||||
// A replica must be specified when restoring a specific generation with multiple replicas.
|
// A replica must be specified when restoring a specific generation with multiple replicas.
|
||||||
if c.generation != "" {
|
if c.generation != "" {
|
||||||
return nil, fmt.Errorf("must specify -replica when restoring from a specific generation")
|
return nil, fmt.Errorf("must specify -replica flag when restoring from a specific generation")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine latest replica to restore from.
|
// Determine latest replica to restore from.
|
||||||
@@ -197,7 +210,7 @@ func (c *RestoreCommand) loadReplicaFromConfig(ctx context.Context, dbPath strin
|
|||||||
|
|
||||||
// Usage prints the help screen to STDOUT.
|
// Usage prints the help screen to STDOUT.
|
||||||
func (c *RestoreCommand) Usage() {
|
func (c *RestoreCommand) Usage() {
|
||||||
fmt.Printf(`
|
fmt.Fprintf(c.stdout, `
|
||||||
The restore command recovers a database from a previous snapshot and WAL.
|
The restore command recovers a database from a previous snapshot and WAL.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
|||||||
330
cmd/litestream/restore_test.go
Normal file
330
cmd/litestream/restore_test.go
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/benbjohnson/litestream/internal/testingutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRestoreCommand(t *testing.T) {
|
||||||
|
t.Run("OK", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "ok")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
m, _, stdout, stderr := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), "-o", filepath.Join(tempDir, "db"), filepath.Join(testDir, "db")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stderr.String(), ""; got != want {
|
||||||
|
t.Fatalf("stderr=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// STDOUT has timing info so we need to grep per line.
|
||||||
|
lines := strings.Split(stdout.String(), "\n")
|
||||||
|
for i, substr := range []string{
|
||||||
|
`restoring snapshot 0000000000000000/00000000 to ` + filepath.Join(tempDir, "db.tmp"),
|
||||||
|
`applied wal 0000000000000000/00000000 elapsed=`,
|
||||||
|
`applied wal 0000000000000000/00000001 elapsed=`,
|
||||||
|
`applied wal 0000000000000000/00000002 elapsed=`,
|
||||||
|
`renaming database from temporary location`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(lines[i], substr) {
|
||||||
|
t.Fatalf("stdout: unexpected line %d:\n%s", i+1, stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ReplicaName", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "replica-name")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
m, _, stdout, stderr := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), "-o", filepath.Join(tempDir, "db"), "-replica", "replica1", filepath.Join(testDir, "db")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stderr.String(), ""; got != want {
|
||||||
|
t.Fatalf("stderr=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// STDOUT has timing info so we need to grep per line.
|
||||||
|
lines := strings.Split(stdout.String(), "\n")
|
||||||
|
for i, substr := range []string{
|
||||||
|
`restoring snapshot 0000000000000001/00000001 to ` + filepath.Join(tempDir, "db.tmp"),
|
||||||
|
`no wal files found, snapshot only`,
|
||||||
|
`renaming database from temporary location`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(lines[i], substr) {
|
||||||
|
t.Fatalf("stdout: unexpected line %d:\n%s", i+1, stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ReplicaURL", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join(testingutil.Getwd(t), "testdata", "restore", "replica-url")
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
replicaURL := "file://" + filepath.ToSlash(testDir) + "/replica"
|
||||||
|
|
||||||
|
m, _, stdout, stderr := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"restore", "-o", filepath.Join(tempDir, "db"), replicaURL}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stderr.String(), ""; got != want {
|
||||||
|
t.Fatalf("stderr=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(stdout.String(), "\n")
|
||||||
|
for i, substr := range []string{
|
||||||
|
`restoring snapshot 0000000000000000/00000000 to ` + filepath.Join(tempDir, "db.tmp"),
|
||||||
|
`no wal files found, snapshot only`,
|
||||||
|
`renaming database from temporary location`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(lines[i], substr) {
|
||||||
|
t.Fatalf("stdout: unexpected line %d:\n%s", i+1, stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("LatestReplica", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "latest-replica")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
m, _, stdout, stderr := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), "-o", filepath.Join(tempDir, "db"), filepath.Join(testDir, "db")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stderr.String(), ""; got != want {
|
||||||
|
t.Fatalf("stderr=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(stdout.String(), "\n")
|
||||||
|
for i, substr := range []string{
|
||||||
|
`restoring snapshot 0000000000000001/00000000 to ` + filepath.Join(tempDir, "db.tmp"),
|
||||||
|
`no wal files found, snapshot only`,
|
||||||
|
`renaming database from temporary location`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(lines[i], substr) {
|
||||||
|
t.Fatalf("stdout: unexpected line %d:\n%s", i+1, stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IfDBNotExistsFlag", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "if-db-not-exists-flag")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, stdout, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), "-if-db-not-exists", filepath.Join(testDir, "db")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stdout.String(), string(testingutil.MustReadFile(t, filepath.Join(testDir, "stdout"))); got != want {
|
||||||
|
t.Fatalf("stdout=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("IfReplicaExists", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "if-replica-exists-flag")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, stdout, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), "-if-replica-exists", filepath.Join(testDir, "db")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stdout.String(), string(testingutil.MustReadFile(t, filepath.Join(testDir, "stdout"))); got != want {
|
||||||
|
t.Fatalf("stdout=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrNoBackups", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "no-backups")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
m, _, stdout, stderr := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), "-o", filepath.Join(tempDir, "db"), filepath.Join(testDir, "db")})
|
||||||
|
if err == nil || err.Error() != `no matching backups found` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
} else if got, want := stdout.String(), string(testingutil.MustReadFile(t, filepath.Join(testDir, "stdout"))); got != want {
|
||||||
|
t.Fatalf("stdout=%q, want %q", got, want)
|
||||||
|
} else if got, want := stderr.String(), string(testingutil.MustReadFile(t, filepath.Join(testDir, "stderr"))); got != want {
|
||||||
|
t.Fatalf("stderr=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrNoGeneration", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "no-generation")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), filepath.Join(testDir, "db")})
|
||||||
|
if err == nil || err.Error() != `no matching backups found` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrOutputPathExists", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "output-path-exists")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), filepath.Join(testDir, "db")})
|
||||||
|
if err == nil || err.Error() != `output file already exists: `+filepath.Join(testDir, "db") {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrDatabaseOrReplicaRequired", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore"})
|
||||||
|
if err == nil || err.Error() != `database path or replica URL required` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrTooManyArguments", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "abc", "123"})
|
||||||
|
if err == nil || err.Error() != `too many arguments` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrInvalidFlags", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-no-such-flag"})
|
||||||
|
if err == nil || err.Error() != `flag provided but not defined: -no-such-flag` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrIndexFlagOnly", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-index", "0", "/var/lib/db"})
|
||||||
|
if err == nil || err.Error() != `must specify -generation flag when using -index flag` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrConfigFileNotFound", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-config", "/no/such/file", "/var/lib/db"})
|
||||||
|
if err == nil || err.Error() != `config file not found: /no/such/file` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrInvalidConfig", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "invalid-config")
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), "/var/lib/db"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `replica path cannot be a url`) {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrMkdir", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(tempDir, "noperm"), 0000); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-o", filepath.Join(tempDir, "noperm", "subdir", "db"), "/var/lib/db"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `permission denied`) {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrNoOutputPathWithReplicaURL", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "file://path/to/replica"})
|
||||||
|
if err == nil || err.Error() != `output path required when using a replica URL` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrReplicaNameWithReplicaURL", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-replica", "replica0", "file://path/to/replica"})
|
||||||
|
if err == nil || err.Error() != `cannot specify both the replica URL and the -replica flag` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrInvalidReplicaURL", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-o", "/tmp/db", "xyz://xyz"})
|
||||||
|
if err == nil || err.Error() != `unknown replica type in config: "xyz"` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrDatabaseNotFound", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "database-not-found")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), "/no/such/db"})
|
||||||
|
if err == nil || err.Error() != `database not found in config: /no/such/db` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrNoReplicas", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "no-replicas")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), "-o", filepath.Join(tempDir, "db"), filepath.Join(testDir, "db")})
|
||||||
|
if err == nil || err.Error() != `database has no replicas: `+filepath.Join(testingutil.Getwd(t), testDir, "db") {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrReplicaNotFound", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "replica-not-found")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), "-o", filepath.Join(tempDir, "db"), "-replica", "no_such_replica", filepath.Join(testDir, "db")})
|
||||||
|
if err == nil || err.Error() != `replica "no_such_replica" not found` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrGenerationWithNoReplicaName", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "generation-with-no-replica")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), "-o", filepath.Join(tempDir, "db"), "-generation", "0000000000000000", filepath.Join(testDir, "db")})
|
||||||
|
if err == nil || err.Error() != `must specify -replica flag when restoring from a specific generation` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrNoSnapshotsAvailable", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "restore", "no-snapshots")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"restore", "-config", filepath.Join(testDir, "litestream.yml"), "-o", filepath.Join(tempDir, "db"), "-generation", "0000000000000000", filepath.Join(testDir, "db")})
|
||||||
|
if err == nil || err.Error() != `cannot determine latest index in generation "0000000000000000": no snapshots available` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Usage", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"restore", "-h"}); err != flag.ErrHelp {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"sort"
|
"sort"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,99 +15,89 @@ import (
|
|||||||
|
|
||||||
// SnapshotsCommand represents a command to list snapshots for a command.
|
// SnapshotsCommand represents a command to list snapshots for a command.
|
||||||
type SnapshotsCommand struct {
|
type SnapshotsCommand struct {
|
||||||
|
stdin io.Reader
|
||||||
|
stdout io.Writer
|
||||||
|
stderr io.Writer
|
||||||
|
|
||||||
configPath string
|
configPath string
|
||||||
noExpandEnv bool
|
noExpandEnv bool
|
||||||
|
|
||||||
|
replicaName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSnapshotsCommand returns a new instance of SnapshotsCommand.
|
||||||
|
func NewSnapshotsCommand(stdin io.Reader, stdout, stderr io.Writer) *SnapshotsCommand {
|
||||||
|
return &SnapshotsCommand{
|
||||||
|
stdin: stdin,
|
||||||
|
stdout: stdout,
|
||||||
|
stderr: stderr,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run executes the command.
|
// Run executes the command.
|
||||||
func (c *SnapshotsCommand) Run(ctx context.Context, args []string) (err error) {
|
func (c *SnapshotsCommand) Run(ctx context.Context, args []string) (ret error) {
|
||||||
fs := flag.NewFlagSet("litestream-snapshots", flag.ContinueOnError)
|
fs := flag.NewFlagSet("litestream-snapshots", flag.ContinueOnError)
|
||||||
registerConfigFlag(fs, &c.configPath, &c.noExpandEnv)
|
registerConfigFlag(fs, &c.configPath, &c.noExpandEnv)
|
||||||
replicaName := fs.String("replica", "", "replica name")
|
fs.StringVar(&c.replicaName, "replica", "", "replica name")
|
||||||
fs.Usage = c.Usage
|
fs.Usage = c.Usage
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if fs.NArg() == 0 || fs.Arg(0) == "" {
|
} else if fs.NArg() == 0 || fs.Arg(0) == "" {
|
||||||
return fmt.Errorf("database path required")
|
return fmt.Errorf("database path or replica URL required")
|
||||||
} else if fs.NArg() > 1 {
|
} else if fs.NArg() > 1 {
|
||||||
return fmt.Errorf("too many arguments")
|
return fmt.Errorf("too many arguments")
|
||||||
}
|
}
|
||||||
|
|
||||||
var db *litestream.DB
|
|
||||||
var r *litestream.Replica
|
|
||||||
if isURL(fs.Arg(0)) {
|
|
||||||
if c.configPath != "" {
|
|
||||||
return fmt.Errorf("cannot specify a replica URL and the -config flag")
|
|
||||||
}
|
|
||||||
if r, err = NewReplicaFromConfig(&ReplicaConfig{URL: fs.Arg(0)}, nil); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if c.configPath == "" {
|
|
||||||
c.configPath = DefaultConfigPath()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load configuration.
|
// Load configuration.
|
||||||
config, err := ReadConfigFile(c.configPath, !c.noExpandEnv)
|
config, err := ReadConfigFile(c.configPath, !c.noExpandEnv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lookup database from configuration file by path.
|
// Determine list of replicas to pull snapshots from.
|
||||||
if path, err := expand(fs.Arg(0)); err != nil {
|
replicas, _, err := loadReplicas(ctx, config, fs.Arg(0), c.replicaName)
|
||||||
return err
|
if err != nil {
|
||||||
} else if dbc := config.DBConfig(path); dbc == nil {
|
|
||||||
return fmt.Errorf("database not found in config: %s", path)
|
|
||||||
} else if db, err = NewDBFromConfig(dbc); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by replica, if specified.
|
// Build list of snapshot metadata with associated replica.
|
||||||
if *replicaName != "" {
|
var infos []replicaSnapshotInfo
|
||||||
if r = db.Replica(*replicaName); r == nil {
|
for _, r := range replicas {
|
||||||
return fmt.Errorf("replica %q not found for database %q", *replicaName, db.Path())
|
a, err := r.Snapshots(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("cannot determine snapshots: %s", err)
|
||||||
|
ret = errExit // signal error return without printing message
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
for i := range a {
|
||||||
|
infos = append(infos, replicaSnapshotInfo{SnapshotInfo: a[i], replicaName: r.Name()})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find snapshots by db or replica.
|
// Sort snapshots by creation time from newest to oldest.
|
||||||
var replicas []*litestream.Replica
|
sort.Slice(infos, func(i, j int) bool { return infos[i].CreatedAt.After(infos[j].CreatedAt) })
|
||||||
if r != nil {
|
|
||||||
replicas = []*litestream.Replica{r}
|
|
||||||
} else {
|
|
||||||
replicas = db.Replicas
|
|
||||||
}
|
|
||||||
|
|
||||||
// List all snapshots.
|
// List all snapshots.
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 8, 2, ' ', 0)
|
w := tabwriter.NewWriter(c.stdout, 0, 8, 2, ' ', 0)
|
||||||
defer w.Flush()
|
defer w.Flush()
|
||||||
|
|
||||||
fmt.Fprintln(w, "replica\tgeneration\tindex\tsize\tcreated")
|
fmt.Fprintln(w, "replica\tgeneration\tindex\tsize\tcreated")
|
||||||
for _, r := range replicas {
|
|
||||||
infos, err := r.Snapshots(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("cannot determine snapshots: %s", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Sort snapshots by creation time from newest to oldest.
|
|
||||||
sort.Slice(infos, func(i, j int) bool { return infos[i].CreatedAt.After(infos[j].CreatedAt) })
|
|
||||||
for _, info := range infos {
|
for _, info := range infos {
|
||||||
fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n",
|
fmt.Fprintf(w, "%s\t%s\t%08x\t%d\t%s\n",
|
||||||
r.Name(),
|
info.replicaName,
|
||||||
info.Generation,
|
info.Generation,
|
||||||
info.Index,
|
info.Index,
|
||||||
info.Size,
|
info.Size,
|
||||||
info.CreatedAt.Format(time.RFC3339),
|
info.CreatedAt.Format(time.RFC3339),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
// Usage prints the help screen to STDOUT.
|
// Usage prints the help screen to STDOUT.
|
||||||
func (c *SnapshotsCommand) Usage() {
|
func (c *SnapshotsCommand) Usage() {
|
||||||
fmt.Printf(`
|
fmt.Fprintf(c.stdout, `
|
||||||
The snapshots command lists all snapshots available for a database or replica.
|
The snapshots command lists all snapshots available for a database or replica.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
@@ -143,3 +133,9 @@ Examples:
|
|||||||
DefaultConfigPath(),
|
DefaultConfigPath(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// replicaSnapshotInfo represents snapshot metadata with associated replica name.
|
||||||
|
type replicaSnapshotInfo struct {
|
||||||
|
litestream.SnapshotInfo
|
||||||
|
replicaName string
|
||||||
|
}
|
||||||
|
|||||||
128
cmd/litestream/snapshots_test.go
Normal file
128
cmd/litestream/snapshots_test.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/benbjohnson/litestream/internal/testingutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSnapshotsCommand(t *testing.T) {
|
||||||
|
t.Run("OK", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "snapshots", "ok")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, stdout, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"snapshots", "-config", filepath.Join(testDir, "litestream.yml"), filepath.Join(testDir, "db")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stdout.String(), string(testingutil.MustReadFile(t, filepath.Join(testDir, "stdout"))); got != want {
|
||||||
|
t.Fatalf("stdout=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ReplicaName", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "snapshots", "replica-name")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, stdout, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"snapshots", "-config", filepath.Join(testDir, "litestream.yml"), "-replica", "replica1", filepath.Join(testDir, "db")}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stdout.String(), string(testingutil.MustReadFile(t, filepath.Join(testDir, "stdout"))); got != want {
|
||||||
|
t.Fatalf("stdout=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ReplicaURL", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join(testingutil.Getwd(t), "testdata", "snapshots", "replica-url")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
replicaURL := "file://" + filepath.ToSlash(testDir) + "/replica"
|
||||||
|
|
||||||
|
m, _, stdout, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"snapshots", replicaURL}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
} else if got, want := stdout.String(), string(testingutil.MustReadFile(t, filepath.Join(testDir, "stdout"))); got != want {
|
||||||
|
t.Fatalf("stdout=%q, want %q", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrDatabaseOrReplicaRequired", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"snapshots"})
|
||||||
|
if err == nil || err.Error() != `database path or replica URL required` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrTooManyArguments", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"snapshots", "abc", "123"})
|
||||||
|
if err == nil || err.Error() != `too many arguments` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrInvalidFlags", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"snapshots", "-no-such-flag"})
|
||||||
|
if err == nil || err.Error() != `flag provided but not defined: -no-such-flag` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrConfigFileNotFound", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"snapshots", "-config", "/no/such/file", "/var/lib/db"})
|
||||||
|
if err == nil || err.Error() != `config file not found: /no/such/file` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrInvalidConfig", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "snapshots", "invalid-config")
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"snapshots", "-config", filepath.Join(testDir, "litestream.yml"), "/var/lib/db"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `replica path cannot be a url`) {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrDatabaseNotFound", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join("testdata", "snapshots", "database-not-found")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"snapshots", "-config", filepath.Join(testDir, "litestream.yml"), "/no/such/db"})
|
||||||
|
if err == nil || err.Error() != `database not found in config: /no/such/db` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrReplicaNotFound", func(t *testing.T) {
|
||||||
|
testDir := filepath.Join(testingutil.Getwd(t), "testdata", "snapshots", "replica-not-found")
|
||||||
|
defer testingutil.Setenv(t, "LITESTREAM_TESTDIR", testDir)()
|
||||||
|
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"snapshots", "-config", filepath.Join(testDir, "litestream.yml"), "-replica", "no_such_replica", filepath.Join(testDir, "db")})
|
||||||
|
if err == nil || err.Error() != `replica "no_such_replica" not found for database "`+filepath.Join(testDir, "db")+`"` {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ErrInvalidReplicaURL", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
err := m.Run(context.Background(), []string{"snapshots", "xyz://xyz"})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), `unknown replica type in config: "xyz"`) {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Usage", func(t *testing.T) {
|
||||||
|
m, _, _, _ := newMain()
|
||||||
|
if err := m.Run(context.Background(), []string{"snapshots", "-h"}); err != flag.ErrHelp {
|
||||||
|
t.Fatalf("unexpected error: %s", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
13
cmd/litestream/testdata/Makefile
vendored
Normal file
13
cmd/litestream/testdata/Makefile
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.PHONY: default
|
||||||
|
default:
|
||||||
|
make -C generations/ok
|
||||||
|
make -C generations/no-database
|
||||||
|
make -C generations/replica-name
|
||||||
|
make -C generations/replica-url
|
||||||
|
make -C restore/latest-replica
|
||||||
|
make -C snapshots/ok
|
||||||
|
make -C snapshots/replica-name
|
||||||
|
make -C snapshots/replica-url
|
||||||
|
make -C wal/ok
|
||||||
|
make -C wal/replica-name
|
||||||
|
make -C wal/replica-url
|
||||||
4
cmd/litestream/testdata/databases/invalid-config/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/databases/invalid-config/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: /var/lib/db
|
||||||
|
replicas:
|
||||||
|
- path: s3://bkt/db
|
||||||
0
cmd/litestream/testdata/databases/no-config/.gitignore
vendored
Normal file
0
cmd/litestream/testdata/databases/no-config/.gitignore
vendored
Normal file
1
cmd/litestream/testdata/databases/no-databases/litestream.yml
vendored
Normal file
1
cmd/litestream/testdata/databases/no-databases/litestream.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dbs:
|
||||||
1
cmd/litestream/testdata/databases/no-databases/stdout
vendored
Normal file
1
cmd/litestream/testdata/databases/no-databases/stdout
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
No databases found in config file.
|
||||||
7
cmd/litestream/testdata/databases/ok/litestream.yml
vendored
Normal file
7
cmd/litestream/testdata/databases/ok/litestream.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
dbs:
|
||||||
|
- path: /var/lib/db
|
||||||
|
replicas:
|
||||||
|
- path: /var/lib/replica
|
||||||
|
- url: s3://mybkt/db
|
||||||
|
|
||||||
|
- path: /my/other/db
|
||||||
3
cmd/litestream/testdata/databases/ok/stdout
vendored
Normal file
3
cmd/litestream/testdata/databases/ok/stdout
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
path replicas
|
||||||
|
/var/lib/db file,s3
|
||||||
|
/my/other/db
|
||||||
2
cmd/litestream/testdata/generations/database-not-found/litestream.yml
vendored
Normal file
2
cmd/litestream/testdata/generations/database-not-found/litestream.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
4
cmd/litestream/testdata/generations/invalid-config/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/generations/invalid-config/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: /var/lib/db
|
||||||
|
replicas:
|
||||||
|
- path: s3://bkt/db
|
||||||
4
cmd/litestream/testdata/generations/no-database/Makefile
vendored
Normal file
4
cmd/litestream/testdata/generations/no-database/Makefile
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.PHONY: default
|
||||||
|
default:
|
||||||
|
TZ=UTC touch -ct 200001010000 replica/generations/0000000000000000/snapshots/00000000.snapshot.lz4
|
||||||
|
TZ=UTC touch -ct 200001020000 replica/generations/0000000000000001/snapshots/00000000.snapshot.lz4
|
||||||
4
cmd/litestream/testdata/generations/no-database/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/generations/no-database/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- path: $LITESTREAM_TESTDIR/replica
|
||||||
Binary file not shown.
Binary file not shown.
3
cmd/litestream/testdata/generations/no-database/stdout
vendored
Normal file
3
cmd/litestream/testdata/generations/no-database/stdout
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
name generation lag start end
|
||||||
|
file 0000000000000000 - 2000-01-01T00:00:00Z 2000-01-01T00:00:00Z
|
||||||
|
file 0000000000000001 - 2000-01-02T00:00:00Z 2000-01-02T00:00:00Z
|
||||||
9
cmd/litestream/testdata/generations/ok/Makefile
vendored
Normal file
9
cmd/litestream/testdata/generations/ok/Makefile
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.PHONY: default
|
||||||
|
default:
|
||||||
|
TZ=UTC touch -ct 200001030000 db
|
||||||
|
TZ=UTC touch -ct 200001010000 replica/generations/0000000000000000/snapshots/00000000.snapshot.lz4
|
||||||
|
TZ=UTC touch -ct 200001020000 replica/generations/0000000000000000/snapshots/00000001.snapshot.lz4
|
||||||
|
TZ=UTC touch -ct 200001010000 replica/generations/0000000000000000/wal/00000000/00000000.wal.lz4
|
||||||
|
TZ=UTC touch -ct 200001020000 replica/generations/0000000000000000/wal/00000000/00000001.wal.lz4
|
||||||
|
TZ=UTC touch -ct 200001030000 replica/generations/0000000000000000/wal/00000001/00000000.wal.lz4
|
||||||
|
TZ=UTC touch -ct 200001010000 replica/generations/0000000000000001/snapshots/00000000.snapshot.lz4
|
||||||
0
cmd/litestream/testdata/generations/ok/db
vendored
Normal file
0
cmd/litestream/testdata/generations/ok/db
vendored
Normal file
4
cmd/litestream/testdata/generations/ok/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/generations/ok/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- path: $LITESTREAM_TESTDIR/replica
|
||||||
0
cmd/litestream/testdata/generations/ok/replica/db
vendored
Normal file
0
cmd/litestream/testdata/generations/ok/replica/db
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
3
cmd/litestream/testdata/generations/ok/stdout
vendored
Normal file
3
cmd/litestream/testdata/generations/ok/stdout
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
name generation lag start end
|
||||||
|
file 0000000000000000 0s 2000-01-01T00:00:00Z 2000-01-03T00:00:00Z
|
||||||
|
file 0000000000000001 48h0m0s 2000-01-01T00:00:00Z 2000-01-01T00:00:00Z
|
||||||
5
cmd/litestream/testdata/generations/replica-name/Makefile
vendored
Normal file
5
cmd/litestream/testdata/generations/replica-name/Makefile
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.PHONY: default
|
||||||
|
default:
|
||||||
|
TZ=UTC touch -ct 200001030000 db
|
||||||
|
TZ=UTC touch -ct 200001010000 replica0/generations/0000000000000000/snapshots/00000000.snapshot.lz4
|
||||||
|
TZ=UTC touch -ct 200001020000 replica1/generations/0000000000000001/snapshots/00000000.snapshot.lz4
|
||||||
0
cmd/litestream/testdata/generations/replica-name/db
vendored
Normal file
0
cmd/litestream/testdata/generations/replica-name/db
vendored
Normal file
7
cmd/litestream/testdata/generations/replica-name/litestream.yml
vendored
Normal file
7
cmd/litestream/testdata/generations/replica-name/litestream.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- name: replica0
|
||||||
|
path: $LITESTREAM_TESTDIR/replica0
|
||||||
|
- name: replica1
|
||||||
|
path: $LITESTREAM_TESTDIR/replica1
|
||||||
0
cmd/litestream/testdata/generations/replica-name/replica0/db
vendored
Normal file
0
cmd/litestream/testdata/generations/replica-name/replica0/db
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
0
cmd/litestream/testdata/generations/replica-name/replica1/db
vendored
Normal file
0
cmd/litestream/testdata/generations/replica-name/replica1/db
vendored
Normal file
Binary file not shown.
2
cmd/litestream/testdata/generations/replica-name/stdout
vendored
Normal file
2
cmd/litestream/testdata/generations/replica-name/stdout
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
name generation lag start end
|
||||||
|
replica1 0000000000000001 24h0m0s 2000-01-02T00:00:00Z 2000-01-02T00:00:00Z
|
||||||
4
cmd/litestream/testdata/generations/replica-not-found/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/generations/replica-not-found/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- url: s3://bkt/db
|
||||||
9
cmd/litestream/testdata/generations/replica-url/Makefile
vendored
Normal file
9
cmd/litestream/testdata/generations/replica-url/Makefile
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.PHONY: default
|
||||||
|
default:
|
||||||
|
TZ=UTC touch -ct 200001010000 replica/generations/0000000000000000/snapshots/00000000.snapshot.lz4
|
||||||
|
TZ=UTC touch -ct 200001020000 replica/generations/0000000000000000/snapshots/00000001.snapshot.lz4
|
||||||
|
TZ=UTC touch -ct 200001010000 replica/generations/0000000000000000/wal/00000000/00000000.wal.lz4
|
||||||
|
TZ=UTC touch -ct 200001020000 replica/generations/0000000000000000/wal/00000000/00000001.wal.lz4
|
||||||
|
TZ=UTC touch -ct 200001030000 replica/generations/0000000000000000/wal/00000001/00000000.wal.lz4
|
||||||
|
TZ=UTC touch -ct 200001020000 replica/generations/0000000000000001/snapshots/00000000.snapshot.lz4
|
||||||
|
|
||||||
4
cmd/litestream/testdata/generations/replica-url/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/generations/replica-url/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- path: $LITESTREAM_TESTDIR/replica
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
3
cmd/litestream/testdata/generations/replica-url/stdout
vendored
Normal file
3
cmd/litestream/testdata/generations/replica-url/stdout
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
name generation lag start end
|
||||||
|
file 0000000000000000 - 2000-01-01T00:00:00Z 2000-01-03T00:00:00Z
|
||||||
|
file 0000000000000001 - 2000-01-02T00:00:00Z 2000-01-02T00:00:00Z
|
||||||
4
cmd/litestream/testdata/restore/database-not-found/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/restore/database-not-found/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- path: $LITESTREAM_TESTDIR/replica
|
||||||
5
cmd/litestream/testdata/restore/generation-with-no-replica/litestream.yml
vendored
Normal file
5
cmd/litestream/testdata/restore/generation-with-no-replica/litestream.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- path: $LITESTREAM_TESTDIR/replica0
|
||||||
|
- path: $LITESTREAM_TESTDIR/replica1
|
||||||
BIN
cmd/litestream/testdata/restore/if-db-not-exists-flag/db
vendored
Normal file
BIN
cmd/litestream/testdata/restore/if-db-not-exists-flag/db
vendored
Normal file
Binary file not shown.
4
cmd/litestream/testdata/restore/if-db-not-exists-flag/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/restore/if-db-not-exists-flag/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- path: $LITESTREAM_TESTDIR/replica
|
||||||
1
cmd/litestream/testdata/restore/if-db-not-exists-flag/stdout
vendored
Normal file
1
cmd/litestream/testdata/restore/if-db-not-exists-flag/stdout
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
database already exists, skipping
|
||||||
4
cmd/litestream/testdata/restore/if-replica-exists-flag/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/restore/if-replica-exists-flag/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- path: $LITESTREAM_TESTDIR/replica
|
||||||
1
cmd/litestream/testdata/restore/if-replica-exists-flag/stdout
vendored
Normal file
1
cmd/litestream/testdata/restore/if-replica-exists-flag/stdout
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
no matching backups found, skipping
|
||||||
4
cmd/litestream/testdata/restore/invalid-config/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/restore/invalid-config/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: /var/lib/db
|
||||||
|
replicas:
|
||||||
|
- path: s3://bkt/db
|
||||||
6
cmd/litestream/testdata/restore/latest-replica/Makefile
vendored
Normal file
6
cmd/litestream/testdata/restore/latest-replica/Makefile
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.PHONY: default
|
||||||
|
default:
|
||||||
|
TZ=UTC touch -ct 200001010000 replica0/generations/0000000000000000/snapshots/00000000.snapshot.lz4
|
||||||
|
TZ=UTC touch -ct 200001020000 replica1/generations/0000000000000002/snapshots/00000000.snapshot.lz4
|
||||||
|
TZ=UTC touch -ct 200001030000 replica0/generations/0000000000000001/snapshots/00000000.snapshot.lz4
|
||||||
|
|
||||||
7
cmd/litestream/testdata/restore/latest-replica/litestream.yml
vendored
Normal file
7
cmd/litestream/testdata/restore/latest-replica/litestream.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- name: replica0
|
||||||
|
path: $LITESTREAM_TESTDIR/replica0
|
||||||
|
- name: replica1
|
||||||
|
path: $LITESTREAM_TESTDIR/replica1
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
4
cmd/litestream/testdata/restore/no-backups/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/restore/no-backups/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- path: $LITESTREAM_TESTDIR/replica
|
||||||
0
cmd/litestream/testdata/restore/no-backups/stderr
vendored
Normal file
0
cmd/litestream/testdata/restore/no-backups/stderr
vendored
Normal file
0
cmd/litestream/testdata/restore/no-backups/stdout
vendored
Normal file
0
cmd/litestream/testdata/restore/no-backups/stdout
vendored
Normal file
4
cmd/litestream/testdata/restore/no-generation/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/restore/no-generation/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- path: $LITESTREAM_TESTDIR/replica
|
||||||
2
cmd/litestream/testdata/restore/no-replicas/litestream.yml
vendored
Normal file
2
cmd/litestream/testdata/restore/no-replicas/litestream.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
4
cmd/litestream/testdata/restore/no-snapshots/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/restore/no-snapshots/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- path: $LITESTREAM_TESTDIR/replica
|
||||||
BIN
cmd/litestream/testdata/restore/ok/00000002.db
vendored
Normal file
BIN
cmd/litestream/testdata/restore/ok/00000002.db
vendored
Normal file
Binary file not shown.
36
cmd/litestream/testdata/restore/ok/README
vendored
Normal file
36
cmd/litestream/testdata/restore/ok/README
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
To reproduce this testdata, run sqlite3 and execute:
|
||||||
|
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
CREATE TABLE t (x);
|
||||||
|
INSERT INTO t (x) VALUES (1);
|
||||||
|
INSERT INTO t (x) VALUES (2);
|
||||||
|
|
||||||
|
sl3 split -o generations/0000000000000000/wal/00000000 db-wal
|
||||||
|
cp db generations/0000000000000000/snapshots/00000000.snapshot
|
||||||
|
lz4 -c --rm generations/0000000000000000/snapshots/00000000.snapshot
|
||||||
|
|
||||||
|
|
||||||
|
Then execute:
|
||||||
|
|
||||||
|
PRAGMA wal_checkpoint(TRUNCATE);
|
||||||
|
INSERT INTO t (x) VALUES (3);
|
||||||
|
|
||||||
|
sl3 split -o generations/0000000000000000/wal/00000001 db-wal
|
||||||
|
|
||||||
|
|
||||||
|
Then execute:
|
||||||
|
|
||||||
|
PRAGMA wal_checkpoint(TRUNCATE);
|
||||||
|
INSERT INTO t (x) VALUES (4);
|
||||||
|
INSERT INTO t (x) VALUES (5);
|
||||||
|
|
||||||
|
sl3 split -o generations/0000000000000000/wal/00000002 db-wal
|
||||||
|
|
||||||
|
|
||||||
|
Finally, obtain the final snapshot:
|
||||||
|
|
||||||
|
PRAGMA wal_checkpoint(TRUNCATE);
|
||||||
|
|
||||||
|
cp db 00000002.db
|
||||||
|
rm db*
|
||||||
|
|
||||||
4
cmd/litestream/testdata/restore/ok/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/restore/ok/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- path: $LITESTREAM_TESTDIR/replica
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
cmd/litestream/testdata/restore/output-path-exists/db
vendored
Normal file
BIN
cmd/litestream/testdata/restore/output-path-exists/db
vendored
Normal file
Binary file not shown.
4
cmd/litestream/testdata/restore/output-path-exists/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/restore/output-path-exists/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- path: $LITESTREAM_TESTDIR/replica
|
||||||
7
cmd/litestream/testdata/restore/replica-name/litestream.yml
vendored
Normal file
7
cmd/litestream/testdata/restore/replica-name/litestream.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- name: replica0
|
||||||
|
path: $LITESTREAM_TESTDIR/replica0
|
||||||
|
- name: replica1
|
||||||
|
path: $LITESTREAM_TESTDIR/replica1
|
||||||
Binary file not shown.
Binary file not shown.
5
cmd/litestream/testdata/restore/replica-not-found/litestream.yml
vendored
Normal file
5
cmd/litestream/testdata/restore/replica-not-found/litestream.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
|
replicas:
|
||||||
|
- name: replica0
|
||||||
|
path: $LITESTREAM_TESTDIR/replica
|
||||||
Binary file not shown.
2
cmd/litestream/testdata/snapshots/database-not-found/litestream.yml
vendored
Normal file
2
cmd/litestream/testdata/snapshots/database-not-found/litestream.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
dbs:
|
||||||
|
- path: $LITESTREAM_TESTDIR/db
|
||||||
4
cmd/litestream/testdata/snapshots/invalid-config/litestream.yml
vendored
Normal file
4
cmd/litestream/testdata/snapshots/invalid-config/litestream.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dbs:
|
||||||
|
- path: /var/lib/db
|
||||||
|
replicas:
|
||||||
|
- path: s3://bkt/db
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user