Add SFTP replica type
This commit is contained in:
14
.github/workflows/test.yml
vendored
14
.github/workflows/test.yml
vendored
@@ -23,6 +23,12 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GOOGLE_APPLICATION_CREDENTIALS: ${{secrets.GOOGLE_APPLICATION_CREDENTIALS}}
|
GOOGLE_APPLICATION_CREDENTIALS: ${{secrets.GOOGLE_APPLICATION_CREDENTIALS}}
|
||||||
|
|
||||||
|
- name: Extract SSH key
|
||||||
|
run: 'echo "$LITESTREAM_SFTP_KEY" > /opt/id_ed25519'
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
LITESTREAM_SFTP_KEY: ${{secrets.LITESTREAM_SFTP_KEY}}
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: go test -v ./...
|
run: go test -v ./...
|
||||||
|
|
||||||
@@ -46,3 +52,11 @@ jobs:
|
|||||||
LITESTREAM_ABS_ACCOUNT_NAME: ${{ secrets.LITESTREAM_ABS_ACCOUNT_NAME }}
|
LITESTREAM_ABS_ACCOUNT_NAME: ${{ secrets.LITESTREAM_ABS_ACCOUNT_NAME }}
|
||||||
LITESTREAM_ABS_ACCOUNT_KEY: ${{ secrets.LITESTREAM_ABS_ACCOUNT_KEY }}
|
LITESTREAM_ABS_ACCOUNT_KEY: ${{ secrets.LITESTREAM_ABS_ACCOUNT_KEY }}
|
||||||
LITESTREAM_ABS_BUCKET: ${{ secrets.LITESTREAM_ABS_BUCKET }}
|
LITESTREAM_ABS_BUCKET: ${{ secrets.LITESTREAM_ABS_BUCKET }}
|
||||||
|
|
||||||
|
- name: Run sftp tests
|
||||||
|
run: go test -v -run=TestReplicaClient . -integration sftp
|
||||||
|
env:
|
||||||
|
LITESTREAM_SFTP_HOST: ${{ secrets.LITESTREAM_SFTP_HOST }}
|
||||||
|
LITESTREAM_SFTP_USER: ${{ secrets.LITESTREAM_SFTP_USER }}
|
||||||
|
LITESTREAM_SFTP_KEY_PATH: /opt/id_ed25519
|
||||||
|
LITESTREAM_SFTP_PATH: ${{ secrets.LITESTREAM_SFTP_PATH }}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/benbjohnson/litestream/file"
|
"github.com/benbjohnson/litestream/file"
|
||||||
"github.com/benbjohnson/litestream/gcs"
|
"github.com/benbjohnson/litestream/gcs"
|
||||||
"github.com/benbjohnson/litestream/s3"
|
"github.com/benbjohnson/litestream/s3"
|
||||||
|
"github.com/benbjohnson/litestream/sftp"
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
"gopkg.in/yaml.v2"
|
"gopkg.in/yaml.v2"
|
||||||
)
|
)
|
||||||
@@ -297,6 +298,12 @@ type ReplicaConfig struct {
|
|||||||
// ABS settings
|
// ABS settings
|
||||||
AccountName string `yaml:"account-name"`
|
AccountName string `yaml:"account-name"`
|
||||||
AccountKey string `yaml:"account-key"`
|
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.
|
// NewReplicaFromConfig instantiates a replica for a DB based on a config.
|
||||||
@@ -344,6 +351,10 @@ func NewReplicaFromConfig(c *ReplicaConfig, db *litestream.DB) (_ *litestream.Re
|
|||||||
if r.Client, err = newABSReplicaClientFromConfig(c, r); err != nil {
|
if r.Client, err = newABSReplicaClientFromConfig(c, r); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
case "sftp":
|
||||||
|
if r.Client, err = newSFTPReplicaClientFromConfig(c, r); err != nil {
|
||||||
|
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", c.Type)
|
||||||
}
|
}
|
||||||
@@ -526,6 +537,56 @@ func newABSReplicaClientFromConfig(c *ReplicaConfig, r *litestream.Replica) (_ *
|
|||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newSFTPReplicaClientFromConfig returns a new instance of sftp.ReplicaClient built from config.
|
||||||
|
func newSFTPReplicaClientFromConfig(c *ReplicaConfig, r *litestream.Replica) (_ *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
|
// applyLitestreamEnv copies "LITESTREAM" prefixed environment variables to
|
||||||
// their AWS counterparts as the "AWS" prefix can be confusing when using a
|
// their AWS counterparts as the "AWS" prefix can be confusing when using a
|
||||||
// non-AWS S3-compatible service.
|
// non-AWS S3-compatible service.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/benbjohnson/litestream/file"
|
"github.com/benbjohnson/litestream/file"
|
||||||
"github.com/benbjohnson/litestream/gcs"
|
"github.com/benbjohnson/litestream/gcs"
|
||||||
"github.com/benbjohnson/litestream/s3"
|
"github.com/benbjohnson/litestream/s3"
|
||||||
|
"github.com/benbjohnson/litestream/sftp"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -114,6 +115,8 @@ func (c *ReplicateCommand) Run(ctx context.Context) (err error) {
|
|||||||
log.Printf("replicating to: name=%q type=%q bucket=%q path=%q sync-interval=%s", r.Name(), client.Type(), client.Bucket, client.Path, r.SyncInterval)
|
log.Printf("replicating to: name=%q type=%q bucket=%q path=%q sync-interval=%s", r.Name(), client.Type(), client.Bucket, client.Path, r.SyncInterval)
|
||||||
case *abs.ReplicaClient:
|
case *abs.ReplicaClient:
|
||||||
log.Printf("replicating to: name=%q type=%q bucket=%q path=%q endpoint=%q sync-interval=%s", r.Name(), client.Type(), client.Bucket, client.Path, client.Endpoint, r.SyncInterval)
|
log.Printf("replicating to: name=%q type=%q bucket=%q path=%q endpoint=%q sync-interval=%s", r.Name(), client.Type(), client.Bucket, client.Path, client.Endpoint, r.SyncInterval)
|
||||||
|
case *sftp.ReplicaClient:
|
||||||
|
log.Printf("replicating to: name=%q type=%q host=%q user=%q path=%q sync-interval=%s", r.Name(), client.Type(), client.Host, client.User, client.Path, r.SyncInterval)
|
||||||
default:
|
default:
|
||||||
log.Printf("replicating to: name=%q type=%q", r.Name(), client.Type())
|
log.Printf("replicating to: name=%q type=%q", r.Name(), client.Type())
|
||||||
}
|
}
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -10,7 +10,9 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.1
|
github.com/davecgh/go-spew v1.1.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.5
|
github.com/mattn/go-sqlite3 v1.14.5
|
||||||
github.com/pierrec/lz4/v4 v4.1.3
|
github.com/pierrec/lz4/v4 v4.1.3
|
||||||
|
github.com/pkg/sftp v1.13.0 // indirect
|
||||||
github.com/prometheus/client_golang v1.9.0
|
github.com/prometheus/client_golang v1.9.0
|
||||||
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||||
golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750
|
golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750
|
||||||
google.golang.org/api v0.45.0
|
google.golang.org/api v0.45.0
|
||||||
|
|||||||
11
go.sum
11
go.sum
@@ -261,6 +261,8 @@ github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvW
|
|||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||||
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
@@ -327,8 +329,11 @@ github.com/pierrec/lz4/v4 v4.1.3 h1:/dvQpkb0o1pVlSgKNQqfkavlnXaIK+hJ0LXsKRUN9D4=
|
|||||||
github.com/pierrec/lz4/v4 v4.1.3/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
github.com/pierrec/lz4/v4 v4.1.3/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
|
github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
|
||||||
|
github.com/pkg/sftp v1.13.0 h1:Riw6pgOKK41foc1I1Uu03CjvbLZDXeGpInycM4shXoI=
|
||||||
|
github.com/pkg/sftp v1.13.0/go.mod h1:41g+FIPlQUTDCveupEmEA65IoiQFrtgCeDopC4ajGIM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||||
@@ -420,6 +425,10 @@ golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8U
|
|||||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY=
|
||||||
|
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a h1:kr2P4QFmQr29mSLA43kwrOcgcReGTfbE9N577tCTuBc=
|
||||||
|
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
@@ -542,6 +551,7 @@ golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191112214154-59a1497f0cea/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -577,6 +587,7 @@ golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||||||
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750 h1:ZBu6861dZq7xBnG1bn5SRU0vA8nx42at4+kP07FMTog=
|
golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750 h1:ZBu6861dZq7xBnG1bn5SRU0vA8nx42at4+kP07FMTog=
|
||||||
golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20210412220455-f1c623a9e750/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/benbjohnson/litestream/file"
|
"github.com/benbjohnson/litestream/file"
|
||||||
"github.com/benbjohnson/litestream/gcs"
|
"github.com/benbjohnson/litestream/gcs"
|
||||||
"github.com/benbjohnson/litestream/s3"
|
"github.com/benbjohnson/litestream/s3"
|
||||||
|
"github.com/benbjohnson/litestream/sftp"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -57,6 +58,15 @@ var (
|
|||||||
absPath = flag.String("abs-path", os.Getenv("LITESTREAM_ABS_PATH"), "")
|
absPath = flag.String("abs-path", os.Getenv("LITESTREAM_ABS_PATH"), "")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SFTP settings
|
||||||
|
var (
|
||||||
|
sftpHost = flag.String("sftp-host", os.Getenv("LITESTREAM_SFTP_HOST"), "")
|
||||||
|
sftpUser = flag.String("sftp-user", os.Getenv("LITESTREAM_SFTP_USER"), "")
|
||||||
|
sftpPassword = flag.String("sftp-password", os.Getenv("LITESTREAM_SFTP_PASSWORD"), "")
|
||||||
|
sftpKeyPath = flag.String("sftp-key-path", os.Getenv("LITESTREAM_SFTP_KEY_PATH"), "")
|
||||||
|
sftpPath = flag.String("sftp-path", os.Getenv("LITESTREAM_SFTP_PATH"), "")
|
||||||
|
)
|
||||||
|
|
||||||
func TestReplicaClient_Generations(t *testing.T) {
|
func TestReplicaClient_Generations(t *testing.T) {
|
||||||
RunWithReplicaClient(t, "OK", func(t *testing.T, c litestream.ReplicaClient) {
|
RunWithReplicaClient(t, "OK", func(t *testing.T, c litestream.ReplicaClient) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
@@ -474,6 +484,8 @@ func NewReplicaClient(tb testing.TB, typ string) litestream.ReplicaClient {
|
|||||||
return NewGCSReplicaClient(tb)
|
return NewGCSReplicaClient(tb)
|
||||||
case abs.ReplicaClientType:
|
case abs.ReplicaClientType:
|
||||||
return NewABSReplicaClient(tb)
|
return NewABSReplicaClient(tb)
|
||||||
|
case sftp.ReplicaClientType:
|
||||||
|
return NewSFTPReplicaClient(tb)
|
||||||
default:
|
default:
|
||||||
tb.Fatalf("invalid replica client type: %q", typ)
|
tb.Fatalf("invalid replica client type: %q", typ)
|
||||||
return nil
|
return nil
|
||||||
@@ -524,13 +536,26 @@ func NewABSReplicaClient(tb testing.TB) *abs.ReplicaClient {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewSFTPReplicaClient returns a new client for integration testing.
|
||||||
|
func NewSFTPReplicaClient(tb testing.TB) *sftp.ReplicaClient {
|
||||||
|
tb.Helper()
|
||||||
|
|
||||||
|
c := sftp.NewReplicaClient()
|
||||||
|
c.Host = *sftpHost
|
||||||
|
c.User = *sftpUser
|
||||||
|
c.Password = *sftpPassword
|
||||||
|
c.KeyPath = *sftpKeyPath
|
||||||
|
c.Path = path.Join(*sftpPath, fmt.Sprintf("%016x", rand.Uint64()))
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
// MustDeleteAll deletes all objects under the client's path.
|
// MustDeleteAll deletes all objects under the client's path.
|
||||||
func MustDeleteAll(tb testing.TB, c litestream.ReplicaClient) {
|
func MustDeleteAll(tb testing.TB, c litestream.ReplicaClient) {
|
||||||
tb.Helper()
|
tb.Helper()
|
||||||
|
|
||||||
generations, err := c.Generations(context.Background())
|
generations, err := c.Generations(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tb.Fatal(err)
|
tb.Fatalf("cannot list generations for deletion: %s", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, generation := range generations {
|
for _, generation := range generations {
|
||||||
@@ -538,4 +563,11 @@ func MustDeleteAll(tb testing.TB, c litestream.ReplicaClient) {
|
|||||||
tb.Fatalf("cannot delete generation: %s", err)
|
tb.Fatalf("cannot delete generation: %s", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch c := c.(type) {
|
||||||
|
case *sftp.ReplicaClient:
|
||||||
|
if err := c.Cleanup(context.Background()); err != nil {
|
||||||
|
tb.Fatalf("cannot cleanup sftp: %s", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
511
sftp/replica_client.go
Normal file
511
sftp/replica_client.go
Normal file
@@ -0,0 +1,511 @@
|
|||||||
|
package sftp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/benbjohnson/litestream"
|
||||||
|
"github.com/pkg/sftp"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO(sftp): Add public key support
|
||||||
|
|
||||||
|
// ReplicaClientType is the client type for this package.
|
||||||
|
const ReplicaClientType = "sftp"
|
||||||
|
|
||||||
|
// Default settings for replica client.
|
||||||
|
const (
|
||||||
|
DefaultDialTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ litestream.ReplicaClient = (*ReplicaClient)(nil)
|
||||||
|
|
||||||
|
// ReplicaClient is a client for writing snapshots & WAL segments to disk.
|
||||||
|
type ReplicaClient struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sshClient *ssh.Client
|
||||||
|
sftpClient *sftp.Client
|
||||||
|
|
||||||
|
// SFTP connection info
|
||||||
|
Host string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Path string
|
||||||
|
KeyPath string
|
||||||
|
DialTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReplicaClient returns a new instance of ReplicaClient.
|
||||||
|
func NewReplicaClient() *ReplicaClient {
|
||||||
|
return &ReplicaClient{
|
||||||
|
DialTimeout: DefaultDialTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type returns "gcs" as the client type.
|
||||||
|
func (c *ReplicaClient) Type() string {
|
||||||
|
return ReplicaClientType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the connection to GCS. No-op if already initialized.
|
||||||
|
func (c *ReplicaClient) Init(ctx context.Context) (_ *sftp.Client, err error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.sftpClient != nil {
|
||||||
|
return c.sftpClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.User == "" {
|
||||||
|
return nil, fmt.Errorf("sftp user required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SSH configuration & auth methods
|
||||||
|
config := &ssh.ClientConfig{
|
||||||
|
User: c.User,
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
BannerCallback: ssh.BannerDisplayStderr(),
|
||||||
|
}
|
||||||
|
if c.Password != "" {
|
||||||
|
config.Auth = append(config.Auth, ssh.Password(c.Password))
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.KeyPath != "" {
|
||||||
|
buf, err := os.ReadFile(c.KeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read sftp key path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signer, err := ssh.ParsePrivateKey(buf)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse sftp key path: %w", err)
|
||||||
|
}
|
||||||
|
config.Auth = append(config.Auth, ssh.PublicKeys(signer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append standard port, if necessary.
|
||||||
|
host := c.Host
|
||||||
|
if _, _, err := net.SplitHostPort(c.Host); err != nil {
|
||||||
|
host = net.JoinHostPort(c.Host, "22")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect via SSH.
|
||||||
|
if c.sshClient, err = ssh.Dial("tcp", host, config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap connection with an SFTP client.
|
||||||
|
if c.sftpClient, err = sftp.NewClient(c.sshClient); err != nil {
|
||||||
|
c.sshClient.Close()
|
||||||
|
c.sshClient = nil
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.sftpClient, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generations returns a list of available generation names.
|
||||||
|
func (c *ReplicaClient) Generations(ctx context.Context) (_ []string, err error) {
|
||||||
|
defer func() { c.resetOnConnError(err) }()
|
||||||
|
|
||||||
|
sftpClient, err := c.Init(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fis, err := sftpClient.ReadDir(litestream.GenerationsPath(c.Path))
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var generations []string
|
||||||
|
for _, fi := range fis {
|
||||||
|
if !fi.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
name := path.Base(fi.Name())
|
||||||
|
if !litestream.IsGenerationName(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
generations = append(generations, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(generations)
|
||||||
|
|
||||||
|
return generations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGeneration deletes all snapshots & WAL segments within a generation.
|
||||||
|
func (c *ReplicaClient) DeleteGeneration(ctx context.Context, generation string) (err error) {
|
||||||
|
defer func() { c.resetOnConnError(err) }()
|
||||||
|
|
||||||
|
sftpClient, err := c.Init(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := litestream.GenerationPath(c.Path, generation)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot determine generation path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dirs []string
|
||||||
|
walker := sftpClient.Walk(dir)
|
||||||
|
for walker.Step() {
|
||||||
|
if err := walker.Err(); err != nil {
|
||||||
|
return fmt.Errorf("cannot walk path %q: %w", walker.Path(), err)
|
||||||
|
}
|
||||||
|
if walker.Stat().IsDir() {
|
||||||
|
dirs = append(dirs, walker.Path())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sftpClient.Remove(walker.Path()); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("cannot delete file %q: %w", walker.Path(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
operationTotalCounterVec.WithLabelValues("DELETE").Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove directories in reverse order after they have been emptied.
|
||||||
|
for i := len(dirs) - 1; i >= 0; i-- {
|
||||||
|
filename := dirs[i]
|
||||||
|
if err := sftpClient.RemoveDirectory(filename); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("cannot delete directory %q: %w", filename, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// log.Printf("%s(%s): retainer: deleting generation: %s", r.db.Path(), r.Name(), generation)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshots returns an iterator over all available snapshots for a generation.
|
||||||
|
func (c *ReplicaClient) Snapshots(ctx context.Context, generation string) (_ litestream.SnapshotIterator, err error) {
|
||||||
|
defer func() { c.resetOnConnError(err) }()
|
||||||
|
|
||||||
|
sftpClient, err := c.Init(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := litestream.SnapshotsPath(c.Path, generation)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot determine snapshots path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fis, err := sftpClient.ReadDir(dir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return litestream.NewSnapshotInfoSliceIterator(nil), nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate over every file and convert to metadata.
|
||||||
|
infos := make([]litestream.SnapshotInfo, 0, len(fis))
|
||||||
|
for _, fi := range fis {
|
||||||
|
// Parse index from filename.
|
||||||
|
index, err := litestream.ParseSnapshotPath(path.Base(fi.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
infos = append(infos, litestream.SnapshotInfo{
|
||||||
|
Generation: generation,
|
||||||
|
Index: index,
|
||||||
|
Size: fi.Size(),
|
||||||
|
CreatedAt: fi.ModTime().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Sort(litestream.SnapshotInfoSlice(infos))
|
||||||
|
|
||||||
|
return litestream.NewSnapshotInfoSliceIterator(infos), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteSnapshot writes LZ4 compressed data from rd to the object storage.
|
||||||
|
func (c *ReplicaClient) WriteSnapshot(ctx context.Context, generation string, index int, rd io.Reader) (info litestream.SnapshotInfo, err error) {
|
||||||
|
defer func() { c.resetOnConnError(err) }()
|
||||||
|
|
||||||
|
sftpClient, err := c.Init(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filename, err := litestream.SnapshotPath(c.Path, generation, index)
|
||||||
|
if err != nil {
|
||||||
|
return info, fmt.Errorf("cannot determine snapshot path: %w", err)
|
||||||
|
}
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
if err := sftpClient.MkdirAll(path.Dir(filename)); err != nil {
|
||||||
|
return info, fmt.Errorf("cannot make parent wal segment directory %q: %w", path.Dir(filename), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := sftpClient.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
|
||||||
|
if err != nil {
|
||||||
|
return info, fmt.Errorf("cannot open snapshot file for writing: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
n, err := io.Copy(f, rd)
|
||||||
|
if err != nil {
|
||||||
|
return info, err
|
||||||
|
} else if err := f.Close(); err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|
||||||
|
operationTotalCounterVec.WithLabelValues("PUT").Inc()
|
||||||
|
operationBytesCounterVec.WithLabelValues("PUT").Add(float64(n))
|
||||||
|
|
||||||
|
// log.Printf("%s(%s): snapshot: creating %s/%08x t=%s", r.db.Path(), r.Name(), generation, index, time.Since(startTime).Truncate(time.Millisecond))
|
||||||
|
|
||||||
|
return litestream.SnapshotInfo{
|
||||||
|
Generation: generation,
|
||||||
|
Index: index,
|
||||||
|
Size: n,
|
||||||
|
CreatedAt: startTime.UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotReader returns a reader for snapshot data at the given generation/index.
|
||||||
|
func (c *ReplicaClient) SnapshotReader(ctx context.Context, generation string, index int) (_ io.ReadCloser, err error) {
|
||||||
|
defer func() { c.resetOnConnError(err) }()
|
||||||
|
|
||||||
|
sftpClient, err := c.Init(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filename, err := litestream.SnapshotPath(c.Path, generation, index)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot determine snapshot path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := sftpClient.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
operationTotalCounterVec.WithLabelValues("GET").Inc()
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSnapshot deletes a snapshot with the given generation & index.
|
||||||
|
func (c *ReplicaClient) DeleteSnapshot(ctx context.Context, generation string, index int) (err error) {
|
||||||
|
defer func() { c.resetOnConnError(err) }()
|
||||||
|
|
||||||
|
sftpClient, err := c.Init(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
filename, err := litestream.SnapshotPath(c.Path, generation, index)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot determine snapshot path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sftpClient.Remove(filename); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("cannot delete snapshot %q: %w", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
operationTotalCounterVec.WithLabelValues("DELETE").Inc()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WALSegments returns an iterator over all available WAL files for a generation.
|
||||||
|
func (c *ReplicaClient) WALSegments(ctx context.Context, generation string) (_ litestream.WALSegmentIterator, err error) {
|
||||||
|
defer func() { c.resetOnConnError(err) }()
|
||||||
|
|
||||||
|
sftpClient, err := c.Init(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := litestream.WALPath(c.Path, generation)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot determine wal path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fis, err := sftpClient.ReadDir(dir)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return litestream.NewWALSegmentInfoSliceIterator(nil), nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate over every file and convert to metadata.
|
||||||
|
infos := make([]litestream.WALSegmentInfo, 0, len(fis))
|
||||||
|
for _, fi := range fis {
|
||||||
|
index, offset, err := litestream.ParseWALSegmentPath(path.Base(fi.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
infos = append(infos, litestream.WALSegmentInfo{
|
||||||
|
Generation: generation,
|
||||||
|
Index: index,
|
||||||
|
Offset: offset,
|
||||||
|
Size: fi.Size(),
|
||||||
|
CreatedAt: fi.ModTime().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Sort(litestream.WALSegmentInfoSlice(infos))
|
||||||
|
|
||||||
|
return litestream.NewWALSegmentInfoSliceIterator(infos), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteWALSegment writes LZ4 compressed data from rd into a file on disk.
|
||||||
|
func (c *ReplicaClient) WriteWALSegment(ctx context.Context, pos litestream.Pos, rd io.Reader) (info litestream.WALSegmentInfo, err error) {
|
||||||
|
defer func() { c.resetOnConnError(err) }()
|
||||||
|
|
||||||
|
sftpClient, err := c.Init(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filename, err := litestream.WALSegmentPath(c.Path, pos.Generation, pos.Index, pos.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return info, fmt.Errorf("cannot determine wal segment path: %w", err)
|
||||||
|
}
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
if err := sftpClient.MkdirAll(path.Dir(filename)); err != nil {
|
||||||
|
return info, fmt.Errorf("cannot make parent snapshot directory %q: %w", path.Dir(filename), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := sftpClient.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC)
|
||||||
|
if err != nil {
|
||||||
|
return info, fmt.Errorf("cannot open snapshot file for writing: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
n, err := io.Copy(f, rd)
|
||||||
|
if err != nil {
|
||||||
|
return info, err
|
||||||
|
} else if err := f.Close(); err != nil {
|
||||||
|
return info, err
|
||||||
|
}
|
||||||
|
|
||||||
|
operationTotalCounterVec.WithLabelValues("PUT").Inc()
|
||||||
|
operationBytesCounterVec.WithLabelValues("PUT").Add(float64(n))
|
||||||
|
|
||||||
|
return litestream.WALSegmentInfo{
|
||||||
|
Generation: pos.Generation,
|
||||||
|
Index: pos.Index,
|
||||||
|
Offset: pos.Offset,
|
||||||
|
Size: n,
|
||||||
|
CreatedAt: startTime.UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WALSegmentReader returns a reader for a section of WAL data at the given index.
|
||||||
|
// Returns os.ErrNotExist if no matching index/offset is found.
|
||||||
|
func (c *ReplicaClient) WALSegmentReader(ctx context.Context, pos litestream.Pos) (_ io.ReadCloser, err error) {
|
||||||
|
defer func() { c.resetOnConnError(err) }()
|
||||||
|
|
||||||
|
sftpClient, err := c.Init(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filename, err := litestream.WALSegmentPath(c.Path, pos.Generation, pos.Index, pos.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot determine wal segment path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := sftpClient.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
operationTotalCounterVec.WithLabelValues("GET").Inc()
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteWALSegments deletes WAL segments with at the given positions.
|
||||||
|
func (c *ReplicaClient) DeleteWALSegments(ctx context.Context, a []litestream.Pos) (err error) {
|
||||||
|
defer func() { c.resetOnConnError(err) }()
|
||||||
|
|
||||||
|
sftpClient, err := c.Init(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pos := range a {
|
||||||
|
filename, err := litestream.WALSegmentPath(c.Path, pos.Generation, pos.Index, pos.Offset)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot determine wal segment path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sftpClient.Remove(filename); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("cannot delete wal segment %q: %w", filename, err)
|
||||||
|
}
|
||||||
|
operationTotalCounterVec.WithLabelValues("DELETE").Inc()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup deletes path & generations directories after empty.
|
||||||
|
func (c *ReplicaClient) Cleanup(ctx context.Context) (err error) {
|
||||||
|
defer func() { c.resetOnConnError(err) }()
|
||||||
|
|
||||||
|
sftpClient, err := c.Init(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sftpClient.RemoveDirectory(litestream.GenerationsPath(c.Path)); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("cannot delete generations path: %w", err)
|
||||||
|
} else if err := sftpClient.RemoveDirectory(c.Path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("cannot delete path: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetOnConnError closes & clears the client if a connection error occurs.
|
||||||
|
func (c *ReplicaClient) resetOnConnError(err error) {
|
||||||
|
if !errors.Is(err, sftp.ErrSSHFxConnectionLost) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.sftpClient != nil {
|
||||||
|
c.sftpClient.Close()
|
||||||
|
c.sftpClient = nil
|
||||||
|
}
|
||||||
|
if c.sshClient != nil {
|
||||||
|
c.sshClient.Close()
|
||||||
|
c.sshClient = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SFTP metrics.
|
||||||
|
var (
|
||||||
|
operationTotalCounterVec = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "litestream_sftp_operation_total",
|
||||||
|
Help: "The number of SFTP operations performed",
|
||||||
|
}, []string{"type"})
|
||||||
|
|
||||||
|
operationBytesCounterVec = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "litestream_sftp_operation_bytes",
|
||||||
|
Help: "The number of bytes used by SFTP operations",
|
||||||
|
}, []string{"type"})
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user