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