litestream 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +28 -5
- data/app/controllers/litestream/application_controller.rb +2 -0
- data/app/controllers/litestream/restorations_controller.rb +1 -1
- data/lib/litestream/commands.rb +143 -33
- data/lib/litestream/generators/litestream/templates/initializer.rb +28 -16
- data/lib/litestream/version.rb +1 -1
- data/lib/litestream.rb +63 -23
- data/lib/puma/plugin/litestream.rb +2 -14
- data/lib/tasks/litestream_tasks.rake +24 -46
- metadata +12 -34
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 006c5aaaf41fb59b8af8178c090aab72e78e8c586c83c59ddc25f25bc232ea98
|
|
4
|
+
data.tar.gz: a320d9128e3f5ad4c757c3463edebdbaecc7542c48008e5b1efa75dc53bdab1a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f75e2d4e857f186de61a3665775d2018373509a4beeb448b11f404a5fa9b55d92da06eda2a75d09304d696b32f3e6e1d73423b851e44a77e7d31fe5bb4813cdd
|
|
7
|
+
data.tar.gz: abc842f2de56524cb9370eed3433c49491dc10adfe23f544b25ecd9017c8008ecbe378efe30a216340f97e6b79c9a138f8e88c03f4695c724427c6d12b22e259
|
data/README.md
CHANGED
|
@@ -80,24 +80,27 @@ LITESTREAM_INSTALL_DIR=.bin
|
|
|
80
80
|
|
|
81
81
|
You configure the Litestream executable through the [`config/litestream.yml` file](https://litestream.io/reference/config/), which is a standard Litestream configuration file as if Litestream was running in a traditional installation.
|
|
82
82
|
|
|
83
|
-
The gem streamlines the configuration process by providing a default configuration file for you. This configuration file will backup all SQLite databases defined in your `config/database.yml` file to one replication bucket. In order to ensure that no secrets are stored in plain-text in your repository, this configuration file leverages Litestream's support for environment variables.
|
|
83
|
+
The gem streamlines the configuration process by providing a default configuration file for you. This configuration file will backup all SQLite databases defined in your `config/database.yml` file to one replication bucket. In order to ensure that no secrets are stored in plain-text in your repository, this configuration file leverages Litestream's support for environment variables. Inspect which environment variables are available by running the `bin/rails litestream:env` command.
|
|
84
|
+
|
|
85
|
+
The default configuration file looks like this if you only have one SQLite database:
|
|
84
86
|
|
|
85
87
|
```yaml
|
|
86
88
|
dbs:
|
|
87
89
|
- path: storage/production.sqlite3
|
|
88
90
|
replicas:
|
|
89
91
|
- type: s3
|
|
90
|
-
bucket: $LITESTREAM_REPLICA_BUCKET
|
|
91
92
|
path: storage/production.sqlite3
|
|
93
|
+
bucket: $LITESTREAM_REPLICA_BUCKET
|
|
92
94
|
access-key-id: $LITESTREAM_ACCESS_KEY_ID
|
|
93
95
|
secret-access-key: $LITESTREAM_SECRET_ACCESS_KEY
|
|
94
96
|
```
|
|
95
97
|
|
|
96
98
|
This is the default for Amazon S3. The full range of possible replica types (e.g. other S3-compatible object storage servers) are covered in Litestream's [replica guides](https://litestream.io/guides/#replica-guides).
|
|
97
99
|
|
|
98
|
-
The gem also provides a default initializer file at `config/initializers/litestream.rb` that allows you to configure
|
|
100
|
+
The gem also provides a default initializer file at `config/initializers/litestream.rb` that allows you to configure various variables referenced in the configuration file in Ruby. By providing a Ruby interface to these environment variables, you can use your preferred method of storing secrets. For example, the default generated file uses Rails' encrypted credentials to store your secrets.
|
|
99
101
|
|
|
100
102
|
```ruby
|
|
103
|
+
# config/initializers/litestream.rb
|
|
101
104
|
Rails.application.configure do
|
|
102
105
|
litestream_credentials = Rails.application.credentials.litestream
|
|
103
106
|
|
|
@@ -107,7 +110,21 @@ Rails.application.configure do
|
|
|
107
110
|
end
|
|
108
111
|
```
|
|
109
112
|
|
|
110
|
-
|
|
113
|
+
Outside of configuring Litestream's replication, you may also configure various other aspects of `litestream-ruby` itself.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
# config/initializers/litestream.rb
|
|
117
|
+
Rails.application.configure do
|
|
118
|
+
# ...
|
|
119
|
+
|
|
120
|
+
# Base controller used for Litestream dashboard
|
|
121
|
+
config.litestream.base_controller_class = "MyApplicationController"
|
|
122
|
+
# Set the location of the Litestream config
|
|
123
|
+
config.litestream.config_path = "config/litestream.yml"
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
However, if you need manual control over the Litestream configuration, you can edit the `config/litestream.yml` file. The full range of possible configurations are covered in Litestream's [configuration reference](https://litestream.io/reference/config/).
|
|
111
128
|
|
|
112
129
|
### Replication
|
|
113
130
|
|
|
@@ -232,6 +249,7 @@ You can verify the integrity of your backed-up databases using the gem's provide
|
|
|
232
249
|
|
|
233
250
|
```ruby
|
|
234
251
|
Litestream.verify! "storage/production.sqlite3"
|
|
252
|
+
Litestream.verify!(replication_sleep: 10) "storage/production.sqlite3"
|
|
235
253
|
```
|
|
236
254
|
|
|
237
255
|
In order to verify that the backup for that database is both restorable and fresh, the method will add a new row to that database under the `_litestream_verification` table, which it will create if needed. It will then wait `replication_sleep` seconds (defaults to 10) to give the Litestream utility time to replicate that change to whatever storage providers you have configured. After that, it will download the latest backup from that storage provider and ensure that this verification row is present in the backup. If the verification row is _not_ present, the method will raise a `Litestream::VerificationFailure` exception. This check ensures that the restored database file:
|
|
@@ -242,6 +260,9 @@ In order to verify that the backup for that database is both restorable and fres
|
|
|
242
260
|
|
|
243
261
|
After restoring the backup, the `Litestream.verify!` method will delete the restored database file. If you need the restored database file, use the `litestream:restore` rake task or `Litestream::Commands.restore` method instead.
|
|
244
262
|
|
|
263
|
+
> [!NOTE]
|
|
264
|
+
> If you configure Litestream's [`sync-interval`](https://litestream.io/reference/config/#replica-settings) to be longer than the default `replication_sleep` value of 10 seconds, you will need to adjust `replication_sleep` to a value larger than `sync-interval`; otherwise, `Litestream.verify!` may appear to fail where it actually simply didn't wait long enough for replication.
|
|
265
|
+
|
|
245
266
|
### Dashboard
|
|
246
267
|
|
|
247
268
|
The gem provides a web dashboard for monitoring the status of your Litestream replication. To mount the dashboard in your Rails application, add the following to your `config/routes.rb` file:
|
|
@@ -413,6 +434,8 @@ s3 a295b16a796689f3 1 0 2036 2024-04-17T00:01:19Z
|
|
|
413
434
|
|
|
414
435
|
In addition to the provided rake tasks, you can also run Litestream commands directly from Ruby. The gem provides a `Litestream::Commands` module that wraps the Litestream CLI commands. This is particularly useful for the introspection commands, as you can use the output in your Ruby code.
|
|
415
436
|
|
|
437
|
+
Commands raise `Litestream::Commands::CommandFailedException` when the Litestream binary exits non-zero, and the exception message includes stderr. Pass `json: true` to receive parsed JSON when using Litestream 0.5 or newer (Litestream 0.3 does not support the `-json` flag), and use `timeout:` to limit how many seconds a command may run.
|
|
438
|
+
|
|
416
439
|
The `Litestream::Commands.databases` method returns an array of hashes with the "path" and "replicas" keys for each database:
|
|
417
440
|
|
|
418
441
|
```ruby
|
|
@@ -506,7 +529,7 @@ time=YYYY-MM-DDTHH:MM:SS level=INFO msg="replicating to" name=s3 type=s3 sync-in
|
|
|
506
529
|
|
|
507
530
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
508
531
|
|
|
509
|
-
To install this gem onto your local machine, run `bundle exec rake install`.
|
|
532
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To download the Litestream binaries run `bundle exec rake download`.
|
|
510
533
|
|
|
511
534
|
For maintainers, to release a new version, run `bin/release $VERSION`, which will create a git tag for the version, push git commits and tags, and push all of the platform-specific `.gem` files to [rubygems.org](https://rubygems.org).
|
|
512
535
|
|
|
@@ -9,7 +9,7 @@ module Litestream
|
|
|
9
9
|
now = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
10
10
|
backup = File.join(dir, "#{base}-#{now}#{ext}")
|
|
11
11
|
|
|
12
|
-
Litestream::Commands.restore(database,
|
|
12
|
+
Litestream::Commands.restore(database, **{"-o" => backup})
|
|
13
13
|
|
|
14
14
|
redirect_to root_path, notice: "Restored to <code>#{backup}</code>."
|
|
15
15
|
end
|
data/lib/litestream/commands.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "open3"
|
|
1
3
|
require_relative "upstream"
|
|
2
|
-
require "logfmt"
|
|
3
4
|
|
|
4
5
|
module Litestream
|
|
5
6
|
module Commands
|
|
@@ -21,6 +22,27 @@ module Litestream
|
|
|
21
22
|
# raised when a litestream command fails
|
|
22
23
|
CommandFailedException = Class.new(StandardError)
|
|
23
24
|
|
|
25
|
+
# raised when a litestream command times out
|
|
26
|
+
CommandTimeoutException = Class.new(CommandFailedException)
|
|
27
|
+
|
|
28
|
+
module Output
|
|
29
|
+
class << self
|
|
30
|
+
def format(data)
|
|
31
|
+
return "" if data.nil? || data.empty?
|
|
32
|
+
|
|
33
|
+
headers = data.first.keys.map(&:to_s)
|
|
34
|
+
widths = headers.map.with_index { |h, i|
|
|
35
|
+
[h.length, data.map { |r| r[data.first.keys[i]].to_s.length }.max].max
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
format_str = widths.map { |w| "%-#{w}s" }.join(" ")
|
|
39
|
+
([headers] + data.map(&:values)).map { |row|
|
|
40
|
+
sprintf(format_str, *row.map(&:to_s))
|
|
41
|
+
}.join("\n")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
24
46
|
class << self
|
|
25
47
|
def platform
|
|
26
48
|
[:cpu, :os].map { |m| Gem::Platform.local.send(m) }.join("-")
|
|
@@ -30,7 +52,10 @@ module Litestream
|
|
|
30
52
|
litestream_install_dir = ENV["LITESTREAM_INSTALL_DIR"]
|
|
31
53
|
if litestream_install_dir
|
|
32
54
|
if File.directory?(litestream_install_dir)
|
|
33
|
-
|
|
55
|
+
unless @litestream_install_dir_noted
|
|
56
|
+
warn "NOTE: using LITESTREAM_INSTALL_DIR to find litestream executable: #{litestream_install_dir}"
|
|
57
|
+
@litestream_install_dir_noted = true
|
|
58
|
+
end
|
|
34
59
|
exe_path = litestream_install_dir
|
|
35
60
|
exe_file = File.expand_path(File.join(litestream_install_dir, "litestream"))
|
|
36
61
|
else
|
|
@@ -77,50 +102,62 @@ module Litestream
|
|
|
77
102
|
exe_file
|
|
78
103
|
end
|
|
79
104
|
|
|
105
|
+
# Replicate runs either as a child process or in the foreground, depending on the context.
|
|
106
|
+
# The Puma plugin starts it as a child and gets its pid back; the rake task runs it in the
|
|
107
|
+
# foreground and streams its output.
|
|
80
108
|
def replicate(async: false, **argv)
|
|
81
|
-
|
|
109
|
+
cmd = prepare("replicate", argv)
|
|
110
|
+
run_replicate(cmd, async: async)
|
|
111
|
+
rescue CommandFailedException
|
|
112
|
+
raise
|
|
113
|
+
rescue
|
|
114
|
+
raise CommandFailedException, "Failed to execute `#{cmd.join(" ")}`"
|
|
82
115
|
end
|
|
83
116
|
|
|
84
|
-
def restore(database,
|
|
117
|
+
def restore(database, **argv)
|
|
85
118
|
raise DatabaseRequiredException, "database argument is required for restore command, e.g. litestream:restore -- --database=path/to/database.sqlite" if database.nil?
|
|
86
|
-
argv.stringify_keys!
|
|
87
119
|
|
|
88
|
-
execute("restore", argv, database,
|
|
120
|
+
execute("restore", argv, database, tabled_output: false)
|
|
89
121
|
end
|
|
90
122
|
|
|
91
|
-
def databases(
|
|
92
|
-
execute("databases", argv
|
|
123
|
+
def databases(**argv)
|
|
124
|
+
execute("databases", argv)
|
|
93
125
|
end
|
|
94
126
|
|
|
95
|
-
def generations(database,
|
|
127
|
+
def generations(database, **argv)
|
|
96
128
|
raise DatabaseRequiredException, "database argument is required for generations command, e.g. litestream:generations -- --database=path/to/database.sqlite" if database.nil?
|
|
97
129
|
|
|
98
|
-
execute("generations", argv, database
|
|
130
|
+
execute("generations", argv, database)
|
|
99
131
|
end
|
|
100
132
|
|
|
101
|
-
def snapshots(database,
|
|
133
|
+
def snapshots(database, **argv)
|
|
102
134
|
raise DatabaseRequiredException, "database argument is required for snapshots command, e.g. litestream:snapshots -- --database=path/to/database.sqlite" if database.nil?
|
|
103
135
|
|
|
104
|
-
execute("snapshots", argv, database
|
|
136
|
+
execute("snapshots", argv, database)
|
|
105
137
|
end
|
|
106
138
|
|
|
107
|
-
def wal(database,
|
|
139
|
+
def wal(database, **argv)
|
|
108
140
|
raise DatabaseRequiredException, "database argument is required for wal command, e.g. litestream:wal -- --database=path/to/database.sqlite" if database.nil?
|
|
109
141
|
|
|
110
|
-
execute("wal", argv, database
|
|
142
|
+
execute("wal", argv, database)
|
|
111
143
|
end
|
|
112
144
|
|
|
113
145
|
private
|
|
114
146
|
|
|
115
|
-
def execute(command, argv = {}, database = nil,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
147
|
+
def execute(command, argv = {}, database = nil, tabled_output: true)
|
|
148
|
+
argv = argv.stringify_keys
|
|
149
|
+
timeout = argv.delete("timeout")
|
|
150
|
+
output = if argv.delete("json")
|
|
151
|
+
argv["-json"] = nil
|
|
152
|
+
:json
|
|
153
|
+
elsif tabled_output
|
|
154
|
+
:table
|
|
121
155
|
else
|
|
122
|
-
|
|
156
|
+
:raw
|
|
123
157
|
end
|
|
158
|
+
|
|
159
|
+
cmd = prepare(command, argv, database)
|
|
160
|
+
run(cmd, output: output, timeout: timeout)
|
|
124
161
|
end
|
|
125
162
|
|
|
126
163
|
def prepare(command, argv = {}, database = nil)
|
|
@@ -132,28 +169,101 @@ module Litestream
|
|
|
132
169
|
|
|
133
170
|
args = {
|
|
134
171
|
"--config" => Litestream.config_path.to_s
|
|
135
|
-
}.merge(argv.stringify_keys).to_a.flatten.compact
|
|
136
|
-
cmd = [executable, command, *args, database].compact
|
|
172
|
+
}.merge(argv.stringify_keys).to_a.flatten.compact.map(&:to_s)
|
|
173
|
+
cmd = [executable, command, *args, database].compact.map(&:to_s)
|
|
137
174
|
puts cmd.inspect if ENV["DEBUG"]
|
|
138
175
|
|
|
139
176
|
cmd
|
|
140
177
|
end
|
|
141
178
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
179
|
+
# Runs the command without a shell and returns its parsed stdout. A non-zero
|
|
180
|
+
# exit raises with stderr. With a timeout, the command runs in its own
|
|
181
|
+
# process group and is killed (TERM, then KILL) when the deadline passes.
|
|
182
|
+
def run(cmd, output:, timeout: nil)
|
|
183
|
+
stdin, stdout, stderr, wait_thread = Open3.popen3(*cmd, pgroup: true)
|
|
184
|
+
stdin.close
|
|
185
|
+
stdout_reader = Thread.new { stdout.read }
|
|
186
|
+
stderr_reader = Thread.new { stderr.read }
|
|
187
|
+
|
|
188
|
+
# The readers finish when the last process holding the pipes exits, so
|
|
189
|
+
# waiting on them covers descendants the direct child may have left behind.
|
|
190
|
+
unless wait_thread.join(timeout) && stdout_reader.join(timeout) && stderr_reader.join(timeout)
|
|
191
|
+
kill_process_group("TERM", wait_thread.pid)
|
|
192
|
+
wait_thread.join(1)
|
|
193
|
+
kill_process_group("KILL", wait_thread.pid)
|
|
194
|
+
wait_thread.join
|
|
195
|
+
[stdout_reader, stderr_reader].each(&:join)
|
|
196
|
+
raise CommandTimeoutException, "Failed to execute `#{cmd[1]}`: timed out after #{timeout} seconds"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
status = wait_thread.value
|
|
200
|
+
unless status.success?
|
|
201
|
+
raise CommandFailedException, "Failed to execute `#{cmd[1]}` (exit status #{status.exitstatus}): #{stderr_reader.value.strip[0, 500]}"
|
|
150
202
|
end
|
|
203
|
+
|
|
204
|
+
case output
|
|
205
|
+
when :json then parse_json(cmd, stdout_reader.value)
|
|
206
|
+
when :table then parse_table(stdout_reader.value)
|
|
207
|
+
else stdout_reader.value
|
|
208
|
+
end
|
|
209
|
+
ensure
|
|
210
|
+
[stdout_reader, stderr_reader].each { |reader| reader&.join }
|
|
211
|
+
[stdin, stdout, stderr].each { |io| io&.close unless io&.closed? }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# macOS answers EPERM rather than ESRCH when every process left in the
|
|
215
|
+
# group has exited but not yet been reaped.
|
|
216
|
+
def kill_process_group(signal, pid)
|
|
217
|
+
Process.kill(signal, -pid)
|
|
218
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Two opt-in restore skips (-if-db-not-exists when the output exists,
|
|
222
|
+
# -if-replica-exists with no backups) exit 0 and print one logfmt line on
|
|
223
|
+
# stdout instead of JSON. They come back as {"skipped" => true, "message" => ...}.
|
|
224
|
+
def parse_json(cmd, stdout)
|
|
225
|
+
stdout = stdout.strip
|
|
226
|
+
return if stdout.empty?
|
|
227
|
+
return JSON.parse(stdout) if stdout.start_with?("{", "[")
|
|
228
|
+
|
|
229
|
+
skipped = stdout.match(/\Atime=\S+ level=\S+ msg=(?:"([^"]*)"|(\S+))/)
|
|
230
|
+
return {"skipped" => true, "message" => skipped[1] || skipped[2]} if skipped
|
|
231
|
+
|
|
232
|
+
raise CommandFailedException, "Unexpected output from `#{cmd[1]}`: #{stdout.lines.first.to_s.strip[0, 200]}"
|
|
151
233
|
end
|
|
152
234
|
|
|
153
|
-
def
|
|
154
|
-
keys, *rows =
|
|
235
|
+
def parse_table(stdout)
|
|
236
|
+
keys, *rows = stdout.strip.split("\n").map { _1.split(/\s+/) }
|
|
237
|
+
return [] unless keys
|
|
238
|
+
|
|
155
239
|
rows.map { keys.zip(_1).to_h }
|
|
156
240
|
end
|
|
241
|
+
|
|
242
|
+
# Async replication returns the pid of the litestream process itself so a
|
|
243
|
+
# supervisor can signal and wait on it. Process.spawn is fork-and-exec in
|
|
244
|
+
# one call, so the pid it returns is the one that becomes litestream. The
|
|
245
|
+
# previous `exec(*cmd) if fork.nil?` also execs in a child but returns
|
|
246
|
+
# into the parent too, and the Puma plugin wrapped that in a fork of its
|
|
247
|
+
# own, so it recorded a pid that exited immediately while the daemon was
|
|
248
|
+
# reparented to init and outlived Puma.
|
|
249
|
+
def run_replicate(cmd, async:)
|
|
250
|
+
if async
|
|
251
|
+
Process.spawn(*cmd)
|
|
252
|
+
else
|
|
253
|
+
# When running in-process, we capture output continuously and write to stdout.
|
|
254
|
+
IO.popen(cmd, err: [:child, :out]) do |io|
|
|
255
|
+
io.each_line { |line| puts line }
|
|
256
|
+
end
|
|
257
|
+
status = $?
|
|
258
|
+
# `replicate` runs until it is signalled, so a signal is how it is
|
|
259
|
+
# meant to end. A non-zero exit is litestream failing to start or
|
|
260
|
+
# dying on its own, which is invisible today: the task exits 0 and a
|
|
261
|
+
# supervisor sees a clean stop rather than a crash to restart.
|
|
262
|
+
if status && !status.success? && !status.signaled?
|
|
263
|
+
raise CommandFailedException, "Failed to execute `replicate` (exit status #{status.exitstatus})"
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
157
267
|
end
|
|
158
268
|
end
|
|
159
269
|
end
|
|
@@ -5,29 +5,41 @@
|
|
|
5
5
|
# or some other mechanism where the values are only available at runtime.
|
|
6
6
|
|
|
7
7
|
Rails.application.configure do
|
|
8
|
-
#
|
|
8
|
+
# Configure Litestream through environment variables. Use Rails encrypted credentials for secrets.
|
|
9
9
|
# litestream_credentials = Rails.application.credentials.litestream
|
|
10
10
|
|
|
11
|
-
# Replica-specific bucket location.
|
|
12
|
-
# This will be your bucket's URL without the `https://` prefix.
|
|
11
|
+
# Replica-specific bucket location. This will be your bucket's URL without the `https://` prefix.
|
|
13
12
|
# For example, if you used DigitalOcean Spaces, your bucket URL could look like:
|
|
13
|
+
#
|
|
14
14
|
# https://myapp.fra1.digitaloceanspaces.com
|
|
15
|
+
#
|
|
15
16
|
# And so you should set your `replica_bucket` to:
|
|
17
|
+
#
|
|
16
18
|
# myapp.fra1.digitaloceanspaces.com
|
|
17
|
-
#
|
|
18
|
-
# Scaleway Object Storage, Google Cloud Storage, Linode Object Storage, and
|
|
19
|
-
# any SFTP server.
|
|
20
|
-
# In this example, we are using Rails encrypted credentials to store the URL to
|
|
21
|
-
# our storage provider bucket.
|
|
19
|
+
#
|
|
22
20
|
# config.litestream.replica_bucket = litestream_credentials&.replica_bucket
|
|
23
|
-
|
|
24
|
-
# Replica-specific authentication key.
|
|
25
|
-
# Litestream needs authentication credentials to access your storage provider bucket.
|
|
26
|
-
# In this example, we are using Rails encrypted credentials to store the access key ID.
|
|
21
|
+
#
|
|
22
|
+
# Replica-specific authentication key. Litestream needs authentication credentials to access your storage provider bucket.
|
|
27
23
|
# config.litestream.replica_key_id = litestream_credentials&.replica_key_id
|
|
28
|
-
|
|
29
|
-
# Replica-specific secret key.
|
|
30
|
-
# Litestream needs authentication credentials to access your storage provider bucket.
|
|
31
|
-
# In this example, we are using Rails encrypted credentials to store the secret access key.
|
|
24
|
+
#
|
|
25
|
+
# Replica-specific secret key. Litestream needs authentication credentials to access your storage provider bucket.
|
|
32
26
|
# config.litestream.replica_access_key = litestream_credentials&.replica_access_key
|
|
27
|
+
#
|
|
28
|
+
# Replica-specific region. Set the bucket’s region. Only used for AWS S3 & Backblaze B2.
|
|
29
|
+
# config.litestream.replica_region = "us-east-1"
|
|
30
|
+
#
|
|
31
|
+
# Replica-specific endpoint. Set the endpoint URL of the S3-compatible service. Only required for non-AWS services.
|
|
32
|
+
# config.litestream.replica_endpoint = "endpoint.your-objectstorage.com"
|
|
33
|
+
|
|
34
|
+
# Configure the default Litestream config path
|
|
35
|
+
# config.config_path = Rails.root.join("config", "litestream.yml")
|
|
36
|
+
|
|
37
|
+
# Configure the Litestream dashboard
|
|
38
|
+
#
|
|
39
|
+
# Set the default base controller class
|
|
40
|
+
# config.litestream.base_controller_class = "MyApplicationController"
|
|
41
|
+
#
|
|
42
|
+
# Set authentication credentials for Litestream dashboard
|
|
43
|
+
# config.litestream.username = litestream_credentials&.username
|
|
44
|
+
# config.litestream.password = litestream_credentials&.password
|
|
33
45
|
end
|
data/lib/litestream/version.rb
CHANGED
data/lib/litestream.rb
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "etc"
|
|
3
4
|
require "sqlite3"
|
|
5
|
+
require "time"
|
|
4
6
|
|
|
5
7
|
module Litestream
|
|
6
8
|
VerificationFailure = Class.new(StandardError)
|
|
7
9
|
|
|
10
|
+
PROCESS_STATES = {
|
|
11
|
+
"D" => "uninterruptible",
|
|
12
|
+
"I" => "idle",
|
|
13
|
+
"R" => "running",
|
|
14
|
+
"S" => "sleeping",
|
|
15
|
+
"T" => "stopped",
|
|
16
|
+
"U" => "uninterruptible",
|
|
17
|
+
"Z" => "zombie"
|
|
18
|
+
}.freeze
|
|
19
|
+
private_constant :PROCESS_STATES
|
|
20
|
+
|
|
8
21
|
class << self
|
|
9
22
|
attr_writer :configuration
|
|
10
23
|
|
|
@@ -33,8 +46,7 @@ module Litestream
|
|
|
33
46
|
end
|
|
34
47
|
end
|
|
35
48
|
|
|
36
|
-
mattr_writer :username, :password, :queue, :replica_bucket, :replica_region, :replica_endpoint, :replica_key_id, :replica_access_key, :systemctl_command,
|
|
37
|
-
:config_path
|
|
49
|
+
mattr_writer :username, :password, :queue, :replica_bucket, :replica_region, :replica_endpoint, :replica_key_id, :replica_access_key, :systemctl_command, :config_path
|
|
38
50
|
mattr_accessor :base_controller_class, default: "::ApplicationController"
|
|
39
51
|
|
|
40
52
|
class << self
|
|
@@ -164,30 +176,58 @@ module Litestream
|
|
|
164
176
|
info
|
|
165
177
|
end
|
|
166
178
|
|
|
179
|
+
# Linux has every process under /proc, even in images that ship no `ps`
|
|
180
|
+
# (Debian slim) or BusyBox's, which lacks `-x` and `lstart` (Alpine).
|
|
167
181
|
def process_info
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return unless exit_code.zero?
|
|
182
|
+
File.directory?("/proc/self") ? proc_process_info : ps_process_info
|
|
183
|
+
end
|
|
171
184
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
185
|
+
def proc_process_info(proc_dir = "/proc")
|
|
186
|
+
boot_time = File.foreach(File.join(proc_dir, "stat")).grep(/\Abtime /).first.split.last.to_i
|
|
187
|
+
|
|
188
|
+
Dir.glob(File.join(proc_dir, "[0-9]*")).each do |dir|
|
|
189
|
+
executable, command = File.read(File.join(dir, "cmdline")).split("\0")
|
|
190
|
+
next unless File.basename(executable.to_s) == "litestream" && command == "replicate"
|
|
191
|
+
|
|
192
|
+
# Fields are counted after the command name's closing paren, since the
|
|
193
|
+
# name itself may contain spaces and parens.
|
|
194
|
+
state, *fields = File.read(File.join(dir, "stat")).rpartition(")").last.split
|
|
195
|
+
start_ticks = fields[18].to_i
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
pid: File.basename(dir),
|
|
199
|
+
status: PROCESS_STATES[state[0]],
|
|
200
|
+
started: Time.at(boot_time + start_ticks.fdiv(Etc.sysconf(Etc::SC_CLK_TCK))).to_datetime
|
|
201
|
+
}
|
|
202
|
+
rescue SystemCallError
|
|
203
|
+
# the process exited while being read, or belongs to another user
|
|
204
|
+
next
|
|
189
205
|
end
|
|
190
|
-
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# macOS. The command name is matched on its own so that a shell or editor
|
|
210
|
+
# whose arguments mention "litestream replicate" is not mistaken for it.
|
|
211
|
+
def ps_process_info
|
|
212
|
+
output = `ps -axo pid=,stat=,lstart=,comm=`
|
|
213
|
+
return unless $?.success?
|
|
214
|
+
|
|
215
|
+
output.each_line do |line|
|
|
216
|
+
# lstart is always five words ("Mon Jul 1 11:10:58 2024"); comm is last
|
|
217
|
+
# because it may contain spaces.
|
|
218
|
+
pid, state, *lstart, command = line.strip.split(" ", 8)
|
|
219
|
+
next unless File.basename(command.to_s) == "litestream"
|
|
220
|
+
next unless `ps -o args= -p #{pid}`.match?(/litestream replicate(?: |$)/)
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
pid: pid,
|
|
224
|
+
status: PROCESS_STATES[state[0]],
|
|
225
|
+
started: Time.strptime(lstart.join(" "), "%a %b %d %H:%M:%S %Y").to_datetime
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
nil
|
|
229
|
+
rescue Errno::ENOENT
|
|
230
|
+
nil
|
|
191
231
|
end
|
|
192
232
|
end
|
|
193
233
|
end
|
|
@@ -2,17 +2,13 @@ require "puma/plugin"
|
|
|
2
2
|
|
|
3
3
|
# Copied from https://github.com/rails/solid_queue/blob/15408647f1780033dad223d3198761ea2e1e983e/lib/puma/plugin/solid_queue.rb
|
|
4
4
|
Puma::Plugin.create do
|
|
5
|
-
attr_reader :
|
|
5
|
+
attr_reader :litestream_pid, :log_writer
|
|
6
6
|
|
|
7
7
|
def start(launcher)
|
|
8
8
|
@log_writer = launcher.log_writer
|
|
9
|
-
@puma_pid = $$
|
|
10
9
|
|
|
11
10
|
launcher.events.on_booted do
|
|
12
|
-
@litestream_pid =
|
|
13
|
-
Thread.new { monitor_puma }
|
|
14
|
-
Litestream::Commands.replicate(async: true)
|
|
15
|
-
end
|
|
11
|
+
@litestream_pid = Litestream::Commands.replicate(async: true)
|
|
16
12
|
|
|
17
13
|
in_background do
|
|
18
14
|
monitor_litestream
|
|
@@ -33,10 +29,6 @@ Puma::Plugin.create do
|
|
|
33
29
|
rescue Errno::ECHILD, Errno::ESRCH
|
|
34
30
|
end
|
|
35
31
|
|
|
36
|
-
def monitor_puma
|
|
37
|
-
monitor(:puma_dead?, "Detected Puma has gone away, stopping Litestream...")
|
|
38
|
-
end
|
|
39
|
-
|
|
40
32
|
def monitor_litestream
|
|
41
33
|
monitor(:litestream_dead?, "Detected Litestream has gone away, stopping Puma...")
|
|
42
34
|
end
|
|
@@ -59,10 +51,6 @@ Puma::Plugin.create do
|
|
|
59
51
|
true
|
|
60
52
|
end
|
|
61
53
|
|
|
62
|
-
def puma_dead?
|
|
63
|
-
Process.ppid != puma_pid
|
|
64
|
-
end
|
|
65
|
-
|
|
66
54
|
def log(...)
|
|
67
55
|
log_writer.log(...)
|
|
68
56
|
end
|
|
@@ -12,83 +12,61 @@ namespace :litestream do
|
|
|
12
12
|
|
|
13
13
|
desc 'Monitor and continuously replicate SQLite databases defined in your config file, for example `rake litestream:replicate -- -exec "foreman start"`'
|
|
14
14
|
task replicate: :environment do
|
|
15
|
-
options =
|
|
16
|
-
if (separator_index = ARGV.index("--"))
|
|
17
|
-
ARGV.slice(separator_index + 1, ARGV.length)
|
|
18
|
-
.map { |pair| pair.split("=") }
|
|
19
|
-
.each { |opt| options[opt[0]] = opt[1] || nil }
|
|
20
|
-
end
|
|
21
|
-
options.symbolize_keys!
|
|
15
|
+
options = parse_argv_options
|
|
22
16
|
|
|
23
|
-
Litestream::Commands.replicate(
|
|
17
|
+
Litestream::Commands.replicate(**options)
|
|
24
18
|
end
|
|
25
19
|
|
|
26
20
|
desc "Restore a SQLite database from a Litestream replica, for example `rake litestream:restore -- -database=storage/production.sqlite3`"
|
|
27
21
|
task restore: :environment do
|
|
28
|
-
options =
|
|
29
|
-
|
|
30
|
-
ARGV.slice(separator_index + 1, ARGV.length)
|
|
31
|
-
.map { |pair| pair.split("=") }
|
|
32
|
-
.each { |opt| options[opt[0]] = opt[1] || nil }
|
|
33
|
-
end
|
|
34
|
-
database = options.delete("--database") || options.delete("-database")
|
|
35
|
-
options.symbolize_keys!
|
|
22
|
+
options = parse_argv_options
|
|
23
|
+
database = options.delete(:"--database") || options.delete(:"-database")
|
|
36
24
|
|
|
37
|
-
Litestream::Commands.restore(database,
|
|
25
|
+
puts Litestream::Commands.restore(database, **options)
|
|
38
26
|
end
|
|
39
27
|
|
|
40
28
|
desc "List all databases and associated replicas in the config file, for example `rake litestream:databases -- -no-expand-env`"
|
|
41
29
|
task databases: :environment do
|
|
42
|
-
options =
|
|
43
|
-
if (separator_index = ARGV.index("--"))
|
|
44
|
-
ARGV.slice(separator_index + 1, ARGV.length)
|
|
45
|
-
.map { |pair| pair.split("=") }
|
|
46
|
-
.each { |opt| options[opt[0]] = opt[1] || nil }
|
|
47
|
-
end
|
|
48
|
-
options.symbolize_keys!
|
|
30
|
+
options = parse_argv_options
|
|
49
31
|
|
|
50
|
-
Litestream::Commands.databases(
|
|
32
|
+
puts Litestream::Commands::Output.format(Litestream::Commands.databases(**options))
|
|
51
33
|
end
|
|
52
34
|
|
|
53
35
|
desc "List all generations for a database or replica, for example `rake litestream:generations -- -database=storage/production.sqlite3`"
|
|
54
36
|
task generations: :environment do
|
|
55
|
-
options =
|
|
56
|
-
|
|
57
|
-
ARGV.slice(separator_index + 1, ARGV.length)
|
|
58
|
-
.map { |pair| pair.split("=") }
|
|
59
|
-
.each { |opt| options[opt[0]] = opt[1] || nil }
|
|
60
|
-
end
|
|
61
|
-
database = options.delete("--database") || options.delete("-database")
|
|
62
|
-
options.symbolize_keys!
|
|
37
|
+
options = parse_argv_options
|
|
38
|
+
database = options.delete(:"--database") || options.delete(:"-database")
|
|
63
39
|
|
|
64
|
-
Litestream::Commands.generations(database,
|
|
40
|
+
puts Litestream::Commands::Output.format(Litestream::Commands.generations(database, **options))
|
|
65
41
|
end
|
|
66
42
|
|
|
67
43
|
desc "List all snapshots for a database or replica, for example `rake litestream:snapshots -- -database=storage/production.sqlite3`"
|
|
68
44
|
task snapshots: :environment do
|
|
69
|
-
options =
|
|
70
|
-
|
|
71
|
-
ARGV.slice(separator_index + 1, ARGV.length)
|
|
72
|
-
.map { |pair| pair.split("=") }
|
|
73
|
-
.each { |opt| options[opt[0]] = opt[1] || nil }
|
|
74
|
-
end
|
|
75
|
-
database = options.delete("--database") || options.delete("-database")
|
|
76
|
-
options.symbolize_keys!
|
|
45
|
+
options = parse_argv_options
|
|
46
|
+
database = options.delete(:"--database") || options.delete(:"-database")
|
|
77
47
|
|
|
78
|
-
Litestream::Commands.snapshots(database,
|
|
48
|
+
puts Litestream::Commands::Output.format(Litestream::Commands.snapshots(database, **options))
|
|
79
49
|
end
|
|
80
50
|
|
|
81
51
|
desc "List all wal files for a database or replica, for example `rake litestream:wal -- -database=storage/production.sqlite3`"
|
|
82
52
|
task wal: :environment do
|
|
53
|
+
options = parse_argv_options
|
|
54
|
+
database = options.delete(:"--database") || options.delete(:"-database")
|
|
55
|
+
|
|
56
|
+
puts Litestream::Commands::Output.format(
|
|
57
|
+
Litestream::Commands.wal(database, **options)
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def parse_argv_options
|
|
83
64
|
options = {}
|
|
84
65
|
if (separator_index = ARGV.index("--"))
|
|
85
66
|
ARGV.slice(separator_index + 1, ARGV.length)
|
|
86
67
|
.map { |pair| pair.split("=") }
|
|
87
68
|
.each { |opt| options[opt[0]] = opt[1] || nil }
|
|
88
69
|
end
|
|
89
|
-
database = options.delete("--database") || options.delete("-database")
|
|
90
70
|
options.symbolize_keys!
|
|
91
|
-
|
|
92
|
-
Litestream::Commands.wal(database, async: true, **options)
|
|
93
71
|
end
|
|
94
72
|
end
|
metadata
CHANGED
|
@@ -1,28 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: litestream
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.15.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stephen Margheim
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
-
- !ruby/object:Gem::Dependency
|
|
13
|
-
name: logfmt
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - ">="
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.0.10
|
|
19
|
-
type: :runtime
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - ">="
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.0.10
|
|
26
12
|
- !ruby/object:Gem::Dependency
|
|
27
13
|
name: sqlite3
|
|
28
14
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -37,6 +23,7 @@ dependencies:
|
|
|
37
23
|
- - ">="
|
|
38
24
|
- !ruby/object:Gem::Version
|
|
39
25
|
version: '0'
|
|
26
|
+
force_ruby_platform: false
|
|
40
27
|
- !ruby/object:Gem::Dependency
|
|
41
28
|
name: actionpack
|
|
42
29
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -51,6 +38,7 @@ dependencies:
|
|
|
51
38
|
- - ">="
|
|
52
39
|
- !ruby/object:Gem::Version
|
|
53
40
|
version: '7.0'
|
|
41
|
+
force_ruby_platform: false
|
|
54
42
|
- !ruby/object:Gem::Dependency
|
|
55
43
|
name: actionview
|
|
56
44
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -65,8 +53,9 @@ dependencies:
|
|
|
65
53
|
- - ">="
|
|
66
54
|
- !ruby/object:Gem::Version
|
|
67
55
|
version: '7.0'
|
|
56
|
+
force_ruby_platform: false
|
|
68
57
|
- !ruby/object:Gem::Dependency
|
|
69
|
-
name:
|
|
58
|
+
name: activejob
|
|
70
59
|
requirement: !ruby/object:Gem::Requirement
|
|
71
60
|
requirements:
|
|
72
61
|
- - ">="
|
|
@@ -79,8 +68,9 @@ dependencies:
|
|
|
79
68
|
- - ">="
|
|
80
69
|
- !ruby/object:Gem::Version
|
|
81
70
|
version: '7.0'
|
|
71
|
+
force_ruby_platform: false
|
|
82
72
|
- !ruby/object:Gem::Dependency
|
|
83
|
-
name:
|
|
73
|
+
name: activesupport
|
|
84
74
|
requirement: !ruby/object:Gem::Requirement
|
|
85
75
|
requirements:
|
|
86
76
|
- - ">="
|
|
@@ -93,6 +83,7 @@ dependencies:
|
|
|
93
83
|
- - ">="
|
|
94
84
|
- !ruby/object:Gem::Version
|
|
95
85
|
version: '7.0'
|
|
86
|
+
force_ruby_platform: false
|
|
96
87
|
- !ruby/object:Gem::Dependency
|
|
97
88
|
name: railties
|
|
98
89
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -107,20 +98,7 @@ dependencies:
|
|
|
107
98
|
- - ">="
|
|
108
99
|
- !ruby/object:Gem::Version
|
|
109
100
|
version: '7.0'
|
|
110
|
-
|
|
111
|
-
name: rubyzip
|
|
112
|
-
requirement: !ruby/object:Gem::Requirement
|
|
113
|
-
requirements:
|
|
114
|
-
- - ">="
|
|
115
|
-
- !ruby/object:Gem::Version
|
|
116
|
-
version: '0'
|
|
117
|
-
type: :development
|
|
118
|
-
prerelease: false
|
|
119
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
-
requirements:
|
|
121
|
-
- - ">="
|
|
122
|
-
- !ruby/object:Gem::Version
|
|
123
|
-
version: '0'
|
|
101
|
+
force_ruby_platform: false
|
|
124
102
|
- !ruby/object:Gem::Dependency
|
|
125
103
|
name: rails
|
|
126
104
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -136,7 +114,7 @@ dependencies:
|
|
|
136
114
|
- !ruby/object:Gem::Version
|
|
137
115
|
version: '0'
|
|
138
116
|
- !ruby/object:Gem::Dependency
|
|
139
|
-
name:
|
|
117
|
+
name: rubyzip
|
|
140
118
|
requirement: !ruby/object:Gem::Requirement
|
|
141
119
|
requirements:
|
|
142
120
|
- - ">="
|
|
@@ -200,7 +178,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
200
178
|
- !ruby/object:Gem::Version
|
|
201
179
|
version: '0'
|
|
202
180
|
requirements: []
|
|
203
|
-
rubygems_version: 3.6.
|
|
181
|
+
rubygems_version: 3.6.7
|
|
204
182
|
specification_version: 4
|
|
205
183
|
summary: Integrate Litestream with the RubyGems infrastructure.
|
|
206
184
|
test_files: []
|