rails-dev 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 +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +134 -0
- data/docs/reference.md +220 -0
- data/exe/rails-dev +6 -0
- data/lib/rails_dev/child.rb +111 -0
- data/lib/rails_dev/cli/base.rb +41 -0
- data/lib/rails_dev/cli/main.rb +69 -0
- data/lib/rails_dev/cli/services.rb +28 -0
- data/lib/rails_dev/cli/shell.rb +25 -0
- data/lib/rails_dev/cli.rb +44 -0
- data/lib/rails_dev/commands.rb +115 -0
- data/lib/rails_dev/compose.rb +97 -0
- data/lib/rails_dev/configuration.rb +137 -0
- data/lib/rails_dev/cookies.rb +22 -0
- data/lib/rails_dev/endpoint.rb +105 -0
- data/lib/rails_dev/foreman.rb +45 -0
- data/lib/rails_dev/hooks.rb +43 -0
- data/lib/rails_dev/instance.rb +145 -0
- data/lib/rails_dev/output.rb +31 -0
- data/lib/rails_dev/portless.mjs +28 -0
- data/lib/rails_dev/portless.rb +110 -0
- data/lib/rails_dev/ports.rb +20 -0
- data/lib/rails_dev/process_table.rb +32 -0
- data/lib/rails_dev/rails_vite.rb +14 -0
- data/lib/rails_dev/railtie.rb +21 -0
- data/lib/rails_dev/service_ports.rb +88 -0
- data/lib/rails_dev/services.rb +37 -0
- data/lib/rails_dev/session.rb +215 -0
- data/lib/rails_dev/state.rb +60 -0
- data/lib/rails_dev/tailscale.rb +59 -0
- data/lib/rails_dev/version.rb +5 -0
- data/lib/rails_dev/vite.mjs +31 -0
- data/lib/rails_dev.rb +55 -0
- metadata +141 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module RailsDev
|
|
6
|
+
module CLI
|
|
7
|
+
class << self
|
|
8
|
+
def run(argv, output: $stdout, error: $stderr)
|
|
9
|
+
output = Output.new(output)
|
|
10
|
+
shell = Shell.new(output, Output.new(error))
|
|
11
|
+
validate_arguments(argv)
|
|
12
|
+
Main.start(argv, debug: true, shell: shell)
|
|
13
|
+
0
|
|
14
|
+
rescue Interrupted => error
|
|
15
|
+
output.puts("[rails-dev] #{error.message}")
|
|
16
|
+
128 + Signal.list.fetch(error.signal)
|
|
17
|
+
rescue Error, Thor::Error, Errno::ENOENT => error
|
|
18
|
+
shell.error("rails-dev: #{error.message}")
|
|
19
|
+
error.is_a?(Error) ? error.status : 1
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def validate_arguments(argv)
|
|
25
|
+
raise Thor::InvocationError, "Specify a command. See rails-dev --help." if argv.empty?
|
|
26
|
+
|
|
27
|
+
options = argv.take_while { |argument| argument != "--" }
|
|
28
|
+
options.each_with_index do |argument, index|
|
|
29
|
+
next unless argument == "--root"
|
|
30
|
+
|
|
31
|
+
value = options[index + 1]
|
|
32
|
+
if value.nil? || value.start_with?("-")
|
|
33
|
+
raise Thor::MalformattedArgumentError, "No value provided for option '--root'"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
require_relative "cli/shell"
|
|
42
|
+
require_relative "cli/base"
|
|
43
|
+
require_relative "cli/services"
|
|
44
|
+
require_relative "cli/main"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler"
|
|
4
|
+
|
|
5
|
+
module RailsDev
|
|
6
|
+
class Commands
|
|
7
|
+
attr_reader :env
|
|
8
|
+
attr_writer :instance
|
|
9
|
+
|
|
10
|
+
def initialize(root, output: $stdout, shutdown_timeout: 3)
|
|
11
|
+
@root = root
|
|
12
|
+
@output = Output.new(output)
|
|
13
|
+
@env = Bundler.unbundled_env
|
|
14
|
+
@children = []
|
|
15
|
+
@shutdown_timeout = shutdown_timeout
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def with_signals
|
|
19
|
+
previous = %w[INT TERM HUP].to_h { |signal| [signal, Signal.trap(signal) { @signal = signal }] }
|
|
20
|
+
yield
|
|
21
|
+
ensure
|
|
22
|
+
stop
|
|
23
|
+
previous&.each { |signal, handler| Signal.trap(signal, handler) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(*argv)
|
|
27
|
+
execute(argv, stream: true)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def capture(*argv)
|
|
31
|
+
execute(argv, stream: false)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def start(name, argv, stream: true, ready_message: nil, keep_stdin_open: false)
|
|
35
|
+
check_for_interrupt
|
|
36
|
+
child = Child.new(name, argv, env: env, root: @root, output: stream ? @output : nil,
|
|
37
|
+
ready_message: ready_message, keep_stdin_open: keep_stdin_open)
|
|
38
|
+
@children << child
|
|
39
|
+
record_processes
|
|
40
|
+
child
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def replace(argv)
|
|
44
|
+
Kernel.exec(env, [argv.first, argv.first], *argv.drop(1), chdir: @root, unsetenv_others: true)
|
|
45
|
+
rescue Errno::ENOENT
|
|
46
|
+
raise Error, "Command not found: #{argv.first}."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def stop(selected = @children.dup)
|
|
50
|
+
return if selected.empty?
|
|
51
|
+
|
|
52
|
+
selected.each { |child| child.signal("TERM") }
|
|
53
|
+
wait_for_shutdown(selected)
|
|
54
|
+
selected.each do |child|
|
|
55
|
+
child.signal("KILL")
|
|
56
|
+
child.reap
|
|
57
|
+
child.close
|
|
58
|
+
@children.delete(child)
|
|
59
|
+
end
|
|
60
|
+
record_processes
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def record_processes
|
|
64
|
+
@instance&.record_processes(@children.map(&:pid))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def check_for_interrupt
|
|
68
|
+
return unless @signal
|
|
69
|
+
|
|
70
|
+
signal = @signal
|
|
71
|
+
@signal = nil
|
|
72
|
+
raise Interrupted, signal
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def monotonic_time
|
|
76
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def say(message)
|
|
80
|
+
@output.puts("[rails-dev] #{message}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def execute(argv, stream:)
|
|
86
|
+
child = start(argv.first, argv, stream: stream)
|
|
87
|
+
wait(child)
|
|
88
|
+
raise Error, child.failure_message unless child.status.success?
|
|
89
|
+
|
|
90
|
+
child.stdout
|
|
91
|
+
ensure
|
|
92
|
+
stop([child]) if child
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def wait(child)
|
|
96
|
+
while child.running?
|
|
97
|
+
check_for_interrupt
|
|
98
|
+
failed = @children.find { |other| other != child && !other.running? }
|
|
99
|
+
raise Error, "#{failed.name} exited while running #{child.name} (#{failed.exit_reason})." if failed
|
|
100
|
+
|
|
101
|
+
sleep 0.03
|
|
102
|
+
end
|
|
103
|
+
child.poll
|
|
104
|
+
check_for_interrupt
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def wait_for_shutdown(selected)
|
|
108
|
+
deadline = monotonic_time + @shutdown_timeout
|
|
109
|
+
until selected.none?(&:group_alive?) || monotonic_time >= deadline
|
|
110
|
+
selected.each(&:poll)
|
|
111
|
+
sleep 0.03
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rubygems/version"
|
|
5
|
+
|
|
6
|
+
module RailsDev
|
|
7
|
+
class Compose
|
|
8
|
+
attr_reader :lock_key
|
|
9
|
+
|
|
10
|
+
def initialize(config, commands, state, runtime)
|
|
11
|
+
@config = config
|
|
12
|
+
@commands = commands
|
|
13
|
+
@state = state
|
|
14
|
+
@runtime = runtime
|
|
15
|
+
@base = ["docker", "compose", "-f", config.compose_file]
|
|
16
|
+
@base += config.profiles.flat_map { |profile| ["--profile", profile] }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def start
|
|
20
|
+
check_version!
|
|
21
|
+
load_project
|
|
22
|
+
@plan = ServicePorts.new(@services, @config)
|
|
23
|
+
return {} if @services.empty?
|
|
24
|
+
|
|
25
|
+
prepare_ports
|
|
26
|
+
@state.synchronize(lock_key) do
|
|
27
|
+
@commands.run(*@command, "up", "-d", "--wait", "--wait-timeout", @config.startup_timeout.to_s,
|
|
28
|
+
"--no-recreate", *@services.keys)
|
|
29
|
+
@plan.environment(endpoints)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def stop
|
|
34
|
+
load_project
|
|
35
|
+
return if @services.empty?
|
|
36
|
+
|
|
37
|
+
@state.synchronize(lock_key) do
|
|
38
|
+
@commands.say("Stopping shared services: #{@services.keys.join(", ")}. Containers and volumes are kept.")
|
|
39
|
+
@commands.run(*@base, "stop", *@services.keys)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def check_version!
|
|
46
|
+
version = @commands.capture("docker", "compose", "version", "--short").strip.delete_prefix("v")
|
|
47
|
+
return if Gem::Version.correct?(version) && Gem::Version.new(version) >= Gem::Version.new("2.24.4")
|
|
48
|
+
|
|
49
|
+
raise Error, "Docker Compose 2.24.4 or newer is required for port overrides."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def load_project
|
|
53
|
+
model = JSON.parse(@commands.capture(*@base, "config", "--format", "json"))
|
|
54
|
+
@lock_key = "compose:#{model.fetch("name")}"
|
|
55
|
+
@services = model.fetch("services", {}).except(*@config.exclude_services)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def prepare_ports
|
|
59
|
+
path = File.join(@runtime, "compose.ports.yml")
|
|
60
|
+
File.write(path, @plan.override, perm: 0o600)
|
|
61
|
+
@command = [*@base, "-f", path]
|
|
62
|
+
@commands.capture(*@command, "config", "--quiet")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def endpoints
|
|
66
|
+
publications = published_ports
|
|
67
|
+
@plan.ports.each_with_object({}) do |(service, ports), result|
|
|
68
|
+
ports.each do |port|
|
|
69
|
+
target, protocol = port.values_at("target", "protocol")
|
|
70
|
+
result[[service, target, protocol]] = endpoint(service, target, protocol, publications.fetch(service, []))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def published_ports
|
|
76
|
+
containers = @commands.capture(*@command, "ps", "--all", "--format", "json", *@services.keys)
|
|
77
|
+
.lines.map { |line| JSON.parse(line) }
|
|
78
|
+
containers.group_by { |container| container.fetch("Service") }.transform_values do |group|
|
|
79
|
+
group.flat_map { |container| container["Publishers"] || [] }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def endpoint(service, target, protocol, publishers)
|
|
84
|
+
bindings = publishers.select do |port|
|
|
85
|
+
port["TargetPort"] == target && port["Protocol"] == protocol && port["PublishedPort"].positive?
|
|
86
|
+
end
|
|
87
|
+
unless bindings.one? && bindings.first["URL"] == "127.0.0.1"
|
|
88
|
+
raise Error, "#{service}:#{target}/#{protocol} must publish one port on 127.0.0.1; " \
|
|
89
|
+
"Docker returned #{bindings.inspect}. Existing containers are not recreated automatically."
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
published = bindings.first.fetch("PublishedPort").to_s
|
|
93
|
+
@commands.say("#{service}:#{target}/#{protocol} -> 127.0.0.1:#{published} (shared; kept running on exit)")
|
|
94
|
+
{ host: "127.0.0.1", port: published }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDev
|
|
4
|
+
class Configuration
|
|
5
|
+
HOOKS = %i[before_start after_ready after_stop around_run].freeze
|
|
6
|
+
|
|
7
|
+
attr_accessor :compose_file, :procfile, :web, :startup_timeout, :shutdown_timeout, :readiness, :https, :name,
|
|
8
|
+
:env, :exclude_services, :profiles, :publish_ports, :integrations
|
|
9
|
+
|
|
10
|
+
attr_reader :connections, :hooks, :optional_processes
|
|
11
|
+
|
|
12
|
+
def self.load(root, global_path: default_global_path)
|
|
13
|
+
config = new
|
|
14
|
+
paths = [global_path, File.join(root, "config/rails_dev.rb"), File.join(root, "config/rails_dev.local.rb")]
|
|
15
|
+
RailsDev.load_configuration(config, paths)
|
|
16
|
+
config.validate!(root)
|
|
17
|
+
config
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.default_global_path
|
|
21
|
+
File.join(ENV.fetch("XDG_CONFIG_HOME", File.join(Dir.home, ".config")), "rails-dev/config.rb")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@https = true
|
|
26
|
+
@procfile = "Procfile.dev"
|
|
27
|
+
@web = "web"
|
|
28
|
+
@startup_timeout = 90
|
|
29
|
+
@shutdown_timeout = 5
|
|
30
|
+
@readiness = {}
|
|
31
|
+
@http = {}
|
|
32
|
+
@optional_processes = {}
|
|
33
|
+
@env = {}
|
|
34
|
+
@exclude_services = []
|
|
35
|
+
@profiles = []
|
|
36
|
+
@publish_ports = {}
|
|
37
|
+
@integrations = []
|
|
38
|
+
@connections = {}
|
|
39
|
+
@hooks = HOOKS.to_h { |name| [name, []] }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
HOOKS.each do |name|
|
|
43
|
+
define_method(name) do |&callback|
|
|
44
|
+
hooks.fetch(name) << callback if callback
|
|
45
|
+
hooks.fetch(name)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def http(name, **options)
|
|
50
|
+
@http[name.to_s] = merge_options(@http.fetch(name.to_s, {}), options)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def endpoints
|
|
54
|
+
defaults = { web.to_s => { port: 3000, env: "PORT", url_env: "RAILS_DEV_URL", readiness: { path: "/up" } } }
|
|
55
|
+
defaults.merge(@http) { |_name, inherited, override| merge_options(inherited, override) }.map do |name, options|
|
|
56
|
+
Endpoint.new(name, defaults: readiness, **options)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def optional(process, with: process)
|
|
61
|
+
@optional_processes[process.to_s] = with.to_s
|
|
62
|
+
@integrations |= [with.to_s]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def enable_integrations(names)
|
|
66
|
+
selected = Array(names).map(&:to_s)
|
|
67
|
+
available = integrations.map(&:to_s)
|
|
68
|
+
unknown = selected - available
|
|
69
|
+
unless unknown.empty?
|
|
70
|
+
raise Error, "Unknown integrations: #{unknown.join(", ")}. " \
|
|
71
|
+
"Available integrations: #{available.empty? ? "none" : available.join(", ")}."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
@enabled_integrations = selected
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def enabled?(name)
|
|
78
|
+
Array(@enabled_integrations).include?(name.to_s)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def connect(service, port:, env:, protocol: "tcp")
|
|
82
|
+
env.each do |variable, template|
|
|
83
|
+
unless variable.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
84
|
+
raise Error, "Invalid environment variable name: #{variable.inspect}."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
tokens = template.scan(/%\{([^}]+)\}/).flatten
|
|
88
|
+
raise Error, "#{variable}: use only %{host} and %{port} placeholders." unless (tokens - %w[host port]).empty?
|
|
89
|
+
|
|
90
|
+
@connections[variable] = { service: service.to_s, port: port, protocol: protocol, value: template }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_connection_environment(env)
|
|
95
|
+
return unless env["DATABASE_URL"] && connections.key?("PGPORT") && !connections.key?("DATABASE_URL")
|
|
96
|
+
|
|
97
|
+
raise Error, "Unset DATABASE_URL or explicitly map it; it overrides the mapped PGPORT."
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate!(root)
|
|
101
|
+
validate_app!(root)
|
|
102
|
+
validate_name!(root)
|
|
103
|
+
if compose_file
|
|
104
|
+
@compose_file = File.expand_path(compose_file, root)
|
|
105
|
+
raise Error, "Compose file does not exist: #{compose_file}" unless File.file?(compose_file)
|
|
106
|
+
end
|
|
107
|
+
unless startup_timeout.positive? && shutdown_timeout.positive?
|
|
108
|
+
raise Error, "Startup and shutdown timeouts must be positive."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
endpoints
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def merge_options(inherited, overrides)
|
|
117
|
+
inherited.merge(overrides) do |key, previous, value|
|
|
118
|
+
key == :readiness && previous.is_a?(Hash) && value.is_a?(Hash) ? previous.merge(value) : value
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def validate_app!(root)
|
|
123
|
+
%w[Gemfile bin/rails].each do |path|
|
|
124
|
+
next if File.file?(File.join(root, path))
|
|
125
|
+
|
|
126
|
+
raise Error, "Expected a Rails app; missing #{path} in #{root}."
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def validate_name!(root)
|
|
131
|
+
@name ||= File.basename(root).downcase.gsub(/[^a-z0-9]+/, "-")[0, 45].gsub(/\A-|-\z/, "")
|
|
132
|
+
return if name.match?(/\A[a-z0-9](?:[a-z0-9-]{0,43}[a-z0-9])?\z/)
|
|
133
|
+
|
|
134
|
+
raise Error, "Set config.name to a hostname label of 1–45 lowercase letters, digits, or internal hyphens."
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDev
|
|
4
|
+
class Cookies
|
|
5
|
+
def initialize(app, prefix:)
|
|
6
|
+
@app = app
|
|
7
|
+
@prefix = prefix
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(env)
|
|
11
|
+
env["HTTP_COOKIE"] = env.fetch("HTTP_COOKIE", "").split(/;\s*/).filter_map do |cookie|
|
|
12
|
+
cookie.delete_prefix(@prefix) if cookie.start_with?(@prefix)
|
|
13
|
+
end.join("; ")
|
|
14
|
+
|
|
15
|
+
status, headers, body = @app.call(env)
|
|
16
|
+
if headers["set-cookie"]
|
|
17
|
+
headers["set-cookie"] = Array(headers["set-cookie"]).map { |cookie| "#{@prefix}#{cookie}" }
|
|
18
|
+
end
|
|
19
|
+
[status, headers, body]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module RailsDev
|
|
6
|
+
class Endpoint
|
|
7
|
+
attr_reader :name, :port
|
|
8
|
+
attr_accessor :url
|
|
9
|
+
|
|
10
|
+
def initialize(name, port:, env:, url_env: "RAILS_DEV_#{name.upcase.tr("-", "_")}_URL", readiness: false,
|
|
11
|
+
defaults: {})
|
|
12
|
+
@name = name
|
|
13
|
+
@port = port
|
|
14
|
+
@preferred_port = port
|
|
15
|
+
@variable = env
|
|
16
|
+
@url_variable = url_env
|
|
17
|
+
@check = readiness
|
|
18
|
+
@options = { path: "/" }.merge(defaults).merge(readiness.is_a?(Hash) ? readiness : { path: readiness })
|
|
19
|
+
return if port.is_a?(Integer) && (1024..65_535).cover?(port)
|
|
20
|
+
|
|
21
|
+
raise Error, "#{name}: port must be an integer between 1024 and 65535."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def allocate(excluding: [])
|
|
25
|
+
@port = Ports.available(@preferred_port, excluding: excluding)
|
|
26
|
+
@url = "http://127.0.0.1:#{port}"
|
|
27
|
+
@ready = @started = @next_check = @last_failure = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def environment
|
|
31
|
+
{ @variable => port.to_s, @url_variable => url }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def ready?(deadline)
|
|
35
|
+
return true if @ready || !@check
|
|
36
|
+
|
|
37
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
38
|
+
@started ||= now
|
|
39
|
+
remaining = remaining_time(now, deadline)
|
|
40
|
+
return false if @next_check && now < @next_check
|
|
41
|
+
|
|
42
|
+
@next_check = now + @options.fetch(:interval, 0.1)
|
|
43
|
+
@ready = check(remaining)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def readiness_failure
|
|
47
|
+
return if @ready || !@check
|
|
48
|
+
|
|
49
|
+
target = if @check.respond_to?(:call)
|
|
50
|
+
"custom readiness check"
|
|
51
|
+
else
|
|
52
|
+
path = @options.fetch(:path).to_s.split(/[?#]/, 2).first
|
|
53
|
+
"#{@options.fetch(:method, "GET").upcase} http://127.0.0.1:#{port}#{path}"
|
|
54
|
+
end
|
|
55
|
+
"#{name} (#{target}): #{@last_failure || "not checked yet"}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def remaining_time(now, deadline)
|
|
61
|
+
remaining = [deadline, @started + @options.fetch(:timeout, Float::INFINITY)].min - now
|
|
62
|
+
unless remaining.positive?
|
|
63
|
+
raise Error, "Startup timed out: #{readiness_failure}. Check the process output and readiness settings."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
remaining
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check(remaining)
|
|
70
|
+
@last_failure = "check did not pass" if @check.respond_to?(:call)
|
|
71
|
+
@check.respond_to?(:call) ? @check.call(self) : healthy_response?(remaining)
|
|
72
|
+
rescue Error
|
|
73
|
+
raise
|
|
74
|
+
rescue StandardError => error
|
|
75
|
+
raise Error, "#{name} readiness check failed: #{error.message}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def healthy_response?(remaining)
|
|
79
|
+
response = connection(remaining).request(request, @options[:body])
|
|
80
|
+
successful_response?(response)
|
|
81
|
+
rescue SystemCallError, IOError, Timeout::Error, Net::ProtocolError => error
|
|
82
|
+
@last_failure = "request failed (#{error.class})"
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def successful_response?(response)
|
|
87
|
+
success = @options.fetch(:success, 200)
|
|
88
|
+
expected = success.respond_to?(:call) ? "a passing custom success check" : Array(success).join(" or ")
|
|
89
|
+
@last_failure = "HTTP #{response.code}; expected #{expected}"
|
|
90
|
+
success.respond_to?(:call) ? success.call(response) : Array(success).include?(response.code.to_i)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def connection(remaining)
|
|
94
|
+
http = Net::HTTP.new("127.0.0.1", port, nil)
|
|
95
|
+
http.open_timeout = http.read_timeout = http.write_timeout = [@options.fetch(:request_timeout, 1), remaining].min
|
|
96
|
+
http
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def request
|
|
100
|
+
method = @options.fetch(:method, "GET").upcase
|
|
101
|
+
headers = { "Host" => URI(url).host }.merge(@options.fetch(:headers, {}))
|
|
102
|
+
Net::HTTPGenericRequest.new(method, !@options[:body].nil?, method != "HEAD", @options.fetch(:path), headers)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDev
|
|
4
|
+
class Foreman
|
|
5
|
+
attr_reader :endpoints
|
|
6
|
+
|
|
7
|
+
def initialize(root, config)
|
|
8
|
+
@root = root
|
|
9
|
+
@config = config
|
|
10
|
+
@path = File.expand_path(config.procfile, root)
|
|
11
|
+
@names = File.readlines(@path).filter_map { |line| line[/\A([A-Za-z0-9_-]+):\s*.+/, 1] }.uniq
|
|
12
|
+
@endpoints = config.endpoints
|
|
13
|
+
validate!
|
|
14
|
+
@endpoints.select! { |endpoint| selected?(endpoint.name) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def command
|
|
18
|
+
[RbConfig.ruby, Gem.bin_path("foreman", "foreman"), "start", "--procfile", @path, "--root", @root,
|
|
19
|
+
"--port", base_port.to_s, "--formation", formation,
|
|
20
|
+
"--timeout", @config.shutdown_timeout.to_s]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def validate!
|
|
26
|
+
missing = (endpoints.map(&:name) + @config.optional_processes.keys).uniq - @names
|
|
27
|
+
raise Error, "#{@config.procfile} is missing processes: #{missing.join(", ")}." unless missing.empty?
|
|
28
|
+
raise Error, "The primary web process must be enabled." unless selected?(@config.web.to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def selected?(name)
|
|
32
|
+
selection = @config.optional_processes[name]
|
|
33
|
+
!selection || @config.enabled?(selection)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def base_port
|
|
37
|
+
port = endpoints.find { |endpoint| endpoint.name == @config.web.to_s }.port
|
|
38
|
+
port - (@names.index(@config.web.to_s) * 100)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def formation
|
|
42
|
+
@names.map { |name| "#{name}=#{selected?(name) ? 1 : 0}" }.join(",")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDev
|
|
4
|
+
class Hooks
|
|
5
|
+
def initialize(config, context)
|
|
6
|
+
@callbacks = config.hooks
|
|
7
|
+
@context = context
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(name)
|
|
11
|
+
@callbacks.fetch(name).each { |callback| invoke(name, callback) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def around(index = 0, &)
|
|
15
|
+
callback = @callbacks.fetch(:around_run)[index]
|
|
16
|
+
return yield unless callback
|
|
17
|
+
|
|
18
|
+
invoke(:around_run, callback, -> { around(index + 1, &) })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def after_stop(failure = nil)
|
|
22
|
+
@callbacks.fetch(:after_stop).reverse_each do |callback|
|
|
23
|
+
invoke(:after_stop, callback)
|
|
24
|
+
rescue StandardError => error
|
|
25
|
+
@context.say(error.message) if failure
|
|
26
|
+
failure ||= error
|
|
27
|
+
end
|
|
28
|
+
raise failure if failure
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def invoke(name, callback, *)
|
|
34
|
+
@context.check_for_interrupt
|
|
35
|
+
callback.call(@context, *)
|
|
36
|
+
@context.check_for_interrupt
|
|
37
|
+
rescue Error
|
|
38
|
+
raise
|
|
39
|
+
rescue StandardError => error
|
|
40
|
+
raise Error, "#{name} hook failed: #{error.message}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|