diff --git a/cmd/litestream/main.go b/cmd/litestream/main.go index c95c5f3..b830014 100644 --- a/cmd/litestream/main.go +++ b/cmd/litestream/main.go @@ -138,18 +138,13 @@ func (c *Config) DBConfig(path string) *DBConfig { } // ReadConfigFile unmarshals config from filename. Expands path if needed. -func ReadConfigFile(filename string) (Config, error) { +func ReadConfigFile(filename string) (_ Config, err error) { config := DefaultConfig() // Expand filename, if necessary. - if prefix := "~" + string(os.PathSeparator); strings.HasPrefix(filename, prefix) { - u, err := user.Current() - if err != nil { - return config, err - } else if u.HomeDir == "" { - return config, fmt.Errorf("home directory unset") - } - filename = filepath.Join(u.HomeDir, strings.TrimPrefix(filename, prefix)) + filename, err = expand(filename) + if err != nil { + return config, err } // Read & deserialize configuration. @@ -174,7 +169,12 @@ type DBConfig struct { } // Normalize expands paths and parses URL-specified replicas. -func (c *DBConfig) Normalize() error { +func (c *DBConfig) Normalize() (err error) { + c.Path, err = expand(c.Path) + if err != nil { + return err + } + for i := range c.Replicas { if err := c.Replicas[i].Normalize(); err != nil { return err @@ -202,27 +202,19 @@ type ReplicaConfig struct { // Normalize expands paths and parses URL-specified replicas. func (c *ReplicaConfig) Normalize() error { - // Expand path filename, if necessary. - if prefix := "~" + string(os.PathSeparator); strings.HasPrefix(c.Path, prefix) { - u, err := user.Current() - if err != nil { - return err - } else if u.HomeDir == "" { - return fmt.Errorf("cannot expand replica path, no home directory available") - } - c.Path = filepath.Join(u.HomeDir, strings.TrimPrefix(c.Path, prefix)) - } - // Attempt to parse as URL. Ignore if it is not a URL or if there is no scheme. u, err := url.Parse(c.Path) if err != nil || u.Scheme == "" { + if c.Type == "" || c.Type == "file" { + c.Path, err = expand(c.Path) + return err + } return nil } switch u.Scheme { case "file": - u.Scheme = "" - c.Type = u.Scheme + c.Type, u.Scheme = u.Scheme, "" c.Path = path.Clean(u.String()) return nil @@ -230,10 +222,6 @@ func (c *ReplicaConfig) Normalize() error { c.Type = u.Scheme c.Path = strings.TrimPrefix(path.Clean(u.Path), "/") c.Bucket = u.Host - if u := u.User; u != nil { - c.AccessKeyID = u.Username() - c.SecretAccessKey, _ = u.Password() - } return nil default: @@ -322,11 +310,7 @@ func newS3ReplicaFromConfig(db *litestream.DB, c *Config, dbc *DBConfig, rc *Rep } // Ensure required settings are set. - if accessKeyID == "" { - return nil, fmt.Errorf("%s: s3 access key id required", db.Path()) - } else if secretAccessKey == "" { - return nil, fmt.Errorf("%s: s3 secret access key required", db.Path()) - } else if bucket == "" { + if bucket == "" { return nil, fmt.Errorf("%s: s3 bucket required", db.Path()) } @@ -352,3 +336,26 @@ func newS3ReplicaFromConfig(db *litestream.DB, c *Config, dbc *DBConfig, rc *Rep } return r, nil } + +// 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 +} diff --git a/cmd/litestream/replicate.go b/cmd/litestream/replicate.go index cfd57d0..6f8e04f 100644 --- a/cmd/litestream/replicate.go +++ b/cmd/litestream/replicate.go @@ -36,12 +36,27 @@ func (c *ReplicateCommand) Run(ctx context.Context, args []string) (err error) { return err } - // Load configuration. - if c.ConfigPath == "" { - return errors.New("-config required") + // Load configuration or use CLI args to build db/replica. + var config Config + if fs.NArg() == 1 { + return fmt.Errorf("must specify at least one replica URL for %s", fs.Arg(0)) + } else if fs.NArg() > 1 { + dbConfig := &DBConfig{Path: fs.Arg(0)} + for _, u := range fs.Args()[1:] { + dbConfig.Replicas = append(dbConfig.Replicas, &ReplicaConfig{Path: u}) + } + config.DBs = []*DBConfig{dbConfig} + } else if c.ConfigPath != "" { + config, err = ReadConfigFile(c.ConfigPath) + if err != nil { + return err + } + } else { + return errors.New("-config flag or database/replica arguments required") } - config, err := ReadConfigFile(c.ConfigPath) - if err != nil { + + // Normalize configuration paths. + if err := config.Normalize(); err != nil { return err } @@ -132,13 +147,17 @@ func (c *ReplicateCommand) Close() (err error) { // Usage prints the help screen to STDOUT. func (c *ReplicateCommand) Usage() { fmt.Printf(` -The replicate command starts a server to monitor & replicate databases -specified in your configuration file. +The replicate command starts a server to monitor & replicate databases. +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 +command line arguments. Usage: litestream replicate [arguments] + litestream replicate [arguments] DB_PATH REPLICA_URL [REPLICA_URL...] + Arguments: -config PATH diff --git a/cmd/litestream/restore.go b/cmd/litestream/restore.go index 7c2365c..68cc6a5 100644 --- a/cmd/litestream/restore.go +++ b/cmd/litestream/restore.go @@ -33,7 +33,7 @@ func (c *RestoreCommand) Run(ctx context.Context, args []string) (err error) { if err := fs.Parse(args); err != nil { return err } 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 { return fmt.Errorf("too many arguments") } @@ -90,7 +90,9 @@ The restore command recovers a database from a previous snapshot and WAL. Usage: - litestream restore [arguments] DB + litestream restore [arguments] DB_PATH + + litestream restore [arguments] REPLICA_URL Arguments: diff --git a/s3/s3.go b/s3/s3.go index fc41b2f..c373f02 100644 --- a/s3/s3.go +++ b/s3/s3.go @@ -14,6 +14,7 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/defaults" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3" "github.com/aws/aws-sdk-go/service/s3/s3manager" @@ -647,10 +648,9 @@ func (r *Replica) Init(ctx context.Context) (err error) { } // Create new AWS session. - sess, err := session.NewSession(&aws.Config{ - Credentials: credentials.NewStaticCredentials(r.AccessKeyID, r.SecretAccessKey, ""), - Region: aws.String(region), - }) + config := r.config() + config.Region = aws.String(region) + sess, err := session.NewSession(config) if err != nil { return fmt.Errorf("cannot create aws session: %w", err) } @@ -659,12 +659,21 @@ func (r *Replica) Init(ctx context.Context) (err error) { return nil } +// config returns the AWS configuration. Uses the default credential chain +// unless a key/secret are explicitly set. +func (r *Replica) config() *aws.Config { + config := defaults.Get().Config + if r.AccessKeyID != "" || r.SecretAccessKey != "" { + config.Credentials = credentials.NewStaticCredentials(r.AccessKeyID, r.SecretAccessKey, "") + } + return config +} + func (r *Replica) findBucketRegion(ctx context.Context, bucket string) (string, error) { // Connect to US standard region to fetch info. - sess, err := session.NewSession(&aws.Config{ - Credentials: credentials.NewStaticCredentials(r.AccessKeyID, r.SecretAccessKey, ""), - Region: aws.String("us-east-1"), - }) + config := r.config() + config.Region = aws.String("us-east-1") + sess, err := session.NewSession(config) if err != nil { return "", err }