Compare commits

..

54 Commits

Author SHA1 Message Date
Ben Johnson
80280fce54 Only update litestream_seq if size is below WAL header size 2024-03-05 15:09:07 -07:00
Jim Kalafut
94f69a0eb3 Print WAL index in hex (#567) 2024-02-14 13:00:47 -07:00
Toni Spets
e4254bbf69 Never directly dereference AWS SDK returned pointers (#557) 2024-01-07 06:18:32 +02:00
Toni Spets
e71e6856d0 Re-enable SFTP integration tests (#550) 2023-12-25 15:31:30 +02:00
Toni Spets
a47d955e3f Re-enable integration tests (#548) 2023-12-24 21:33:12 +02:00
Toni Spets
69a24afc04 Update dependencies (#546) 2023-12-24 19:59:19 +02:00
Toni Spets
adfec9a19d Enable S3 mock tests with moto (#545) 2023-12-24 19:59:05 +02:00
Toni Spets
dae4f6e481 Tolerate file deletion race on CurrentShadowWALIndex (#544) 2023-12-19 05:50:46 +02:00
Toni Spets
676810cc13 Use safe checkpointing before snapshots (#522) 2023-12-18 13:30:59 +02:00
Toni Spets
0a7f6e9345 Never expect the replica iterators to be sorted (#538) 2023-12-18 13:27:50 +02:00
Toni Spets
1af88c4052 Restore without SQLITE_FCNTL_PERSIST_WAL (#529) 2023-12-16 12:04:14 +02:00
Toni Spets
c633eb1fea Ignore WAL indices before snapshot that is being restored (#527) 2023-12-16 12:01:33 +02:00
Toni Spets
7badf0e549 Prevent deadlocks with replicas (#524) 2023-12-16 11:53:09 +02:00
Toni Spets
91ad34d709 Fix Windows builds and test they compile (#543) 2023-12-15 22:57:23 +02:00
Toni Spets
6824eb61a8 Enable staticcheck, fix issues (#542) 2023-12-15 22:07:22 +02:00
Toni Spets
1a96ad4389 Add pre-commit linting, test all PRs (#541) 2023-12-15 21:49:29 +02:00
Toni Spets
25ac72ae6c Remove verbose flag from restore doc (#540) 2023-12-15 21:20:49 +02:00
Toni Spets
85ddf32225 Always release long-running fd on db close (#519) 2023-11-14 10:31:28 -07:00
Toni Spets
ae4c9918d9 Allow giving context to database close (#520) 2023-11-14 10:28:58 -07:00
Ben Johnson
977d4a5ee4 Fix replica-without-db logger (#512) 2023-10-24 09:51:17 -05:00
Ben Johnson
c81010e7ab Fix darwin make target 2023-10-23 15:01:27 -06:00
guangwu
c1ae968188 fix: typo (#509) 2023-10-19 18:29:04 -06:00
Toni Spets
9f0e50ddf7 Add missing slog calls to commands (#508) 2023-10-19 18:28:49 -06:00
Toni Spets
fe9ab5c517 Force truncation checkpoint if WAL becomes runaway (#473) 2023-10-19 18:27:51 -06:00
Toni Spets
d02ba97453 Sync replica snapshots to previous (#480) 2023-10-19 18:27:15 -06:00
Toni Spets
b1abd6bd99 Use structured logging with slog (#475) 2023-10-16 15:05:22 -06:00
Ben Johnson
fd892eef6d Re-add arm/v7 build to Docker (#502) 2023-08-15 07:37:32 -06:00
Markus Schanz
1bfcaa4a17 Recognize Scaleway S3 replica URLs (#499) 2023-08-13 18:07:19 -06:00
Alex Garcia
a369b05ee4 Build darwin-arm64 build during make dist-macos (#500) 2023-08-09 13:00:50 -06:00
Toni Spets
e0493f979a Copy WAL frames through temp file to shadow (#474) 2023-08-08 11:40:43 -06:00
Ben Johnson
016546a3d5 Static release builds only (#497) 2023-08-08 11:33:08 -06:00
Ben Johnson
10f97f90f2 Fix GitHub Action OS (#496) 2023-08-08 10:39:41 -06:00
Ben Johnson
3de4391349 Update dependencies (#495) 2023-08-08 09:31:59 -06:00
Toni Spets
2512d35d8d Prevent checkpoints during snapshots (#477) 2023-08-07 20:20:27 -06:00
Ben Toews
749bc0d95a Allow DB.MetaPath to be configured (#485) 2023-06-09 10:26:51 -06:00
Ben Toews
2045363cd1 Don't kill exec process immediately (#484) 2023-06-08 10:57:50 -06:00
Evan Phoenix
18760d2a7a Plumb a custom logger through the core rather than only in Restore (#481) 2023-05-19 20:34:50 -06:00
Ben Johnson
ad3d65382f Update GitHub Actions (#467) 2023-05-02 17:11:41 -06:00
Tyler Davis
4abb3d15f2 fix: update go versions in mod and docker
- Update Go module to v1.19 format
- Docker builder pinned to Go v1.20.1
- Alpine image pinned to 3.17.2 (rather than `latest`)
2023-05-02 16:07:46 -06:00
Erik Kristensen
3368b7cf44 fix: remove debug code 2023-05-02 16:03:31 -06:00
Erik Kristensen
ae670b0d27 fix: aws credential chain by using aws.Config 2023-05-02 16:03:31 -06:00
Lincoln Stoll
5afd0bf161 Handle errors when deleting objects from S3
I recently noticed that the cost for ListBucket calls was increasing for an
application that was using Litestream. After investigating it seemed that the
bucket had retained the entire history of data, while Litestream was
continually logging that it was deleting the same data:

```
2022-10-30T12:00:27Z (s3): wal segmented deleted before 0792d3393bf79ced/00000233: n=1428
<snip>
2022-10-30T13:00:24Z (s3): wal segmented deleted before 0792d3393bf79ced/00000233: n=1428
```

This is occuring because the DeleteObjects call is a batch item, that returns
the individual object deletion errors in the response[1]. The S3 replica client
discards the response, and only handles errors in the original API call. I had
a misconfigured IAM policy that meant all deletes were failing, but this never
actually bubbled up as a real error.

To fix this, I added a check for the response body to handle any errors the
operation might have encountered. Because this may include a large number of
errors (in this case 1428 of them), the output is summarized to avoid an overly
large error message. When items are not found, they will not return an error[2]
- they will still be marked as deleted, so this change should be in-line with
the original intentions of this code.

1: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html#API_DeleteObjects_Example_2
2: https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteObjects.html
2023-05-02 16:03:31 -06:00
Jose Diaz-Gonzalez
6b93b6012a Update readme to note that this tool is for disaster recovery, not streaming replication
Refs #411
2023-05-02 16:03:31 -06:00
Ben Johnson
cca838b671 Use sqlite3_file_control(SQLITE_FCNTL_PERSIST_WAL) to persist WAL
Previously, Litestream would avoid closing the SQLite3 connection
in order to ensure that the WAL file was not cleaned up by the
database if it was the last connection. This commit changes the
behavior by introducing a file control call to perform the same
action. This allows us to close the database file normally in all
cases.
2023-05-02 16:03:31 -06:00
Toni Spets
a34a92c0b9 Client side encryption support for remote storage (#468) 2023-05-01 10:06:01 -06:00
Ben Johnson
68e60cbfdf README (#466) 2023-03-31 17:55:14 -07:00
Ben Johnson
366cfc6baa Upgrade golang.org/x/sync v0.0.0-20210220032951-036812b2e83c => v0.0.0-20220722155255-886fb9371eb4 2022-07-25 13:16:26 -06:00
Ben Johnson
adf971f669 Upgrade github.com/prometheus/client_golang v1.9.0 => v1.12.2 2022-07-25 13:16:26 -06:00
Ben Johnson
fa3f8a21c8 Upgrade github.com/pierrec/lz4/v4@v4.1.15 2022-07-25 13:16:26 -06:00
Ben Johnson
fafe08ed90 Add Docker build for v0.3.x 2022-07-25 12:39:12 -06:00
Ben Johnson
360183dc96 Fix up v0.3.x CI tests 2022-03-10 11:25:56 -07:00
Ben Johnson
cb1b1a0afe Upgrade go-sqlite3 to v1.14.12 2022-03-10 11:25:56 -07:00
Ben Johnson
393317b6f8 Fix FindMinSnapshotByGeneration() loop ref bug 2021-12-05 09:42:49 -07:00
Ben Johnson
1e6878998c Reduce snapshot check frequency
Previously, a bug was introduced that added a `LIST` operation
on every replica sync which significantly increased the cost of
running Litestream against S3. This changes the behavior to only
issue the `LIST` operation when the generation has changed.
2021-10-12 09:47:29 -06:00
355 changed files with 4776 additions and 11821 deletions

View File

@@ -15,4 +15,3 @@ If you find mistakes in the documentation, please submit a fix to the
[new-issue]: https://github.com/benbjohnson/litestream/issues/new [new-issue]: https://github.com/benbjohnson/litestream/issues/new
[docs]: https://github.com/benbjohnson/litestream.io [docs]: https://github.com/benbjohnson/litestream.io

View File

@@ -1,30 +0,0 @@
name: "Build and Unit Test"
on: pull_request
jobs:
build:
name: Build
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: '1.17'
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ inputs.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: ${{ inputs.os }}-go-
- name: Build binary
run: go install ./cmd/litestream
- name: Run unit tests
run: make testdata && go test -v --coverprofile=.coverage.out ./... && go tool cover -html .coverage.out -o .coverage.html
- uses: actions/upload-artifact@v3
with:
name: code-coverage
path: .coverage.html

View File

@@ -1,38 +0,0 @@
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
schedule:
- cron: '20 16 * * 4'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

229
.github/workflows/commit.yml vendored Normal file
View File

@@ -0,0 +1,229 @@
on:
push:
pull_request:
types:
- opened
- synchronize
- reopened
env:
GO_VERSION: "1.21"
name: Commit
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- run: |
go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest
export PATH="$HOME/go/bin:$PATH"
- uses: pre-commit/action@v3.0.0
build-windows:
name: Build Windows
runs-on: ubuntu-latest
steps:
- run: sudo apt-get install -y mingw-w64
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- run: |
go build ./cmd/litestream/
file ./litestream.exe
env:
CGO_ENABLED: "1"
GOOS: windows
GOARCH: amd64
CC: x86_64-w64-mingw32-gcc
build:
name: Build & Unit Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- run: go env
- run: go install ./cmd/litestream
- run: go test -v ./...
# long-running-test:
# name: Run Long Running Unit Test
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v2
# - uses: actions/setup-go@v2
# with:
# go-version: '1.20'
# - uses: actions/cache@v2
# with:
# path: ~/go/pkg/mod
# key: ${{ inputs.os }}-go-${{ hashFiles('**/go.sum') }}
# restore-keys: ${{ inputs.os }}-go-
#
# - run: go install ./cmd/litestream
# - run: go test -v -run=TestCmd_Replicate_LongRunning ./integration -long-running-duration 1m
s3-mock-test:
name: Run S3 Mock Tests
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
# cache: 'pip'
- run: pip install moto[s3,server]
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- run: go env
- run: go install ./cmd/litestream
- run: ./etc/s3_mock.py go test -v ./replica_client_test.go -integration s3
s3-integration-test:
name: Run S3 Integration Tests
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
concurrency:
group: integration-test-s3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- run: go env
- run: go install ./cmd/litestream
- run: go test -v ./replica_client_test.go -integration s3
env:
LITESTREAM_S3_ACCESS_KEY_ID: ${{ secrets.LITESTREAM_S3_ACCESS_KEY_ID }}
LITESTREAM_S3_SECRET_ACCESS_KEY: ${{ secrets.LITESTREAM_S3_SECRET_ACCESS_KEY }}
LITESTREAM_S3_REGION: us-east-1
LITESTREAM_S3_BUCKET: integration.litestream.io
gcp-integration-test:
name: Run GCP Integration Tests
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
concurrency:
group: integration-test-gcp
steps:
- name: Extract GCP credentials
run: 'echo "$GOOGLE_APPLICATION_CREDENTIALS" > /opt/gcp.json'
shell: bash
env:
GOOGLE_APPLICATION_CREDENTIALS: ${{secrets.GOOGLE_APPLICATION_CREDENTIALS}}
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- run: go env
- run: go install ./cmd/litestream
- run: go test -v ./replica_client_test.go -integration gcs
env:
GOOGLE_APPLICATION_CREDENTIALS: /opt/gcp.json
LITESTREAM_GCS_BUCKET: integration.litestream.io
abs-integration-test:
name: Run Azure Blob Store Integration Tests
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
concurrency:
group: integration-test-abs
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- run: go env
- run: go install ./cmd/litestream
- run: go test -v ./replica_client_test.go -integration abs
env:
LITESTREAM_ABS_ACCOUNT_NAME: ${{ secrets.LITESTREAM_ABS_ACCOUNT_NAME }}
LITESTREAM_ABS_ACCOUNT_KEY: ${{ secrets.LITESTREAM_ABS_ACCOUNT_KEY }}
LITESTREAM_ABS_BUCKET: integration
sftp-integration-test:
name: Run SFTP Integration Tests
runs-on: ubuntu-latest
needs: build
steps:
- name: Prepare OpenSSH server
run: |-
sudo mkdir -p /test/etc/ssh /test/home /run/sshd /test/data/
sudo ssh-keygen -t ed25519 -f /test/etc/ssh/id_ed25519_host -N ""
sudo ssh-keygen -t ed25519 -f /test/etc/ssh/id_ed25519 -N ""
sudo chmod 0600 /test/etc/ssh/id_ed25519_host /test/etc/ssh/id_ed25519
sudo chmod 0644 /test/etc/ssh/id_ed25519_host.pub /test/etc/ssh/id_ed25519.pub
sudo cp /test/etc/ssh/id_ed25519 /test/id_ed25519
sudo chown $USER /test/id_ed25519
sudo tee /test/etc/ssh/sshd_config <<EOF
Port 2222
HostKey /test/etc/ssh/id_ed25519_host
AuthorizedKeysFile /test/etc/ssh/id_ed25519.pub
AuthenticationMethods publickey
Subsystem sftp internal-sftp
UsePAM no
LogLevel DEBUG
EOF
sudo /usr/sbin/sshd -e -f /test/etc/ssh/sshd_config -E /test/debug.log
- name: Test OpenSSH server works with pubkey auth
run: ssh -v -i /test/id_ed25519 -o StrictHostKeyChecking=accept-new -p 2222 root@localhost whoami || (sudo cat /test/debug.log && exit 1)
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: ${{ env.GO_VERSION }}
- run: go env
- run: go install ./cmd/litestream
- run: go test -v ./replica_client_test.go -integration sftp
env:
LITESTREAM_SFTP_HOST: "localhost:2222"
LITESTREAM_SFTP_USER: "root"
LITESTREAM_SFTP_KEY_PATH: /test/id_ed25519
LITESTREAM_SFTP_PATH: /test/data

View File

@@ -1,18 +0,0 @@
name: golangci-lint
on:
pull_request:
permissions:
contents: read
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: golangci/golangci-lint-action@v2
with:
version: latest
args: --timeout=10m

View File

@@ -1,138 +0,0 @@
name: Integration Tests
on:
pull_request:
branches-ignore:
- "dependabot/**"
jobs:
s3-integration-test:
name: Run S3 Integration Tests
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: '1.17'
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ inputs.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: ${{ inputs.os }}-go-
- run: go install ./cmd/litestream
- run: go test -v -run=TestReplicaClient ./integration -replica-type s3
env:
LITESTREAM_S3_ACCESS_KEY_ID: ${{ secrets.LITESTREAM_S3_ACCESS_KEY_ID }}
LITESTREAM_S3_SECRET_ACCESS_KEY: ${{ secrets.LITESTREAM_S3_SECRET_ACCESS_KEY }}
LITESTREAM_S3_REGION: us-east-1
LITESTREAM_S3_BUCKET: integration.litestream.io
gcp-integration-test:
name: Run GCP Integration Tests
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: '1.17'
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ inputs.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: ${{ inputs.os }}-go-
- name: Extract GCP credentials
run: 'echo "$GOOGLE_APPLICATION_CREDENTIALS" > /opt/gcp.json'
shell: bash
env:
GOOGLE_APPLICATION_CREDENTIALS: ${{secrets.GOOGLE_APPLICATION_CREDENTIALS}}
- run: go test -v -run=TestReplicaClient ./integration -replica-type gs
env:
GOOGLE_APPLICATION_CREDENTIALS: /opt/gcp.json
LITESTREAM_GS_BUCKET: integration.litestream.io
abs-integration-test:
name: Run Azure Blob Store Integration Tests
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: '1.17'
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ inputs.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: ${{ inputs.os }}-go-
- run: go test -v -run=TestReplicaClient ./integration -replica-type abs
env:
LITESTREAM_ABS_ACCOUNT_NAME: ${{ secrets.LITESTREAM_ABS_ACCOUNT_NAME }}
LITESTREAM_ABS_ACCOUNT_KEY: ${{ secrets.LITESTREAM_ABS_ACCOUNT_KEY }}
LITESTREAM_ABS_BUCKET: integration
sftp-integration-test:
name: Run SFTP Integration Tests
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: '1.17'
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ inputs.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: ${{ inputs.os }}-go-
- name: Extract SSH key
run: 'echo "$LITESTREAM_SFTP_KEY" > /opt/id_ed25519'
shell: bash
env:
LITESTREAM_SFTP_KEY: ${{secrets.LITESTREAM_SFTP_KEY}}
- name: Run sftp tests w/ key
run: go test -v -run=TestReplicaClient ./integration -replica-type sftp
env:
LITESTREAM_SFTP_HOST: litestream-test-sftp.fly.dev:2222
LITESTREAM_SFTP_USER: litestream
LITESTREAM_SFTP_PATH: /litestream
LITESTREAM_SFTP_KEY_PATH: /opt/id_ed25519
- name: Run sftp tests w/ password
run: go test -v -run=TestReplicaClient ./integration -replica-type sftp
env:
LITESTREAM_SFTP_HOST: litestream-test-sftp.fly.dev:2222
LITESTREAM_SFTP_USER: litestream
LITESTREAM_SFTP_PASSWORD: ${{ secrets.LITESTREAM_SFTP_PASSWORD }}
LITESTREAM_SFTP_PATH: /litestream
long-running-test:
name: Run Long-Running Test
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: '1.17'
- uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ inputs.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: ${{ inputs.os }}-go-
- run: go install ./cmd/litestream
- run: go test -v -run=TestCmd_Replicate_LongRunning ./integration -long-running-duration 1m

View File

@@ -2,20 +2,20 @@ on:
release: release:
types: types:
- published - published
pull_request: # pull_request:
types: # types:
- opened # - opened
- synchronize # - synchronize
- reopened # - reopened
branches-ignore: # branches-ignore:
- "dependabot/**" # - "dependabot/**"
name: Release (Docker) name: Release (Docker)
jobs: jobs:
docker: docker:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
PLATFORMS: "${{ github.event_name == 'release' && 'linux/amd64,linux/arm64,linux/arm/v7' || 'linux/amd64' }}" PLATFORMS: "linux/amd64,linux/arm64,linux/arm/v7"
VERSION: "${{ github.event_name == 'release' && github.event.release.name || github.sha }}" VERSION: "${{ github.event_name == 'release' && github.event.release.name || github.sha }}"
steps: steps:
@@ -27,7 +27,7 @@ jobs:
with: with:
username: benbjohnson username: benbjohnson
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- id: meta - id: meta
uses: docker/metadata-action@v3 uses: docker/metadata-action@v3
with: with:
@@ -39,7 +39,7 @@ jobs:
type=sha,format=long type=sha,format=long
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
- uses: docker/build-push-action@v2 - uses: docker/build-push-action@v2
with: with:
context: . context: .

View File

@@ -1,75 +1,46 @@
on: on:
release: release:
types: types:
- published - created
pull_request:
types:
- opened
- synchronize
- reopened
branches-ignore:
- "dependabot/**"
name: Release (Linux) name: release (linux)
jobs: jobs:
build: build:
runs-on: ubuntu-18.04 runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
include: include:
- arch: amd64 - arch: amd64
cc: gcc cc: gcc
- arch: amd64
cc: gcc
static: true
- arch: arm64 - arch: arm64
cc: aarch64-linux-gnu-gcc cc: aarch64-linux-gnu-gcc
- arch: arm64
cc: aarch64-linux-gnu-gcc
static: true
- arch: arm - arch: arm
arm: 6 arm: 6
cc: arm-linux-gnueabi-gcc cc: arm-linux-gnueabi-gcc
- arch: arm
arm: 6
cc: arm-linux-gnueabi-gcc
static: true
- arch: arm - arch: arm
arm: 7 arm: 7
cc: arm-linux-gnueabihf-gcc cc: arm-linux-gnueabihf-gcc
- arch: arm
arm: 7
cc: arm-linux-gnueabihf-gcc
static: true
env: env:
GOOS: linux GOOS: linux
GOARCH: ${{ matrix.arch }} GOARCH: ${{ matrix.arch }}
GOARM: ${{ matrix.arm }} GOARM: ${{ matrix.arm }}
CC: ${{ matrix.cc }} CC: ${{ matrix.cc }}
LDFLAGS: ${{ matrix.static && '-extldflags "-static"' || '' }}
TAGS: ${{ matrix.static && 'osusergo,netgo,sqlite_omit_load_extension' || '' }}
SUFFIX: "${{ matrix.static && '-static' || ''}}"
VERSION: "${{ github.event_name == 'release' && github.event.release.name || github.sha }}"
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-go@v2 - uses: actions/setup-go@v2
with: with:
go-version: '1.17' go-version: '1.21'
- id: release
uses: bruceadams/get-release@v1.2.2
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Install cross-compilers - name: Install cross-compilers
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf gcc-arm-linux-gnueabi sudo apt-get install -y gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf gcc-arm-linux-gnueabi
- name: Install nfpm - name: Install nfpm
@@ -79,56 +50,32 @@ jobs:
- name: Build litestream - name: Build litestream
run: | run: |
rm -rf dist && mkdir -p dist rm -rf dist
mkdir -p dist
cp etc/litestream.yml etc/litestream.service dist cp etc/litestream.yml etc/litestream.service dist
cat etc/nfpm.yml | LITESTREAM_VERSION=${{ env.VERSION }} envsubst > dist/nfpm.yml cat etc/nfpm.yml | LITESTREAM_VERSION=${{ steps.release.outputs.tag_name }} envsubst > dist/nfpm.yml
CGO_ENABLED=1 go build -ldflags "-s -w -extldflags "-static" -X 'main.Version=${{ steps.release.outputs.tag_name }}'" -tags osusergo,netgo,sqlite_omit_load_extension -o dist/litestream ./cmd/litestream
CGO_ENABLED=1 go build -ldflags "-s -w ${{ env.LDFLAGS }} -X 'main.Version=${{ env.VERSION }}'" -tags "${{ env.TAGS }}" -o dist/litestream ./cmd/litestream
cd dist cd dist
tar -czvf litestream-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}${{ env.SUFFIX }}.tar.gz litestream tar -czvf litestream-${{ steps.release.outputs.tag_name }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}.tar.gz litestream
../nfpm pkg --config nfpm.yml --packager deb --target litestream-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}${{ env.SUFFIX }}.deb ../nfpm pkg --config nfpm.yml --packager deb --target litestream-${{ steps.release.outputs.tag_name }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}.deb
- name: Upload binary artifact
uses: actions/upload-artifact@v2
with:
name: litestream-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}${{ env.SUFFIX }}.tar.gz
path: dist/litestream-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}${{ env.SUFFIX }}.tar.gz
if-no-files-found: error
- name: Upload debian artifact
uses: actions/upload-artifact@v2
with:
name: litestream-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}${{ env.SUFFIX }}.deb
path: dist/litestream-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}${{ env.SUFFIX }}.deb
if-no-files-found: error
- name: Get release
id: release
uses: bruceadams/get-release@v1.2.3
if: github.event_name == 'release'
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Upload release tarball - name: Upload release tarball
uses: actions/upload-release-asset@v1.0.2 uses: actions/upload-release-asset@v1.0.2
if: github.event_name == 'release'
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
with: with:
upload_url: ${{ steps.release.outputs.upload_url }} upload_url: ${{ steps.release.outputs.upload_url }}
asset_path: ./dist/litestream-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}${{ env.SUFFIX }}.tar.gz asset_path: ./dist/litestream-${{ steps.release.outputs.tag_name }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}.tar.gz
asset_name: litestream-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}${{ env.SUFFIX }}.tar.gz asset_name: litestream-${{ steps.release.outputs.tag_name }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}.tar.gz
asset_content_type: application/gzip asset_content_type: application/gzip
- name: Upload debian package - name: Upload debian package
uses: actions/upload-release-asset@v1.0.2 uses: actions/upload-release-asset@v1.0.2
if: github.event_name == 'release'
env: env:
GITHUB_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ github.token }}
with: with:
upload_url: ${{ steps.release.outputs.upload_url }} upload_url: ${{ steps.release.outputs.upload_url }}
asset_path: ./dist/litestream-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}${{ env.SUFFIX }}.deb asset_path: ./dist/litestream-${{ steps.release.outputs.tag_name }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}.deb
asset_name: litestream-${{ env.VERSION }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}${{ env.SUFFIX }}.deb asset_name: litestream-${{ steps.release.outputs.tag_name }}-${{ env.GOOS }}-${{ env.GOARCH }}${{ env.GOARM }}.deb
asset_content_type: application/octet-stream asset_content_type: application/octet-stream

1
.gitignore vendored
View File

@@ -1,3 +1,2 @@
.coverage.*
.DS_Store .DS_Store
/dist /dist

20
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,20 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-beta.5
hooks:
- id: go-imports-repo
args:
- "-local"
- "github.com/benbjohnson/litestrem"
- "-w"
- id: go-vet-repo-mod
- id: go-staticcheck-repo-mod

View File

@@ -1,4 +1,4 @@
FROM golang:1.17 as builder FROM golang:1.21.3 as builder
WORKDIR /src/litestream WORKDIR /src/litestream
COPY . . COPY . .
@@ -10,7 +10,7 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
go build -ldflags "-s -w -X 'main.Version=${LITESTREAM_VERSION}' -extldflags '-static'" -tags osusergo,netgo,sqlite_omit_load_extension -o /usr/local/bin/litestream ./cmd/litestream go build -ldflags "-s -w -X 'main.Version=${LITESTREAM_VERSION}' -extldflags '-static'" -tags osusergo,netgo,sqlite_omit_load_extension -o /usr/local/bin/litestream ./cmd/litestream
FROM alpine FROM alpine:3.17.2
COPY --from=builder /usr/local/bin/litestream /usr/local/bin/litestream COPY --from=builder /usr/local/bin/litestream /usr/local/bin/litestream
ENTRYPOINT ["/usr/local/bin/litestream"] ENTRYPOINT ["/usr/local/bin/litestream"]
CMD [] CMD []

View File

@@ -199,4 +199,4 @@
distributed under the License is distributed on an "AS IS" BASIS, distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.

View File

@@ -1,11 +1,5 @@
.PHONY: default
default: default:
.PHONY: testdata
testdata:
make -C testdata
make -C cmd/litestream testdata
docker: docker:
docker build -t litestream . docker build -t litestream .
@@ -26,11 +20,17 @@ ifndef LITESTREAM_VERSION
$(error LITESTREAM_VERSION is undefined) $(error LITESTREAM_VERSION is undefined)
endif endif
mkdir -p dist mkdir -p dist
go build -v -ldflags "-s -w -X 'main.Version=${LITESTREAM_VERSION}'" -o dist/litestream ./cmd/litestream
GOOS=darwin GOARCH=amd64 CC="gcc -target amd64-apple-macos11" CGO_ENABLED=1 go build -v -ldflags "-s -w -X 'main.Version=${LITESTREAM_VERSION}'" -o dist/litestream ./cmd/litestream
gon etc/gon.hcl gon etc/gon.hcl
mv dist/litestream.zip dist/litestream-${LITESTREAM_VERSION}-darwin-amd64.zip mv dist/litestream.zip dist/litestream-${LITESTREAM_VERSION}-darwin-amd64.zip
openssl dgst -sha256 dist/litestream-${LITESTREAM_VERSION}-darwin-amd64.zip openssl dgst -sha256 dist/litestream-${LITESTREAM_VERSION}-darwin-amd64.zip
GOOS=darwin GOARCH=arm64 CC="gcc -target arm64-apple-macos11" CGO_ENABLED=1 go build -v -ldflags "-s -w -X 'main.Version=${LITESTREAM_VERSION}'" -o dist/litestream ./cmd/litestream
gon etc/gon.hcl
mv dist/litestream.zip dist/litestream-${LITESTREAM_VERSION}-darwin-arm64.zip
openssl dgst -sha256 dist/litestream-${LITESTREAM_VERSION}-darwin-arm64.zip
clean: clean:
rm -rf dist rm -rf dist

View File

@@ -6,7 +6,7 @@ Litestream
![test](https://github.com/benbjohnson/litestream/workflows/test/badge.svg) ![test](https://github.com/benbjohnson/litestream/workflows/test/badge.svg)
========== ==========
Litestream is a standalone streaming replication tool for SQLite. It runs as a Litestream is a standalone disaster recovery tool for SQLite. It runs as a
background process and safely replicates changes incrementally to another file background process and safely replicates changes incrementally to another file
or S3. Litestream only communicates with SQLite through the SQLite API so it or S3. Litestream only communicates with SQLite through the SQLite API so it
will not corrupt your database. will not corrupt your database.
@@ -58,4 +58,3 @@ If you find mistakes in the documentation, please submit a fix to the
[new-issue]: https://github.com/benbjohnson/litestream/issues/new [new-issue]: https://github.com/benbjohnson/litestream/issues/new
[docs]: https://github.com/benbjohnson/litestream.io [docs]: https://github.com/benbjohnson/litestream.io

View File

@@ -102,7 +102,7 @@ func (c *ReplicaClient) Generations(ctx context.Context) ([]string, error) {
internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "LIST").Inc() internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "LIST").Inc()
resp, err := c.containerURL.ListBlobsHierarchySegment(ctx, marker, "/", azblob.ListBlobsSegmentOptions{ resp, err := c.containerURL.ListBlobsHierarchySegment(ctx, marker, "/", azblob.ListBlobsSegmentOptions{
Prefix: path.Join(c.Path, "generations") + "/", Prefix: litestream.GenerationsPath(c.Path) + "/",
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -125,17 +125,18 @@ func (c *ReplicaClient) Generations(ctx context.Context) ([]string, error) {
func (c *ReplicaClient) DeleteGeneration(ctx context.Context, generation string) error { func (c *ReplicaClient) DeleteGeneration(ctx context.Context, generation string) error {
if err := c.Init(ctx); err != nil { if err := c.Init(ctx); err != nil {
return err return err
} else if generation == "" {
return fmt.Errorf("generation required")
} }
prefix := path.Join(c.Path, "generations", generation) + "/" dir, err := litestream.GenerationPath(c.Path, generation)
if err != nil {
return fmt.Errorf("cannot determine generation path: %w", err)
}
var marker azblob.Marker var marker azblob.Marker
for marker.NotDone() { for marker.NotDone() {
internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "LIST").Inc() internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "LIST").Inc()
resp, err := c.containerURL.ListBlobsFlatSegment(ctx, marker, azblob.ListBlobsSegmentOptions{Prefix: prefix}) resp, err := c.containerURL.ListBlobsFlatSegment(ctx, marker, azblob.ListBlobsSegmentOptions{Prefix: dir + "/"})
if err != nil { if err != nil {
return err return err
} }
@@ -170,11 +171,12 @@ func (c *ReplicaClient) Snapshots(ctx context.Context, generation string) (lites
func (c *ReplicaClient) WriteSnapshot(ctx context.Context, generation string, index int, rd io.Reader) (info litestream.SnapshotInfo, err error) { func (c *ReplicaClient) WriteSnapshot(ctx context.Context, generation string, index int, rd io.Reader) (info litestream.SnapshotInfo, err error) {
if err := c.Init(ctx); err != nil { if err := c.Init(ctx); err != nil {
return info, err return info, err
} else if generation == "" {
return info, fmt.Errorf("generation required")
} }
key := path.Join(c.Path, "generations", generation, "snapshots", litestream.FormatIndex(index)+".snapshot.lz4") key, err := litestream.SnapshotPath(c.Path, generation, index)
if err != nil {
return info, fmt.Errorf("cannot determine snapshot path: %w", err)
}
startTime := time.Now() startTime := time.Now()
rc := internal.NewReadCounter(rd) rc := internal.NewReadCounter(rd)
@@ -190,6 +192,8 @@ func (c *ReplicaClient) WriteSnapshot(ctx context.Context, generation string, in
internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "PUT").Inc() internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "PUT").Inc()
internal.OperationBytesCounterVec.WithLabelValues(ReplicaClientType, "PUT").Add(float64(rc.N())) internal.OperationBytesCounterVec.WithLabelValues(ReplicaClientType, "PUT").Add(float64(rc.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{ return litestream.SnapshotInfo{
Generation: generation, Generation: generation,
Index: index, Index: index,
@@ -202,11 +206,12 @@ func (c *ReplicaClient) WriteSnapshot(ctx context.Context, generation string, in
func (c *ReplicaClient) SnapshotReader(ctx context.Context, generation string, index int) (io.ReadCloser, error) { func (c *ReplicaClient) SnapshotReader(ctx context.Context, generation string, index int) (io.ReadCloser, error) {
if err := c.Init(ctx); err != nil { if err := c.Init(ctx); err != nil {
return nil, err return nil, err
} else if generation == "" {
return nil, fmt.Errorf("generation required")
} }
key := path.Join(c.Path, "generations", generation, "snapshots", litestream.FormatIndex(index)+".snapshot.lz4") key, err := litestream.SnapshotPath(c.Path, generation, index)
if err != nil {
return nil, fmt.Errorf("cannot determine snapshot path: %w", err)
}
blobURL := c.containerURL.NewBlobURL(key) blobURL := c.containerURL.NewBlobURL(key)
resp, err := blobURL.Download(ctx, 0, 0, azblob.BlobAccessConditions{}, false, azblob.ClientProvidedKeyOptions{}) resp, err := blobURL.Download(ctx, 0, 0, azblob.BlobAccessConditions{}, false, azblob.ClientProvidedKeyOptions{})
@@ -226,11 +231,12 @@ func (c *ReplicaClient) SnapshotReader(ctx context.Context, generation string, i
func (c *ReplicaClient) DeleteSnapshot(ctx context.Context, generation string, index int) error { func (c *ReplicaClient) DeleteSnapshot(ctx context.Context, generation string, index int) error {
if err := c.Init(ctx); err != nil { if err := c.Init(ctx); err != nil {
return err return err
} else if generation == "" {
return fmt.Errorf("generation required")
} }
key := path.Join(c.Path, "generations", generation, "snapshots", litestream.FormatIndex(index)+".snapshot.lz4") key, err := litestream.SnapshotPath(c.Path, generation, index)
if err != nil {
return fmt.Errorf("cannot determine snapshot path: %w", err)
}
internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "DELETE").Inc() internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "DELETE").Inc()
@@ -255,11 +261,12 @@ func (c *ReplicaClient) WALSegments(ctx context.Context, generation string) (lit
func (c *ReplicaClient) WriteWALSegment(ctx context.Context, pos litestream.Pos, rd io.Reader) (info litestream.WALSegmentInfo, err error) { func (c *ReplicaClient) WriteWALSegment(ctx context.Context, pos litestream.Pos, rd io.Reader) (info litestream.WALSegmentInfo, err error) {
if err := c.Init(ctx); err != nil { if err := c.Init(ctx); err != nil {
return info, err return info, err
} else if pos.Generation == "" {
return info, fmt.Errorf("generation required")
} }
key := path.Join(c.Path, "generations", pos.Generation, "wal", litestream.FormatIndex(pos.Index), litestream.FormatOffset(pos.Offset)+".wal.lz4") key, 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() startTime := time.Now()
rc := internal.NewReadCounter(rd) rc := internal.NewReadCounter(rd)
@@ -289,11 +296,12 @@ func (c *ReplicaClient) WriteWALSegment(ctx context.Context, pos litestream.Pos,
func (c *ReplicaClient) WALSegmentReader(ctx context.Context, pos litestream.Pos) (io.ReadCloser, error) { func (c *ReplicaClient) WALSegmentReader(ctx context.Context, pos litestream.Pos) (io.ReadCloser, error) {
if err := c.Init(ctx); err != nil { if err := c.Init(ctx); err != nil {
return nil, err return nil, err
} else if pos.Generation == "" {
return nil, fmt.Errorf("generation required")
} }
key := path.Join(c.Path, "generations", pos.Generation, "wal", litestream.FormatIndex(pos.Index), litestream.FormatOffset(pos.Offset)+".wal.lz4") key, 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)
}
blobURL := c.containerURL.NewBlobURL(key) blobURL := c.containerURL.NewBlobURL(key)
resp, err := blobURL.Download(ctx, 0, 0, azblob.BlobAccessConditions{}, false, azblob.ClientProvidedKeyOptions{}) resp, err := blobURL.Download(ctx, 0, 0, azblob.BlobAccessConditions{}, false, azblob.ClientProvidedKeyOptions{})
@@ -316,12 +324,11 @@ func (c *ReplicaClient) DeleteWALSegments(ctx context.Context, a []litestream.Po
} }
for _, pos := range a { for _, pos := range a {
if pos.Generation == "" { key, err := litestream.WALSegmentPath(c.Path, pos.Generation, pos.Index, pos.Offset)
return fmt.Errorf("generation required") if err != nil {
return fmt.Errorf("cannot determine wal segment path: %w", err)
} }
key := path.Join(c.Path, "generations", pos.Generation, "wal", litestream.FormatIndex(pos.Index), litestream.FormatOffset(pos.Offset)+".wal.lz4")
internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "DELETE").Inc() internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "DELETE").Inc()
blobURL := c.containerURL.NewBlobURL(key) blobURL := c.containerURL.NewBlobURL(key)
@@ -365,24 +372,24 @@ func newSnapshotIterator(ctx context.Context, generation string, client *Replica
func (itr *snapshotIterator) fetch() error { func (itr *snapshotIterator) fetch() error {
defer close(itr.ch) defer close(itr.ch)
if itr.generation == "" { dir, err := litestream.SnapshotsPath(itr.client.Path, itr.generation)
return fmt.Errorf("generation required") if err != nil {
return fmt.Errorf("cannot determine snapshots path: %w", err)
} }
prefix := path.Join(itr.client.Path, "generations", itr.generation) + "/"
var marker azblob.Marker var marker azblob.Marker
for marker.NotDone() { for marker.NotDone() {
internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "LIST").Inc() internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "LIST").Inc()
resp, err := itr.client.containerURL.ListBlobsFlatSegment(itr.ctx, marker, azblob.ListBlobsSegmentOptions{Prefix: prefix}) resp, err := itr.client.containerURL.ListBlobsFlatSegment(itr.ctx, marker, azblob.ListBlobsSegmentOptions{Prefix: dir + "/"})
if err != nil { if err != nil {
return err return err
} }
marker = resp.NextMarker marker = resp.NextMarker
for _, item := range resp.Segment.BlobItems { for _, item := range resp.Segment.BlobItems {
index, err := internal.ParseSnapshotPath(path.Base(item.Name)) key := path.Base(item.Name)
index, err := litestream.ParseSnapshotPath(key)
if err != nil { if err != nil {
continue continue
} }
@@ -471,24 +478,24 @@ func newWALSegmentIterator(ctx context.Context, generation string, client *Repli
func (itr *walSegmentIterator) fetch() error { func (itr *walSegmentIterator) fetch() error {
defer close(itr.ch) defer close(itr.ch)
if itr.generation == "" { dir, err := litestream.WALPath(itr.client.Path, itr.generation)
return fmt.Errorf("generation required") if err != nil {
return fmt.Errorf("cannot determine wal path: %w", err)
} }
prefix := path.Join(itr.client.Path, "generations", itr.generation, "wal")
var marker azblob.Marker var marker azblob.Marker
for marker.NotDone() { for marker.NotDone() {
internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "LIST").Inc() internal.OperationTotalCounterVec.WithLabelValues(ReplicaClientType, "LIST").Inc()
resp, err := itr.client.containerURL.ListBlobsFlatSegment(itr.ctx, marker, azblob.ListBlobsSegmentOptions{Prefix: prefix}) resp, err := itr.client.containerURL.ListBlobsFlatSegment(itr.ctx, marker, azblob.ListBlobsSegmentOptions{Prefix: dir + "/"})
if err != nil { if err != nil {
return err return err
} }
marker = resp.NextMarker marker = resp.NextMarker
for _, item := range resp.Segment.BlobItems { for _, item := range resp.Segment.BlobItems {
key := strings.TrimPrefix(item.Name, prefix+"/") key := path.Base(item.Name)
index, offset, err := internal.ParseWALSegmentPath(key) index, offset, err := litestream.ParseWALSegmentPath(key)
if err != nil { if err != nil {
continue continue
} }

View File

@@ -1,6 +0,0 @@
.PHONY: default
default:
.PHONY: testdata
testdata:
make -C testdata

View File

@@ -4,34 +4,18 @@ import (
"context" "context"
"flag" "flag"
"fmt" "fmt"
"io" "os"
"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
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)
registerConfigFlag(fs, &c.configPath, &c.noExpandEnv) configPath, noExpandEnv := registerConfigFlag(fs)
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
@@ -40,16 +24,16 @@ func (c *DatabasesCommand) Run(ctx context.Context, args []string) (err error) {
} }
// Load configuration. // Load configuration.
config, err := ReadConfigFile(c.configPath, !c.noExpandEnv) if *configPath == "" {
*configPath = DefaultConfigPath()
}
config, err := ReadConfigFile(*configPath, !*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(c.stdout, 0, 8, 2, ' ', 0) w := tabwriter.NewWriter(os.Stdout, 0, 8, 2, ' ', 0)
defer w.Flush() defer w.Flush()
fmt.Fprintln(w, "path\treplicas") fmt.Fprintln(w, "path\treplicas")
@@ -75,7 +59,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.Fprintf(c.stdout, ` fmt.Printf(`
The databases command lists all databases in the configuration file. The databases command lists all databases in the configuration file.
Usage: Usage:

View File

@@ -1,66 +0,0 @@
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.ReadFile(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.ReadFile(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)
}
})
}

View File

@@ -4,116 +4,119 @@ import (
"context" "context"
"flag" "flag"
"fmt" "fmt"
"io"
"os" "os"
"sort"
"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
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) (ret error) { func (c *GenerationsCommand) Run(ctx context.Context, args []string) (err error) {
fs := flag.NewFlagSet("litestream-generations", flag.ContinueOnError) fs := flag.NewFlagSet("litestream-generations", flag.ContinueOnError)
registerConfigFlag(fs, &c.configPath, &c.noExpandEnv) configPath, noExpandEnv := registerConfigFlag(fs)
fs.StringVar(&c.replicaName, "replica", "", "replica name") replicaName := fs.String("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.Arg(0) == "" { } else if fs.NArg() == 0 || 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")
} }
// Load configuration. var db *litestream.DB
config, err := ReadConfigFile(c.configPath, !c.noExpandEnv) var r *litestream.Replica
if err != nil { dbUpdatedAt := time.Now()
return err if isURL(fs.Arg(0)) {
} if *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 *configPath == "" {
*configPath = DefaultConfigPath()
}
replicas, db, err := loadReplicas(ctx, config, fs.Arg(0), c.replicaName) // Load configuration.
if err != nil { config, err := ReadConfigFile(*configPath, !*noExpandEnv)
return err if err != nil {
} return err
}
// Determine last time database or WAL was updated. // Lookup database from configuration file by path.
var dbUpdatedAt time.Time if path, err := expand(fs.Arg(0)); err != nil {
if db != nil { return err
if dbUpdatedAt, err = db.UpdatedAt(); err != nil && !os.IsNotExist(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.
if dbUpdatedAt, err = db.UpdatedAt(); err != nil {
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(c.stdout, 0, 8, 2, ' ', 0) w := tabwriter.NewWriter(os.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 {
fmt.Fprintf(c.stderr, "%s: cannot list generations: %s", r.Name(), err) r.Logger().Error("cannot list generations", "error", err)
ret = errExit // signal error return without printing message
continue continue
} }
sort.Strings(generations)
// Iterate over each generation for the replica. // Iterate over each generation for the replica.
for _, generation := range generations { for _, generation := range generations {
createdAt, updatedAt, err := litestream.GenerationTimeBounds(ctx, r.Client(), generation) createdAt, updatedAt, err := r.GenerationTimeBounds(ctx, generation)
if err != nil { if err != nil {
fmt.Fprintf(c.stderr, "%s: cannot determine generation time bounds: %s", r.Name(), err) r.Logger().Error("cannot determine generation time bounds", "error", 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,
lag, truncateDuration(dbUpdatedAt.Sub(updatedAt)).String(),
createdAt.Format(time.RFC3339), createdAt.Format(time.RFC3339),
updatedAt.Format(time.RFC3339), updatedAt.Format(time.RFC3339),
) )
} }
} }
return ret return nil
} }
// Usage prints the help message to STDOUT. // Usage prints the help message to STDOUT.
func (c *GenerationsCommand) Usage() { func (c *GenerationsCommand) Usage() {
fmt.Fprintf(c.stdout, ` fmt.Printf(`
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.
@@ -121,7 +124,7 @@ cover.
Usage: Usage:
litestream generations [arguments] DB_PATH litestream generations [arguments] DB_PATH
litestream generations [arguments] REPLICA_URL litestream generations [arguments] REPLICA_URL
Arguments: Arguments:
@@ -140,3 +143,29 @@ 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
}

View File

@@ -1,140 +0,0 @@
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.ReadFile(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.ReadFile(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.ReadFile(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.ReadFile(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)
}
})
}

View File

@@ -5,25 +5,22 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"io" "log/slog"
"io/ioutil"
"log"
"net/url" "net/url"
"os" "os"
"os/signal"
"os/user" "os/user"
"path" "path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"syscall"
"time" "time"
"filippo.io/age"
"github.com/benbjohnson/litestream" "github.com/benbjohnson/litestream"
"github.com/benbjohnson/litestream/abs" "github.com/benbjohnson/litestream/abs"
"github.com/benbjohnson/litestream/gs" "github.com/benbjohnson/litestream/file"
"github.com/benbjohnson/litestream/http" "github.com/benbjohnson/litestream/gcs"
"github.com/benbjohnson/litestream/s3" "github.com/benbjohnson/litestream/s3"
"github.com/benbjohnson/litestream/sftp" "github.com/benbjohnson/litestream/sftp"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
@@ -35,40 +32,36 @@ var (
Version = "(development build)" Version = "(development build)"
) )
// errExit is a terminal error for indicating program should quit. // errStop is a terminal error for indicating program should quit.
var errExit = errors.New("exit") var errStop = errors.New("stop")
func main() { func main() {
log.SetFlags(0) m := NewMain()
log.SetOutput(os.Stdout) if err := m.Run(context.Background(), os.Args[1:]); err == flag.ErrHelp || err == errStop {
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) os.Exit(1)
} else if err != nil { } else if err != nil {
log.Println(err) slog.Error("failed to run", "error", err)
os.Exit(1) os.Exit(1)
} }
} }
// 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(stdin io.Reader, stdout, stderr io.Writer) *Main { func NewMain() *Main {
return &Main{ return &Main{}
stdin: stdin,
stdout: stdout,
stderr: stderr,
}
} }
// Run executes the program. // Run executes the program.
func (m *Main) Run(ctx context.Context, args []string) (err error) { func (m *Main) Run(ctx context.Context, args []string) (err error) {
// Execute replication command if running as a Windows service.
if isService, err := isWindowsService(); err != nil {
return err
} else if isService {
return runWindowsService(ctx)
}
// Copy "LITESTEAM" environment credentials. // Copy "LITESTEAM" environment credentials.
applyLitestreamEnv() applyLitestreamEnv()
@@ -80,43 +73,36 @@ func (m *Main) Run(ctx context.Context, args []string) (err error) {
switch cmd { switch cmd {
case "databases": case "databases":
return NewDatabasesCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args) return (&DatabasesCommand{}).Run(ctx, args)
case "generations": case "generations":
return NewGenerationsCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args) return (&GenerationsCommand{}).Run(ctx, args)
case "replicate": case "replicate":
c := NewReplicateCommand(m.stdin, m.stdout, m.stderr) c := NewReplicateCommand()
if err := c.ParseFlags(ctx, args); err != nil { if err := c.ParseFlags(ctx, args); err != nil {
return err return err
} }
// Setup signal handler. // Setup signal handler.
ctx, cancel := context.WithCancel(ctx) signalCh := signalChan()
defer cancel()
signalCh := make(chan os.Signal, 1)
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM)
if err := c.Run(ctx); err != nil { if err := c.Run(); err != nil {
return err return err
} }
// Wait for signal to stop program. // Wait for signal to stop program.
select { select {
case <-ctx.Done():
fmt.Fprintln(m.stdout, "context done, litestream shutting down")
case err = <-c.execCh: case err = <-c.execCh:
cancel() slog.Info("subprocess exited, litestream shutting down")
fmt.Fprintln(m.stdout, "subprocess exited, litestream shutting down")
case sig := <-signalCh: case sig := <-signalCh:
cancel() slog.Info("signal received, litestream shutting down")
fmt.Fprintln(m.stdout, "signal received, litestream shutting down")
if c.cmd != nil { if c.cmd != nil {
fmt.Fprintln(m.stdout, "sending signal to exec process") slog.Info("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.Fprintln(m.stdout, "waiting for exec process to close") slog.Info("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)
} }
@@ -127,17 +113,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.Fprintln(m.stdout, "litestream shut down") slog.Info("litestream shut down")
return err return err
case "restore": case "restore":
return NewRestoreCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args) return (&RestoreCommand{}).Run(ctx, args)
case "snapshots": case "snapshots":
return NewSnapshotsCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args) return (&SnapshotsCommand{}).Run(ctx, args)
case "version": case "version":
return NewVersionCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args) return (&VersionCommand{}).Run(ctx, args)
case "wal": case "wal":
return NewWALCommand(m.stdin, m.stdout, m.stderr).Run(ctx, args) return (&WALCommand{}).Run(ctx, args)
default: default:
if cmd == "" || cmd == "help" || strings.HasPrefix(cmd, "-") { if cmd == "" || cmd == "help" || strings.HasPrefix(cmd, "-") {
m.Usage() m.Usage()
@@ -149,7 +135,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.Fprintln(m.stdout, ` fmt.Println(`
litestream is a tool for replicating SQLite databases. litestream is a tool for replicating SQLite databases.
Usage: Usage:
@@ -183,6 +169,16 @@ type Config struct {
// Global S3 settings // Global S3 settings
AccessKeyID string `yaml:"access-key-id"` AccessKeyID string `yaml:"access-key-id"`
SecretAccessKey string `yaml:"secret-access-key"` SecretAccessKey string `yaml:"secret-access-key"`
// Logging
Logging LoggingConfig `yaml:"logging"`
}
// LoggingConfig configures logging.
type LoggingConfig struct {
Level string `yaml:"level"`
Type string `yaml:"type"`
Stderr bool `yaml:"stderr"`
} }
// propagateGlobalSettings copies global S3 settings to replica configs. // propagateGlobalSettings copies global S3 settings to replica configs.
@@ -216,34 +212,7 @@ 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 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() config := DefaultConfig()
// Expand filename, if necessary. // Expand filename, if necessary.
@@ -253,9 +222,10 @@ 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 := os.ReadFile(filename)
buf, err := ioutil.ReadFile(filename) if os.IsNotExist(err) {
if err != nil { return config, fmt.Errorf("config file not found: %s", filename)
} else if err != nil {
return config, err return config, err
} }
@@ -278,18 +248,47 @@ func readConfigFile(filename string, expandEnv bool) (_ Config, err error) {
// Propage settings from global config to replica configs. // Propage settings from global config to replica configs.
config.propagateGlobalSettings() config.propagateGlobalSettings()
// Configure logging.
logOutput := os.Stdout
if config.Logging.Stderr {
logOutput = os.Stderr
}
logOptions := slog.HandlerOptions{
Level: slog.LevelInfo,
}
switch strings.ToUpper(config.Logging.Level) {
case "DEBUG":
logOptions.Level = slog.LevelDebug
case "WARN", "WARNING":
logOptions.Level = slog.LevelWarn
case "ERROR":
logOptions.Level = slog.LevelError
}
var logHandler slog.Handler
switch config.Logging.Type {
case "json":
logHandler = slog.NewJSONHandler(logOutput, &logOptions)
case "text", "":
logHandler = slog.NewTextHandler(logOutput, &logOptions)
}
// Set global default logger.
slog.SetDefault(slog.New(logHandler))
return config, nil return config, nil
} }
// DBConfig represents the configuration for a single database. // DBConfig represents the configuration for a single database.
type DBConfig struct { type DBConfig struct {
Path string `yaml:"path"` Path string `yaml:"path"`
Upstream UpstreamConfig `yaml:"upstream"` MetaPath *string `yaml:"meta-path"`
MonitorDelayInterval *time.Duration `yaml:"monitor-delay-interval"` MonitorInterval *time.Duration `yaml:"monitor-interval"`
CheckpointInterval *time.Duration `yaml:"checkpoint-interval"` CheckpointInterval *time.Duration `yaml:"checkpoint-interval"`
MinCheckpointPageN *int `yaml:"min-checkpoint-page-count"` MinCheckpointPageN *int `yaml:"min-checkpoint-page-count"`
MaxCheckpointPageN *int `yaml:"max-checkpoint-page-count"` MaxCheckpointPageN *int `yaml:"max-checkpoint-page-count"`
ShadowRetentionN *int `yaml:"shadow-retention-count"`
Replicas []*ReplicaConfig `yaml:"replicas"` Replicas []*ReplicaConfig `yaml:"replicas"`
} }
@@ -300,27 +299,16 @@ func NewDBFromConfig(dbc *DBConfig) (*litestream.DB, error) {
if err != nil { if err != nil {
return nil, err 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. // Initialize database with given path.
db := litestream.NewDB(path) db := litestream.NewDB(path)
// Attach upstream HTTP client if specified.
if upstreamURL := dbc.Upstream.URL; upstreamURL != "" {
// Use local database path if upstream path is not specified.
upstreamPath := dbc.Upstream.Path
if upstreamPath == "" {
upstreamPath = db.Path()
}
db.StreamClient = http.NewClient(upstreamURL, upstreamPath)
}
// Override default database settings if specified in configuration. // Override default database settings if specified in configuration.
if dbc.MonitorDelayInterval != nil { if dbc.MetaPath != nil {
db.MonitorDelayInterval = *dbc.MonitorDelayInterval db.SetMetaPath(*dbc.MetaPath)
}
if dbc.MonitorInterval != nil {
db.MonitorInterval = *dbc.MonitorInterval
} }
if dbc.CheckpointInterval != nil { if dbc.CheckpointInterval != nil {
db.CheckpointInterval = *dbc.CheckpointInterval db.CheckpointInterval = *dbc.CheckpointInterval
@@ -331,9 +319,6 @@ func NewDBFromConfigWithPath(dbc *DBConfig, path string) (*litestream.DB, error)
if dbc.MaxCheckpointPageN != nil { if dbc.MaxCheckpointPageN != nil {
db.MaxCheckpointPageN = *dbc.MaxCheckpointPageN db.MaxCheckpointPageN = *dbc.MaxCheckpointPageN
} }
if dbc.ShadowRetentionN != nil {
db.ShadowRetentionN = *dbc.ShadowRetentionN
}
// Instantiate and attach replicas. // Instantiate and attach replicas.
for _, rc := range dbc.Replicas { for _, rc := range dbc.Replicas {
@@ -347,11 +332,6 @@ func NewDBFromConfigWithPath(dbc *DBConfig, path string) (*litestream.DB, error)
return db, nil return db, nil
} }
type UpstreamConfig struct {
URL string `yaml:"url"`
Path string `yaml:"path"`
}
// ReplicaConfig represents the configuration for a single replica in a database. // ReplicaConfig represents the configuration for a single replica in a database.
type ReplicaConfig struct { type ReplicaConfig struct {
Type string `yaml:"type"` // "file", "s3" Type string `yaml:"type"` // "file", "s3"
@@ -382,6 +362,12 @@ type ReplicaConfig struct {
User string `yaml:"user"` User string `yaml:"user"`
Password string `yaml:"password"` Password string `yaml:"password"`
KeyPath string `yaml:"key-path"` KeyPath string `yaml:"key-path"`
// Encryption identities and recipients
Age struct {
Identities []string `yaml:"identities"`
Recipients []string `yaml:"recipients"`
} `yaml:"age"`
} }
// NewReplicaFromConfig instantiates a replica for a DB based on a config. // NewReplicaFromConfig instantiates a replica for a DB based on a config.
@@ -391,35 +377,8 @@ func NewReplicaFromConfig(c *ReplicaConfig, db *litestream.DB) (_ *litestream.Re
return nil, fmt.Errorf("replica path cannot be a url, please use the 'url' field instead: %s", 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. // Build replica.
r := litestream.NewReplica(db, c.Name, client) r := litestream.NewReplica(db, c.Name)
if v := c.Retention; v != nil { if v := c.Retention; v != nil {
r.Retention = *v r.Retention = *v
} }
@@ -435,12 +394,54 @@ func NewReplicaFromConfig(c *ReplicaConfig, db *litestream.DB) (_ *litestream.Re
if v := c.ValidationInterval; v != nil { if v := c.ValidationInterval; v != nil {
r.ValidationInterval = *v r.ValidationInterval = *v
} }
for _, str := range c.Age.Identities {
identities, err := age.ParseIdentities(strings.NewReader(str))
if err != nil {
return nil, err
}
r.AgeIdentities = append(r.AgeIdentities, identities...)
}
for _, str := range c.Age.Recipients {
recipients, err := age.ParseRecipients(strings.NewReader(str))
if err != nil {
return nil, err
}
r.AgeRecipients = append(r.AgeRecipients, recipients...)
}
// Build and set client on replica.
switch c.ReplicaType() {
case "file":
if r.Client, err = newFileReplicaClientFromConfig(c, r); err != nil {
return nil, err
}
case "s3":
if r.Client, err = newS3ReplicaClientFromConfig(c, r); err != nil {
return nil, err
}
case "gcs":
if r.Client, err = newGCSReplicaClientFromConfig(c, r); err != nil {
return nil, err
}
case "abs":
if r.Client, err = newABSReplicaClientFromConfig(c, r); err != nil {
return nil, err
}
case "sftp":
if r.Client, err = newSFTPReplicaClientFromConfig(c, r); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown replica type in config: %q", c.Type)
}
return r, nil return r, nil
} }
// newFileReplicaClientFromConfig returns a new instance of FileReplicaClient built from config. // newFileReplicaClientFromConfig returns a new instance of file.ReplicaClient built from config.
func newFileReplicaClientFromConfig(c *ReplicaConfig) (_ *litestream.FileReplicaClient, err error) { func newFileReplicaClientFromConfig(c *ReplicaConfig, r *litestream.Replica) (_ *file.ReplicaClient, err error) {
// Ensure URL & path are not both specified. // Ensure URL & path are not both specified.
if c.URL != "" && c.Path != "" { if c.URL != "" && c.Path != "" {
return nil, fmt.Errorf("cannot specify url & path for file replica") return nil, fmt.Errorf("cannot specify url & path for file replica")
@@ -465,11 +466,13 @@ func newFileReplicaClientFromConfig(c *ReplicaConfig) (_ *litestream.FileReplica
} }
// Instantiate replica and apply time fields, if set. // Instantiate replica and apply time fields, if set.
return litestream.NewFileReplicaClient(path), nil client := file.NewReplicaClient(path)
client.Replica = r
return client, nil
} }
// newS3ReplicaClientFromConfig returns a new instance of s3.ReplicaClient built from config. // newS3ReplicaClientFromConfig returns a new instance of s3.ReplicaClient built from config.
func newS3ReplicaClientFromConfig(c *ReplicaConfig) (_ *s3.ReplicaClient, err error) { func newS3ReplicaClientFromConfig(c *ReplicaConfig, r *litestream.Replica) (_ *s3.ReplicaClient, err error) {
// Ensure URL & constituent parts are not both specified. // Ensure URL & constituent parts are not both specified.
if c.URL != "" && c.Path != "" { if c.URL != "" && c.Path != "" {
return nil, fmt.Errorf("cannot specify url & path for s3 replica") return nil, fmt.Errorf("cannot specify url & path for s3 replica")
@@ -531,13 +534,13 @@ func newS3ReplicaClientFromConfig(c *ReplicaConfig) (_ *s3.ReplicaClient, err er
return client, nil return client, nil
} }
// newGSReplicaClientFromConfig returns a new instance of gs.ReplicaClient built from config. // newGCSReplicaClientFromConfig returns a new instance of gcs.ReplicaClient built from config.
func newGSReplicaClientFromConfig(c *ReplicaConfig) (_ *gs.ReplicaClient, err error) { func newGCSReplicaClientFromConfig(c *ReplicaConfig, r *litestream.Replica) (_ *gcs.ReplicaClient, err error) {
// Ensure URL & constituent parts are not both specified. // Ensure URL & constituent parts are not both specified.
if c.URL != "" && c.Path != "" { if c.URL != "" && c.Path != "" {
return nil, fmt.Errorf("cannot specify url & path for gs replica") return nil, fmt.Errorf("cannot specify url & path for gcs replica")
} else if c.URL != "" && c.Bucket != "" { } else if c.URL != "" && c.Bucket != "" {
return nil, fmt.Errorf("cannot specify url & bucket for gs replica") return nil, fmt.Errorf("cannot specify url & bucket for gcs replica")
} }
bucket, path := c.Bucket, c.Path bucket, path := c.Bucket, c.Path
@@ -560,18 +563,18 @@ func newGSReplicaClientFromConfig(c *ReplicaConfig) (_ *gs.ReplicaClient, err er
// Ensure required settings are set. // Ensure required settings are set.
if bucket == "" { if bucket == "" {
return nil, fmt.Errorf("bucket required for gs replica") return nil, fmt.Errorf("bucket required for gcs replica")
} }
// Build replica. // Build replica.
client := gs.NewReplicaClient() client := gcs.NewReplicaClient()
client.Bucket = bucket client.Bucket = bucket
client.Path = path client.Path = path
return client, nil return client, nil
} }
// newABSReplicaClientFromConfig returns a new instance of abs.ReplicaClient built from config. // newABSReplicaClientFromConfig returns a new instance of abs.ReplicaClient built from config.
func newABSReplicaClientFromConfig(c *ReplicaConfig) (_ *abs.ReplicaClient, err error) { func newABSReplicaClientFromConfig(c *ReplicaConfig, r *litestream.Replica) (_ *abs.ReplicaClient, err error) {
// Ensure URL & constituent parts are not both specified. // Ensure URL & constituent parts are not both specified.
if c.URL != "" && c.Path != "" { if c.URL != "" && c.Path != "" {
return nil, fmt.Errorf("cannot specify url & path for abs replica") return nil, fmt.Errorf("cannot specify url & path for abs replica")
@@ -614,7 +617,7 @@ func newABSReplicaClientFromConfig(c *ReplicaConfig) (_ *abs.ReplicaClient, err
} }
// newSFTPReplicaClientFromConfig returns a new instance of sftp.ReplicaClient built from config. // newSFTPReplicaClientFromConfig returns a new instance of sftp.ReplicaClient built from config.
func newSFTPReplicaClientFromConfig(c *ReplicaConfig) (_ *sftp.ReplicaClient, err error) { func newSFTPReplicaClientFromConfig(c *ReplicaConfig, r *litestream.Replica) (_ *sftp.ReplicaClient, err error) {
// Ensure URL & constituent parts are not both specified. // Ensure URL & constituent parts are not both specified.
if c.URL != "" && c.Path != "" { if c.URL != "" && c.Path != "" {
return nil, fmt.Errorf("cannot specify url & path for sftp replica") return nil, fmt.Errorf("cannot specify url & path for sftp replica")
@@ -720,12 +723,12 @@ func DefaultConfigPath() string {
if v := os.Getenv("LITESTREAM_CONFIG"); v != "" { if v := os.Getenv("LITESTREAM_CONFIG"); v != "" {
return v return v
} }
return "/etc/litestream.yml" return defaultConfigPath
} }
func registerConfigFlag(fs *flag.FlagSet, configPath *string, noExpandEnv *bool) { func registerConfigFlag(fs *flag.FlagSet) (configPath *string, noExpandEnv *bool) {
fs.StringVar(configPath, "config", "", "config path") return fs.String("config", "", "config path"),
fs.BoolVar(noExpandEnv, "no-expand-env", false, "do not expand env vars in config") fs.Bool("no-expand-env", false, "do not expand env vars in config")
} }
// expand returns an absolute path for s. // expand returns an absolute path for s.
@@ -759,7 +762,7 @@ var _ flag.Value = (*indexVar)(nil)
// String returns an 8-character hexadecimal value. // String returns an 8-character hexadecimal value.
func (v *indexVar) String() string { func (v *indexVar) String() string {
return litestream.FormatIndex(int(*v)) return fmt.Sprintf("%08x", int(*v))
} }
// Set parses s into an integer from a hexadecimal value. // Set parses s into an integer from a hexadecimal value.
@@ -771,45 +774,3 @@ 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
}

View File

@@ -0,0 +1,26 @@
//go:build !windows
package main
import (
"context"
"os"
"os/signal"
"syscall"
)
const defaultConfigPath = "/etc/litestream.yml"
func isWindowsService() (bool, error) {
return false, nil
}
func runWindowsService(ctx context.Context) error {
panic("cannot run windows service as unix process")
}
func signalChan() <-chan os.Signal {
ch := make(chan os.Signal, 2)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
return ch
}

View File

@@ -1,29 +1,21 @@
package main_test package main_test
import ( import (
"bytes"
"io"
"io/ioutil"
"log"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/benbjohnson/litestream"
main "github.com/benbjohnson/litestream/cmd/litestream" main "github.com/benbjohnson/litestream/cmd/litestream"
"github.com/benbjohnson/litestream/gs" "github.com/benbjohnson/litestream/file"
"github.com/benbjohnson/litestream/gcs"
"github.com/benbjohnson/litestream/s3" "github.com/benbjohnson/litestream/s3"
) )
func init() {
litestream.LogFlags = log.Lmsgprefix | log.Ldate | log.Ltime | log.Lmicroseconds | log.LUTC | log.Lshortfile
}
func TestReadConfigFile(t *testing.T) { func TestReadConfigFile(t *testing.T) {
// Ensure global AWS settings are propagated down to replica configurations. // Ensure global AWS settings are propagated down to replica configurations.
t.Run("PropagateGlobalSettings", func(t *testing.T) { t.Run("PropagateGlobalSettings", func(t *testing.T) {
filename := filepath.Join(t.TempDir(), "litestream.yml") filename := filepath.Join(t.TempDir(), "litestream.yml")
if err := ioutil.WriteFile(filename, []byte(` if err := os.WriteFile(filename, []byte(`
access-key-id: XXX access-key-id: XXX
secret-access-key: YYY secret-access-key: YYY
@@ -55,7 +47,7 @@ dbs:
os.Setenv("LITESTREAM_TEST_1872363", "s3://foo/bar") os.Setenv("LITESTREAM_TEST_1872363", "s3://foo/bar")
filename := filepath.Join(t.TempDir(), "litestream.yml") filename := filepath.Join(t.TempDir(), "litestream.yml")
if err := ioutil.WriteFile(filename, []byte(` if err := os.WriteFile(filename, []byte(`
dbs: dbs:
- path: $LITESTREAM_TEST_0129380 - path: $LITESTREAM_TEST_0129380
replicas: replicas:
@@ -82,7 +74,7 @@ dbs:
os.Setenv("LITESTREAM_TEST_9847533", "s3://foo/bar") os.Setenv("LITESTREAM_TEST_9847533", "s3://foo/bar")
filename := filepath.Join(t.TempDir(), "litestream.yml") filename := filepath.Join(t.TempDir(), "litestream.yml")
if err := ioutil.WriteFile(filename, []byte(` if err := os.WriteFile(filename, []byte(`
dbs: dbs:
- path: /path/to/db - path: /path/to/db
replicas: replicas:
@@ -104,7 +96,7 @@ func TestNewFileReplicaFromConfig(t *testing.T) {
r, err := main.NewReplicaFromConfig(&main.ReplicaConfig{Path: "/foo"}, nil) r, err := main.NewReplicaFromConfig(&main.ReplicaConfig{Path: "/foo"}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} else if client, ok := r.Client().(*litestream.FileReplicaClient); !ok { } else if client, ok := r.Client.(*file.ReplicaClient); !ok {
t.Fatal("unexpected replica type") t.Fatal("unexpected replica type")
} else if got, want := client.Path(), "/foo"; got != want { } else if got, want := client.Path(), "/foo"; got != want {
t.Fatalf("Path=%s, want %s", got, want) t.Fatalf("Path=%s, want %s", got, want)
@@ -116,7 +108,7 @@ func TestNewS3ReplicaFromConfig(t *testing.T) {
r, err := main.NewReplicaFromConfig(&main.ReplicaConfig{URL: "s3://foo/bar"}, nil) r, err := main.NewReplicaFromConfig(&main.ReplicaConfig{URL: "s3://foo/bar"}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} else if client, ok := r.Client().(*s3.ReplicaClient); !ok { } else if client, ok := r.Client.(*s3.ReplicaClient); !ok {
t.Fatal("unexpected replica type") t.Fatal("unexpected replica type")
} else if got, want := client.Bucket, "foo"; got != want { } else if got, want := client.Bucket, "foo"; got != want {
t.Fatalf("Bucket=%s, want %s", got, want) t.Fatalf("Bucket=%s, want %s", got, want)
@@ -135,7 +127,7 @@ func TestNewS3ReplicaFromConfig(t *testing.T) {
r, err := main.NewReplicaFromConfig(&main.ReplicaConfig{URL: "s3://foo.localhost:9000/bar"}, nil) r, err := main.NewReplicaFromConfig(&main.ReplicaConfig{URL: "s3://foo.localhost:9000/bar"}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} else if client, ok := r.Client().(*s3.ReplicaClient); !ok { } else if client, ok := r.Client.(*s3.ReplicaClient); !ok {
t.Fatal("unexpected replica type") t.Fatal("unexpected replica type")
} else if got, want := client.Bucket, "foo"; got != want { } else if got, want := client.Bucket, "foo"; got != want {
t.Fatalf("Bucket=%s, want %s", got, want) t.Fatalf("Bucket=%s, want %s", got, want)
@@ -154,7 +146,7 @@ func TestNewS3ReplicaFromConfig(t *testing.T) {
r, err := main.NewReplicaFromConfig(&main.ReplicaConfig{URL: "s3://foo.s3.us-west-000.backblazeb2.com/bar"}, nil) r, err := main.NewReplicaFromConfig(&main.ReplicaConfig{URL: "s3://foo.s3.us-west-000.backblazeb2.com/bar"}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} else if client, ok := r.Client().(*s3.ReplicaClient); !ok { } else if client, ok := r.Client.(*s3.ReplicaClient); !ok {
t.Fatal("unexpected replica type") t.Fatal("unexpected replica type")
} else if got, want := client.Bucket, "foo"; got != want { } else if got, want := client.Bucket, "foo"; got != want {
t.Fatalf("Bucket=%s, want %s", got, want) t.Fatalf("Bucket=%s, want %s", got, want)
@@ -170,11 +162,11 @@ func TestNewS3ReplicaFromConfig(t *testing.T) {
}) })
} }
func TestNewGSReplicaFromConfig(t *testing.T) { func TestNewGCSReplicaFromConfig(t *testing.T) {
r, err := main.NewReplicaFromConfig(&main.ReplicaConfig{URL: "gs://foo/bar"}, nil) r, err := main.NewReplicaFromConfig(&main.ReplicaConfig{URL: "gcs://foo/bar"}, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} else if client, ok := r.Client().(*gs.ReplicaClient); !ok { } else if client, ok := r.Client.(*gcs.ReplicaClient); !ok {
t.Fatal("unexpected replica type") t.Fatal("unexpected replica type")
} else if got, want := client.Bucket, "foo"; got != want { } else if got, want := client.Bucket, "foo"; got != want {
t.Fatalf("Bucket=%s, want %s", got, want) t.Fatalf("Bucket=%s, want %s", got, want)
@@ -182,17 +174,3 @@ func TestNewGSReplicaFromConfig(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
}

View File

@@ -0,0 +1,112 @@
//go:build windows
package main
import (
"context"
"io"
"log/slog"
"os"
"os/signal"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/eventlog"
)
const defaultConfigPath = `C:\Litestream\litestream.yml`
// serviceName is the Windows Service name.
const serviceName = "Litestream"
// isWindowsService returns true if currently executing within a Windows service.
func isWindowsService() (bool, error) {
return svc.IsWindowsService()
}
func runWindowsService(ctx context.Context) error {
// Attempt to install new log service. This will fail if already installed.
// We don't log the error because we don't have anywhere to log until we open the log.
_ = eventlog.InstallAsEventCreate(serviceName, eventlog.Error|eventlog.Warning|eventlog.Info)
elog, err := eventlog.Open(serviceName)
if err != nil {
return err
}
defer elog.Close()
// Set eventlog as log writer while running.
slog.SetDefault(slog.New(slog.NewTextHandler((*eventlogWriter)(elog), nil)))
defer slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
slog.Info("Litestream service starting")
if err := svc.Run(serviceName, &windowsService{ctx: ctx}); err != nil {
return errStop
}
slog.Info("Litestream service stopped")
return nil
}
// windowsService is an interface adapter for svc.Handler.
type windowsService struct {
ctx context.Context
}
func (s *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, statusCh chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) {
var err error
// Notify Windows that the service is starting up.
statusCh <- svc.Status{State: svc.StartPending}
// Instantiate replication command and load configuration.
c := NewReplicateCommand()
if c.Config, err = ReadConfigFile(DefaultConfigPath(), true); err != nil {
slog.Error("cannot load configuration", "error", err)
return true, 1
}
// Execute replication command.
if err := c.Run(); err != nil {
slog.Error("cannot replicate", "error", err)
statusCh <- svc.Status{State: svc.StopPending}
return true, 2
}
// Notify Windows that the service is now running.
statusCh <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop}
for {
select {
case req := <-r:
switch req.Cmd {
case svc.Stop:
c.Close()
statusCh <- svc.Status{State: svc.StopPending}
return false, windows.NO_ERROR
case svc.Interrogate:
statusCh <- req.CurrentStatus
default:
slog.Error("Litestream service received unexpected change request", "cmd", req.Cmd)
}
}
}
}
// Ensure implementation implements io.Writer interface.
var _ io.Writer = (*eventlogWriter)(nil)
// eventlogWriter is an adapter for using eventlog.Log as an io.Writer.
type eventlogWriter eventlog.Log
func (w *eventlogWriter) Write(p []byte) (n int, err error) {
elog := (*eventlog.Log)(w)
return 0, elog.Info(1, string(p))
}
func signalChan() <-chan os.Signal {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
return ch
}

View File

@@ -4,45 +4,36 @@ import (
"context" "context"
"flag" "flag"
"fmt" "fmt"
"io" "log/slog"
"log" "net"
"net/http"
_ "net/http/pprof"
"os" "os"
"os/exec" "os/exec"
"github.com/benbjohnson/litestream" "github.com/benbjohnson/litestream"
"github.com/benbjohnson/litestream/abs" "github.com/benbjohnson/litestream/abs"
"github.com/benbjohnson/litestream/gs" "github.com/benbjohnson/litestream/file"
"github.com/benbjohnson/litestream/http" "github.com/benbjohnson/litestream/gcs"
"github.com/benbjohnson/litestream/s3" "github.com/benbjohnson/litestream/s3"
"github.com/benbjohnson/litestream/sftp" "github.com/benbjohnson/litestream/sftp"
"github.com/mattn/go-shellwords" "github.com/mattn/go-shellwords"
"github.com/prometheus/client_golang/prometheus/promhttp"
) )
// 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
noExpandEnv bool
cmd *exec.Cmd // subcommand cmd *exec.Cmd // subcommand
execCh chan error // subcommand error channel execCh chan error // subcommand error channel
Config Config Config Config
server *litestream.Server // List of managed databases specified in the config.
httpServer *http.Server DBs []*litestream.DB
} }
// NewReplicateCommand returns a new instance of ReplicateCommand. func NewReplicateCommand() *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),
} }
} }
@@ -51,8 +42,7 @@ func NewReplicateCommand(stdin io.Reader, stdout, stderr io.Writer) *ReplicateCo
func (c *ReplicateCommand) ParseFlags(ctx context.Context, args []string) (err error) { func (c *ReplicateCommand) ParseFlags(ctx context.Context, args []string) (err error) {
fs := flag.NewFlagSet("litestream-replicate", flag.ContinueOnError) fs := flag.NewFlagSet("litestream-replicate", flag.ContinueOnError)
execFlag := fs.String("exec", "", "execute subcommand") execFlag := fs.String("exec", "", "execute subcommand")
addr := fs.String("addr", "", "HTTP bind address (host:port)") configPath, noExpandEnv := registerConfigFlag(fs)
registerConfigFlag(fs, &c.configPath, &c.noExpandEnv)
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
@@ -62,7 +52,7 @@ func (c *ReplicateCommand) ParseFlags(ctx context.Context, args []string) (err e
if fs.NArg() == 1 { if fs.NArg() == 1 {
return fmt.Errorf("must specify at least one replica URL for %s", fs.Arg(0)) return fmt.Errorf("must specify at least one replica URL for %s", fs.Arg(0))
} else if fs.NArg() > 1 { } else if fs.NArg() > 1 {
if c.configPath != "" { if *configPath != "" {
return fmt.Errorf("cannot specify a replica URL and the -config flag") return fmt.Errorf("cannot specify a replica URL and the -config flag")
} }
@@ -76,18 +66,15 @@ func (c *ReplicateCommand) ParseFlags(ctx context.Context, args []string) (err e
} }
c.Config.DBs = []*DBConfig{dbConfig} c.Config.DBs = []*DBConfig{dbConfig}
} else { } else {
if c.configPath == "" { if *configPath == "" {
c.configPath = DefaultConfigPath() *configPath = DefaultConfigPath()
} }
if c.Config, err = ReadConfigFile(c.configPath, !c.noExpandEnv); err != nil { if c.Config, err = ReadConfigFile(*configPath, !*noExpandEnv); err != nil {
return err return err
} }
} }
// Override config with flags, if specified. // Override config exec command, if specified.
if *addr != "" {
c.Config.Addr = *addr
}
if *execFlag != "" { if *execFlag != "" {
c.Config.Exec = *execFlag c.Config.Exec = *execFlag
} }
@@ -96,62 +83,66 @@ func (c *ReplicateCommand) ParseFlags(ctx context.Context, args []string) (err e
} }
// Run loads all databases specified in the configuration. // Run loads all databases specified in the configuration.
func (c *ReplicateCommand) Run(ctx context.Context) (err error) { func (c *ReplicateCommand) Run() (err error) {
// Display version information. // Display version information.
log.Printf("litestream %s", Version) slog.Info("litestream", "version", Version)
// Setup databases. // Setup databases.
if len(c.Config.DBs) == 0 { if len(c.Config.DBs) == 0 {
log.Println("no databases specified in configuration") slog.Error("no databases specified in configuration")
} }
c.server = litestream.NewServer()
if err := c.server.Open(); err != nil {
return fmt.Errorf("open server: %w", err)
}
// Add databases to the server.
for _, dbConfig := range c.Config.DBs { for _, dbConfig := range c.Config.DBs {
path, err := expand(dbConfig.Path) db, err := NewDBFromConfig(dbConfig)
if err != nil { if err != nil {
return err return err
} }
if err := c.server.Watch(path, func(path string) (*litestream.DB, error) { // Open database & attach to program.
return NewDBFromConfigWithPath(dbConfig, path) if err := db.Open(); err != nil {
}); err != nil {
return err return err
} }
c.DBs = append(c.DBs, db)
} }
// Notify user that initialization is done. // Notify user that initialization is done.
for _, db := range c.server.DBs() { for _, db := range c.DBs {
log.Printf("initialized db: %s", db.Path()) slog.Info("initialized db", "path", db.Path())
for _, r := range db.Replicas { for _, r := range db.Replicas {
switch client := r.Client().(type) { slog := slog.With("name", r.Name(), "type", r.Client.Type(), "sync-interval", r.SyncInterval)
case *litestream.FileReplicaClient: switch client := r.Client.(type) {
log.Printf("replicating to: name=%q type=%q path=%q", r.Name(), client.Type(), client.Path()) case *file.ReplicaClient:
slog.Info("replicating to", "path", client.Path())
case *s3.ReplicaClient: case *s3.ReplicaClient:
log.Printf("replicating to: name=%q type=%q bucket=%q path=%q region=%q endpoint=%q sync-interval=%s", r.Name(), client.Type(), client.Bucket, client.Path, client.Region, client.Endpoint, r.SyncInterval) slog.Info("replicating to", "bucket", client.Bucket, "path", client.Path, "region", client.Region, "endpoint", client.Endpoint)
case *gs.ReplicaClient: case *gcs.ReplicaClient:
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) slog.Info("replicating to", "bucket", client.Bucket, "path", client.Path)
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) slog.Info("replicating to", "bucket", client.Bucket, "path", client.Path, "endpoint", client.Endpoint)
case *sftp.ReplicaClient: 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) slog.Info("replicating to", "host", client.Host, "user", client.User, "path", client.Path)
default: default:
log.Printf("replicating to: name=%q type=%q", r.Name(), client.Type()) slog.Info("replicating to")
} }
} }
} }
// Serve HTTP if enabled. // Serve metrics over HTTP if enabled.
if c.Config.Addr != "" { if c.Config.Addr != "" {
c.httpServer = http.NewServer(c.server, c.Config.Addr) hostport := c.Config.Addr
if err := c.httpServer.Open(); err != nil { if host, port, _ := net.SplitHostPort(c.Config.Addr); port == "" {
return fmt.Errorf("cannot start http server: %w", err) return fmt.Errorf("must specify port for bind address: %q", c.Config.Addr)
} else if host == "" {
hostport = net.JoinHostPort("localhost", port)
} }
log.Printf("http server running at %s", c.httpServer.URL())
slog.Info("serving metrics on", "url", fmt.Sprintf("http://%s/metrics", hostport))
go func() {
http.Handle("/metrics", promhttp.Handler())
if err := http.ListenAndServe(c.Config.Addr, nil); err != nil {
slog.Error("cannot start metrics server", "error", err)
}
}()
} }
// Parse exec commands args & start subprocess. // Parse exec commands args & start subprocess.
@@ -161,14 +152,8 @@ func (c *ReplicateCommand) Run(ctx context.Context) (err error) {
return fmt.Errorf("cannot parse exec command: %w", err) return fmt.Errorf("cannot parse exec command: %w", err)
} }
// Pass first database path to child process. c.cmd = exec.Command(execArgs[0], execArgs[1:]...)
env := os.Environ() c.cmd.Env = os.Environ()
if dbs := c.server.DBs(); len(dbs) > 0 {
env = append(env, fmt.Sprintf("LITESTREAM_DB_PATH=%s", dbs[0].Path()))
}
c.cmd = exec.CommandContext(ctx, execArgs[0], execArgs[1:]...)
c.cmd.Env = env
c.cmd.Stdout = os.Stdout c.cmd.Stdout = os.Stdout
c.cmd.Stderr = os.Stderr c.cmd.Stderr = os.Stderr
if err := c.cmd.Start(); err != nil { if err := c.cmd.Start(); err != nil {
@@ -177,21 +162,17 @@ func (c *ReplicateCommand) Run(ctx context.Context) (err error) {
go func() { c.execCh <- c.cmd.Wait() }() go func() { c.execCh <- c.cmd.Wait() }()
} }
log.Printf("litestream initialization complete")
return nil return nil
} }
// Close closes the HTTP server & all open databases. // Close closes all open databases.
func (c *ReplicateCommand) Close() (err error) { func (c *ReplicateCommand) Close() (err error) {
if c.httpServer != nil { for _, db := range c.DBs {
if e := c.httpServer.Close(); e != nil && err == nil { if e := db.Close(context.Background()); e != nil {
err = e db.Logger.Error("error closing db", "error", e)
} if err == nil {
} err = e
if c.server != nil { }
if e := c.server.Close(); e != nil && err == nil {
err = e
} }
} }
return err return err
@@ -199,8 +180,8 @@ 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.Fprintf(c.stdout, ` fmt.Printf(`
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
command line arguments. command line arguments.
@@ -221,10 +202,6 @@ Arguments:
Executes a subcommand. Litestream will exit when the child Executes a subcommand. Litestream will exit when the child
process exits. Useful for simple process management. process exits. Useful for simple process management.
-addr BIND_ADDR
Starts an HTTP server that reports prometheus metrics and provides
an endpoint for live read replication. (e.g. ":9090")
-no-expand-env -no-expand-env
Disables environment variable expansion in configuration file. Disables environment variable expansion in configuration file.

View File

@@ -1,136 +0,0 @@
package main_test
import (
"context"
"database/sql"
"errors"
"fmt"
"hash/crc64"
"io"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"golang.org/x/sync/errgroup"
)
func TestReplicateCommand(t *testing.T) {
if testing.Short() {
t.Skip("long running test, skipping")
} else if runtime.GOOS != "linux" {
t.Skip("must run system tests on Linux, skipping")
}
const writeTime = 10 * time.Second
dir := t.TempDir()
configPath := filepath.Join(dir, "litestream.yml")
dbPath := filepath.Join(dir, "db")
restorePath := filepath.Join(dir, "restored")
replicaPath := filepath.Join(dir, "replica")
if err := os.WriteFile(configPath, []byte(`
dbs:
- path: `+dbPath+`
replicas:
- path: `+replicaPath+`
`), 0666); err != nil {
t.Fatal(err)
}
// Generate data into SQLite database from separate goroutine.
g, ctx := errgroup.WithContext(context.Background())
mainctx, cancel := context.WithCancel(ctx)
g.Go(func() error {
defer cancel()
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return err
}
defer db.Close()
if _, err := db.ExecContext(ctx, `PRAGMA journal_mode = WAL`); err != nil {
return fmt.Errorf("cannot enable wal: %w", err)
} else if _, err := db.ExecContext(ctx, `PRAGMA synchronous = NORMAL`); err != nil {
return fmt.Errorf("cannot enable wal: %w", err)
} else if _, err := db.ExecContext(ctx, `CREATE TABLE t (id INTEGER PRIMARY KEY)`); err != nil {
return fmt.Errorf("cannot create table: %w", err)
}
ticker := time.NewTicker(1 * time.Millisecond)
defer ticker.Stop()
timer := time.NewTimer(writeTime)
defer timer.Stop()
for i := 0; ; i++ {
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
case <-ticker.C:
if _, err := db.ExecContext(ctx, `INSERT INTO t (id) VALUES (?);`, i); err != nil {
return fmt.Errorf("cannot insert: i=%d err=%w", i, err)
}
}
}
})
// Replicate database unless the context is canceled.
g.Go(func() error {
m, _, _, _ := newMain()
return m.Run(mainctx, []string{"replicate", "-config", configPath})
})
if err := g.Wait(); err != nil {
t.Fatal(err)
}
// Checkpoint database.
mustCheckpoint(t, dbPath)
chksum0 := mustChecksum(t, dbPath)
// Restore to another path.
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)
}
// Verify contents match.
if chksum1 := mustChecksum(t, restorePath); chksum0 != chksum1 {
t.Fatal("restore mismatch")
}
}
func mustCheckpoint(tb testing.TB, path string) {
tb.Helper()
db, err := sql.Open("sqlite3", path)
if err != nil {
tb.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`); err != nil {
tb.Fatal(err)
}
}
func mustChecksum(tb testing.TB, path string) uint64 {
tb.Helper()
f, err := os.Open(path)
if err != nil {
tb.Fatal(err)
}
defer f.Close()
h := crc64.New(crc64.MakeTable(crc64.ISO))
if _, err := io.Copy(h, f); err != nil {
tb.Fatal(err)
}
return h.Sum64()
}

View File

@@ -2,12 +2,11 @@ package main
import ( import (
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"io" "log/slog"
"log"
"os" "os"
"path/filepath"
"strconv" "strconv"
"time" "time"
@@ -15,50 +14,22 @@ 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
// CLI options
configPath string // path to config file
noExpandEnv bool // if true, do not expand env variables in config
outputPath string // path to restore database to
replicaName string // optional, name of replica to restore from
generation string // optional, generation to restore
targetIndex int // optional, last WAL index to replay
timestamp time.Time // optional, restore to point-in-time (ISO 8601)
ifDBNotExists bool // if true, skips restore if output path already exists
ifReplicaExists bool // if true, skips if no backups exist
opt litestream.RestoreOptions
}
// NewRestoreCommand returns a new instance of RestoreCommand.
func NewRestoreCommand(stdin io.Reader, stdout, stderr io.Writer) *RestoreCommand {
return &RestoreCommand{
stdin: stdin,
stdout: stdout,
stderr: stderr,
targetIndex: -1,
opt: litestream.NewRestoreOptions(),
}
}
// Run executes the command. // Run executes the command.
func (c *RestoreCommand) Run(ctx context.Context, args []string) (err error) { func (c *RestoreCommand) Run(ctx context.Context, args []string) (err error) {
opt := litestream.NewRestoreOptions()
fs := flag.NewFlagSet("litestream-restore", flag.ContinueOnError) fs := flag.NewFlagSet("litestream-restore", flag.ContinueOnError)
registerConfigFlag(fs, &c.configPath, &c.noExpandEnv) configPath, noExpandEnv := registerConfigFlag(fs)
fs.StringVar(&c.outputPath, "o", "", "output path") fs.StringVar(&opt.OutputPath, "o", "", "output path")
fs.StringVar(&c.replicaName, "replica", "", "replica name") fs.StringVar(&opt.ReplicaName, "replica", "", "replica name")
fs.StringVar(&c.generation, "generation", "", "generation name") fs.StringVar(&opt.Generation, "generation", "", "generation name")
fs.Var((*indexVar)(&c.targetIndex), "index", "wal index") fs.Var((*indexVar)(&opt.Index), "index", "wal index")
timestampStr := fs.String("timestamp", "", "point-in-time restore (ISO 8601)") fs.IntVar(&opt.Parallelism, "parallelism", opt.Parallelism, "parallelism")
fs.IntVar(&c.opt.Parallelism, "parallelism", c.opt.Parallelism, "parallelism") ifDBNotExists := fs.Bool("if-db-not-exists", false, "")
fs.BoolVar(&c.ifDBNotExists, "if-db-not-exists", false, "") ifReplicaExists := fs.Bool("if-replica-exists", false, "")
fs.BoolVar(&c.ifReplicaExists, "if-replica-exists", false, "") timestampStr := fs.String("timestamp", "", "timestamp")
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
@@ -67,122 +38,82 @@ 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")
} }
pathOrURL := fs.Arg(0)
// Parse timestamp. // Parse timestamp, if specified.
if *timestampStr != "" { if *timestampStr != "" {
if c.timestamp, err = time.Parse(time.RFC3339Nano, *timestampStr); err != nil { if opt.Timestamp, err = time.Parse(time.RFC3339, *timestampStr); err != nil {
return fmt.Errorf("invalid -timestamp, expected ISO 8601: %w", err) return errors.New("invalid -timestamp, must specify in ISO 8601 format (e.g. 2000-01-01T00:00:00Z)")
} }
} }
// Ensure a generation is specified if target index is specified. // Determine replica & generation to restore from.
if c.targetIndex != -1 && !c.timestamp.IsZero() { var r *litestream.Replica
return fmt.Errorf("cannot specify both -index flag and -timestamp flag") if isURL(fs.Arg(0)) {
} else if c.targetIndex != -1 && c.generation == "" { if *configPath != "" {
return fmt.Errorf("must specify -generation flag when using -index flag") return fmt.Errorf("cannot specify a replica URL and the -config flag")
} else if !c.timestamp.IsZero() && c.generation == "" { }
return fmt.Errorf("must specify -generation flag when using -timestamp flag") if r, err = c.loadFromURL(ctx, fs.Arg(0), *ifDBNotExists, &opt); err == errSkipDBExists {
slog.Info("database already exists, skipping")
return nil
} else if err != nil {
return err
}
} else {
if *configPath == "" {
*configPath = DefaultConfigPath()
}
if r, err = c.loadFromConfig(ctx, fs.Arg(0), *configPath, !*noExpandEnv, *ifDBNotExists, &opt); err == errSkipDBExists {
slog.Info("database already exists, skipping")
return nil
} else if err != nil {
return err
}
} }
// Default to original database path if output path not specified. // Return an error if no matching targets found.
if !isURL(pathOrURL) && c.outputPath == "" { // If optional flag set, return success. Useful for automated recovery.
c.outputPath = pathOrURL if opt.Generation == "" {
} if *ifReplicaExists {
slog.Info("no matching backups found")
// Exit successfully if the output file already exists and flag is set.
if _, err := os.Stat(c.outputPath); os.IsNotExist(err) {
// 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) return fmt.Errorf("no matching backups found")
} }
// Load configuration. return r.Restore(ctx, opt)
config, err := ReadConfigFile(c.configPath, !c.noExpandEnv)
if err != nil {
return err
}
// Build replica from either a URL or config.
r, err := c.loadReplica(ctx, config, pathOrURL)
if err != nil {
return err
}
// Determine latest generation if one is not specified.
if c.generation == "" {
if c.generation, err = litestream.FindLatestGeneration(ctx, r.Client()); err == litestream.ErrNoGeneration {
// Return an error if no matching targets found.
// If optional flag set, return success. Useful for automated recovery.
if c.ifReplicaExists {
fmt.Fprintln(c.stdout, "no matching backups found, skipping")
return nil
}
return fmt.Errorf("no matching backups found")
} else if err != nil {
return fmt.Errorf("cannot determine latest generation: %w", err)
}
}
// Determine the maximum available index for the generation if one is not specified.
if !c.timestamp.IsZero() {
if c.targetIndex, err = litestream.FindIndexByTimestamp(ctx, r.Client(), c.generation, c.timestamp); err != nil {
return fmt.Errorf("cannot find index for timestamp in generation %q: %w", c.generation, err)
}
} else if c.targetIndex == -1 {
if c.targetIndex, err = litestream.FindMaxIndexByGeneration(ctx, r.Client(), c.generation); err != nil {
return fmt.Errorf("cannot determine latest index in generation %q: %w", c.generation, err)
}
}
// Find lastest snapshot that occurs before the index.
// TODO: Optionally allow -snapshot-index
if c.snapshotIndex, err = litestream.FindSnapshotForIndex(ctx, r.Client(), c.generation, c.targetIndex); err != nil {
return fmt.Errorf("cannot find snapshot index: %w", err)
}
// 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)
} }
func (c *RestoreCommand) loadReplica(ctx context.Context, config Config, arg string) (*litestream.Replica, error) { // loadFromURL creates a replica & updates the restore options from a replica URL.
if isURL(arg) { func (c *RestoreCommand) loadFromURL(ctx context.Context, replicaURL string, ifDBNotExists bool, opt *litestream.RestoreOptions) (*litestream.Replica, error) {
return c.loadReplicaFromURL(ctx, config, arg) if opt.OutputPath == "" {
return nil, fmt.Errorf("output path required")
} }
return c.loadReplicaFromConfig(ctx, config, arg)
}
// loadReplicaFromURL creates a replica & updates the restore options from a replica URL. // Exit successfully if the output file already exists.
func (c *RestoreCommand) loadReplicaFromURL(ctx context.Context, config Config, replicaURL string) (*litestream.Replica, error) { if _, err := os.Stat(opt.OutputPath); !os.IsNotExist(err) && ifDBNotExists {
if c.replicaName != "" { return nil, errSkipDBExists
return nil, fmt.Errorf("cannot specify both the replica URL and the -replica flag")
} else if c.outputPath == "" {
return nil, fmt.Errorf("output path required when using a replica URL")
} }
syncInterval := litestream.DefaultSyncInterval syncInterval := litestream.DefaultSyncInterval
return NewReplicaFromConfig(&ReplicaConfig{ r, err := NewReplicaFromConfig(&ReplicaConfig{
URL: replicaURL, URL: replicaURL,
AccessKeyID: config.AccessKeyID, SyncInterval: &syncInterval,
SecretAccessKey: config.SecretAccessKey,
SyncInterval: &syncInterval,
}, nil) }, nil)
if err != nil {
return nil, err
}
opt.Generation, _, err = r.CalcRestoreTarget(ctx, *opt)
return r, err
} }
// loadReplicaFromConfig returns replicas based on the specific config path. // loadFromConfig returns a replica & updates the restore options from a DB reference.
func (c *RestoreCommand) loadReplicaFromConfig(ctx context.Context, config Config, dbPath string) (_ *litestream.Replica, err error) { func (c *RestoreCommand) loadFromConfig(ctx context.Context, dbPath, configPath string, expandEnv, ifDBNotExists bool, opt *litestream.RestoreOptions) (*litestream.Replica, error) {
// Load configuration.
config, err := ReadConfigFile(configPath, expandEnv)
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
@@ -194,40 +125,31 @@ func (c *RestoreCommand) loadReplicaFromConfig(ctx context.Context, config Confi
db, err := NewDBFromConfig(dbConfig) db, err := NewDBFromConfig(dbConfig)
if err != nil { if err != nil {
return nil, err return nil, err
} else if len(db.Replicas) == 0 {
return nil, fmt.Errorf("database has no replicas: %s", dbPath)
} }
// Filter by replica name if specified. // Restore into original database path if not specified.
if c.replicaName != "" { if opt.OutputPath == "" {
r := db.Replica(c.replicaName) opt.OutputPath = dbPath
if r == nil {
return nil, fmt.Errorf("replica %q not found", c.replicaName)
}
return r, nil
} }
// Choose only replica if only one available and no name is specified. // Exit successfully if the output file already exists.
if len(db.Replicas) == 1 { if _, err := os.Stat(opt.OutputPath); !os.IsNotExist(err) && ifDBNotExists {
return db.Replicas[0], nil return nil, errSkipDBExists
} }
// A replica must be specified when restoring a specific generation with multiple replicas. // Determine the appropriate replica & generation to restore from,
if c.generation != "" { r, generation, err := db.CalcRestoreTarget(ctx, *opt)
return nil, fmt.Errorf("must specify -replica flag when restoring from a specific generation")
}
// Determine latest replica to restore from.
r, err := litestream.LatestReplica(ctx, db.Replicas)
if err != nil { if err != nil {
return nil, fmt.Errorf("cannot determine latest replica: %w", err) return nil, err
} }
opt.Generation = generation
return r, nil return r, nil
} }
// Usage prints the help screen to STDOUT. // Usage prints the help screen to STDOUT.
func (c *RestoreCommand) Usage() { func (c *RestoreCommand) Usage() {
fmt.Fprintf(c.stdout, ` fmt.Printf(`
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:
@@ -257,9 +179,9 @@ Arguments:
Restore up to a specific hex-encoded WAL index (inclusive). Restore up to a specific hex-encoded WAL index (inclusive).
Defaults to use the highest available index. Defaults to use the highest available index.
-timestamp DATETIME -timestamp TIMESTAMP
Restore up to a specific point-in-time. Must be ISO 8601. Restore to a specific point-in-time.
Cannot be specified with -index flag. Defaults to use the latest available backup.
-o PATH -o PATH
Output path of the restored database. Output path of the restored database.
@@ -281,6 +203,9 @@ Examples:
# Restore latest replica for database to original location. # Restore latest replica for database to original location.
$ litestream restore /path/to/db $ litestream restore /path/to/db
# Restore replica for database to a given point in time.
$ litestream restore -timestamp 2020-01-01T00:00:00Z /path/to/db
# Restore latest replica for database to new /tmp directory # Restore latest replica for database to new /tmp directory
$ litestream restore -o /tmp/db /path/to/db $ litestream restore -o /tmp/db /path/to/db
@@ -290,10 +215,9 @@ Examples:
# Restore database from specific generation on S3. # Restore database from specific generation on S3.
$ litestream restore -replica s3 -generation xxxxxxxx /path/to/db $ litestream restore -replica s3 -generation xxxxxxxx /path/to/db
# Restore database to a specific point in time.
$ litestream restore -generation xxxxxxxx -timestamp 2000-01-01T00:00:00Z /path/to/db
`[1:], `[1:],
DefaultConfigPath(), DefaultConfigPath(),
) )
} }
var errSkipDBExists = errors.New("database already exists, skipping")

View File

@@ -1,330 +0,0 @@
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/0000000000000000 to ` + filepath.Join(tempDir, "db.tmp"),
`applied wal 0000000000000000/0000000000000000 elapsed=`,
`applied wal 0000000000000000/0000000000000001 elapsed=`,
`applied wal 0000000000000000/0000000000000002 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/0000000000000001 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/0000000000000000 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/0000000000000000 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.ReadFile(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.ReadFile(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.ReadFile(t, filepath.Join(testDir, "stdout"))); got != want {
t.Fatalf("stdout=%q, want %q", got, want)
} else if got, want := stderr.String(), string(testingutil.ReadFile(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", filepath.Join(t.TempDir(), "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)
}
})
}

View File

@@ -4,9 +4,8 @@ import (
"context" "context"
"flag" "flag"
"fmt" "fmt"
"io" "log/slog"
"log" "os"
"sort"
"text/tabwriter" "text/tabwriter"
"time" "time"
@@ -14,90 +13,95 @@ 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
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) (ret error) { func (c *SnapshotsCommand) Run(ctx context.Context, args []string) (err error) {
fs := flag.NewFlagSet("litestream-snapshots", flag.ContinueOnError) fs := flag.NewFlagSet("litestream-snapshots", flag.ContinueOnError)
registerConfigFlag(fs, &c.configPath, &c.noExpandEnv) configPath, noExpandEnv := registerConfigFlag(fs)
fs.StringVar(&c.replicaName, "replica", "", "replica name") replicaName := fs.String("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 or replica URL required") return fmt.Errorf("database path required")
} else if fs.NArg() > 1 { } else if fs.NArg() > 1 {
return fmt.Errorf("too many arguments") return fmt.Errorf("too many arguments")
} }
// Load configuration. var db *litestream.DB
config, err := ReadConfigFile(c.configPath, !c.noExpandEnv) var r *litestream.Replica
if err != nil { if isURL(fs.Arg(0)) {
return err if *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 *configPath == "" {
*configPath = DefaultConfigPath()
}
// Determine list of replicas to pull snapshots from. // Load configuration.
replicas, _, err := loadReplicas(ctx, config, fs.Arg(0), c.replicaName) config, err := ReadConfigFile(*configPath, !*noExpandEnv)
if err != nil {
return err
}
// Build list of snapshot metadata with associated replica.
var infos []replicaSnapshotInfo
for _, r := range replicas {
a, err := r.Snapshots(ctx)
if err != nil { if err != nil {
log.Printf("cannot determine snapshots: %s", err) return err
ret = errExit // signal error return without printing message
continue
} }
for i := range a {
infos = append(infos, replicaSnapshotInfo{SnapshotInfo: a[i], replicaName: r.Name()}) // Lookup database from configuration file by path.
if path, err := expand(fs.Arg(0)); err != nil {
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())
}
} }
} }
// Sort snapshots by creation time from newest to oldest. // Find snapshots by db or replica.
sort.Slice(infos, func(i, j int) bool { return infos[i].CreatedAt.After(infos[j].CreatedAt) }) var replicas []*litestream.Replica
if r != nil {
replicas = []*litestream.Replica{r}
} else {
replicas = db.Replicas
}
// List all snapshots. // List all snapshots.
w := tabwriter.NewWriter(c.stdout, 0, 8, 2, ' ', 0) w := tabwriter.NewWriter(os.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 _, info := range infos { for _, r := range replicas {
fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%s\n", infos, err := r.Snapshots(ctx)
info.replicaName, if err != nil {
info.Generation, slog.Error("cannot determine snapshots", "error", err)
litestream.FormatIndex(info.Index), continue
info.Size, }
info.CreatedAt.Format(time.RFC3339), for _, info := range infos {
) fmt.Fprintf(w, "%s\t%s\t%d\t%d\t%s\n",
r.Name(),
info.Generation,
info.Index,
info.Size,
info.CreatedAt.Format(time.RFC3339),
)
}
} }
return ret return nil
} }
// Usage prints the help screen to STDOUT. // Usage prints the help screen to STDOUT.
func (c *SnapshotsCommand) Usage() { func (c *SnapshotsCommand) Usage() {
fmt.Fprintf(c.stdout, ` fmt.Printf(`
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:
@@ -133,9 +137,3 @@ Examples:
DefaultConfigPath(), DefaultConfigPath(),
) )
} }
// replicaSnapshotInfo represents snapshot metadata with associated replica name.
type replicaSnapshotInfo struct {
litestream.SnapshotInfo
replicaName string
}

View File

@@ -1,128 +0,0 @@
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.ReadFile(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.ReadFile(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.ReadFile(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)
}
})
}

View File

@@ -1,13 +0,0 @@
.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

View File

@@ -1,4 +0,0 @@
dbs:
- path: /var/lib/db
replicas:
- path: s3://bkt/db

View File

@@ -1 +0,0 @@
dbs:

View File

@@ -1 +0,0 @@
No databases found in config file.

View File

@@ -1,7 +0,0 @@
dbs:
- path: /var/lib/db
replicas:
- path: /var/lib/replica
- url: s3://mybkt/db
- path: /my/other/db

View File

@@ -1,3 +0,0 @@
path replicas
/var/lib/db file,s3
/my/other/db

View File

@@ -1,2 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db

View File

@@ -1,4 +0,0 @@
dbs:
- path: /var/lib/db
replicas:
- path: s3://bkt/db

View File

@@ -1,4 +0,0 @@
.PHONY: default
default:
TZ=UTC touch -ct 200001010000 replica/generations/0000000000000000/snapshots/0000000000000000.snapshot.lz4
TZ=UTC touch -ct 200001020000 replica/generations/0000000000000001/snapshots/0000000000000000.snapshot.lz4

View File

@@ -1,4 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- path: $LITESTREAM_TESTDIR/replica

View File

@@ -1,3 +0,0 @@
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

View File

@@ -1,9 +0,0 @@
.PHONY: default
default:
TZ=UTC touch -ct 200001030000 db
TZ=UTC touch -ct 200001010000 replica/generations/0000000000000000/snapshots/0000000000000000.snapshot.lz4
TZ=UTC touch -ct 200001020000 replica/generations/0000000000000000/snapshots/0000000000000001.snapshot.lz4
TZ=UTC touch -ct 200001010000 replica/generations/0000000000000000/wal/0000000000000000/0000000000000000.wal.lz4
TZ=UTC touch -ct 200001020000 replica/generations/0000000000000000/wal/0000000000000000/0000000000000001.wal.lz4
TZ=UTC touch -ct 200001030000 replica/generations/0000000000000000/wal/0000000000000001/0000000000000000.wal.lz4
TZ=UTC touch -ct 200001010000 replica/generations/0000000000000001/snapshots/0000000000000000.snapshot.lz4

View File

@@ -1,4 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- path: $LITESTREAM_TESTDIR/replica

View File

@@ -1,3 +0,0 @@
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

View File

@@ -1,5 +0,0 @@
.PHONY: default
default:
TZ=UTC touch -ct 200001030000 db
TZ=UTC touch -ct 200001010000 replica0/generations/0000000000000000/snapshots/0000000000000000.snapshot.lz4
TZ=UTC touch -ct 200001020000 replica1/generations/0000000000000001/snapshots/0000000000000000.snapshot.lz4

View File

@@ -1,7 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- name: replica0
path: $LITESTREAM_TESTDIR/replica0
- name: replica1
path: $LITESTREAM_TESTDIR/replica1

View File

@@ -1,2 +0,0 @@
name generation lag start end
replica1 0000000000000001 24h0m0s 2000-01-02T00:00:00Z 2000-01-02T00:00:00Z

View File

@@ -1,4 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- url: s3://bkt/db

View File

@@ -1,9 +0,0 @@
.PHONY: default
default:
TZ=UTC touch -ct 200001010000 replica/generations/0000000000000000/snapshots/0000000000000000.snapshot.lz4
TZ=UTC touch -ct 200001020000 replica/generations/0000000000000000/snapshots/0000000000000001.snapshot.lz4
TZ=UTC touch -ct 200001010000 replica/generations/0000000000000000/wal/0000000000000000/0000000000000000.wal.lz4
TZ=UTC touch -ct 200001020000 replica/generations/0000000000000000/wal/0000000000000000/0000000000000001.wal.lz4
TZ=UTC touch -ct 200001030000 replica/generations/0000000000000000/wal/0000000000000001/0000000000000000.wal.lz4
TZ=UTC touch -ct 200001020000 replica/generations/0000000000000001/snapshots/0000000000000000.snapshot.lz4

View File

@@ -1,4 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- path: $LITESTREAM_TESTDIR/replica

View File

@@ -1,3 +0,0 @@
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

View File

@@ -1,4 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- path: $LITESTREAM_TESTDIR/replica

View File

@@ -1,5 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- path: $LITESTREAM_TESTDIR/replica0
- path: $LITESTREAM_TESTDIR/replica1

View File

@@ -1,4 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- path: $LITESTREAM_TESTDIR/replica

View File

@@ -1 +0,0 @@
database already exists, skipping

View File

@@ -1,4 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- path: $LITESTREAM_TESTDIR/replica

View File

@@ -1 +0,0 @@
no matching backups found, skipping

View File

@@ -1,4 +0,0 @@
dbs:
- path: /var/lib/db
replicas:
- path: s3://bkt/db

View File

@@ -1,6 +0,0 @@
.PHONY: default
default:
TZ=UTC touch -ct 200001010000 replica0/generations/0000000000000000/snapshots/0000000000000000.snapshot.lz4
TZ=UTC touch -ct 200001020000 replica1/generations/0000000000000002/snapshots/0000000000000000.snapshot.lz4
TZ=UTC touch -ct 200001030000 replica0/generations/0000000000000001/snapshots/0000000000000000.snapshot.lz4

View File

@@ -1,7 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- name: replica0
path: $LITESTREAM_TESTDIR/replica0
- name: replica1
path: $LITESTREAM_TESTDIR/replica1

View File

@@ -1,4 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- path: $LITESTREAM_TESTDIR/replica

View File

@@ -1,4 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- path: $LITESTREAM_TESTDIR/replica

View File

@@ -1,2 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db

View File

@@ -1,4 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- path: $LITESTREAM_TESTDIR/replica

View File

@@ -1,36 +0,0 @@
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/0000000000000000 db-wal
cp db generations/0000000000000000/snapshots/0000000000000000.snapshot
lz4 -c --rm generations/0000000000000000/snapshots/0000000000000000.snapshot
Then execute:
PRAGMA wal_checkpoint(TRUNCATE);
INSERT INTO t (x) VALUES (3);
sl3 split -o generations/0000000000000000/wal/0000000000000001 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/0000000000000002 db-wal
Finally, obtain the final snapshot:
PRAGMA wal_checkpoint(TRUNCATE);
cp db 0000000000000002.db
rm db*

View File

@@ -1,4 +0,0 @@
dbs:
- path: $LITESTREAM_TESTDIR/db
replicas:
- path: $LITESTREAM_TESTDIR/replica

Some files were not shown because too many files have changed in this diff Show More