package main import ( "context" "errors" "flag" "fmt" "io" "io/ioutil" "log" "net/url" "os" "os/signal" "os/user" "path" "path/filepath" "regexp" "strconv" "strings" "syscall" "time" "github.com/benbjohnson/litestream" "github.com/benbjohnson/litestream/abs" "github.com/benbjohnson/litestream/gs" "github.com/benbjohnson/litestream/s3" "github.com/benbjohnson/litestream/sftp" _ "github.com/mattn/go-sqlite3" "gopkg.in/yaml.v2" ) // Build information. var ( Version = "(development build)" ) // errExit is a terminal error for indicating program should quit. var errExit = errors.New("exit") func main() { log.SetFlags(0) log.SetOutput(os.Stdout) m := NewMain(os.Stdin, os.Stdout, os.Stderr) if err := m.Run(context.Background(), os.Args[1:]); err == flag.ErrHelp || err == errExit { os.Exit(1) } else if err != nil { log.Println(err) os.Exit(1) } } // Main represents the main program execution. type Main struct { stdin io.Reader stdout io.Writer stderr io.Writer } // NewMain returns a new instance of Main. func NewMain(stdin io.Reader, stdout, stderr io.Writer) *Main { return &Main{ stdin: stdin, stdout: stdout, stderr: stderr, } } // Run executes the program. func (m *Main) Run(ctx context.Context, args []string) (err error) { // Copy "LITESTEAM" environment credentials. applyLitestreamEnv() // Extract command name. var cmd string if len(args) > 0 { cmd, args = args[0], args[1:] } switch cmd { case "databases": return NewDatabasesCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args) case "generations": return NewGenerationsCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args) case "replicate": c := NewReplicateCommand(m.stdin, m.stdout, m.stderr) if err := c.ParseFlags(ctx, args); err != nil { return err } // Setup signal handler. ctx, cancel := context.WithCancel(ctx) defer cancel() signalCh := make(chan os.Signal, 1) signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM) if err := c.Run(ctx); err != nil { return err } // Wait for signal to stop program. select { case <-ctx.Done(): fmt.Fprintln(m.stdout, "context done, litestream shutting down") case err = <-c.execCh: cancel() fmt.Fprintln(m.stdout, "subprocess exited, litestream shutting down") case sig := <-signalCh: cancel() fmt.Fprintln(m.stdout, "signal received, litestream shutting down") if c.cmd != nil { fmt.Fprintln(m.stdout, "sending signal to exec process") if err := c.cmd.Process.Signal(sig); err != nil { return fmt.Errorf("cannot signal exec process: %w", err) } fmt.Fprintln(m.stdout, "waiting for exec process to close") if err := <-c.execCh; err != nil && !strings.HasPrefix(err.Error(), "signal:") { return fmt.Errorf("cannot wait for exec process: %w", err) } } } // Gracefully close. if e := c.Close(); e != nil && err == nil { err = e } fmt.Fprintln(m.stdout, "litestream shut down") return err case "restore": return NewRestoreCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args) case "snapshots": return NewSnapshotsCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args) case "version": return NewVersionCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args) case "wal": return NewWALCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args) default: if cmd == "" || cmd == "help" || strings.HasPrefix(cmd, "-") { m.Usage() return flag.ErrHelp } return fmt.Errorf("litestream %s: unknown command", cmd) } } // Usage prints the help screen to STDOUT. func (m *Main) Usage() { fmt.Fprintln(m.stdout, ` litestream is a tool for replicating SQLite databases. Usage: litestream [arguments] The commands are: databases list databases specified in config file generations list available generations for a database replicate runs a server to replicate databases restore recovers database backup from a replica snapshots list available snapshots for a database version prints the binary version wal list available WAL files for a database `[1:]) } // Config represents a configuration file for the litestream daemon. type Config struct { // Bind address for serving metrics. Addr string `yaml:"addr"` // List of databases to manage. DBs []*DBConfig `yaml:"dbs"` // Subcommand to execute during replication. // Litestream will shutdown when subcommand exits. Exec string `yaml:"exec"` // Global S3 settings AccessKeyID string `yaml:"access-key-id"` SecretAccessKey string `yaml:"secret-access-key"` } // propagateGlobalSettings copies global S3 settings to replica configs. func (c *Config) propagateGlobalSettings() { for _, dbc := range c.DBs { for _, rc := range dbc.Replicas { if rc.AccessKeyID == "" { rc.AccessKeyID = c.AccessKeyID } if rc.SecretAccessKey == "" { rc.SecretAccessKey = c.SecretAccessKey } } } } // DefaultConfig returns a new instance of Config with defaults set. func DefaultConfig() Config { return Config{} } // DBConfig returns database configuration by path. func (c *Config) DBConfig(path string) *DBConfig { for _, dbConfig := range c.DBs { if dbConfig.Path == path { return dbConfig } } return nil } // ReadConfigFile unmarshals config from filename. Expands path if needed. // 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 Config, err error) { var filenames []string if filename != "" { filenames = append(filenames, filename) } filenames = append(filenames, "./litestream.yml") filenames = append(filenames, DefaultConfigPath()) for _, name := range filenames { isDefaultPath := name != filename if config, err = readConfigFile(name, expandEnv); os.IsNotExist(err) { if isDefaultPath { continue } return config, fmt.Errorf("config file not found: %s", filename) } else if err != nil { return config, err } break } return config, nil } // readConfigFile unmarshals config from filename. Expands path if needed. // If expandEnv is true then environment variables are expanded in the config. func readConfigFile(filename string, expandEnv bool) (_ Config, err error) { config := DefaultConfig() // Expand filename, if necessary. filename, err = expand(filename) if err != nil { return config, err } // Read configuration. // Do not return an error if using default path and file is missing. buf, err := ioutil.ReadFile(filename) if err != nil { return config, err } // Expand environment variables, if enabled. if expandEnv { buf = []byte(os.ExpandEnv(string(buf))) } if err := yaml.Unmarshal(buf, &config); err != nil { return config, err } // Normalize paths. for _, dbConfig := range config.DBs { if dbConfig.Path, err = expand(dbConfig.Path); err != nil { return config, err } } // Propage settings from global config to replica configs. config.propagateGlobalSettings() return config, nil } // DBConfig represents the configuration for a single database. type DBConfig struct { Path string `yaml:"path"` MonitorDelayInterval *time.Duration `yaml:"monitor-delay-interval"` CheckpointInterval *time.Duration `yaml:"checkpoint-interval"` MinCheckpointPageN *int `yaml:"min-checkpoint-page-count"` MaxCheckpointPageN *int `yaml:"max-checkpoint-page-count"` ShadowRetentionN *int `yaml:"shadow-retention-count"` Replicas []*ReplicaConfig `yaml:"replicas"` } // NewDBFromConfig instantiates a DB based on a configuration. func NewDBFromConfig(dbc *DBConfig) (*litestream.DB, error) { path, err := expand(dbc.Path) if err != nil { return nil, err } return NewDBFromConfigWithPath(dbc, path) } // NewDBFromConfigWithPath instantiates a DB based on a configuration and using a given path. func NewDBFromConfigWithPath(dbc *DBConfig, path string) (*litestream.DB, error) { // Initialize database with given path. db := litestream.NewDB(path) // Override default database settings if specified in configuration. if dbc.MonitorDelayInterval != nil { db.MonitorDelayInterval = *dbc.MonitorDelayInterval } if dbc.CheckpointInterval != nil { db.CheckpointInterval = *dbc.CheckpointInterval } if dbc.MinCheckpointPageN != nil { db.MinCheckpointPageN = *dbc.MinCheckpointPageN } if dbc.MaxCheckpointPageN != nil { db.MaxCheckpointPageN = *dbc.MaxCheckpointPageN } if dbc.ShadowRetentionN != nil { db.ShadowRetentionN = *dbc.ShadowRetentionN } // Instantiate and attach replicas. for _, rc := range dbc.Replicas { r, err := NewReplicaFromConfig(rc, db) if err != nil { return nil, err } db.Replicas = append(db.Replicas, r) } return db, nil } // ReplicaConfig represents the configuration for a single replica in a database. type ReplicaConfig struct { Type string `yaml:"type"` // "file", "s3" Name string `yaml:"name"` // name of replica, optional. Path string `yaml:"path"` URL string `yaml:"url"` Retention *time.Duration `yaml:"retention"` RetentionCheckInterval *time.Duration `yaml:"retention-check-interval"` SyncInterval *time.Duration `yaml:"sync-interval"` SnapshotInterval *time.Duration `yaml:"snapshot-interval"` ValidationInterval *time.Duration `yaml:"validation-interval"` // S3 settings AccessKeyID string `yaml:"access-key-id"` SecretAccessKey string `yaml:"secret-access-key"` Region string `yaml:"region"` Bucket string `yaml:"bucket"` Endpoint string `yaml:"endpoint"` ForcePathStyle *bool `yaml:"force-path-style"` SkipVerify bool `yaml:"skip-verify"` // ABS settings AccountName string `yaml:"account-name"` AccountKey string `yaml:"account-key"` // SFTP settings Host string `yaml:"host"` User string `yaml:"user"` Password string `yaml:"password"` KeyPath string `yaml:"key-path"` } // NewReplicaFromConfig instantiates a replica for a DB based on a config. func NewReplicaFromConfig(c *ReplicaConfig, db *litestream.DB) (_ *litestream.Replica, err error) { // Ensure user did not specify URL in path. if isURL(c.Path) { return nil, fmt.Errorf("replica path cannot be a url, please use the 'url' field instead: %s", c.Path) } // Build and set client on replica. var client litestream.ReplicaClient switch typ := c.ReplicaType(); typ { case "file": if client, err = newFileReplicaClientFromConfig(c); err != nil { return nil, err } case "s3": if client, err = newS3ReplicaClientFromConfig(c); err != nil { return nil, err } case "gs": if client, err = newGSReplicaClientFromConfig(c); err != nil { return nil, err } case "abs": if client, err = newABSReplicaClientFromConfig(c); err != nil { return nil, err } case "sftp": if client, err = newSFTPReplicaClientFromConfig(c); err != nil { return nil, err } default: return nil, fmt.Errorf("unknown replica type in config: %q", typ) } // Build replica. r := litestream.NewReplica(db, c.Name, client) if v := c.Retention; v != nil { r.Retention = *v } if v := c.RetentionCheckInterval; v != nil { r.RetentionCheckInterval = *v } if v := c.SyncInterval; v != nil { r.SyncInterval = *v } if v := c.SnapshotInterval; v != nil { r.SnapshotInterval = *v } if v := c.ValidationInterval; v != nil { r.ValidationInterval = *v } return r, nil } // newFileReplicaClientFromConfig returns a new instance of FileReplicaClient built from config. func newFileReplicaClientFromConfig(c *ReplicaConfig) (_ *litestream.FileReplicaClient, err error) { // Ensure URL & path are not both specified. if c.URL != "" && c.Path != "" { return nil, fmt.Errorf("cannot specify url & path for file replica") } // Parse path from URL, if specified. path := c.Path if c.URL != "" { if _, _, path, err = ParseReplicaURL(c.URL); err != nil { return nil, err } } // Ensure path is set explicitly or derived from URL field. if path == "" { return nil, fmt.Errorf("file replica path required") } // Expand home prefix and return absolute path. if path, err = expand(path); err != nil { return nil, err } // Instantiate replica and apply time fields, if set. return litestream.NewFileReplicaClient(path), nil } // newS3ReplicaClientFromConfig returns a new instance of s3.ReplicaClient built from config. func newS3ReplicaClientFromConfig(c *ReplicaConfig) (_ *s3.ReplicaClient, err error) { // Ensure URL & constituent parts are not both specified. if c.URL != "" && c.Path != "" { return nil, fmt.Errorf("cannot specify url & path for s3 replica") } else if c.URL != "" && c.Bucket != "" { return nil, fmt.Errorf("cannot specify url & bucket for s3 replica") } bucket, path := c.Bucket, c.Path region, endpoint, skipVerify := c.Region, c.Endpoint, c.SkipVerify // Use path style if an endpoint is explicitly set. This works because the // only service to not use path style is AWS which does not use an endpoint. forcePathStyle := (endpoint != "") if v := c.ForcePathStyle; v != nil { forcePathStyle = *v } // Apply settings from URL, if specified. if c.URL != "" { _, host, upath, err := ParseReplicaURL(c.URL) if err != nil { return nil, err } ubucket, uregion, uendpoint, uforcePathStyle := s3.ParseHost(host) // Only apply URL parts to field that have not been overridden. if path == "" { path = upath } if bucket == "" { bucket = ubucket } if region == "" { region = uregion } if endpoint == "" { endpoint = uendpoint } if !forcePathStyle { forcePathStyle = uforcePathStyle } } // Ensure required settings are set. if bucket == "" { return nil, fmt.Errorf("bucket required for s3 replica") } // Build replica. client := s3.NewReplicaClient() client.AccessKeyID = c.AccessKeyID client.SecretAccessKey = c.SecretAccessKey client.Bucket = bucket client.Path = path client.Region = region client.Endpoint = endpoint client.ForcePathStyle = forcePathStyle client.SkipVerify = skipVerify return client, nil } // newGSReplicaClientFromConfig returns a new instance of gs.ReplicaClient built from config. func newGSReplicaClientFromConfig(c *ReplicaConfig) (_ *gs.ReplicaClient, err error) { // Ensure URL & constituent parts are not both specified. if c.URL != "" && c.Path != "" { return nil, fmt.Errorf("cannot specify url & path for gs replica") } else if c.URL != "" && c.Bucket != "" { return nil, fmt.Errorf("cannot specify url & bucket for gs replica") } bucket, path := c.Bucket, c.Path // Apply settings from URL, if specified. if c.URL != "" { _, uhost, upath, err := ParseReplicaURL(c.URL) if err != nil { return nil, err } // Only apply URL parts to field that have not been overridden. if path == "" { path = upath } if bucket == "" { bucket = uhost } } // Ensure required settings are set. if bucket == "" { return nil, fmt.Errorf("bucket required for gs replica") } // Build replica. client := gs.NewReplicaClient() client.Bucket = bucket client.Path = path return client, nil } // newABSReplicaClientFromConfig returns a new instance of abs.ReplicaClient built from config. func newABSReplicaClientFromConfig(c *ReplicaConfig) (_ *abs.ReplicaClient, err error) { // Ensure URL & constituent parts are not both specified. if c.URL != "" && c.Path != "" { return nil, fmt.Errorf("cannot specify url & path for abs replica") } else if c.URL != "" && c.Bucket != "" { return nil, fmt.Errorf("cannot specify url & bucket for abs replica") } // Build replica. client := abs.NewReplicaClient() client.AccountName = c.AccountName client.AccountKey = c.AccountKey client.Bucket = c.Bucket client.Path = c.Path client.Endpoint = c.Endpoint // Apply settings from URL, if specified. if c.URL != "" { u, err := url.Parse(c.URL) if err != nil { return nil, err } if client.AccountName == "" && u.User != nil { client.AccountName = u.User.Username() } if client.Bucket == "" { client.Bucket = u.Host } if client.Path == "" { client.Path = strings.TrimPrefix(path.Clean(u.Path), "/") } } // Ensure required settings are set. if client.Bucket == "" { return nil, fmt.Errorf("bucket required for abs replica") } return client, nil } // newSFTPReplicaClientFromConfig returns a new instance of sftp.ReplicaClient built from config. func newSFTPReplicaClientFromConfig(c *ReplicaConfig) (_ *sftp.ReplicaClient, err error) { // Ensure URL & constituent parts are not both specified. if c.URL != "" && c.Path != "" { return nil, fmt.Errorf("cannot specify url & path for sftp replica") } else if c.URL != "" && c.Host != "" { return nil, fmt.Errorf("cannot specify url & host for sftp replica") } host, user, password, path := c.Host, c.User, c.Password, c.Path // Apply settings from URL, if specified. if c.URL != "" { u, err := url.Parse(c.URL) if err != nil { return nil, err } // Only apply URL parts to field that have not been overridden. if host == "" { host = u.Host } if user == "" && u.User != nil { user = u.User.Username() } if password == "" && u.User != nil { password, _ = u.User.Password() } if path == "" { path = u.Path } } // Ensure required settings are set. if host == "" { return nil, fmt.Errorf("host required for sftp replica") } else if user == "" { return nil, fmt.Errorf("user required for sftp replica") } // Build replica. client := sftp.NewReplicaClient() client.Host = host client.User = user client.Password = password client.Path = path client.KeyPath = c.KeyPath return client, nil } // applyLitestreamEnv copies "LITESTREAM" prefixed environment variables to // their AWS counterparts as the "AWS" prefix can be confusing when using a // non-AWS S3-compatible service. func applyLitestreamEnv() { if v, ok := os.LookupEnv("LITESTREAM_ACCESS_KEY_ID"); ok { if _, ok := os.LookupEnv("AWS_ACCESS_KEY_ID"); !ok { os.Setenv("AWS_ACCESS_KEY_ID", v) } } if v, ok := os.LookupEnv("LITESTREAM_SECRET_ACCESS_KEY"); ok { if _, ok := os.LookupEnv("AWS_SECRET_ACCESS_KEY"); !ok { os.Setenv("AWS_SECRET_ACCESS_KEY", v) } } } // ParseReplicaURL parses a replica URL. func ParseReplicaURL(s string) (scheme, host, urlpath string, err error) { u, err := url.Parse(s) if err != nil { return "", "", "", err } switch u.Scheme { case "file": scheme, u.Scheme = u.Scheme, "" return scheme, "", path.Clean(u.String()), nil case "": return u.Scheme, u.Host, u.Path, fmt.Errorf("replica url scheme required: %s", s) default: return u.Scheme, u.Host, strings.TrimPrefix(path.Clean(u.Path), "/"), nil } } // isURL returns true if s can be parsed and has a scheme. func isURL(s string) bool { return regexp.MustCompile(`^\w+:\/\/`).MatchString(s) } // ReplicaType returns the type based on the type field or extracted from the URL. func (c *ReplicaConfig) ReplicaType() string { scheme, _, _, _ := ParseReplicaURL(c.URL) if scheme != "" { return scheme } else if c.Type != "" { return c.Type } return "file" } // DefaultConfigPath returns the default config path. func DefaultConfigPath() string { if v := os.Getenv("LITESTREAM_CONFIG"); v != "" { return v } return "/etc/litestream.yml" } func registerConfigFlag(fs *flag.FlagSet, configPath *string, noExpandEnv *bool) { fs.StringVar(configPath, "config", "", "config path") fs.BoolVar(noExpandEnv, "no-expand-env", false, "do not expand env vars in config") } // expand returns an absolute path for s. func expand(s string) (string, error) { // Just expand to absolute path if there is no home directory prefix. prefix := "~" + string(os.PathSeparator) if s != "~" && !strings.HasPrefix(s, prefix) { return filepath.Abs(s) } // Look up home directory. u, err := user.Current() if err != nil { return "", err } else if u.HomeDir == "" { return "", fmt.Errorf("cannot expand path %s, no home directory available", s) } // Return path with tilde replaced by the home directory. if s == "~" { return u.HomeDir, nil } return filepath.Join(u.HomeDir, strings.TrimPrefix(s, prefix)), nil } // indexVar allows the flag package to parse index flags as 4-byte hexadecimal values. type indexVar int // Ensure type implements interface. var _ flag.Value = (*indexVar)(nil) // String returns an 8-character hexadecimal value. func (v *indexVar) String() string { return litestream.FormatIndex(int(*v)) } // Set parses s into an integer from a hexadecimal value. func (v *indexVar) Set(s string) error { i, err := strconv.ParseInt(s, 16, 32) if err != nil { return fmt.Errorf("invalid hexadecimal format") } *v = indexVar(i) 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 }