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.
@@ -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