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 +7 -0
- data/LICENSE +20 -0
- data/README.md +92 -0
- data/Rakefile +13 -0
- data/app/controllers/companion/application_controller.rb +6 -0
- data/app/controllers/companion/apps_controller.rb +16 -0
- data/app/helpers/companion/application_helper.rb +32 -0
- data/app/jobs/companion/application_job.rb +6 -0
- data/app/views/companion/apps/index.html.erb +38 -0
- data/app/views/companion/apps/show.html.erb +141 -0
- data/app/views/layouts/companion/application.html.erb +19 -0
- data/config/routes.rb +6 -0
- data/lib/companion/app.rb +181 -0
- data/lib/companion/configuration.rb +26 -0
- data/lib/companion/engine.rb +20 -0
- data/lib/companion/process_sample.rb +77 -0
- data/lib/companion/proxy.rb +94 -0
- data/lib/companion/version.rb +6 -0
- data/lib/companion.rb +59 -0
- data/lib/tasks/companion_tasks.rake +6 -0
- metadata +92 -0
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,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,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,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
|
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 }
|
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: []
|