hotswap 0.1.5 → 0.2.1
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/lib/hotswap/cli.rb +23 -102
- data/lib/hotswap/database.rb +111 -0
- data/lib/hotswap/railtie.rb +15 -7
- data/lib/hotswap/socket_server.rb +10 -0
- data/lib/hotswap/version.rb +1 -1
- data/lib/hotswap.rb +35 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2313b39ed36d4fd0d236192de9c34f9ac7546984f8a076d474f035abddef947
|
|
4
|
+
data.tar.gz: a7458d108f6d2051a08c89aa3a8357c3cb72ccb892c7669b69d3deeaf1f5abe3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 53ea80ca58d9f342fc04bc51385674ee5b5f8d3dd64a63a18869b5d4fdc81f8ac74c80a6901991d8709d65aeb565d19a5ca3cbeaa19e1a0408ab4ca9ce0dd154
|
|
7
|
+
data.tar.gz: dfbb24c4a439443db7e43d19f65b9964c86505eda6479fa6ec4ca75cba93b25cc619a6d845aa6ca16a822bc187c2f53f8cabdbf66d4ffae98dc9e93682324fbb
|
data/lib/hotswap/cli.rb
CHANGED
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
require "thor"
|
|
2
|
-
require "tempfile"
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "sqlite3"
|
|
5
2
|
|
|
6
3
|
module Hotswap
|
|
7
4
|
class CLI < Thor
|
|
@@ -20,8 +17,6 @@ module Hotswap
|
|
|
20
17
|
def stderr = @_stderr
|
|
21
18
|
end
|
|
22
19
|
|
|
23
|
-
# Thread-safe IO: each connection gets its own IO via thread-local storage
|
|
24
|
-
# instead of swapping global $stdin/$stdout/$stderr.
|
|
25
20
|
def self.run(args, stdin: $stdin, stdout: $stdout, stderr: $stderr)
|
|
26
21
|
Thread.current[:hotswap_stdin] = stdin
|
|
27
22
|
Thread.current[:hotswap_stdout] = stdout
|
|
@@ -37,52 +32,42 @@ module Hotswap
|
|
|
37
32
|
Thread.current[:hotswap_stderr] = nil
|
|
38
33
|
end
|
|
39
34
|
|
|
40
|
-
desc "cp SRC DST", "Copy a database to/from the running server
|
|
35
|
+
desc "cp SRC DST", "Copy a database to/from the running server"
|
|
41
36
|
long_desc <<~DESC
|
|
42
37
|
Copy a SQLite database to or from the running server.
|
|
43
38
|
|
|
44
|
-
|
|
45
|
-
Use '-' for stdin/stdout.
|
|
39
|
+
If SRC or DST matches a managed database path, hotswap treats it as
|
|
40
|
+
the live database. Use '-' for stdin/stdout.
|
|
46
41
|
|
|
47
42
|
Examples:
|
|
48
43
|
|
|
49
|
-
hotswap cp ./new.sqlite3
|
|
50
|
-
hotswap cp
|
|
51
|
-
hotswap cp -
|
|
52
|
-
hotswap cp
|
|
44
|
+
hotswap cp ./new.sqlite3 db/production.sqlite3 # push
|
|
45
|
+
hotswap cp db/production.sqlite3 ./backup.sqlite3 # pull
|
|
46
|
+
hotswap cp - db/production.sqlite3 # push from stdin
|
|
47
|
+
hotswap cp db/production.sqlite3 - # pull to stdout
|
|
53
48
|
DESC
|
|
54
49
|
def cp(src, dst)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
io_err.write("ERROR: database_path not configured\n")
|
|
58
|
-
return
|
|
59
|
-
end
|
|
50
|
+
src_db = resolve_database(src)
|
|
51
|
+
dst_db = resolve_database(dst)
|
|
60
52
|
|
|
61
|
-
if
|
|
62
|
-
io_err.write("ERROR: source and destination can't both be
|
|
53
|
+
if src_db && dst_db
|
|
54
|
+
io_err.write("ERROR: source and destination can't both be managed databases\n")
|
|
63
55
|
return
|
|
64
56
|
end
|
|
65
57
|
|
|
66
|
-
if
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
58
|
+
if dst_db
|
|
59
|
+
source = (src == "-") ? io_in : src
|
|
60
|
+
dst_db.push(source, stdout: io_out, stderr: io_err)
|
|
61
|
+
elsif src_db
|
|
62
|
+
destination = (dst == "-") ? io_out : dst
|
|
63
|
+
src_db.pull(destination, stderr: io_err)
|
|
70
64
|
else
|
|
71
|
-
|
|
65
|
+
paths = Hotswap.databases.map(&:path).join(", ")
|
|
66
|
+
io_err.write("ERROR: neither path matches a managed database (#{paths})\n")
|
|
72
67
|
end
|
|
73
68
|
end
|
|
74
69
|
|
|
75
|
-
|
|
76
|
-
def push
|
|
77
|
-
cp("-", "database")
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
desc "pull", "Snapshot the running database to stdout"
|
|
81
|
-
def pull
|
|
82
|
-
cp("database", "-")
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
desc "version", "Print the hotswap version"
|
|
70
|
+
desc "version", "Print the hotswap version"
|
|
86
71
|
def version
|
|
87
72
|
io_out.write("hotswap #{Hotswap::VERSION}\n")
|
|
88
73
|
end
|
|
@@ -93,73 +78,9 @@ module Hotswap
|
|
|
93
78
|
def io_out = Thread.current[:hotswap_stdout] || $stdout
|
|
94
79
|
def io_err = Thread.current[:hotswap_stderr] || $stderr
|
|
95
80
|
|
|
96
|
-
def
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
dir = File.dirname(db_path)
|
|
100
|
-
temp = Tempfile.new(["hotswap", ".sqlite3"], dir)
|
|
101
|
-
begin
|
|
102
|
-
IO.copy_stream(input, temp)
|
|
103
|
-
temp.close
|
|
104
|
-
|
|
105
|
-
db = SQLite3::Database.new(temp.path)
|
|
106
|
-
result = db.execute("PRAGMA integrity_check")
|
|
107
|
-
db.close
|
|
108
|
-
unless result == [["ok"]]
|
|
109
|
-
io_err.write("ERROR: integrity check failed\n")
|
|
110
|
-
return
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
io_err.write("Swapping database...\n")
|
|
114
|
-
|
|
115
|
-
Middleware::SWAP_LOCK.synchronize do
|
|
116
|
-
if defined?(ActiveRecord::Base)
|
|
117
|
-
ActiveRecord::Base.connection_handler.clear_all_connections!
|
|
118
|
-
end
|
|
119
|
-
File.rename(temp.path, db_path)
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
if defined?(ActiveRecord::Base)
|
|
123
|
-
ActiveRecord::Base.establish_connection
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
io_out.write("OK\n")
|
|
127
|
-
rescue => e
|
|
128
|
-
io_err.write("ERROR: #{e.message}\n")
|
|
129
|
-
ensure
|
|
130
|
-
input.close if input.is_a?(File)
|
|
131
|
-
temp.unlink if temp && File.exist?(temp.path)
|
|
132
|
-
end
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def pull_database(dst, db_path)
|
|
136
|
-
unless File.exist?(db_path)
|
|
137
|
-
io_err.write("ERROR: database file not found\n")
|
|
138
|
-
return
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
dir = File.dirname(db_path)
|
|
142
|
-
temp = Tempfile.new(["hotswap-pull", ".sqlite3"], dir)
|
|
143
|
-
begin
|
|
144
|
-
src_db = SQLite3::Database.new(db_path)
|
|
145
|
-
dst_db = SQLite3::Database.new(temp.path)
|
|
146
|
-
b = SQLite3::Backup.new(dst_db, "main", src_db, "main")
|
|
147
|
-
b.step(-1)
|
|
148
|
-
b.finish
|
|
149
|
-
dst_db.close
|
|
150
|
-
src_db.close
|
|
151
|
-
|
|
152
|
-
if dst == "-"
|
|
153
|
-
File.open(temp.path, "rb") { |f| IO.copy_stream(f, io_out) }
|
|
154
|
-
else
|
|
155
|
-
FileUtils.cp(temp.path, dst)
|
|
156
|
-
io_err.write("OK\n")
|
|
157
|
-
end
|
|
158
|
-
rescue => e
|
|
159
|
-
io_err.write("ERROR: #{e.message}\n")
|
|
160
|
-
ensure
|
|
161
|
-
temp.unlink if temp && File.exist?(temp.path)
|
|
162
|
-
end
|
|
81
|
+
def resolve_database(path)
|
|
82
|
+
return nil if path == "-"
|
|
83
|
+
Hotswap.find_database(path) || (path == "database" && Hotswap.databases.first)
|
|
163
84
|
end
|
|
164
85
|
end
|
|
165
86
|
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
require "tempfile"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
|
|
5
|
+
module Hotswap
|
|
6
|
+
class Database
|
|
7
|
+
attr_reader :path
|
|
8
|
+
|
|
9
|
+
def initialize(path)
|
|
10
|
+
@path = File.expand_path(path)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Push a new database from an IO stream or file path
|
|
14
|
+
def push(source, stdout: $stdout, stderr: $stderr)
|
|
15
|
+
source_label = source.is_a?(String) ? source : "stdin"
|
|
16
|
+
logger.info "push started: #{source_label} → #{@path}"
|
|
17
|
+
|
|
18
|
+
input = source.is_a?(String) ? File.open(source, "rb") : source
|
|
19
|
+
|
|
20
|
+
dir = File.dirname(@path)
|
|
21
|
+
temp = Tempfile.new(["hotswap", ".sqlite3"], dir)
|
|
22
|
+
begin
|
|
23
|
+
IO.copy_stream(input, temp)
|
|
24
|
+
temp.close
|
|
25
|
+
logger.info "received #{File.size(temp.path)} bytes, running integrity check"
|
|
26
|
+
|
|
27
|
+
db = SQLite3::Database.new(temp.path)
|
|
28
|
+
result = db.execute("PRAGMA integrity_check")
|
|
29
|
+
db.close
|
|
30
|
+
unless result == [["ok"]]
|
|
31
|
+
logger.error "integrity check failed for #{source_label}"
|
|
32
|
+
stderr.write("ERROR: integrity check failed\n")
|
|
33
|
+
return false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
logger.info "integrity check passed, acquiring swap lock"
|
|
37
|
+
stderr.write("Swapping database...\n")
|
|
38
|
+
|
|
39
|
+
Middleware::SWAP_LOCK.synchronize do
|
|
40
|
+
if defined?(ActiveRecord::Base)
|
|
41
|
+
ActiveRecord::Base.connection_handler.clear_all_connections!
|
|
42
|
+
logger.info "disconnected ActiveRecord"
|
|
43
|
+
end
|
|
44
|
+
File.rename(temp.path, @path)
|
|
45
|
+
logger.info "renamed #{temp.path} → #{@path}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
if defined?(ActiveRecord::Base)
|
|
49
|
+
ActiveRecord::Base.establish_connection
|
|
50
|
+
logger.info "reconnected ActiveRecord"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
logger.info "push complete: #{@path}"
|
|
54
|
+
stdout.write("OK\n")
|
|
55
|
+
true
|
|
56
|
+
rescue => e
|
|
57
|
+
logger.error "push failed: #{e.message}"
|
|
58
|
+
stderr.write("ERROR: #{e.message}\n")
|
|
59
|
+
false
|
|
60
|
+
ensure
|
|
61
|
+
input.close if source.is_a?(String) && input.is_a?(File)
|
|
62
|
+
temp.unlink if temp && File.exist?(temp.path)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Pull the database to an IO stream or file path
|
|
67
|
+
def pull(destination, stderr: $stderr)
|
|
68
|
+
dest_label = destination.is_a?(String) ? destination : "stdout"
|
|
69
|
+
logger.info "pull started: #{@path} → #{dest_label}"
|
|
70
|
+
|
|
71
|
+
unless File.exist?(@path)
|
|
72
|
+
logger.error "database file not found at #{@path}"
|
|
73
|
+
stderr.write("ERROR: database file not found at #{@path}\n")
|
|
74
|
+
return false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
dir = File.dirname(@path)
|
|
78
|
+
temp = Tempfile.new(["hotswap-pull", ".sqlite3"], dir)
|
|
79
|
+
begin
|
|
80
|
+
src_db = SQLite3::Database.new(@path)
|
|
81
|
+
dst_db = SQLite3::Database.new(temp.path)
|
|
82
|
+
b = SQLite3::Backup.new(dst_db, "main", src_db, "main")
|
|
83
|
+
b.step(-1)
|
|
84
|
+
b.finish
|
|
85
|
+
dst_db.close
|
|
86
|
+
src_db.close
|
|
87
|
+
logger.info "backup complete: #{File.size(temp.path)} bytes"
|
|
88
|
+
|
|
89
|
+
if destination.is_a?(String)
|
|
90
|
+
FileUtils.cp(temp.path, destination)
|
|
91
|
+
logger.info "pull complete: #{@path} → #{destination}"
|
|
92
|
+
stderr.write("OK\n")
|
|
93
|
+
else
|
|
94
|
+
File.open(temp.path, "rb") { |f| IO.copy_stream(f, destination) }
|
|
95
|
+
logger.info "pull complete: #{@path} → stdout"
|
|
96
|
+
end
|
|
97
|
+
true
|
|
98
|
+
rescue => e
|
|
99
|
+
logger.error "pull failed: #{e.message}"
|
|
100
|
+
stderr.write("ERROR: #{e.message}\n")
|
|
101
|
+
false
|
|
102
|
+
ensure
|
|
103
|
+
temp.unlink if temp && File.exist?(temp.path)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def logger = Hotswap.logger
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/hotswap/railtie.rb
CHANGED
|
@@ -2,18 +2,26 @@ module Hotswap
|
|
|
2
2
|
class Railtie < Rails::Railtie
|
|
3
3
|
config.hotswap = ActiveSupport::OrderedOptions.new
|
|
4
4
|
|
|
5
|
+
initializer "hotswap.logger" do
|
|
6
|
+
Hotswap.logger = Rails.logger
|
|
7
|
+
end
|
|
8
|
+
|
|
5
9
|
initializer "hotswap.configure" do |app|
|
|
6
|
-
#
|
|
7
|
-
if app.config.hotswap.
|
|
8
|
-
|
|
10
|
+
# Discover all SQLite databases from Rails config
|
|
11
|
+
if app.config.hotswap.database_paths
|
|
12
|
+
Array(app.config.hotswap.database_paths).each { |p| Hotswap.register(p) }
|
|
13
|
+
elsif app.config.hotswap.database_path
|
|
14
|
+
Hotswap.register(app.config.hotswap.database_path)
|
|
9
15
|
else
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
db_configs = ActiveRecord::Base.configurations.configs_for(env_name: Rails.env)
|
|
17
|
+
db_configs.each do |db_config|
|
|
18
|
+
if db_config.adapter.include?("sqlite")
|
|
19
|
+
Hotswap.register(db_config.database)
|
|
20
|
+
end
|
|
13
21
|
end
|
|
14
22
|
end
|
|
15
23
|
|
|
16
|
-
#
|
|
24
|
+
# Socket paths
|
|
17
25
|
if app.config.hotswap.socket_path
|
|
18
26
|
Hotswap.socket_path = app.config.hotswap.socket_path
|
|
19
27
|
else
|
|
@@ -27,10 +27,15 @@ module Hotswap
|
|
|
27
27
|
|
|
28
28
|
@thread = Thread.new { accept_loop }
|
|
29
29
|
@thread.report_on_exception = false
|
|
30
|
+
|
|
31
|
+
logger.info "listening on #{@socket_path}"
|
|
32
|
+
logger.info "stderr socket on #{@stderr_socket_path}"
|
|
33
|
+
logger.info "managing #{Hotswap.databases.size} database(s): #{Hotswap.databases.map(&:path).join(', ')}"
|
|
30
34
|
self
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
def stop
|
|
38
|
+
logger.info "shutting down"
|
|
34
39
|
@server&.close
|
|
35
40
|
@stderr_server&.close
|
|
36
41
|
@thread&.kill
|
|
@@ -45,6 +50,8 @@ module Hotswap
|
|
|
45
50
|
|
|
46
51
|
private
|
|
47
52
|
|
|
53
|
+
def logger = Hotswap.logger
|
|
54
|
+
|
|
48
55
|
def accept_loop
|
|
49
56
|
ios = [@server, @stderr_server]
|
|
50
57
|
loop do
|
|
@@ -75,6 +82,7 @@ module Hotswap
|
|
|
75
82
|
|
|
76
83
|
def handle_connection(socket)
|
|
77
84
|
unless IO.select([socket], nil, nil, CONNECTION_TIMEOUT)
|
|
85
|
+
logger.warn "connection timed out"
|
|
78
86
|
socket.write("ERROR: connection timeout\n") rescue nil
|
|
79
87
|
return
|
|
80
88
|
end
|
|
@@ -83,6 +91,7 @@ module Hotswap
|
|
|
83
91
|
return unless line
|
|
84
92
|
|
|
85
93
|
parts = Shellwords.split(line.strip)
|
|
94
|
+
logger.info "command: #{parts.join(' ')}" unless parts.empty?
|
|
86
95
|
|
|
87
96
|
# Grab the stderr socket if one is waiting
|
|
88
97
|
stderr_io = take_stderr_client
|
|
@@ -94,6 +103,7 @@ module Hotswap
|
|
|
94
103
|
stderr: stderr_io || $stderr
|
|
95
104
|
)
|
|
96
105
|
rescue => e
|
|
106
|
+
logger.error "connection error: #{e.message}"
|
|
97
107
|
socket.write("ERROR: #{e.message}\n") rescue nil
|
|
98
108
|
ensure
|
|
99
109
|
stderr_io&.close rescue nil
|
data/lib/hotswap/version.rb
CHANGED
data/lib/hotswap.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
require "logger"
|
|
1
2
|
require_relative "hotswap/version"
|
|
2
3
|
require_relative "hotswap/middleware"
|
|
4
|
+
require_relative "hotswap/database"
|
|
3
5
|
require_relative "hotswap/cli"
|
|
4
6
|
require_relative "hotswap/socket_server"
|
|
5
7
|
require_relative "hotswap/railtie" if defined?(Rails::Railtie)
|
|
@@ -8,11 +10,43 @@ module Hotswap
|
|
|
8
10
|
class Error < StandardError; end
|
|
9
11
|
|
|
10
12
|
class << self
|
|
11
|
-
attr_accessor :
|
|
13
|
+
attr_accessor :socket_path, :stderr_socket_path
|
|
14
|
+
attr_writer :logger
|
|
15
|
+
|
|
16
|
+
def logger
|
|
17
|
+
@logger ||= Logger.new($stdout, progname: "hotswap")
|
|
18
|
+
end
|
|
12
19
|
|
|
13
20
|
def configure
|
|
14
21
|
yield self
|
|
15
22
|
end
|
|
23
|
+
|
|
24
|
+
# Registry of managed databases
|
|
25
|
+
def databases
|
|
26
|
+
@databases ||= []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def register(path)
|
|
30
|
+
db = Database.new(path)
|
|
31
|
+
databases << db unless databases.any? { |d| d.path == db.path }
|
|
32
|
+
db
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def find_database(path)
|
|
36
|
+
return nil if path == "-"
|
|
37
|
+
resolved = File.expand_path(path)
|
|
38
|
+
databases.find { |db| db.path == resolved }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Backward compat: single database_path getter/setter
|
|
42
|
+
def database_path=(path)
|
|
43
|
+
@databases = []
|
|
44
|
+
register(path) if path
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def database_path
|
|
48
|
+
databases.first&.path
|
|
49
|
+
end
|
|
16
50
|
end
|
|
17
51
|
|
|
18
52
|
self.socket_path = "tmp/sockets/hotswap.sock"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hotswap
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brad Gessler
|
|
@@ -160,6 +160,7 @@ files:
|
|
|
160
160
|
- exe/hotswap
|
|
161
161
|
- lib/hotswap.rb
|
|
162
162
|
- lib/hotswap/cli.rb
|
|
163
|
+
- lib/hotswap/database.rb
|
|
163
164
|
- lib/hotswap/middleware.rb
|
|
164
165
|
- lib/hotswap/railtie.rb
|
|
165
166
|
- lib/hotswap/socket_server.rb
|