siding 0.0.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 +7 -0
- data/CLAUDE.md +248 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +182 -0
- data/Rakefile +51 -0
- data/exe/siding +5 -0
- data/lib/siding/boot_component.rb +18 -0
- data/lib/siding/cli.rb +459 -0
- data/lib/siding/client.rb +417 -0
- data/lib/siding/error.rb +5 -0
- data/lib/siding/invocation.rb +35 -0
- data/lib/siding/life_cycle.rb +114 -0
- data/lib/siding/load_manifest.rb +475 -0
- data/lib/siding/logger.rb +89 -0
- data/lib/siding/platform.rb +37 -0
- data/lib/siding/project_key.rb +64 -0
- data/lib/siding/protocol.rb +154 -0
- data/lib/siding/restarter.rb +205 -0
- data/lib/siding/runtime.rb +165 -0
- data/lib/siding/server.rb +398 -0
- data/lib/siding/staleness.rb +191 -0
- data/lib/siding/version.rb +5 -0
- data/lib/siding/watch.rb +115 -0
- data/lib/siding/worker.rb +266 -0
- data/lib/siding.rb +27 -0
- metadata +97 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
require_relative "boot_component"
|
|
7
|
+
|
|
8
|
+
module Siding
|
|
9
|
+
class LoadManifest
|
|
10
|
+
RELOADABLE = :reloadable
|
|
11
|
+
REBOOT = :reboot
|
|
12
|
+
|
|
13
|
+
FileEntry = Struct.new(:path, :size, :mtime, :scope)
|
|
14
|
+
DirectoryEntry = Struct.new(:path, :entry_digest, :recursive, :scope)
|
|
15
|
+
EnvEntry = Struct.new(:key, :value_digest)
|
|
16
|
+
|
|
17
|
+
EXCLUDED_SUBDIRECTORIES = %w[tmp log storage node_modules .git vendor/bundle].freeze
|
|
18
|
+
|
|
19
|
+
INITIALIZER_DIRECTORY = File.join("config", "initializers")
|
|
20
|
+
|
|
21
|
+
attr_reader :app_root, :file_entries, :directory_entries, :env_entries, :bundle_token, :bundle_files, :bundle_path, :captured_at
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def around_boot(app_root:)
|
|
25
|
+
env = EnvProbe.start
|
|
26
|
+
loads = LoadProbe.start
|
|
27
|
+
before = $LOADED_FEATURES.dup
|
|
28
|
+
yield
|
|
29
|
+
|
|
30
|
+
build(app_root:, loaded: ($LOADED_FEATURES - before) + loads.finish, env_reads: env.finish)
|
|
31
|
+
ensure
|
|
32
|
+
env&.stop
|
|
33
|
+
loads&.stop
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build(app_root:, loaded:, env_reads: {})
|
|
37
|
+
new(app_root:, loaded:, env_reads:)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def label_for(bundle_token:, files:, directories:, envs:)
|
|
41
|
+
digest = Digest::SHA256.new
|
|
42
|
+
digest << "bundle\0#{bundle_token}\n"
|
|
43
|
+
files.each { |path, stamp| digest << "file\0#{path}\0#{stamp}\n" }
|
|
44
|
+
directories.each { |path, entry_digest| digest << "dir\0#{path}\0#{entry_digest}\n" }
|
|
45
|
+
envs.each { |key, value| digest << "env\0#{key}\0#{value.nil? ? "\1" : value}\n" }
|
|
46
|
+
digest.hexdigest[0, 12]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def stamp_for(path)
|
|
50
|
+
stat = File.stat(path)
|
|
51
|
+
"#{stat.size}:#{format('%.6f', stat.mtime.to_f)}"
|
|
52
|
+
rescue SystemCallError
|
|
53
|
+
# A loaded file that no longer exists is a change like any other, and one that must not be
|
|
54
|
+
# mistaken for "unchanged" by a comparison against nil on both sides.
|
|
55
|
+
"missing"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def entry_digest_for(path, recursive:)
|
|
59
|
+
names = child_names(path, recursive: recursive)
|
|
60
|
+
return "missing" if names.nil?
|
|
61
|
+
|
|
62
|
+
Digest::SHA256.hexdigest(names.sort.join("\n"))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def child_names(path, recursive:)
|
|
66
|
+
recursive ? Dir.glob("**/*", base: path).sort : Dir.children(path).sort
|
|
67
|
+
rescue SystemCallError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def digest_env_value(value)
|
|
72
|
+
Digest::SHA256.hexdigest(value.nil? ? "0" : "1\0#{value}")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def token_for(paths)
|
|
76
|
+
digest = Digest::SHA256.new
|
|
77
|
+
paths.each do |path|
|
|
78
|
+
digest << path << "\0"
|
|
79
|
+
digest << (File.file?(path) ? File.read(path) : "")
|
|
80
|
+
end
|
|
81
|
+
digest.hexdigest[0, 16]
|
|
82
|
+
rescue SystemCallError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def initialize(app_root:, loaded:, env_reads: {})
|
|
88
|
+
@app_root = realpath(app_root)
|
|
89
|
+
@captured_at = Time.now
|
|
90
|
+
@bundle_path = discover_bundle_path
|
|
91
|
+
@bundle_files = discover_bundle_files
|
|
92
|
+
@bundle_token = self.class.token_for(@bundle_files)
|
|
93
|
+
@file_entries = capture_file_entries(loaded)
|
|
94
|
+
@directory_entries = capture_directory_entries
|
|
95
|
+
@env_entries = capture_env_entries(env_reads)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def revision_label
|
|
99
|
+
@revision_label ||= self.class.label_for(
|
|
100
|
+
bundle_token: bundle_token,
|
|
101
|
+
files: file_entries.map { |entry| [entry.path, "#{entry.size}:#{format('%.6f', entry.mtime)}"] },
|
|
102
|
+
directories: directory_entries.map { |entry| [entry.path, entry.entry_digest] },
|
|
103
|
+
envs: env_entries.map { |entry| [entry.key, entry.value_digest] }
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def to_s = "#{file_entries.size} files, #{directory_entries.size} directories, " \
|
|
108
|
+
"#{env_entries.size} environment variables"
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def realpath(path)
|
|
113
|
+
File.realpath(path)
|
|
114
|
+
rescue SystemCallError
|
|
115
|
+
File.expand_path(path)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def capture_file_entries(loaded)
|
|
119
|
+
local_roots = local_gem_roots
|
|
120
|
+
reloadable = reloadable_paths
|
|
121
|
+
|
|
122
|
+
entries = (loaded + configuration_files).uniq.filter_map do |path|
|
|
123
|
+
next unless watchable?(path, local_roots)
|
|
124
|
+
|
|
125
|
+
stat = file_stat(path)
|
|
126
|
+
next if stat.nil?
|
|
127
|
+
|
|
128
|
+
FileEntry.new(path, stat.size, stat.mtime.to_f, reloadable.include?(path) ? RELOADABLE : REBOOT)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
(entries + boot_file_entries).uniq(&:path)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def boot_file_entries
|
|
135
|
+
BootComponent.paths.filter_map do |path|
|
|
136
|
+
stat = file_stat(path)
|
|
137
|
+
next if stat.nil? || !stat.file?
|
|
138
|
+
|
|
139
|
+
FileEntry.new(path, stat.size, stat.mtime.to_f, REBOOT)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def configuration_files
|
|
144
|
+
Dir.glob(File.join(app_root, "config", "*")).select { |path| File.file?(path) }
|
|
145
|
+
rescue SystemCallError
|
|
146
|
+
[]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def watchable?(path, local_roots)
|
|
150
|
+
return true if under_any?(path, local_roots)
|
|
151
|
+
return false unless under?(path, app_root)
|
|
152
|
+
return false if bundle_path && under?(path, bundle_path)
|
|
153
|
+
|
|
154
|
+
excluded_directories.none? { |directory| under?(path, directory) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def excluded_directories
|
|
158
|
+
@excluded_directories ||= EXCLUDED_SUBDIRECTORIES.map { |name| File.join(app_root, name) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def file_stat(path)
|
|
162
|
+
File.stat(path)
|
|
163
|
+
rescue SystemCallError
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def capture_directory_entries
|
|
168
|
+
local_roots = local_gem_roots
|
|
169
|
+
|
|
170
|
+
entries = autoload_roots.filter_map do |directory, reloadable|
|
|
171
|
+
next unless watchable?(directory, local_roots)
|
|
172
|
+
|
|
173
|
+
DirectoryEntry.new(directory, self.class.entry_digest_for(directory, recursive: true), true, reloadable ? RELOADABLE : REBOOT)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
initializers = File.join(app_root, INITIALIZER_DIRECTORY)
|
|
177
|
+
if File.directory?(initializers)
|
|
178
|
+
entries << DirectoryEntry.new(initializers, self.class.entry_digest_for(initializers, recursive: true), true, REBOOT)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
entries.concat(boot_directory_entries)
|
|
182
|
+
entries.uniq(&:path)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def boot_directory_entries
|
|
186
|
+
BootComponent.paths.filter_map do |path|
|
|
187
|
+
next unless File.directory?(path)
|
|
188
|
+
|
|
189
|
+
DirectoryEntry.new(path, self.class.entry_digest_for(path, recursive: true), true, REBOOT)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def reloadable_paths
|
|
194
|
+
loaders = reloading_loaders
|
|
195
|
+
return Set.new if loaders.empty?
|
|
196
|
+
|
|
197
|
+
loaders.each_with_object(Set.new) do |loader, paths|
|
|
198
|
+
loader.all_expected_cpaths.each_key { |path| paths << path }
|
|
199
|
+
end
|
|
200
|
+
rescue StandardError, NotImplementedError
|
|
201
|
+
Set.new
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def reloading_loaders
|
|
205
|
+
return [] unless rails_application?
|
|
206
|
+
return [] unless ::Rails.application.config.respond_to?(:enable_reloading)
|
|
207
|
+
return [] unless ::Rails.application.config.enable_reloading
|
|
208
|
+
|
|
209
|
+
::Rails.autoloaders.to_a.select { |loader| loader.reloading_enabled? }
|
|
210
|
+
rescue StandardError
|
|
211
|
+
[]
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def autoload_roots
|
|
215
|
+
return [] unless rails_application?
|
|
216
|
+
|
|
217
|
+
roots = {}
|
|
218
|
+
::Rails.autoloaders.to_a.each do |loader|
|
|
219
|
+
reloadable = loader.reloading_enabled?
|
|
220
|
+
loader.dirs.each { |dir| roots[dir] = reloadable if File.directory?(dir) }
|
|
221
|
+
end
|
|
222
|
+
roots.to_a
|
|
223
|
+
rescue StandardError
|
|
224
|
+
[]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def rails_application?
|
|
228
|
+
defined?(::Rails) && ::Rails.respond_to?(:application) && !::Rails.application.nil?
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def discover_bundle_path
|
|
232
|
+
return nil unless defined?(::Bundler)
|
|
233
|
+
|
|
234
|
+
realpath(::Bundler.bundle_path.to_s)
|
|
235
|
+
rescue StandardError
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def discover_bundle_files
|
|
240
|
+
gemfile =
|
|
241
|
+
begin
|
|
242
|
+
defined?(::Bundler) ? ::Bundler.default_gemfile.to_s : File.join(app_root, "Gemfile")
|
|
243
|
+
rescue StandardError
|
|
244
|
+
File.join(app_root, "Gemfile")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
[gemfile, "#{gemfile}.lock"]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def local_gem_roots
|
|
251
|
+
@local_gem_roots ||= begin
|
|
252
|
+
if defined?(::Bundler)
|
|
253
|
+
::Bundler.load.specs.filter_map { |spec| spec.full_gem_path if local_source?(spec.source) }
|
|
254
|
+
else
|
|
255
|
+
[]
|
|
256
|
+
end
|
|
257
|
+
rescue StandardError
|
|
258
|
+
[]
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def local_source?(source)
|
|
263
|
+
defined?(::Bundler::Source::Path) && source.is_a?(::Bundler::Source::Path)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def capture_env_entries(env_reads)
|
|
267
|
+
env_reads.map { |key, value| EnvEntry.new(key, self.class.digest_env_value(value)) }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def under?(path, root)
|
|
271
|
+
return false if root.nil? || root.empty?
|
|
272
|
+
|
|
273
|
+
path == root || path.start_with?("#{root}#{File::SEPARATOR}")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def under_any?(path, roots) = roots.any? { |root| under?(path, root) }
|
|
277
|
+
|
|
278
|
+
# Watches `Kernel#load` for the duration of a boot.
|
|
279
|
+
#
|
|
280
|
+
# `$LOADED_FEATURES` only knows about `require`. Rails runs `config/initializers/*.rb` and
|
|
281
|
+
# `config/routes.rb` through `load`, precisely so they can be re-run, and those files are
|
|
282
|
+
# therefore invisible to a manifest built from the feature list alone -- an initializer could
|
|
283
|
+
# be edited all day without the tool noticing. Observing the interpreter is the same answer the
|
|
284
|
+
# rest of this class gives: what the boot actually loaded is what gets watched.
|
|
285
|
+
module LoadProbe
|
|
286
|
+
class << self
|
|
287
|
+
def start
|
|
288
|
+
install
|
|
289
|
+
@paths = []
|
|
290
|
+
@recording = true
|
|
291
|
+
self
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def stop
|
|
295
|
+
@recording = false
|
|
296
|
+
self
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def finish
|
|
300
|
+
stop
|
|
301
|
+
@paths
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def note(path)
|
|
305
|
+
return unless @recording
|
|
306
|
+
return unless path.is_a?(String) || path.respond_to?(:to_path)
|
|
307
|
+
|
|
308
|
+
@paths << File.expand_path(path.to_s)
|
|
309
|
+
rescue StandardError
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
private
|
|
314
|
+
|
|
315
|
+
# Prepended to `Kernel` once, and guarded by a flag rather than removed -- for the same
|
|
316
|
+
# reason as the environment probe, and with the same cost when idle.
|
|
317
|
+
def install
|
|
318
|
+
return if @installed
|
|
319
|
+
|
|
320
|
+
::Kernel.prepend(Interceptor)
|
|
321
|
+
@installed = true
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
module Interceptor
|
|
326
|
+
def load(path, *args)
|
|
327
|
+
LoadProbe.note(path)
|
|
328
|
+
super
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
module EnvProbe
|
|
334
|
+
IGNORED_PREFIXES = %w[SIDING_ BUNDLE_ BUNDLER_ RUBY GEM_ XDG_ LC_].freeze
|
|
335
|
+
IGNORED_KEYS = %w[
|
|
336
|
+
RAILS_ENV RACK_ENV RAILS_GROUPS PATH PWD OLDPWD SHLVL _ LANG LANGUAGE
|
|
337
|
+
TERM TERM_PROGRAM TERM_PROGRAM_VERSION TERM_SESSION_ID COLORTERM COLUMNS LINES
|
|
338
|
+
SSH_AUTH_SOCK SSH_CLIENT SSH_CONNECTION SSH_TTY TMUX TMUX_PANE STY WINDOWID DISPLAY
|
|
339
|
+
DBUS_SESSION_BUS_ADDRESS
|
|
340
|
+
SOURCE_DATE_EPOCH DEBUG_RESOLVER DEBUG_RESOLVER_TREE SKIP_BUNDLER_CHECKSUM PAGER RI_PAGER
|
|
341
|
+
].to_set.freeze
|
|
342
|
+
|
|
343
|
+
class << self
|
|
344
|
+
def start
|
|
345
|
+
install
|
|
346
|
+
@reads = []
|
|
347
|
+
@seen = Set.new
|
|
348
|
+
@writes = Set.new
|
|
349
|
+
@recording = true
|
|
350
|
+
self
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def stop
|
|
354
|
+
@recording = false
|
|
355
|
+
self
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def finish
|
|
359
|
+
stop
|
|
360
|
+
keys = @reads - @writes.to_a
|
|
361
|
+
keys.to_h { |key| [key, raw(key)] }
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def recording? = @recording
|
|
365
|
+
|
|
366
|
+
def around_write(key)
|
|
367
|
+
return yield unless recording?
|
|
368
|
+
|
|
369
|
+
before = raw(key)
|
|
370
|
+
result = yield
|
|
371
|
+
note_write(key) unless raw(key) == before
|
|
372
|
+
result
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def around_bulk_write
|
|
376
|
+
return yield unless recording?
|
|
377
|
+
|
|
378
|
+
before = ENV.to_hash
|
|
379
|
+
result = yield
|
|
380
|
+
after = ENV.to_hash
|
|
381
|
+
(before.keys | after.keys).each do |key|
|
|
382
|
+
note_write(key) unless before[key] == after[key]
|
|
383
|
+
end
|
|
384
|
+
result
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def note_write(key)
|
|
388
|
+
@writes << key
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def note_read(key)
|
|
392
|
+
return unless recording?
|
|
393
|
+
return unless key.is_a?(String)
|
|
394
|
+
return if ignored?(key)
|
|
395
|
+
return unless @seen.add?(key)
|
|
396
|
+
|
|
397
|
+
@reads << key
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def ignored?(key)
|
|
401
|
+
IGNORED_KEYS.include?(key) || IGNORED_PREFIXES.any? { |prefix| key.start_with?(prefix) }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
private
|
|
405
|
+
|
|
406
|
+
def install
|
|
407
|
+
return if @installed
|
|
408
|
+
|
|
409
|
+
@raw = ENV.method(:[])
|
|
410
|
+
ENV.singleton_class.prepend(Interceptor)
|
|
411
|
+
@installed = true
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def raw(key) = @raw.call(key)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
module Interceptor
|
|
418
|
+
def [](key)
|
|
419
|
+
EnvProbe.note_read(key)
|
|
420
|
+
super
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def fetch(key, *args, &block)
|
|
424
|
+
EnvProbe.note_read(key)
|
|
425
|
+
super
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def key?(key)
|
|
429
|
+
EnvProbe.note_read(key)
|
|
430
|
+
super
|
|
431
|
+
end
|
|
432
|
+
alias has_key? key?
|
|
433
|
+
alias include? key?
|
|
434
|
+
alias member? key?
|
|
435
|
+
|
|
436
|
+
def dig(key, *args)
|
|
437
|
+
EnvProbe.note_read(key)
|
|
438
|
+
super
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def values_at(*keys)
|
|
442
|
+
keys.each { |key| EnvProbe.note_read(key) }
|
|
443
|
+
super
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def slice(*keys)
|
|
447
|
+
keys.each { |key| EnvProbe.note_read(key) }
|
|
448
|
+
super
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def []=(key, value)
|
|
452
|
+
EnvProbe.around_write(key) { super }
|
|
453
|
+
end
|
|
454
|
+
alias store []=
|
|
455
|
+
|
|
456
|
+
def delete(key, &block)
|
|
457
|
+
EnvProbe.around_write(key) { super }
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def update(*others, &block)
|
|
461
|
+
EnvProbe.around_bulk_write { super }
|
|
462
|
+
end
|
|
463
|
+
alias merge! update
|
|
464
|
+
|
|
465
|
+
def replace(other)
|
|
466
|
+
EnvProbe.around_bulk_write { super }
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def clear
|
|
470
|
+
EnvProbe.around_bulk_write { super }
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Siding
|
|
4
|
+
class Logger
|
|
5
|
+
TERMINAL = "/dev/tty"
|
|
6
|
+
OFF_VALUES = ["0", "false", "no", "off", ""].freeze
|
|
7
|
+
|
|
8
|
+
Event = Struct.new(:at, :level, :message, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
attr_reader :events
|
|
11
|
+
|
|
12
|
+
def initialize(env: ENV, log_path: nil)
|
|
13
|
+
@verbose = self.class.verbose?(env)
|
|
14
|
+
@log_path = log_path
|
|
15
|
+
@events = []
|
|
16
|
+
@terminal = :unopened
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.verbose?(env)
|
|
20
|
+
value = env["SIDING_LOG"]
|
|
21
|
+
return false if value.nil?
|
|
22
|
+
|
|
23
|
+
!OFF_VALUES.include?(value.strip.downcase)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def verbose? = @verbose
|
|
27
|
+
|
|
28
|
+
def notice(message)
|
|
29
|
+
record(:notice, message)
|
|
30
|
+
write_to_terminal(message)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def debug(message)
|
|
34
|
+
return unless verbose?
|
|
35
|
+
|
|
36
|
+
record(:debug, message)
|
|
37
|
+
write_to_terminal(message) || write_to_log(message)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def record(level, message)
|
|
41
|
+
@events << Event.new(at: Time.now, level: level, message: message)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def terminal_available? = !terminal.nil?
|
|
45
|
+
|
|
46
|
+
def close
|
|
47
|
+
@terminal.close if @terminal.is_a?(IO) && !@terminal.closed?
|
|
48
|
+
rescue IOError, SystemCallError
|
|
49
|
+
nil
|
|
50
|
+
ensure
|
|
51
|
+
@terminal = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def terminal
|
|
57
|
+
return @terminal unless @terminal == :unopened
|
|
58
|
+
|
|
59
|
+
@terminal = begin
|
|
60
|
+
File.open(TERMINAL, "w")
|
|
61
|
+
rescue SystemCallError
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def write_to_terminal(message)
|
|
67
|
+
io = terminal
|
|
68
|
+
return false unless io
|
|
69
|
+
|
|
70
|
+
io.write("#{format_line(message)}\n")
|
|
71
|
+
io.flush
|
|
72
|
+
true
|
|
73
|
+
rescue IOError, SystemCallError
|
|
74
|
+
@terminal = nil
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def write_to_log(message)
|
|
79
|
+
return false unless @log_path
|
|
80
|
+
|
|
81
|
+
File.open(@log_path, "a") { |f| f.write("#{format_line(message)}\n") }
|
|
82
|
+
true
|
|
83
|
+
rescue SystemCallError
|
|
84
|
+
false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def format_line(message) = "[siding] #{message}"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module Siding
|
|
6
|
+
module Platform
|
|
7
|
+
UNIX_SOCKET_PATH_LIMIT = 104
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def host_os = RbConfig::CONFIG["host_os"]
|
|
12
|
+
def linux? = host_os.match?(/linux/i)
|
|
13
|
+
def macos? = host_os.match?(/darwin/i)
|
|
14
|
+
def windows? = host_os.match?(/mswin|mingw|cygwin/i)
|
|
15
|
+
def supported? = linux? || macos?
|
|
16
|
+
|
|
17
|
+
def unsupported_reason
|
|
18
|
+
return nil if supported?
|
|
19
|
+
|
|
20
|
+
if windows?
|
|
21
|
+
"Windows is not supported: it provides neither fork() with copy-on-write nor " \
|
|
22
|
+
"file-descriptor passing over Unix domain sockets. Commands run unaccelerated."
|
|
23
|
+
else
|
|
24
|
+
"Unrecognized platform #{host_os.inspect}. Only Linux and macOS are supported. " \
|
|
25
|
+
"Commands run unaccelerated."
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def description
|
|
30
|
+
return "Linux" if linux?
|
|
31
|
+
return "macOS" if macos?
|
|
32
|
+
return "Windows" if windows?
|
|
33
|
+
|
|
34
|
+
host_os.to_s
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
|
|
6
|
+
module Siding
|
|
7
|
+
class ProjectKey
|
|
8
|
+
# Long enough that a collision is not a practical concern, short enough that the socket path
|
|
9
|
+
# built from it stays inside the platform's ~100-byte sockaddr_un limit.
|
|
10
|
+
DIGEST_LENGTH = 12
|
|
11
|
+
|
|
12
|
+
attr_reader :app_root, :uid, :tool_version, :ruby_version, :app_env
|
|
13
|
+
|
|
14
|
+
def self.for(app_root, env: ENV)
|
|
15
|
+
new(
|
|
16
|
+
app_root: app_root,
|
|
17
|
+
uid: Process.uid,
|
|
18
|
+
tool_version: Siding::VERSION,
|
|
19
|
+
ruby_version: RUBY_VERSION,
|
|
20
|
+
app_env: app_env_from(env)
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.app_env_from(env)
|
|
25
|
+
value = env["RAILS_ENV"] || env["RACK_ENV"]
|
|
26
|
+
value.nil? || value.empty? ? "development" : value
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(app_root:, uid:, tool_version:, ruby_version:, app_env:)
|
|
30
|
+
@app_root = File.realpath(app_root)
|
|
31
|
+
@uid = uid
|
|
32
|
+
@tool_version = tool_version
|
|
33
|
+
@ruby_version = ruby_version
|
|
34
|
+
@app_env = app_env
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def digest
|
|
38
|
+
@digest ||= Digest::SHA256.hexdigest(fields.join("\0"))[0, DIGEST_LENGTH]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def label
|
|
42
|
+
"#{File.basename(app_root)}-#{app_env}-#{digest}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fields
|
|
46
|
+
[app_root, uid.to_s, tool_version, ruby_version, app_env]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ==(other)
|
|
50
|
+
other.is_a?(ProjectKey) && fields == other.fields
|
|
51
|
+
end
|
|
52
|
+
alias eql? ==
|
|
53
|
+
|
|
54
|
+
def hash
|
|
55
|
+
fields.hash
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def to_s = label
|
|
59
|
+
|
|
60
|
+
def inspect
|
|
61
|
+
"#<#{self.class} #{label} root=#{app_root} uid=#{uid} tool=#{tool_version} ruby=#{ruby_version}>"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|