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,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDev
|
|
4
|
+
class Instance
|
|
5
|
+
def initialize(directory)
|
|
6
|
+
@directory = directory
|
|
7
|
+
@path = File.join(directory, "instance.json")
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def id
|
|
11
|
+
File.basename(@directory)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def record(**details)
|
|
15
|
+
data = File.file?(@path) ? read : {}
|
|
16
|
+
File.write("#{@path}.tmp", JSON.generate(data.merge(details.transform_keys(&:to_s))), perm: 0o600)
|
|
17
|
+
File.rename("#{@path}.tmp", @path)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def record_processes(groups)
|
|
21
|
+
record(processes: ProcessTable.new.members(groups))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def snapshot
|
|
25
|
+
File.open(File.join(@directory, "owner.lock")) do |owner|
|
|
26
|
+
data = read.merge("id" => id)
|
|
27
|
+
data["status"] = interrupted_status(data) if owner.flock(File::LOCK_EX | File::LOCK_NB)
|
|
28
|
+
data
|
|
29
|
+
end
|
|
30
|
+
rescue Errno::ENOENT
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def stop
|
|
35
|
+
File.open(File.join(@directory, "owner.lock")) do |owner|
|
|
36
|
+
stop_owner(owner)
|
|
37
|
+
recover if File.file?(@path)
|
|
38
|
+
end
|
|
39
|
+
rescue Errno::ENOENT
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def read
|
|
46
|
+
JSON.parse(File.read(@path))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def interrupted_status(data)
|
|
50
|
+
records = data["processes"]
|
|
51
|
+
return "interrupted" unless records
|
|
52
|
+
|
|
53
|
+
table = ProcessTable.new
|
|
54
|
+
return "orphaned" if records.any? { |identity| table.include?(identity) }
|
|
55
|
+
|
|
56
|
+
table.members(process_groups(records)).empty? ? "stale" : "interrupted"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def process_groups(records)
|
|
60
|
+
records.map { |identity| identity[1] }.uniq
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def stop_owner(owner)
|
|
64
|
+
return if owner.flock(File::LOCK_EX | File::LOCK_NB)
|
|
65
|
+
|
|
66
|
+
timeout = read.fetch("shutdown_timeout", 5)
|
|
67
|
+
signal_owner(owner, "TERM")
|
|
68
|
+
return if wait(timeout + 2) { owner.flock(File::LOCK_EX | File::LOCK_NB) }
|
|
69
|
+
|
|
70
|
+
signal_owner(owner, "KILL")
|
|
71
|
+
return if wait(2) { owner.flock(File::LOCK_EX | File::LOCK_NB) }
|
|
72
|
+
|
|
73
|
+
raise Error, "Instance #{id} is still stopping; retry with rails-dev stop #{id}."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def signal_owner(owner, signal)
|
|
77
|
+
data = read
|
|
78
|
+
identity = data["owner"]
|
|
79
|
+
if ProcessTable.new.include?(identity)
|
|
80
|
+
signal(signal, identity.first)
|
|
81
|
+
elsif !owner.flock(File::LOCK_EX | File::LOCK_NB)
|
|
82
|
+
raise Error, recovery_error("Cannot verify the launcher for #{id} (PID #{data.fetch("pid")}). " \
|
|
83
|
+
"Stop it from its original terminal.")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def recovery_error(message)
|
|
88
|
+
"#{message}\nInspect #{@path} and verify process ownership before manual cleanup."
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def recover
|
|
92
|
+
data = read
|
|
93
|
+
@records = data.fetch("processes") do
|
|
94
|
+
raise Error, recovery_error("Cannot recover #{id}: process ownership was not recorded.")
|
|
95
|
+
end
|
|
96
|
+
@records = verified_members
|
|
97
|
+
record(processes: @records)
|
|
98
|
+
stop_groups(data.fetch("shutdown_timeout", 5))
|
|
99
|
+
FileUtils.remove_entry(@directory)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def verified_members
|
|
103
|
+
table = ProcessTable.new
|
|
104
|
+
members = table.members(process_groups(@records))
|
|
105
|
+
verified = process_groups(@records.select { |identity| table.include?(identity) })
|
|
106
|
+
unverified = process_groups(members) - verified
|
|
107
|
+
unless unverified.empty?
|
|
108
|
+
raise Error, recovery_error("Cannot verify process groups #{unverified.join(", ")} for #{id}. " \
|
|
109
|
+
"Recovery record kept.")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
members
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def stop_groups(timeout)
|
|
116
|
+
signal_groups("TERM")
|
|
117
|
+
return if wait(timeout) { verified_members.empty? }
|
|
118
|
+
|
|
119
|
+
signal_groups("KILL")
|
|
120
|
+
return if wait(2) { verified_members.empty? }
|
|
121
|
+
|
|
122
|
+
raise Error, "Instance #{id} is still stopping; retry with rails-dev stop #{id}."
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def signal_groups(value)
|
|
126
|
+
process_groups(verified_members).each { |group| signal(value, -group) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def signal(value, pid)
|
|
130
|
+
Process.kill(value, pid)
|
|
131
|
+
rescue Errno::ESRCH
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def wait(timeout)
|
|
136
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
137
|
+
loop do
|
|
138
|
+
return true if yield
|
|
139
|
+
return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
140
|
+
|
|
141
|
+
sleep 0.05
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module RailsDev
|
|
6
|
+
class Output < SimpleDelegator
|
|
7
|
+
def write(*)
|
|
8
|
+
super
|
|
9
|
+
rescue Errno::EIO, Errno::EPIPE
|
|
10
|
+
0
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def puts(*)
|
|
14
|
+
super
|
|
15
|
+
rescue Errno::EIO, Errno::EPIPE
|
|
16
|
+
nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def print(*)
|
|
20
|
+
super
|
|
21
|
+
rescue Errno::EIO, Errno::EPIPE
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def flush
|
|
26
|
+
super
|
|
27
|
+
rescue Errno::EIO, Errno::EPIPE
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { pathToFileURL } from 'node:url'
|
|
2
|
+
|
|
3
|
+
const [modulePath, directory, routeJSON] = process.argv.slice(2)
|
|
4
|
+
const { RouteStore } = await import(pathToFileURL(modulePath).href)
|
|
5
|
+
const store = new RouteStore(directory)
|
|
6
|
+
const routes = Object.entries(JSON.parse(routeJSON))
|
|
7
|
+
|
|
8
|
+
function stop() {
|
|
9
|
+
cleanup()
|
|
10
|
+
process.exit(0)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function cleanup() {
|
|
14
|
+
for (const [hostname] of routes) store.removeRoute(hostname, process.pid)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
process.on('SIGTERM', stop)
|
|
18
|
+
process.on('SIGINT', stop)
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
for (const [hostname, port] of routes) store.addRoute(hostname, port, process.pid)
|
|
22
|
+
setInterval(() => {}, 60_000)
|
|
23
|
+
console.log('HTTPS routes registered')
|
|
24
|
+
} catch (error) {
|
|
25
|
+
cleanup()
|
|
26
|
+
console.error(error.message)
|
|
27
|
+
process.exitCode = 1
|
|
28
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module RailsDev
|
|
6
|
+
class Portless
|
|
7
|
+
VERSION = "0.15.6"
|
|
8
|
+
|
|
9
|
+
attr_reader :url
|
|
10
|
+
|
|
11
|
+
def initialize(commands)
|
|
12
|
+
@commands = commands
|
|
13
|
+
data = commands.env.fetch("XDG_DATA_HOME", File.join(Dir.home, ".local/share"))
|
|
14
|
+
@prefix = File.join(data, "rails-dev/portless", VERSION)
|
|
15
|
+
@package = File.join(@prefix, "lib/node_modules/portless")
|
|
16
|
+
@directory = File.expand_path(commands.env.fetch("PORTLESS_STATE_DIR", File.join(Dir.home, ".portless")))
|
|
17
|
+
commands.env["PORTLESS_STATE_DIR"] = @directory
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def setup(port: 443)
|
|
21
|
+
raise Error, "Proxy port must be between 1 and 65535." unless (1..65_535).cover?(port)
|
|
22
|
+
|
|
23
|
+
@commands.with_signals { install }
|
|
24
|
+
@commands.env.merge!("PORTLESS_LAN" => "0", "PORTLESS_WILDCARD" => "0")
|
|
25
|
+
@commands.replace([*cli, "service", "install", "--https", "--port", port.to_s, "--tld", "localhost"])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def install
|
|
29
|
+
version = @commands.capture("node", "--version").delete_prefix("v").to_i
|
|
30
|
+
raise Error, "Portless requires Node.js 24 or newer." if version < 24
|
|
31
|
+
return if installed?
|
|
32
|
+
|
|
33
|
+
@commands.run("npm", "install", "--global", "--prefix", @prefix,
|
|
34
|
+
"--ignore-scripts", "--no-audit", "--no-fund", "portless@#{VERSION}")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def doctor
|
|
38
|
+
require_installation
|
|
39
|
+
@commands.replace([*cli, "doctor"])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def check
|
|
43
|
+
require_installation
|
|
44
|
+
@url = URI(@commands.capture(*cli, "get", "rails-dev", "--no-worktree").strip)
|
|
45
|
+
unless url.is_a?(URI::HTTPS) && url.host == "rails-dev.localhost"
|
|
46
|
+
raise Error, "The shared Portless proxy must use HTTPS and .localhost. Run rails-dev setup."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
verify_proxy
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def start(name, endpoints:, web:)
|
|
53
|
+
routes = endpoints.to_h do |endpoint|
|
|
54
|
+
host = hostname(name, endpoint, web)
|
|
55
|
+
endpoint.url = origin(host)
|
|
56
|
+
[host, endpoint.port]
|
|
57
|
+
end
|
|
58
|
+
@commands.env["NODE_EXTRA_CA_CERTS"] ||= File.join(@directory, "ca.pem")
|
|
59
|
+
yield @commands.start("https", ["node", File.join(__dir__, "portless.mjs"),
|
|
60
|
+
File.join(@package, "dist/index.js"), @directory, JSON.generate(routes)],
|
|
61
|
+
ready_message: "HTTPS routes registered")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def hostname(name, endpoint, web)
|
|
67
|
+
label = "#{name}-#{web.port}"
|
|
68
|
+
label += "-#{endpoint.name.downcase.tr("_", "-")}" unless endpoint == web
|
|
69
|
+
"#{label}.localhost"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def cli
|
|
73
|
+
["node", File.join(@package, "dist/cli.js")]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def installed?
|
|
77
|
+
path = File.join(@package, "package.json")
|
|
78
|
+
File.file?(path) && JSON.parse(File.read(path))["version"] == VERSION
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def require_installation
|
|
82
|
+
return if installed?
|
|
83
|
+
|
|
84
|
+
raise Error, "Portless #{VERSION} is not installed for rails-dev. Run rails-dev setup once on this machine."
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def verify_proxy
|
|
88
|
+
return if proxy_connection.get("/")["X-Portless"] == "1"
|
|
89
|
+
|
|
90
|
+
raise Error, "The HTTPS listener is not Portless. Run rails-dev doctor."
|
|
91
|
+
rescue SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::OpenTimeout, Net::ReadTimeout => error
|
|
92
|
+
raise Error, "Cannot reach the HTTPS proxy: #{error.message}. Run rails-dev setup or rails-dev doctor."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def proxy_connection
|
|
96
|
+
http = Net::HTTP.new(url.host, url.port, nil)
|
|
97
|
+
http.ipaddr = "127.0.0.1"
|
|
98
|
+
http.use_ssl = true
|
|
99
|
+
http.ca_file = File.join(@directory, "ca.pem")
|
|
100
|
+
http.open_timeout = http.read_timeout = 3
|
|
101
|
+
http
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def origin(host)
|
|
105
|
+
uri = url.dup
|
|
106
|
+
uri.host = host
|
|
107
|
+
uri.to_s
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
5
|
+
module RailsDev
|
|
6
|
+
module Ports
|
|
7
|
+
def self.available(first, excluding: [])
|
|
8
|
+
(first..65_535).each do |port|
|
|
9
|
+
next if excluding.include?(port)
|
|
10
|
+
|
|
11
|
+
begin
|
|
12
|
+
TCPServer.open("127.0.0.1", port) { return port }
|
|
13
|
+
rescue Errno::EADDRINUSE, Errno::EACCES
|
|
14
|
+
next
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
raise Error, "No available local port at or above #{first}."
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module RailsDev
|
|
6
|
+
class ProcessTable
|
|
7
|
+
def initialize
|
|
8
|
+
output, status = Open3.capture2({ "LC_ALL" => "C", "TZ" => "UTC0" },
|
|
9
|
+
"ps", "-A", "-o", "pid=,pgid=,stat=,lstart=")
|
|
10
|
+
raise Error, "Cannot inspect running processes." unless status.success?
|
|
11
|
+
|
|
12
|
+
@entries = output.lines.filter_map do |line|
|
|
13
|
+
pid, group, state, started = line.strip.split(/\s+/, 4)
|
|
14
|
+
[pid.to_i, [pid.to_i, group.to_i, started]] unless state.start_with?("Z", "X")
|
|
15
|
+
end.to_h
|
|
16
|
+
rescue Errno::ENOENT
|
|
17
|
+
raise Error, "Cannot inspect running processes: ps is unavailable."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def identity(pid)
|
|
21
|
+
@entries[pid]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def include?(identity)
|
|
25
|
+
identity && self.identity(identity.first) == identity
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def members(groups)
|
|
29
|
+
@entries.values.select { |entry| groups.include?(entry[1]) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module RailsDev
|
|
6
|
+
class RailsViteRailtie < Rails::Railtie
|
|
7
|
+
initializer "rails_dev.vite", before: :load_config_initializers do
|
|
8
|
+
next unless Rails.env.development? && ENV.fetch("RAILS_DEV_RUNTIME", nil) && ENV["RAILS_DEV_VITE_URL"]
|
|
9
|
+
|
|
10
|
+
RailsVite.config.dev_meta_path = Pathname.new(ENV.fetch("RAILS_DEV_RUNTIME")).join("rails-vite.json")
|
|
11
|
+
RailsVite.config.auto_build = false
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "cookies"
|
|
6
|
+
|
|
7
|
+
module RailsDev
|
|
8
|
+
class Railtie < Rails::Railtie
|
|
9
|
+
initializer "rails_dev.configure", before: :load_config_initializers do |app|
|
|
10
|
+
next unless Rails.env.development? && ENV.key?("RAILS_DEV_RUNTIME") && ENV.key?("RAILS_DEV_URL")
|
|
11
|
+
|
|
12
|
+
app.middleware.insert_before ActionDispatch::Cookies, Cookies, prefix: "rails-dev-#{ENV.fetch("RAILS_PORT")}-"
|
|
13
|
+
|
|
14
|
+
origin = URI(ENV.fetch("RAILS_DEV_URL"))
|
|
15
|
+
url_options = { host: origin.host, port: origin.port, protocol: origin.scheme }
|
|
16
|
+
app.config.hosts << origin.host
|
|
17
|
+
app.config.action_mailer.default_url_options = url_options
|
|
18
|
+
app.routes.default_url_options.merge!(url_options)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsDev
|
|
4
|
+
class ServicePorts
|
|
5
|
+
attr_reader :ports
|
|
6
|
+
|
|
7
|
+
def initialize(services, config)
|
|
8
|
+
@config = config
|
|
9
|
+
@ports = services.to_h { |name, service| [name, service_ports(name, service)] }
|
|
10
|
+
validate_connections
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def override
|
|
14
|
+
entries = ports.map do |name, mappings|
|
|
15
|
+
" #{name.to_json}:\n ports: !override #{mappings.to_json}\n"
|
|
16
|
+
end
|
|
17
|
+
entries += @config.exclude_services.map { |name| " #{name.to_json}: !reset null\n" }
|
|
18
|
+
"services:\n#{entries.join}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def environment(endpoints)
|
|
22
|
+
@config.connections.to_h do |variable, mapping|
|
|
23
|
+
endpoint = endpoints.fetch([mapping[:service], mapping[:port], mapping[:protocol]])
|
|
24
|
+
value = mapping.fetch(:value).gsub("%{host}", endpoint.fetch(:host)).gsub("%{port}", endpoint.fetch(:port))
|
|
25
|
+
[variable, value]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def service_ports(name, service)
|
|
32
|
+
validate_service(name, service)
|
|
33
|
+
declared = declared_ports(service)
|
|
34
|
+
additional = @config.publish_ports.fetch(name, []).flat_map do |port|
|
|
35
|
+
target, protocol = port.to_s.split("/", 2)
|
|
36
|
+
expand(target, protocol || "tcp")
|
|
37
|
+
end
|
|
38
|
+
(declared + additional).uniq
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate_service(name, service)
|
|
42
|
+
if service["network_mode"] == "host" || service.fetch("scale", 1) != 1 ||
|
|
43
|
+
service.dig("deploy", "replicas").to_i > 1
|
|
44
|
+
raise Error, "#{name}: host networking and multiple replicas are not supported."
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def declared_ports(service)
|
|
49
|
+
service.fetch("ports", []).flat_map do |port|
|
|
50
|
+
expand(port.fetch("target"), port.fetch("protocol", "tcp"))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def expand(target, protocol)
|
|
55
|
+
match = /\A(\d+)(?:-(\d+))?\z/.match(target.to_s)
|
|
56
|
+
raise Error, "Invalid container port: #{target}/#{protocol}." unless match && %w[tcp udp].include?(protocol)
|
|
57
|
+
|
|
58
|
+
port_range(match).map { |port| { "target" => port, "host_ip" => "127.0.0.1", "protocol" => protocol } }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def port_range(match)
|
|
62
|
+
first = match[1].to_i
|
|
63
|
+
last = (match[2] || match[1]).to_i
|
|
64
|
+
unless first.positive? && last >= first && last <= 65_535
|
|
65
|
+
raise Error, "Invalid container port range: #{match[0]}."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
first..last
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_connections
|
|
72
|
+
validate_publications
|
|
73
|
+
@config.connections.each do |variable, mapping|
|
|
74
|
+
port = ports.fetch(mapping.fetch(:service), [])
|
|
75
|
+
next if port.any? { |entry| entry["target"] == mapping[:port] && entry["protocol"] == mapping[:protocol] }
|
|
76
|
+
|
|
77
|
+
raise Error, "#{variable}: mapped port is not published by an active service. " \
|
|
78
|
+
"Use Compose ports or publish_ports."
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_publications
|
|
83
|
+
@config.publish_ports.each_key do |name|
|
|
84
|
+
raise Error, "Cannot publish ports for inactive or unknown service #{name.inspect}." unless ports.key?(name)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
module RailsDev
|
|
6
|
+
class Services
|
|
7
|
+
def initialize(root, config, output: $stdout)
|
|
8
|
+
@config = config
|
|
9
|
+
@commands = Commands.new(root, output: output)
|
|
10
|
+
@state = State.new(@commands)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(action, argv = [])
|
|
14
|
+
@commands.with_signals do
|
|
15
|
+
@commands.env.merge!(@config.env)
|
|
16
|
+
manage(action) if @config.compose_file
|
|
17
|
+
raise Error, "Set config.compose_file to manage Compose services." if !@config.compose_file && action != "exec"
|
|
18
|
+
end
|
|
19
|
+
@commands.replace(argv) if action == "exec"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def manage(action)
|
|
25
|
+
@config.validate_connection_environment(@commands.env) unless action == "stop"
|
|
26
|
+
FileUtils.mkdir_p(@state.directory, mode: 0o700)
|
|
27
|
+
Dir.mktmpdir("services-", @state.directory) do |runtime|
|
|
28
|
+
compose = Compose.new(@config, @commands, @state, runtime)
|
|
29
|
+
if action == "stop"
|
|
30
|
+
compose.stop
|
|
31
|
+
else
|
|
32
|
+
@commands.env.merge!(compose.start)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|