companion 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ab5a186cad9e99952e804f33a041b30ca7081bb527469dc9010d8d8349aeb373
4
+ data.tar.gz: 3069e33d2e3cdd63ded2c08c6ec45757f95da56d9f65a65eaadf3f89b74b2e15
5
+ SHA512:
6
+ metadata.gz: ba4abd0a3d492ea8219e22ea675a5e89e29cf0b205bef4dfbeebb53d7f324f755d294f81eb6739e21dd5820b6073c5dfe1dc63dd0c2dedb47e89f1c28531333e
7
+ data.tar.gz: 3104d72f722a21932b301998de14e82c022e68bfc40e4796857db97acf19f1d9678933d89ec9709e93337b2df73080e7ed17626aadb2ee6aa5df5cea339ea116
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Kevin Sylvestre
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,92 @@
1
+ # Companion
2
+
3
+ Companion is a mountable Rails engine for running a companion application (e.g. Node, Python, Go, etc.) alongside a Rails application. Companion spawns and supervises the process, then proxies traffic from a mounted path in your Rails routes to the spawned process.
4
+
5
+ ## Usage
6
+
7
+ ### Installation
8
+
9
+ **Gemfile**
10
+
11
+ ```ruby
12
+ gem "companion"
13
+ ```
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ ### Configuration
20
+
21
+ Initialize your application via a an initializer (e.g. `config/initializers/companion.rb`):
22
+
23
+ ```ruby
24
+ # config/initializers/companion.rb
25
+ Companion.configure do |config|
26
+ config.app :dashboard do |app|
27
+ app.command = -> { |port| "npm run serve -- --port {port}" }
28
+ app.directory = Rails.root.join("dashboard")
29
+ app.environment = { "NODE_ENV" => Rails.env.production? ? "production" : "development" }
30
+ end
31
+ end
32
+ ```
33
+
34
+ ### Routing
35
+
36
+ Mount your application as an engine in `config/routes.rb`:
37
+
38
+ ```ruby
39
+ # config/routes.rb
40
+ Rails.application.routes.draw do
41
+ mount Companion::Engine.app(:dashboard), at: "/dashboard"
42
+ end
43
+ ```
44
+
45
+ ## Examples
46
+
47
+ **companions/greeter/Gemfile**
48
+
49
+ ```ruby
50
+ source "https://rubygems.org"
51
+
52
+ gemspec
53
+
54
+ gem "rack"
55
+ ```
56
+
57
+ **companions/greeter/config.ru**
58
+
59
+ ```ruby
60
+ # companions/greeter/config.ru
61
+
62
+ run do |env|
63
+ [200, { "content-type" => "text/plain" }, ["Greetings!"]]
64
+ end
65
+ ```
66
+
67
+ **config/initializers/companion.rb**
68
+
69
+ ```ruby
70
+ Companion.configure do |config|
71
+ config.app :greeter do |app|
72
+ app.command = -> { |port| "rackup --port #{port}" }
73
+ app.directory = Rails.root.join("companions", "greeter")
74
+ end
75
+ end
76
+ ```
77
+
78
+ **config/routes.rb**
79
+
80
+ ```ruby
81
+ Rails.application.routes.draw do
82
+ mount Companion::Engine.app(:greeter), at: "/greeter"
83
+ end
84
+ ```
85
+
86
+ ```bash
87
+ rails server
88
+ ```
89
+
90
+ ```bash
91
+ curl --verbose https://localhost:3000/greeter
92
+ ```
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+
5
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
6
+ load "rails/tasks/engine.rake"
7
+
8
+ require "bundler/gem_tasks"
9
+ require "rspec/core/rake_task"
10
+
11
+ RSpec::Core::RakeTask.new(:spec)
12
+
13
+ task default: :spec
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Companion
4
+ class ApplicationController < ActionController::Base
5
+ end
6
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Companion
4
+ class AppsController < ApplicationController
5
+ def index
6
+ @apps = Companion.config.apps.values
7
+ end
8
+
9
+ def show
10
+ @app = Companion.config.apps.values.find { |app| app.name.to_s == params[:id] }
11
+ raise ActionController::RoutingError, "unknown app #{params[:id].inspect}" unless @app
12
+
13
+ @sample = @app.sample
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Companion
4
+ module ApplicationHelper
5
+ def companion_command(app)
6
+ app.command.respond_to?(:call) ? String(app.command.call("$PORT")) : String(app.command)
7
+ end
8
+
9
+ def companion_uptime(app)
10
+ app.started_at ? time_ago_in_words(app.started_at) : "—"
11
+ end
12
+
13
+ def companion_directory(app)
14
+ return "—" unless app.directory
15
+
16
+ Pathname(app.directory).expand_path.relative_path_from(Rails.root).to_s
17
+ end
18
+
19
+ def companion_environment(app)
20
+ filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
21
+ filter.filter(app.environment.to_h { |key, value| [String(key), value] })
22
+ end
23
+
24
+ def companion_cpu(value)
25
+ number_to_percentage(value, precision: 1)
26
+ end
27
+
28
+ def companion_memory(rss, percentage)
29
+ "#{number_to_human_size(rss)} (#{number_to_percentage(percentage, precision: 1)})"
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Companion
4
+ class ApplicationJob < ActiveJob::Base
5
+ end
6
+ end
@@ -0,0 +1,38 @@
1
+ <header>
2
+ <h1>Companion</h1>
3
+ </header>
4
+
5
+ <main>
6
+ <% if @apps.empty? %>
7
+ <p>No apps are registered.</p>
8
+ <% else %>
9
+ <figure>
10
+ <table>
11
+ <thead>
12
+ <tr>
13
+ <th>Name</th>
14
+ <th>Status</th>
15
+ <th>PID</th>
16
+ <th>URL</th>
17
+ <th>Uptime</th>
18
+ <th>Command</th>
19
+ <th>Directory</th>
20
+ </tr>
21
+ </thead>
22
+ <tbody>
23
+ <% @apps.each do |app| %>
24
+ <tr id="app_<%= app.name %>">
25
+ <td><%= link_to app.name, app_path(app.name) %></td>
26
+ <td><%= app.status %></td>
27
+ <td><%= app.pid || "—" %></td>
28
+ <td><%= app.url || "—" %></td>
29
+ <td><%= companion_uptime(app) %></td>
30
+ <td><code><%= companion_command(app) %></code></td>
31
+ <td><code><%= companion_directory(app) %></code></td>
32
+ </tr>
33
+ <% end %>
34
+ </tbody>
35
+ </table>
36
+ </figure>
37
+ <% end %>
38
+ </main>
@@ -0,0 +1,141 @@
1
+ <header>
2
+ <nav>
3
+ <ul>
4
+ <li><%= link_to "Companion", root_path %></li>
5
+ <li><strong><%= @app.name %></strong></li>
6
+ </ul>
7
+ </nav>
8
+ <h1><%= @app.name %></h1>
9
+ </header>
10
+
11
+ <main>
12
+ <section id="resources">
13
+ <h2>Resources</h2>
14
+ <% if @sample %>
15
+ <figure>
16
+ <table>
17
+ <tbody>
18
+ <tr>
19
+ <th scope="row">CPU</th>
20
+ <td id="cpu"><%= companion_cpu(@sample.cpu) %></td>
21
+ </tr>
22
+ <tr>
23
+ <th scope="row">Memory</th>
24
+ <td id="memory"><%= companion_memory(@sample.rss, @sample.memory) %></td>
25
+ </tr>
26
+ <tr>
27
+ <th scope="row">Processes</th>
28
+ <td><%= @sample.entries.size %></td>
29
+ </tr>
30
+ <tr>
31
+ <th scope="row">Sampled</th>
32
+ <td><%= @sample.sampled_at.to_fs(:db) %></td>
33
+ </tr>
34
+ </tbody>
35
+ </table>
36
+ </figure>
37
+ <small>CPU is a percentage of one core, totalled across the app's process group.</small>
38
+ <% elsif @app.running? %>
39
+ <p>Resource usage is unavailable.</p>
40
+ <% else %>
41
+ <p>The app is not running.</p>
42
+ <% end %>
43
+ </section>
44
+
45
+ <section id="details">
46
+ <h2>Details</h2>
47
+ <figure>
48
+ <table>
49
+ <tbody>
50
+ <tr>
51
+ <th scope="row">Status</th>
52
+ <td><%= @app.status %></td>
53
+ </tr>
54
+ <tr>
55
+ <th scope="row">PID</th>
56
+ <td><%= @app.pid || "—" %></td>
57
+ </tr>
58
+ <tr>
59
+ <th scope="row">URL</th>
60
+ <td><%= @app.url ? link_to(@app.url, @app.url) : "—" %></td>
61
+ </tr>
62
+ <tr>
63
+ <th scope="row">Host</th>
64
+ <td><code><%= @app.host %></code></td>
65
+ </tr>
66
+ <tr>
67
+ <th scope="row">Port</th>
68
+ <td><%= @app.port || "—" %></td>
69
+ </tr>
70
+ <tr>
71
+ <th scope="row">Started</th>
72
+ <td><%= @app.started_at ? "#{@app.started_at.to_fs(:db)} (#{companion_uptime(@app)} ago)" : "—" %></td>
73
+ </tr>
74
+ <tr>
75
+ <th scope="row">Boot timeout</th>
76
+ <td><%= @app.boot_timeout %>s</td>
77
+ </tr>
78
+ <tr>
79
+ <th scope="row">Command</th>
80
+ <td><code><%= companion_command(@app) %></code></td>
81
+ </tr>
82
+ <tr>
83
+ <th scope="row">Directory</th>
84
+ <td><code><%= companion_directory(@app) %></code></td>
85
+ </tr>
86
+ </tbody>
87
+ </table>
88
+ </figure>
89
+ </section>
90
+
91
+ <% if @sample %>
92
+ <section id="processes">
93
+ <h2>Processes</h2>
94
+ <figure>
95
+ <table>
96
+ <thead>
97
+ <tr>
98
+ <th>PID</th>
99
+ <th>PPID</th>
100
+ <th>CPU</th>
101
+ <th>Memory</th>
102
+ <th>Command</th>
103
+ </tr>
104
+ </thead>
105
+ <tbody>
106
+ <% @sample.entries.each do |entry| %>
107
+ <tr id="process_<%= entry.pid %>">
108
+ <td><%= entry.pid %></td>
109
+ <td><%= entry.ppid %></td>
110
+ <td><%= companion_cpu(entry.cpu) %></td>
111
+ <td><%= companion_memory(entry.rss, entry.memory) %></td>
112
+ <td><code><%= entry.command %></code></td>
113
+ </tr>
114
+ <% end %>
115
+ </tbody>
116
+ </table>
117
+ </figure>
118
+ </section>
119
+ <% end %>
120
+
121
+ <section id="environment">
122
+ <h2>Environment</h2>
123
+ <% environment = companion_environment(@app) %>
124
+ <% if environment.empty? %>
125
+ <p>No extra environment variables are set.</p>
126
+ <% else %>
127
+ <figure>
128
+ <table>
129
+ <tbody>
130
+ <% environment.each do |key, value| %>
131
+ <tr>
132
+ <th scope="row"><code><%= key %></code></th>
133
+ <td><code><%= value.nil? ? "(unset)" : value %></code></td>
134
+ </tr>
135
+ <% end %>
136
+ </tbody>
137
+ </table>
138
+ </figure>
139
+ <% end %>
140
+ </section>
141
+ </main>
@@ -0,0 +1,19 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Companion</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta http-equiv="refresh" content="5">
7
+ <%= csrf_meta_tags %>
8
+ <%= csp_meta_tag %>
9
+
10
+ <%= yield :head %>
11
+
12
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
13
+ </head>
14
+ <body>
15
+
16
+ <%= yield %>
17
+
18
+ </body>
19
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Companion::Engine.routes.draw do
4
+ root "apps#index"
5
+ resources :apps, only: :show
6
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "timeout"
5
+
6
+ module Companion
7
+ # A companion web process that is booted on demand on a random local port
8
+ # and stopped when the host application exits.
9
+ class App
10
+ # The interface companion apps bind to unless {#host} is set.
11
+ DEFAULT_HOST = "127.0.0.1"
12
+
13
+ # Wildcard bind addresses mapped to the loopback address used to reach them.
14
+ WILDCARD_HOSTS = { "0.0.0.0" => "127.0.0.1", "::" => "::1" }.freeze
15
+
16
+ # @return [Symbol] the name the app is registered under
17
+ attr_reader :name
18
+
19
+ # @return [Integer, nil] the port the app is listening on, or nil when stopped
20
+ attr_reader :port
21
+
22
+ # @return [Integer, nil] the process group leader's pid, or nil when stopped
23
+ attr_reader :pid
24
+
25
+ # @return [ActiveSupport::TimeWithZone, nil] when the app finished booting, or nil when stopped
26
+ attr_reader :started_at
27
+
28
+ # @return [String, #call, nil] the shell command to run, or a callable that
29
+ # receives the assigned port and returns one
30
+ attr_accessor :command
31
+
32
+ # @return [String, Pathname, nil] the working directory for the process;
33
+ # defaults to the current directory
34
+ attr_accessor :directory
35
+
36
+ # @return [Hash] extra environment variables for the process
37
+ attr_accessor :environment
38
+
39
+ # @return [Numeric] seconds to wait for the app to accept connections
40
+ attr_accessor :boot_timeout
41
+
42
+ # @return [String] the interface the app binds to, passed to the process as +HOST+;
43
+ # defaults to {DEFAULT_HOST}
44
+ attr_accessor :host
45
+
46
+ # @param name [Symbol] the name to register the app under
47
+ def initialize(name)
48
+ @name = name
49
+ @host = DEFAULT_HOST
50
+ @environment = {}
51
+ @boot_timeout = 30
52
+ @mutex = Mutex.new
53
+ end
54
+
55
+ # Checks that the app has everything it needs to boot.
56
+ #
57
+ # @raise [ConfigurationError] if no command is set
58
+ # @return [void]
59
+ def validate!
60
+ raise ConfigurationError, "app #{name.inspect} requires a command" if command.nil?
61
+ end
62
+
63
+ # @return [String, nil] the base URL of the running app, or nil when stopped
64
+ def url
65
+ return unless port
66
+
67
+ address = connect_host
68
+ address = "[#{address}]" if address.include?(":")
69
+ "http://#{address}:#{port}"
70
+ end
71
+
72
+ # Reports whether the spawned process is still alive.
73
+ #
74
+ # @return [Boolean]
75
+ def running?
76
+ return false unless @pid
77
+
78
+ Process.waitpid(@pid, Process::WNOHANG).nil?
79
+ rescue Errno::ECHILD
80
+ false
81
+ end
82
+
83
+ # @return [Symbol] +:running+ or +:stopped+
84
+ def status
85
+ running? ? :running : :stopped
86
+ end
87
+
88
+ # Spawns the app on a free port and blocks until it accepts connections.
89
+ # Does nothing if the app is already running.
90
+ #
91
+ # @raise [BootError] if the process exits or does not listen within {#boot_timeout}
92
+ # @return [void]
93
+ def start
94
+ @mutex.synchronize do
95
+ return if running?
96
+
97
+ @port = available_port
98
+ @pid = Process.spawn(spawn_environment, resolved_command, chdir: String(directory || Dir.pwd), pgroup: true)
99
+ wait_until_listening
100
+ @started_at = Time.current
101
+ end
102
+ end
103
+
104
+ # Sends TERM to the app's process group and waits for it to exit.
105
+ # Does nothing if the app was never started.
106
+ #
107
+ # @return [void]
108
+ def stop
109
+ @mutex.synchronize do
110
+ return unless @pid
111
+
112
+ begin
113
+ Process.kill("TERM", -@pid)
114
+ Process.wait(@pid)
115
+ rescue Errno::ESRCH, Errno::ECHILD
116
+ # Already gone.
117
+ end
118
+ @pid = @port = @started_at = nil
119
+ end
120
+ end
121
+
122
+ # Samples the CPU and memory usage of the app's process group.
123
+ #
124
+ # @return [ProcessSample, nil] nil when stopped or if sampling is unavailable
125
+ def sample
126
+ ProcessSample.capture(@pid) if running?
127
+ end
128
+
129
+ # @return [Proxy] a memoized Rack endpoint that forwards requests to this app
130
+ def proxy
131
+ @proxy ||= Proxy.new(self)
132
+ end
133
+
134
+ private
135
+
136
+ # @return [String] the command to spawn, with callables invoked with the assigned port
137
+ def resolved_command
138
+ command.respond_to?(:call) ? command.call(@port) : String(command)
139
+ end
140
+
141
+ # @return [Hash{String => String, nil}] the configured environment with string keys and +HOST+ and +PORT+ set
142
+ def spawn_environment
143
+ environment.to_h { |key, value| [String(key), (String(value) unless value.nil?)] }
144
+ .merge("HOST" => String(host), "PORT" => String(@port))
145
+ end
146
+
147
+ # @return [String] the address to connect to the app on, with wildcard hosts mapped to loopback
148
+ def connect_host
149
+ WILDCARD_HOSTS.fetch(String(host), String(host))
150
+ end
151
+
152
+ # Asks the OS for an unused port by briefly binding to port 0.
153
+ #
154
+ # @return [Integer]
155
+ def available_port
156
+ server = TCPServer.new(String(host), 0)
157
+ server.addr[1]
158
+ ensure
159
+ server&.close
160
+ end
161
+
162
+ # Polls the app's port until it accepts a connection.
163
+ #
164
+ # @raise [BootError] if the process exits or the timeout elapses first
165
+ # @return [void]
166
+ def wait_until_listening
167
+ Timeout.timeout(boot_timeout) do
168
+ loop do
169
+ raise BootError, "app #{name.inspect} exited during boot" unless running?
170
+
171
+ TCPSocket.new(connect_host, @port).close
172
+ break
173
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL
174
+ sleep 0.1
175
+ end
176
+ end
177
+ rescue Timeout::Error
178
+ raise BootError, "app #{name.inspect} did not listen on port #{@port} within #{boot_timeout}s"
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Companion
4
+ # Holds the set of registered apps.
5
+ class Configuration
6
+ # @return [Hash{Symbol => App}] registered apps keyed by name
7
+ attr_reader :apps
8
+
9
+ def initialize
10
+ @apps = {}
11
+ end
12
+
13
+ # Registers an app, replacing any existing app with the same name.
14
+ #
15
+ # @param name [Symbol]
16
+ # @yieldparam app [App] the new app, for setting its options
17
+ # @raise [ConfigurationError] if the app is invalid after the block runs
18
+ # @return [App]
19
+ def app(name)
20
+ app = App.new(name)
21
+ yield(app) if block_given?
22
+ app.validate!
23
+ @apps[app.name] = app
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Companion
4
+ # Rails integration for Companion.
5
+ class Engine < ::Rails::Engine
6
+ isolate_namespace Companion
7
+
8
+ # Builds a Rack endpoint for mounting an app in the host's routes. The app
9
+ # is looked up per request, so it may be registered after routes load.
10
+ #
11
+ # @example
12
+ # mount Companion::Engine.app(:dashboard), at: "/dashboard"
13
+ #
14
+ # @param name [Symbol] the registered app's name
15
+ # @return [Proc] a Rack endpoint
16
+ def self.app(name)
17
+ ->(env) { Companion[name].proxy.call(env) }
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Companion
6
+ # A point-in-time snapshot of the resource usage of every process in an
7
+ # app's process group, read from +ps+.
8
+ class ProcessSample
9
+ # A single process in the sampled group.
10
+ #
11
+ # @!attribute pid [Integer]
12
+ # @!attribute ppid [Integer] the parent's pid
13
+ # @!attribute cpu [Float] CPU usage as a percentage of one core, as reported by +ps+
14
+ # @!attribute memory [Float] resident memory as a percentage of physical memory
15
+ # @!attribute rss [Integer] resident memory in bytes
16
+ # @!attribute command [String]
17
+ Entry = Data.define(:pid, :ppid, :cpu, :memory, :rss, :command)
18
+
19
+ # Each column gets its own +-o+ flag so empty headers are not parsed as part of the next column.
20
+ COLUMNS = %w[pid ppid pgid pcpu pmem rss command].freeze
21
+
22
+ # Samples every process whose process group is led by +pgid+.
23
+ #
24
+ # @param pgid [Integer] the process group leader's pid
25
+ # @return [ProcessSample, nil] nil if +ps+ is unavailable or no process is in the group
26
+ def self.capture(pgid)
27
+ output, status = Open3.capture2("ps", "-A", "-ww", *COLUMNS.flat_map { |column| ["-o", "#{column}="] })
28
+ return unless status.success?
29
+
30
+ entries = output.each_line.filter_map { |line| parse(line, pgid) }
31
+ new(pgid, entries, Time.current) unless entries.empty?
32
+ rescue SystemCallError
33
+ nil
34
+ end
35
+
36
+ # @param line [String] a row of +ps+ output
37
+ # @param pgid [Integer]
38
+ # @return [Entry, nil] nil unless the row belongs to the process group
39
+ def self.parse(line, pgid)
40
+ pid, ppid, group, cpu, memory, rss, command = line.strip.split(/\s+/, COLUMNS.size)
41
+ return unless Integer(group, exception: false) == pgid
42
+
43
+ Entry.new(pid: Integer(pid), ppid: Integer(ppid), cpu: Float(cpu), memory: Float(memory),
44
+ rss: Integer(rss) * 1024, command: String(command))
45
+ end
46
+ private_class_method :parse
47
+
48
+ # @return [Array<Entry>] the processes in the group, leader first
49
+ attr_reader :entries
50
+
51
+ # @return [ActiveSupport::TimeWithZone] when the sample was taken
52
+ attr_reader :sampled_at
53
+
54
+ # @param pgid [Integer] the process group leader's pid
55
+ # @param entries [Array<Entry>]
56
+ # @param sampled_at [ActiveSupport::TimeWithZone]
57
+ def initialize(pgid, entries, sampled_at)
58
+ @entries = entries.sort_by { |entry| [entry.pid == pgid ? 0 : 1, entry.pid] }
59
+ @sampled_at = sampled_at
60
+ end
61
+
62
+ # @return [Float] combined CPU usage across the group, as a percentage of one core
63
+ def cpu
64
+ entries.sum(&:cpu)
65
+ end
66
+
67
+ # @return [Float] combined resident memory as a percentage of physical memory
68
+ def memory
69
+ entries.sum(&:memory)
70
+ end
71
+
72
+ # @return [Integer] combined resident memory in bytes
73
+ def rss
74
+ entries.sum(&:rss)
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "http"
4
+
5
+ module Companion
6
+ # A Rack endpoint that boots an {App} on first use and forwards requests to it.
7
+ class Proxy
8
+ # Connection-scoped headers that must not be forwarded between hops (RFC 9110 §7.6.1).
9
+ HOP_BY_HOP_HEADERS = %w[
10
+ connection keep-alive proxy-authenticate proxy-authorization
11
+ te trailer transfer-encoding upgrade
12
+ ].freeze
13
+
14
+ # @param app [App] the app to forward requests to
15
+ def initialize(app)
16
+ @app = app
17
+ end
18
+
19
+ # Forwards a Rack request to the app, starting it if needed.
20
+ #
21
+ # @param env [Hash] the Rack environment
22
+ # @return [Array(Integer, Hash, Array<String>)] a Rack response; 502 if the
23
+ # app fails to boot or the upstream request fails
24
+ def call(env)
25
+ @app.start unless @app.running?
26
+
27
+ request = Rack::Request.new(env)
28
+ body = request_body(request)
29
+ headers = request_headers(request)
30
+ headers["transfer-encoding"] = "chunked" if body && !request.content_length
31
+
32
+ response = HTTP.request(request.request_method.downcase.to_sym, url(request), headers:, body:)
33
+
34
+ [response.code, response_headers(response), [String(response)]]
35
+ rescue BootError, HTTP::Error, SystemCallError => e
36
+ [502, { "content-type" => "text/plain" }, ["Bad Gateway: #{e.message}"]]
37
+ end
38
+
39
+ private
40
+
41
+ # @param request [Rack::Request]
42
+ # @return [String] the upstream URL for the request's path and query string
43
+ def url(request)
44
+ path = request.path_info.presence || "/"
45
+ path += "?#{request.query_string}" if request.query_string.present?
46
+
47
+ "#{@app.url}#{path}"
48
+ end
49
+
50
+ # @param request [Rack::Request]
51
+ # @return [IO, nil] the request body stream, or nil when the request has no body
52
+ def request_body(request)
53
+ request.body if request.content_length.to_i.positive? || request.get_header("HTTP_TRANSFER_ENCODING")
54
+ end
55
+
56
+ # Builds the upstream request headers, dropping hop-by-hop headers and
57
+ # adding the standard +X-Forwarded-*+ headers.
58
+ #
59
+ # @param request [Rack::Request]
60
+ # @return [Hash{String => String}]
61
+ def request_headers(request)
62
+ headers = {}
63
+ request.each_header do |key, value|
64
+ next unless key.start_with?("HTTP_")
65
+
66
+ name = key.delete_prefix("HTTP_").downcase.tr("_", "-")
67
+ headers[name] = value unless HOP_BY_HOP_HEADERS.include?(name)
68
+ end
69
+ headers["content-type"] = request.content_type if request.content_type
70
+ headers["content-length"] = request.content_length if request.content_length
71
+ headers["x-forwarded-for"] = [headers["x-forwarded-for"], request.ip].compact.join(", ")
72
+ headers["x-forwarded-host"] = request.host_with_port
73
+ headers["x-forwarded-proto"] = request.scheme
74
+ headers["x-forwarded-prefix"] = request.script_name if request.script_name.present?
75
+ headers
76
+ end
77
+
78
+ # Converts upstream headers to Rack headers, dropping hop-by-hop headers and
79
+ # +content-length+ (the body is re-buffered). Repeated headers become arrays.
80
+ #
81
+ # @param response [HTTP::Response]
82
+ # @return [Hash{String => String, Array<String>}]
83
+ def response_headers(response)
84
+ headers = {}
85
+ response.headers.each do |key, value|
86
+ name = key.downcase
87
+ next if HOP_BY_HOP_HEADERS.include?(name) || name == "content-length"
88
+
89
+ headers[name] = headers.key?(name) ? [*headers[name], value] : value
90
+ end
91
+ headers
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Companion
4
+ # @return [String] the gem version
5
+ VERSION = "0.1.0"
6
+ end
data/lib/companion.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "companion/version"
4
+ require "companion/process_sample"
5
+ require "companion/app"
6
+ require "companion/configuration"
7
+ require "companion/proxy"
8
+ require "companion/engine"
9
+
10
+ # Runs companion web apps alongside a Rails application and proxies requests to them.
11
+ module Companion
12
+ # Base class for all Companion errors.
13
+ class Error < StandardError; end
14
+
15
+ # Raised when an app is misconfigured or looked up by an unknown name.
16
+ class ConfigurationError < Error; end
17
+
18
+ # Raised when an app exits or fails to listen while booting.
19
+ class BootError < Error; end
20
+
21
+ class << self
22
+ # @return [Configuration] the memoized global configuration
23
+ def config
24
+ @config ||= Configuration.new
25
+ end
26
+
27
+ # @yieldparam config [Configuration]
28
+ # @return [void]
29
+ def configure
30
+ yield(config)
31
+ end
32
+
33
+ # Looks up a registered app.
34
+ #
35
+ # @param name [Symbol]
36
+ # @raise [ConfigurationError] if no app is registered under +name+
37
+ # @return [App]
38
+ def [](name)
39
+ config.apps.fetch(name) { raise ConfigurationError, "unknown app #{name.inspect}" }
40
+ end
41
+
42
+ # Stops every registered app, keeping the configuration.
43
+ #
44
+ # @return [void]
45
+ def stop
46
+ config.apps.each_value(&:stop)
47
+ end
48
+
49
+ # Stops every app and discards the configuration.
50
+ #
51
+ # @return [void]
52
+ def reset!
53
+ stop
54
+ @config = nil
55
+ end
56
+ end
57
+ end
58
+
59
+ at_exit { Companion.stop }
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # desc "Explaining what the task does"
4
+ # task :companion do
5
+ # # Task goes here
6
+ # end
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: companion
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Kevin Sylvestre
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: http
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Companion is a mountable engine that spawns and supervises companion
41
+ applications.
42
+ email:
43
+ - kevin@ksylvest.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - LICENSE
49
+ - README.md
50
+ - Rakefile
51
+ - app/controllers/companion/application_controller.rb
52
+ - app/controllers/companion/apps_controller.rb
53
+ - app/helpers/companion/application_helper.rb
54
+ - app/jobs/companion/application_job.rb
55
+ - app/views/companion/apps/index.html.erb
56
+ - app/views/companion/apps/show.html.erb
57
+ - app/views/layouts/companion/application.html.erb
58
+ - config/routes.rb
59
+ - lib/companion.rb
60
+ - lib/companion/app.rb
61
+ - lib/companion/configuration.rb
62
+ - lib/companion/engine.rb
63
+ - lib/companion/process_sample.rb
64
+ - lib/companion/proxy.rb
65
+ - lib/companion/version.rb
66
+ - lib/tasks/companion_tasks.rake
67
+ homepage: https://github.com/ksylvest/companion
68
+ licenses:
69
+ - MIT
70
+ metadata:
71
+ rubygems_mfa_required: 'true'
72
+ homepage_uri: https://github.com/ksylvest/companion
73
+ source_code_uri: https://github.com/ksylvest/companion
74
+ changelog_uri: https://github.com/ksylvest/companion/releases
75
+ rdoc_options: []
76
+ require_paths:
77
+ - lib
78
+ required_ruby_version: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: 3.2.0
83
+ required_rubygems_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ requirements: []
89
+ rubygems_version: 4.0.16
90
+ specification_version: 4
91
+ summary: Spawn companion apps and proxy traffic to them.
92
+ test_files: []