copse 0.1.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 +7 -0
- data/CHANGELOG.md +55 -0
- data/LICENSE.txt +21 -0
- data/README.md +220 -0
- data/lib/copse/database.rb +106 -0
- data/lib/copse/procfile.rb +254 -0
- data/lib/copse/railtie.rb +72 -0
- data/lib/copse/session.rb +558 -0
- data/lib/copse/url_options.rb +38 -0
- data/lib/copse/version.rb +5 -0
- data/lib/copse/worktree.rb +133 -0
- data/lib/copse.rb +102 -0
- data/lib/generators/copse/install_generator.rb +142 -0
- data/lib/generators/copse/templates/Procfile.dev.tt +1 -0
- data/lib/generators/copse/templates/dev.tt +7 -0
- metadata +63 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
require_relative "url_options"
|
|
6
|
+
require_relative "database"
|
|
7
|
+
|
|
8
|
+
module Copse
|
|
9
|
+
# Points generated URLs at the Copse hostname in development, gives a linked
|
|
10
|
+
# worktree its own development database, and reports both on boot.
|
|
11
|
+
#
|
|
12
|
+
# All the decision logic lives in Copse::UrlOptions and Copse::Database, which
|
|
13
|
+
# know nothing about Rails; this class is only the wiring.
|
|
14
|
+
class Railtie < ::Rails::Railtie
|
|
15
|
+
initializer "copse.default_url_options" do |app|
|
|
16
|
+
routes = app.routes
|
|
17
|
+
|
|
18
|
+
if UrlOptions.apply?(rails_env: ::Rails.env, existing_host: routes.default_url_options[:host])
|
|
19
|
+
options = UrlOptions.for
|
|
20
|
+
routes.default_url_options.merge!(options)
|
|
21
|
+
|
|
22
|
+
# Reached through on_load rather than `config.action_mailer`, which raises
|
|
23
|
+
# NoMethodError in an app that does not load Action Mailer at all.
|
|
24
|
+
ActiveSupport.on_load(:action_mailer) do
|
|
25
|
+
# Checked again here: an app can set mailer URL options without setting
|
|
26
|
+
# route ones, and Copse should not override either.
|
|
27
|
+
if default_url_options.to_h[:host].to_s.empty?
|
|
28
|
+
self.default_url_options = default_url_options.to_h.merge(options)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# R14. Puma prints its own `Listening on http://127.0.0.1:<port>` line too;
|
|
33
|
+
# that one reports the bound address, and binding to the hostname would
|
|
34
|
+
# depend on the system resolver. Both lines are correct about different
|
|
35
|
+
# things, which is why this one names Copse explicitly.
|
|
36
|
+
$stdout.puts "=> Copse: #{UrlOptions.url} (Puma reports the bound address separately)"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A linked worktree gets its own development database, so two branches of one
|
|
41
|
+
# app do not share one schema.
|
|
42
|
+
#
|
|
43
|
+
# Placed *after* `active_record.initialize_database` because that initializer
|
|
44
|
+
# is what assigns `ActiveRecord::Base.configurations` -- before it there is
|
|
45
|
+
# nothing to rename. It also calls `establish_connection`, which is why
|
|
46
|
+
# Copse::Database mutates each configuration in place rather than replacing
|
|
47
|
+
# it; see the note there.
|
|
48
|
+
#
|
|
49
|
+
# Unlike the URL options this does not require COPSE_URL. The database has to
|
|
50
|
+
# be the same one whether you arrived by `bin/dev`, `bin/rails console`, or
|
|
51
|
+
# `bin/rails db:prepare` -- a database only reachable from `bin/dev` is a
|
|
52
|
+
# database no rake task could create.
|
|
53
|
+
initializer "copse.database", after: "active_record.initialize_database" do
|
|
54
|
+
next unless Database.apply?(rails_env: ::Rails.env)
|
|
55
|
+
|
|
56
|
+
suffix = Database.suffix(root: ::Rails.root.to_s)
|
|
57
|
+
next if suffix.to_s.empty?
|
|
58
|
+
|
|
59
|
+
ActiveSupport.on_load(:active_record) do
|
|
60
|
+
renamed = Database.apply(ActiveRecord::Base.configurations, suffix: suffix)
|
|
61
|
+
|
|
62
|
+
# Only when Copse booted the app. Every other development command
|
|
63
|
+
# (`bin/rails console`, every rake task) is renamed too, and printing there
|
|
64
|
+
# would put a Copse line in front of unrelated output; the tasks that
|
|
65
|
+
# matter name the database themselves.
|
|
66
|
+
unless renamed.empty? || UrlOptions.url.to_s.empty?
|
|
67
|
+
$stdout.puts "=> Copse: database #{renamed.join(', ')}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "socket"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Copse
|
|
9
|
+
# Everything the session needs before it spawns anything: the environment, a
|
|
10
|
+
# safe temporary Procfile, and a trustworthy answer to whether foreman works.
|
|
11
|
+
class Session
|
|
12
|
+
MIN_FOREMAN_VERSION = "0.90.0"
|
|
13
|
+
|
|
14
|
+
# How long to wait before checking whether foreman died on the spot. Long
|
|
15
|
+
# enough to catch a missing binary or an empty Procfile, short enough to be
|
|
16
|
+
# invisible next to a Rails boot.
|
|
17
|
+
FOREMAN_STARTUP_GRACE = 0.3
|
|
18
|
+
|
|
19
|
+
# How long teardown waits for a child to honour SIGTERM before SIGKILL. Chosen
|
|
20
|
+
# to sit just past foreman's own 5s escalation so its children get their
|
|
21
|
+
# graceful window first.
|
|
22
|
+
REAP_TIMEOUT = 6
|
|
23
|
+
REAP_POLL = 0.05
|
|
24
|
+
|
|
25
|
+
INTERRUPTED_STATUS = 130
|
|
26
|
+
|
|
27
|
+
attr_reader :worktree, :root
|
|
28
|
+
|
|
29
|
+
def initialize(worktree, root: nil, out: $stdout)
|
|
30
|
+
@worktree = worktree
|
|
31
|
+
@root = File.expand_path(root || worktree.root)
|
|
32
|
+
@out = out
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Boots the app and returns the foreground process's exit status.
|
|
36
|
+
#
|
|
37
|
+
# The load-bearing property is that the web process is spawned with this
|
|
38
|
+
# process's own stdin, stdout, and stderr and stays in the terminal's
|
|
39
|
+
# foreground process group, so `binding.irb` and `debug` behave exactly as
|
|
40
|
+
# they do under a bare `rails server`. Nothing may come between the two.
|
|
41
|
+
def start
|
|
42
|
+
@out.puts "=> Copse: #{worktree.url}"
|
|
43
|
+
|
|
44
|
+
# The guard spans everything after the banner, not just the foreground wait.
|
|
45
|
+
# The foreman probe spawns a child of its own, and a signal arriving during it
|
|
46
|
+
# used to escape the handler entirely -- which surfaced as a bogus "cannot run
|
|
47
|
+
# foreman" error rather than a clean interrupt.
|
|
48
|
+
guarded do
|
|
49
|
+
# No secondaries means no foreman -- not even a preflight. The Procfile the
|
|
50
|
+
# install generator writes has only a `web` line, so preflighting a tool
|
|
51
|
+
# this run will never use would refuse to boot the most common app. Running
|
|
52
|
+
# `foreman start` against an empty Procfile is fatal anyway.
|
|
53
|
+
next run_foreground unless secondaries?
|
|
54
|
+
|
|
55
|
+
unless foreman_available?
|
|
56
|
+
@out.puts foreman_error_message
|
|
57
|
+
next 1
|
|
58
|
+
end
|
|
59
|
+
@out.puts foreman_version_warning if foreman_outdated?
|
|
60
|
+
|
|
61
|
+
@foreman_pid = spawn_foreman(write_temp_procfile)
|
|
62
|
+
report_if_foreman_died_early
|
|
63
|
+
run_foreground
|
|
64
|
+
end
|
|
65
|
+
ensure
|
|
66
|
+
teardown
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Runs the body with SIGTERM converted to an Interrupt, and keeps that
|
|
72
|
+
# conversion in place while `start`'s `ensure` tears down.
|
|
73
|
+
#
|
|
74
|
+
# Two windows depend on this. Without the trap at all, SIGTERM kills the
|
|
75
|
+
# process outright and `ensure` never runs. With the trap restored too early, a
|
|
76
|
+
# second SIGTERM arriving during teardown kills us between `terminate_web` and
|
|
77
|
+
# `terminate_foreman` -- which was measured to leave the watcher alive.
|
|
78
|
+
# Interrupt is caught here rather than inside `run_foreground` so the foreman
|
|
79
|
+
# startup grace is covered too; otherwise a signal during that sleep escaped as
|
|
80
|
+
# a raw backtrace.
|
|
81
|
+
def guarded
|
|
82
|
+
previous = Signal.trap("TERM") { raise Interrupt }
|
|
83
|
+
@term_trap_previous = previous
|
|
84
|
+
yield
|
|
85
|
+
rescue Interrupt
|
|
86
|
+
# Ctrl-C, or a SIGTERM converted above. The TTY already delivered SIGINT to
|
|
87
|
+
# the whole foreground group, so there is nothing to announce -- `ensure`
|
|
88
|
+
# cleans up.
|
|
89
|
+
INTERRUPTED_STATUS
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def restore_term_trap
|
|
93
|
+
return unless defined?(@term_trap_previous)
|
|
94
|
+
|
|
95
|
+
Signal.trap("TERM", @term_trap_previous || "DEFAULT")
|
|
96
|
+
remove_instance_variable(:@term_trap_previous)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def run_foreground
|
|
100
|
+
@web_pid = Process.spawn(web_env, web_command, chdir: root)
|
|
101
|
+
_, status = Process.waitpid2(@web_pid)
|
|
102
|
+
@web_pid = nil
|
|
103
|
+
|
|
104
|
+
code = status.exitstatus || INTERRUPTED_STATUS
|
|
105
|
+
report_port_collision if code != 0 && port_in_use?
|
|
106
|
+
code
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def spawn_foreman(path)
|
|
110
|
+
Process.spawn(
|
|
111
|
+
foreman_env,
|
|
112
|
+
"foreman", "start",
|
|
113
|
+
"-f", path,
|
|
114
|
+
# Not optional. Foreman takes each child's working directory from the
|
|
115
|
+
# Procfile's own directory, so without this every app-relative command
|
|
116
|
+
# (`bin/rails tailwindcss:watch`, `yarn build --watch`) would run from the
|
|
117
|
+
# temp directory and die with "unknown command". Copse's own cwd does not
|
|
118
|
+
# help; only this does.
|
|
119
|
+
"-d", root,
|
|
120
|
+
# jsbundling-rails and cssbundling-rails both pass this. Without it
|
|
121
|
+
# foreman loads the app's .env, which can clobber the derived PORT.
|
|
122
|
+
"--env", "/dev/null",
|
|
123
|
+
chdir: root
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A developer should not spend an hour editing CSS with no watcher running.
|
|
128
|
+
# Nothing monitors foreman while the web process holds the foreground, so
|
|
129
|
+
# check once here.
|
|
130
|
+
def report_if_foreman_died_early
|
|
131
|
+
sleep FOREMAN_STARTUP_GRACE
|
|
132
|
+
pid, status = Process.waitpid2(@foreman_pid, Process::WNOHANG)
|
|
133
|
+
return if pid.nil?
|
|
134
|
+
|
|
135
|
+
@foreman_reaped = true
|
|
136
|
+
@out.puts "copse: foreman exited immediately (status #{status.exitstatus}). " \
|
|
137
|
+
"The #{secondaries.size == 1 ? 'process' : 'processes'} " \
|
|
138
|
+
"#{secondaries.map(&:name).join(', ')} are not running."
|
|
139
|
+
rescue Errno::ECHILD
|
|
140
|
+
@foreman_reaped = true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Foreman is signalled *first*, then waited on.
|
|
144
|
+
#
|
|
145
|
+
# Ordering matters: `Process.waitpid` on the web process is unbounded, so a web
|
|
146
|
+
# process that delays or ignores SIGTERM used to park teardown here forever and
|
|
147
|
+
# foreman was never signalled at all -- every watcher survived. Signalling
|
|
148
|
+
# foreman up front means its own 5s SIGTERM-to-SIGKILL escalation runs in
|
|
149
|
+
# parallel with the web process shutting down.
|
|
150
|
+
def teardown
|
|
151
|
+
signal_foreman
|
|
152
|
+
terminate_web
|
|
153
|
+
reap_foreman
|
|
154
|
+
remove_temp_procfile
|
|
155
|
+
restore_term_trap
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def terminate_web
|
|
159
|
+
return if @web_pid.nil?
|
|
160
|
+
|
|
161
|
+
Process.kill("TERM", @web_pid)
|
|
162
|
+
reap(@web_pid, "the web process")
|
|
163
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
164
|
+
# Already gone.
|
|
165
|
+
ensure
|
|
166
|
+
@web_pid = nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Waits for `pid`, escalating to SIGKILL rather than blocking forever. Teardown
|
|
170
|
+
# must always complete; a child that ignores SIGTERM is not a reason to hang.
|
|
171
|
+
def reap(pid, what, timeout: REAP_TIMEOUT)
|
|
172
|
+
deadline = Time.now + timeout
|
|
173
|
+
loop do
|
|
174
|
+
return if Process.waitpid(pid, Process::WNOHANG)
|
|
175
|
+
break if Time.now >= deadline
|
|
176
|
+
|
|
177
|
+
sleep REAP_POLL
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
@out.puts "copse: #{what} did not exit within #{timeout}s; sending SIGKILL."
|
|
181
|
+
Process.kill("KILL", pid)
|
|
182
|
+
Process.waitpid(pid)
|
|
183
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
184
|
+
# Already gone.
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Signals foreman's own pid and lets foreman reap its children.
|
|
188
|
+
#
|
|
189
|
+
# Never a negative pgid. Foreman does not call setsid, so its process group is
|
|
190
|
+
# the caller's own -- `Process.kill("-TERM", pgid)` would signal the
|
|
191
|
+
# developer's shell session.
|
|
192
|
+
# Signals foreman's own pid and lets foreman reap its children.
|
|
193
|
+
#
|
|
194
|
+
# Never a negative pgid. Foreman does not call setsid, so its process group is
|
|
195
|
+
# the caller's own -- `Process.kill("-TERM", pgid)` would signal the
|
|
196
|
+
# developer's shell session.
|
|
197
|
+
def signal_foreman
|
|
198
|
+
return if @foreman_pid.nil? || @foreman_reaped
|
|
199
|
+
|
|
200
|
+
Process.kill("TERM", @foreman_pid)
|
|
201
|
+
rescue Errno::ESRCH
|
|
202
|
+
# Expected on Ctrl-C: the TTY signalled foreman directly, because its
|
|
203
|
+
# children share this terminal's foreground process group.
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def reap_foreman
|
|
207
|
+
return if @foreman_pid.nil? || @foreman_reaped
|
|
208
|
+
|
|
209
|
+
reap(@foreman_pid, "foreman")
|
|
210
|
+
ensure
|
|
211
|
+
@foreman_pid = nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def remove_temp_procfile
|
|
215
|
+
return if @procfile_dir.nil?
|
|
216
|
+
|
|
217
|
+
FileUtils.remove_entry(@procfile_dir) if File.exist?(@procfile_dir)
|
|
218
|
+
ensure
|
|
219
|
+
@procfile_dir = nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Derived ports are uncoordinated by design, so collisions happen. Puma's
|
|
223
|
+
# "Address already in use" does not reach us -- the web process owns the
|
|
224
|
+
# terminal and we see only an exit status -- so check whether the port is
|
|
225
|
+
# actually held before blaming a collision. A non-zero exit has many causes.
|
|
226
|
+
def port_in_use?
|
|
227
|
+
TCPServer.new("127.0.0.1", worktree.port).close
|
|
228
|
+
false
|
|
229
|
+
rescue Errno::EADDRINUSE
|
|
230
|
+
true
|
|
231
|
+
rescue SystemCallError
|
|
232
|
+
false
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def report_port_collision
|
|
236
|
+
@out.puts "copse: port #{worktree.port} (derived from #{worktree.host}) is already in use. " \
|
|
237
|
+
"Another app or worktree most likely derived the same port."
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
public
|
|
241
|
+
|
|
242
|
+
def procfile
|
|
243
|
+
return @procfile if defined?(@procfile)
|
|
244
|
+
|
|
245
|
+
@procfile = Procfile.load(procfile_path)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def procfile_path = File.join(root, "Procfile.dev")
|
|
249
|
+
|
|
250
|
+
# Hands the whole Procfile -- `web` included -- to Overmind, replacing this
|
|
251
|
+
# process. Never returns.
|
|
252
|
+
#
|
|
253
|
+
# There is no split session here and no foreground/background distinction to
|
|
254
|
+
# preserve: Overmind gives every process its own tmux pty, so `binding.irb`
|
|
255
|
+
# works over `overmind connect web` rather than by holding this terminal. All
|
|
256
|
+
# Copse contributes on this path is the environment.
|
|
257
|
+
#
|
|
258
|
+
# The environment is `copse_env` unoffset -- deliberately not
|
|
259
|
+
# `foreman_port_env`. Overmind derives each child's port as
|
|
260
|
+
# `base + index * 100` from PORT just as foreman does, but here it supervises
|
|
261
|
+
# `web` too, so offsetting the base would hand `web` a port Copse never
|
|
262
|
+
# derived and the banner above would name the wrong URL.
|
|
263
|
+
# `-p` before the caller's own arguments, so `bin/dev -p 4000` still wins.
|
|
264
|
+
#
|
|
265
|
+
# The base port is passed as a *flag* rather than left to the inherited PORT
|
|
266
|
+
# because only the flag survives Overmind's env files. Measured against
|
|
267
|
+
# Overmind 2.5.1: with `PORT` in `.overmind.env` and the derived port merely
|
|
268
|
+
# inherited, the app booted on the env file's port; with `-p` it booted on the
|
|
269
|
+
# derived one. Secondaries still get `base + index * 100`.
|
|
270
|
+
def exec_overmind(args = [])
|
|
271
|
+
@out.puts "=> Copse: #{worktree.url}"
|
|
272
|
+
@out.puts overmind_web_position_warning if web_out_of_position?
|
|
273
|
+
@out.puts overmind_port_flag_warning if web_port_flag?
|
|
274
|
+
|
|
275
|
+
# Matches the `chdir: root` both foreman and the foreground web spawn use, but
|
|
276
|
+
# for a different reason. The processes themselves are fine either way --
|
|
277
|
+
# Overmind takes their working directory from the Procfile's own directory, so
|
|
278
|
+
# `bin/rails server` resolves even when invoked from elsewhere. The *socket* is
|
|
279
|
+
# not: `.overmind.sock` is created relative to Overmind's own cwd, and
|
|
280
|
+
# `overmind connect web` from the app root then dials a path that does not
|
|
281
|
+
# exist.
|
|
282
|
+
Dir.chdir(root)
|
|
283
|
+
exec(overmind_env, "overmind", "start", "-f", procfile_path, "-p", worktree.port.to_s, *args)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# An explicit `--port` on the `web` line beats PORT in `rails server`, and
|
|
287
|
+
# vite_ruby's own example ships one. The foreman path strips it, but only
|
|
288
|
+
# because Copse builds that command itself; here Overmind runs the app's own
|
|
289
|
+
# Procfile, and rewriting it into a temp copy would mean `overmind restart` and
|
|
290
|
+
# the file the developer edits were no longer the same thing. So this is a
|
|
291
|
+
# warning, the same call the signal-transparency transform makes for shapes it
|
|
292
|
+
# will not rewrite.
|
|
293
|
+
def web_port_flag?
|
|
294
|
+
entry = procfile&.web
|
|
295
|
+
!entry.nil? && Procfile.port_flag?(entry.command)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def overmind_port_flag_warning
|
|
299
|
+
"copse: the `web` line in Procfile.dev sets an explicit port, which beats the derived " \
|
|
300
|
+
"#{worktree.port} -- so the app will not be at #{worktree.url}. Remove the flag."
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# OVERMIND_SKIP_ENV is this path's `--env /dev/null`, and for the same reason:
|
|
304
|
+
# Overmind loads the app's `.env` and applies it *over* the environment it was
|
|
305
|
+
# handed, so anything Copse derives is otherwise beatable by a stale `.env`.
|
|
306
|
+
#
|
|
307
|
+
# It is not the port's only defence -- `-p` above is, and it covers
|
|
308
|
+
# `.overmind.env`, which this flag does not skip. What this buys is that both
|
|
309
|
+
# supervisors see the same environment. That matters more here than it looks:
|
|
310
|
+
# the overmind `bin/dev` falls back to the foreman session on a machine without
|
|
311
|
+
# overmind, so if the two disagreed about `.env`, one committed repo would
|
|
312
|
+
# behave differently per teammate.
|
|
313
|
+
#
|
|
314
|
+
# Only the supervisor's env-file loading is suppressed, not the app's.
|
|
315
|
+
# dotenv-rails still reads `.env` inside Rails, exactly as on the foreman path.
|
|
316
|
+
def overmind_env
|
|
317
|
+
copse_env.merge("OVERMIND_SKIP_ENV" => "1")
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Whether `overmind start` will actually work. Overmind is a Go binary rather
|
|
321
|
+
# than a gem, so unlike foreman there is no bundler environment to strip and no
|
|
322
|
+
# version-manager shim to see past -- but probing still beats `command -v`,
|
|
323
|
+
# which succeeds on an unexecutable file.
|
|
324
|
+
#
|
|
325
|
+
# `--version`, not `version`: Overmind has no `version` subcommand and exits 3
|
|
326
|
+
# on one, which would make every probe fail and silently downgrade the whole
|
|
327
|
+
# path to foreman.
|
|
328
|
+
def overmind_available?
|
|
329
|
+
_out, _err, status = Open3.capture3("overmind", "--version")
|
|
330
|
+
status.success?
|
|
331
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
332
|
+
false
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# The variables every process Copse starts receives.
|
|
336
|
+
#
|
|
337
|
+
# COPSE_PORT exists because foreman rewrites PORT for its own children
|
|
338
|
+
# (base_port + index * 100), so a secondary never sees the derived port under
|
|
339
|
+
# the name PORT. COPSE_PORT is the name foreman does not touch.
|
|
340
|
+
# COPSE_DATABASE_SUFFIX is nil in a main worktree, which keeps its plain
|
|
341
|
+
# database name. Nil rather than a missing key on purpose: Process.spawn merges
|
|
342
|
+
# its env hash into the inherited one, so leaving the key out would let a stale
|
|
343
|
+
# value inherited from elsewhere -- a shell opened from a linked worktree's
|
|
344
|
+
# session -- reach a main worktree's children and be believed. Nil is the only
|
|
345
|
+
# way to say "unset this".
|
|
346
|
+
def copse_env
|
|
347
|
+
{
|
|
348
|
+
"PORT" => worktree.port.to_s,
|
|
349
|
+
"COPSE_PORT" => worktree.port.to_s,
|
|
350
|
+
"COPSE_HOST" => worktree.host,
|
|
351
|
+
"COPSE_URL" => worktree.url,
|
|
352
|
+
"VITE_RUBY_PORT" => worktree.companion_port.to_s,
|
|
353
|
+
"COPSE_DATABASE_SUFFIX" => worktree.database_suffix
|
|
354
|
+
}
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# The foreground process keeps the inherited bundler environment: it *is* the
|
|
358
|
+
# Rails app and needs its bundle.
|
|
359
|
+
def web_env
|
|
360
|
+
copse_env
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Foreman derives each child's port as `base_port + index * 100`, and takes
|
|
364
|
+
# base_port from PORT. Handing it the derived port therefore gave the *first*
|
|
365
|
+
# secondary exactly the web process's port -- so a secondary that binds it
|
|
366
|
+
# collides with puma, and copse's own collision message then blames another
|
|
367
|
+
# worktree.
|
|
368
|
+
#
|
|
369
|
+
# Offsetting the base is the least-bad of three options. Removing PORT entirely
|
|
370
|
+
# looks cleaner but is worse: foreman then falls back to 5000, which is the
|
|
371
|
+
# macOS AirPlay Receiver port and is on our own reserved list. Secondaries that
|
|
372
|
+
# need the app's real port read COPSE_PORT, which foreman does not touch.
|
|
373
|
+
FOREMAN_PORT_OFFSET = 100
|
|
374
|
+
|
|
375
|
+
def foreman_port_env
|
|
376
|
+
{ "PORT" => (worktree.port + FOREMAN_PORT_OFFSET).to_s }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# The environment foreman is actually spawned with -- whichever candidate the
|
|
380
|
+
# probe found works. Falls back to the preferred candidate when nothing has
|
|
381
|
+
# been probed yet.
|
|
382
|
+
def foreman_env
|
|
383
|
+
foreman_version
|
|
384
|
+
@foreman_env || foreman_env_candidates.first
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Two ways foreman can be reachable, and neither one covers both.
|
|
388
|
+
#
|
|
389
|
+
# Stripping the bundler environment first is what handles the common case:
|
|
390
|
+
# `bin/dev` has to load the bundle to require copse at all, so foreman would
|
|
391
|
+
# otherwise inherit BUNDLE_GEMFILE and RUBYOPT and die with "foreman is not
|
|
392
|
+
# currently included in the bundle" even though it is installed. That is what
|
|
393
|
+
# Rails' own /bin/sh `bin/dev` achieves by exec'ing foreman outside the bundle.
|
|
394
|
+
#
|
|
395
|
+
# But if foreman is provided *only* by the app's Gemfile, the stripped
|
|
396
|
+
# environment is the one that cannot see it. So the inherited environment is
|
|
397
|
+
# the second candidate rather than an alternative design: probing both is what
|
|
398
|
+
# makes foreman-as-a-system-gem and foreman-in-the-Gemfile both work.
|
|
399
|
+
def foreman_env_candidates
|
|
400
|
+
base = copse_env.merge(foreman_port_env)
|
|
401
|
+
overrides = bundler_overrides
|
|
402
|
+
return [base] if overrides.empty?
|
|
403
|
+
|
|
404
|
+
[base.merge(overrides), base]
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Process.spawn *merges* its env hash rather than replacing the environment,
|
|
408
|
+
# so handing it Bundler.original_env is not enough: the inherited BUNDLE_*
|
|
409
|
+
# keys survive the merge. Removing them requires explicit nils.
|
|
410
|
+
def bundler_overrides
|
|
411
|
+
return {} unless defined?(Bundler) && Bundler.respond_to?(:original_env)
|
|
412
|
+
|
|
413
|
+
original = Bundler.original_env
|
|
414
|
+
overrides = {}
|
|
415
|
+
(ENV.keys - original.keys).each { |key| overrides[key] = nil }
|
|
416
|
+
original.each { |key, value| overrides[key] = value if ENV[key] != value }
|
|
417
|
+
overrides
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
# The foreground command, with any explicit port flag removed so the derived
|
|
421
|
+
# PORT applies. Falls back to `bin/rails server` when there is no Procfile, or
|
|
422
|
+
# when a Procfile exists but names no `web` process.
|
|
423
|
+
# The foreground command goes through the same signal-transparency transform as
|
|
424
|
+
# the secondaries. Without it, a `web` line needing a shell (`... 2>&1`) made
|
|
425
|
+
# @web_pid the shell rather than the app: teardown reaped the shell instantly
|
|
426
|
+
# and the real server survived, reparented to pid 1, still holding the derived
|
|
427
|
+
# port. `exec` is only added when the command would have gone through /bin/sh
|
|
428
|
+
# anyway, so the common metacharacter-free case is untouched.
|
|
429
|
+
def web_command
|
|
430
|
+
entry = procfile&.web
|
|
431
|
+
return "bin/rails server" if entry.nil?
|
|
432
|
+
|
|
433
|
+
command, warning = Procfile.signal_transparent(Procfile.strip_port_flag(entry.command))
|
|
434
|
+
@out.puts "copse: `web` #{warning}" if warning
|
|
435
|
+
command
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Non-web entries. When a Procfile exists but has no `web` line, every entry
|
|
439
|
+
# is a secondary and the foreground falls back to `bin/rails server`.
|
|
440
|
+
def secondaries
|
|
441
|
+
return [] if procfile.nil?
|
|
442
|
+
|
|
443
|
+
procfile.web ? procfile.secondaries : procfile.entries
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def secondaries?
|
|
447
|
+
secondaries.any?
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Only Overmind cares: it hands `web` the port at its Procfile index, so a
|
|
451
|
+
# `web` line that is not first gets `port + index * 100`. On the foreman path
|
|
452
|
+
# Copse spawns `web` itself with the derived PORT, so its position is
|
|
453
|
+
# irrelevant.
|
|
454
|
+
def web_out_of_position?
|
|
455
|
+
entry = procfile&.web
|
|
456
|
+
!entry.nil? && procfile.entries.first != entry
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def overmind_web_position_warning
|
|
460
|
+
index = procfile.entries.index(procfile.web)
|
|
461
|
+
"copse: `web` is entry #{index + 1} in Procfile.dev, so overmind will start it on " \
|
|
462
|
+
"#{worktree.port + index * 100} rather than the derived port #{worktree.port} " \
|
|
463
|
+
"(each process gets base + index * 100). Move `web` to the top of Procfile.dev."
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Writes the secondaries to a Procfile foreman can run, inside a private
|
|
467
|
+
# directory. The file's contents are commands foreman will execute, and
|
|
468
|
+
# derived hostnames are deliberately reproducible, so a predictable path in a
|
|
469
|
+
# world-writable directory would be a symlink/TOCTOU surface.
|
|
470
|
+
#
|
|
471
|
+
# Returns the Procfile path. The directory is recorded on the instance the
|
|
472
|
+
# moment it exists, so teardown can remove it even if a later step in this
|
|
473
|
+
# method raises -- returning it to the caller would have leaked it.
|
|
474
|
+
def write_temp_procfile
|
|
475
|
+
dir = Dir.mktmpdir("copse-")
|
|
476
|
+
@procfile_dir = dir
|
|
477
|
+
File.chmod(0o700, dir)
|
|
478
|
+
path = File.join(dir, "Procfile")
|
|
479
|
+
|
|
480
|
+
lines = secondaries.map do |entry|
|
|
481
|
+
command, warning = Procfile.signal_transparent(entry.command)
|
|
482
|
+
@out.puts "copse: `#{entry.name}` #{warning}" if warning
|
|
483
|
+
stdin_warning = Procfile.stdin_sensitive_warning(entry.command)
|
|
484
|
+
@out.puts "copse: `#{entry.name}` #{stdin_warning}" if stdin_warning
|
|
485
|
+
"#{entry.name}: #{command}\n"
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
489
|
+
file.write(lines.join)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
path
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Whether `foreman start` will actually work.
|
|
496
|
+
#
|
|
497
|
+
# Probing is the only reliable answer. `command -v foreman` and
|
|
498
|
+
# File.executable? both succeed on a version-manager shim whose gem is absent
|
|
499
|
+
# for the active Ruby, and the real invocation then dies with a
|
|
500
|
+
# Gem::GemNotFoundException backtrace. Capturing stderr is the point: it is
|
|
501
|
+
# what keeps that backtrace off the developer's screen.
|
|
502
|
+
#
|
|
503
|
+
# Probes with the same environment the real spawn uses -- otherwise the
|
|
504
|
+
# bundler case passes the probe and fails the run.
|
|
505
|
+
def foreman_available?
|
|
506
|
+
foreman_version
|
|
507
|
+
@foreman_probe[:ok]
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def foreman_version
|
|
511
|
+
return @foreman_probe[:version] if defined?(@foreman_probe)
|
|
512
|
+
|
|
513
|
+
@foreman_probe = { ok: false, version: nil }
|
|
514
|
+
|
|
515
|
+
foreman_env_candidates.each do |candidate|
|
|
516
|
+
out, _err, status = begin
|
|
517
|
+
Open3.capture3(candidate, "foreman", "version")
|
|
518
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
519
|
+
# Not on this candidate's PATH, or not executable. Try the next one.
|
|
520
|
+
next
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
next unless status.success?
|
|
524
|
+
|
|
525
|
+
# Remember the environment that worked: the real spawn must use the same
|
|
526
|
+
# one, or the probe proves nothing.
|
|
527
|
+
@foreman_env = candidate
|
|
528
|
+
@foreman_probe = { ok: true, version: out.strip }
|
|
529
|
+
break
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
@foreman_probe[:version]
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# One clear line, never a backtrace.
|
|
536
|
+
def foreman_error_message
|
|
537
|
+
"copse: cannot run `foreman`, which is needed for the #{secondaries.size} non-web " \
|
|
538
|
+
"#{secondaries.size == 1 ? 'process' : 'processes'} in Procfile.dev. " \
|
|
539
|
+
"Add `foreman` to your Gemfile, or install it for the Ruby you are using " \
|
|
540
|
+
"(a version manager keeps gems per Ruby version, so switching versions can " \
|
|
541
|
+
"leave foreman behind)."
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def foreman_outdated?
|
|
545
|
+
version = foreman_version
|
|
546
|
+
return false if version.nil?
|
|
547
|
+
|
|
548
|
+
Gem::Version.new(version) < Gem::Version.new(MIN_FOREMAN_VERSION)
|
|
549
|
+
rescue ArgumentError
|
|
550
|
+
false
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def foreman_version_warning
|
|
554
|
+
"copse: foreman #{foreman_version} is older than #{MIN_FOREMAN_VERSION}; " \
|
|
555
|
+
"process teardown is only verified from #{MIN_FOREMAN_VERSION} onward."
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Copse
|
|
4
|
+
# Decides whether Copse should set `default_url_options`, and to what.
|
|
5
|
+
#
|
|
6
|
+
# Deliberately Rails-free so every guard can be tested without booting an
|
|
7
|
+
# application -- which matters because only one Rails::Application can be
|
|
8
|
+
# initialized per process.
|
|
9
|
+
module UrlOptions
|
|
10
|
+
# Copse only speaks up when all three are true:
|
|
11
|
+
# * this is development,
|
|
12
|
+
# * the app was actually booted by Copse (COPSE_URL is present), and
|
|
13
|
+
# * the app has not already set its own host.
|
|
14
|
+
#
|
|
15
|
+
# A plain `bin/rails server`, any other environment, and any app with explicit
|
|
16
|
+
# URL options are all left alone.
|
|
17
|
+
def self.apply?(env: ENV, rails_env:, existing_host: nil)
|
|
18
|
+
return false unless rails_env.to_s == "development"
|
|
19
|
+
return false if env["COPSE_URL"].to_s.empty?
|
|
20
|
+
return false unless existing_host.to_s.empty?
|
|
21
|
+
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The options to merge. Prefers COPSE_PORT over PORT: under foreman, PORT is
|
|
26
|
+
# rewritten per child, while COPSE_PORT always carries the derived port.
|
|
27
|
+
def self.for(env: ENV)
|
|
28
|
+
options = { host: env["COPSE_HOST"] }
|
|
29
|
+
port = env["COPSE_PORT"] || env["PORT"]
|
|
30
|
+
options[:port] = Integer(port, exception: false) unless port.to_s.empty?
|
|
31
|
+
options.compact
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.url(env: ENV)
|
|
35
|
+
env["COPSE_URL"]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|