workspaces 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/DESIGN.md +67 -0
- data/LICENSE +21 -0
- data/README.md +146 -0
- data/exe/workspaces +5 -0
- data/lib/workspaces/backend.rb +108 -0
- data/lib/workspaces/cli.rb +189 -0
- data/lib/workspaces/command_runner.rb +49 -0
- data/lib/workspaces/config.rb +116 -0
- data/lib/workspaces/coordinator.rb +203 -0
- data/lib/workspaces/dashboard/app.js +132 -0
- data/lib/workspaces/dashboard/checkout_authentication_error.html.erb +22 -0
- data/lib/workspaces/dashboard/environment.js +120 -0
- data/lib/workspaces/dashboard/pull_request.html.erb +37 -0
- data/lib/workspaces/dashboard/styles.css +461 -0
- data/lib/workspaces/dashboard/template.html.erb +82 -0
- data/lib/workspaces/dashboard_view.rb +96 -0
- data/lib/workspaces/environment_overrides.rb +177 -0
- data/lib/workspaces/front_door.rb +275 -0
- data/lib/workspaces/paths.rb +63 -0
- data/lib/workspaces/port_allocator.rb +35 -0
- data/lib/workspaces/presentation.rb +68 -0
- data/lib/workspaces/recipe.rb +135 -0
- data/lib/workspaces/registry.rb +55 -0
- data/lib/workspaces/state_store.rb +84 -0
- data/lib/workspaces/tee_log.rb +39 -0
- data/lib/workspaces/tls.rb +112 -0
- data/lib/workspaces/version.rb +3 -0
- data/lib/workspaces/workflow.rb +78 -0
- data/lib/workspaces/worktree.rb +218 -0
- data/lib/workspaces.rb +5 -0
- metadata +121 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
|
|
6
|
+
module Workspaces
|
|
7
|
+
class EnvironmentOverrides
|
|
8
|
+
class Invalid < ArgumentError; end
|
|
9
|
+
|
|
10
|
+
NAME_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
11
|
+
BLOCKED_PREFIXES = %w(WORKSPACE_ WORKSPACES_).freeze
|
|
12
|
+
BLOCKED_NAMES = %w(PORT WWW_HOST CANONICAL_HOST WEBPACK_ASSET_HOST).freeze
|
|
13
|
+
MAX_ENTRIES = 100
|
|
14
|
+
MAX_VALUE_BYTES = 8192
|
|
15
|
+
MAX_BODY_BYTES = 128 * 1024
|
|
16
|
+
|
|
17
|
+
def initialize(workspace_id, home: Config.home)
|
|
18
|
+
@workspace_id = Config.validate_id!(workspace_id.to_s)
|
|
19
|
+
@home = home
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def load
|
|
23
|
+
reject_symlink!(directory_path)
|
|
24
|
+
reject_symlink!(file_path)
|
|
25
|
+
return {} unless file_path.exist?
|
|
26
|
+
|
|
27
|
+
parsed = JSON.parse(file_path.read)
|
|
28
|
+
raise Invalid, 'Stored environment overrides are invalid' unless parsed.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
validate_entries!(parsed)
|
|
31
|
+
rescue JSON::ParserError
|
|
32
|
+
raise Invalid, 'Stored environment overrides are invalid'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def keys
|
|
36
|
+
load.keys.sort
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def apply_patch(set:, remove:)
|
|
40
|
+
validate_patch!(set, remove)
|
|
41
|
+
updated = load.merge(set)
|
|
42
|
+
remove.each { |name| updated.delete(name) }
|
|
43
|
+
validate_entries!(updated)
|
|
44
|
+
persist(updated)
|
|
45
|
+
updated
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def clear!
|
|
49
|
+
delete_file!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def scrub(value)
|
|
53
|
+
text = value.to_s.encode('UTF-8', invalid: :replace, undef: :replace)
|
|
54
|
+
scrub_values.each do |secret|
|
|
55
|
+
text = text.gsub(secret, '[FILTERED]')
|
|
56
|
+
end
|
|
57
|
+
text
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
attr_reader :workspace_id, :home
|
|
63
|
+
|
|
64
|
+
def directory_path
|
|
65
|
+
home.join('environment')
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def file_path
|
|
69
|
+
directory_path.join("#{workspace_id}.json")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_patch!(set, remove)
|
|
73
|
+
set_names = validate_set!(set)
|
|
74
|
+
remove_names = validate_remove!(remove)
|
|
75
|
+
return unless set_names.intersect?(remove_names)
|
|
76
|
+
|
|
77
|
+
raise Invalid, 'Environment changes cannot set and remove the same name'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_set!(set)
|
|
81
|
+
raise Invalid, 'Environment updates must be a mapping of names to string values' unless set.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
set.each do |name, value|
|
|
84
|
+
validate_name!(name)
|
|
85
|
+
validate_value!(value)
|
|
86
|
+
end
|
|
87
|
+
set.keys
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_remove!(remove)
|
|
91
|
+
raise Invalid, 'Environment removals must be an array of names' unless remove.is_a?(Array)
|
|
92
|
+
|
|
93
|
+
remove.each { |name| validate_name!(name) }
|
|
94
|
+
remove
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_entries!(entries)
|
|
98
|
+
raise Invalid, 'Stored environment overrides are invalid' unless entries.is_a?(Hash)
|
|
99
|
+
raise Invalid, 'Environment overrides are limited to 100 entries' if entries.length > MAX_ENTRIES
|
|
100
|
+
|
|
101
|
+
entries.each_with_object({}) do |(name, value), normalized|
|
|
102
|
+
validate_name!(name)
|
|
103
|
+
validate_value!(value)
|
|
104
|
+
normalized[name] = value
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def validate_name!(name)
|
|
109
|
+
unless name.is_a?(String) && name.match?(NAME_PATTERN)
|
|
110
|
+
raise Invalid, 'Environment names must match /^[A-Za-z_][A-Za-z0-9_]*$/'
|
|
111
|
+
end
|
|
112
|
+
raise Invalid, 'Environment name is reserved for the workspace runner' if reserved_name?(name)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_value!(value)
|
|
116
|
+
raise Invalid, 'Environment values must be strings' unless value.is_a?(String)
|
|
117
|
+
raise Invalid, 'Environment values must not contain NUL bytes' if value.include?("\0")
|
|
118
|
+
return if value.bytesize <= MAX_VALUE_BYTES
|
|
119
|
+
|
|
120
|
+
raise Invalid, 'Environment values must be 8192 bytes or smaller'
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def reserved_name?(name)
|
|
124
|
+
BLOCKED_NAMES.include?(name) || BLOCKED_PREFIXES.any? { |prefix| name.start_with?(prefix) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def persist(entries)
|
|
128
|
+
ensure_directory!
|
|
129
|
+
reject_symlink!(file_path)
|
|
130
|
+
return delete_file! if entries.empty?
|
|
131
|
+
|
|
132
|
+
temp_path = directory_path.join(".#{workspace_id}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
|
|
133
|
+
begin
|
|
134
|
+
write_temp_file(temp_path, entries)
|
|
135
|
+
File.rename(temp_path, file_path)
|
|
136
|
+
File.chmod(0o600, file_path)
|
|
137
|
+
ensure
|
|
138
|
+
File.unlink(temp_path) if temp_path.exist?
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def write_temp_file(path, entries)
|
|
143
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
144
|
+
file.write(JSON.pretty_generate(entries))
|
|
145
|
+
file.flush
|
|
146
|
+
file.fsync
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def delete_file!
|
|
151
|
+
return unless file_path.exist?
|
|
152
|
+
|
|
153
|
+
reject_symlink!(file_path)
|
|
154
|
+
File.unlink(file_path)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def ensure_directory!
|
|
158
|
+
if directory_path.exist?
|
|
159
|
+
reject_symlink!(directory_path)
|
|
160
|
+
raise Invalid, 'Environment storage path is invalid' unless directory_path.directory?
|
|
161
|
+
else
|
|
162
|
+
directory_path.mkpath
|
|
163
|
+
end
|
|
164
|
+
File.chmod(0o700, directory_path)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def reject_symlink!(path)
|
|
168
|
+
raise Invalid, 'Environment storage does not allow symlinks' if path.symlink?
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def scrub_values
|
|
172
|
+
load.values.reject(&:empty?).uniq.sort_by { |value| -value.bytesize }
|
|
173
|
+
rescue Invalid
|
|
174
|
+
[]
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
require 'rack'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require_relative 'coordinator'
|
|
6
|
+
require_relative 'dashboard_view'
|
|
7
|
+
require_relative 'environment_overrides'
|
|
8
|
+
|
|
9
|
+
module Workspaces
|
|
10
|
+
class FrontDoor
|
|
11
|
+
WORKSPACE_ROUTE = %r{\A/workspaces/([a-z0-9]+(?:-[a-z0-9]+)*)
|
|
12
|
+
(?:/(status|start|prepare|restart|stop|environment))?\z}x
|
|
13
|
+
|
|
14
|
+
def initialize(coordinator: Coordinator.new, registry: Registry.new)
|
|
15
|
+
@coordinator = coordinator
|
|
16
|
+
@registry = registry
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
request = Rack::Request.new(env)
|
|
21
|
+
authority = request_authority(request)
|
|
22
|
+
return response(400, 'Unexpected Host header.') unless authority
|
|
23
|
+
|
|
24
|
+
id = Config.preview_workspace_id(authority.host)
|
|
25
|
+
return proxy(id, request) if id && valid_authority?(authority, URI(Config.preview_url(id)))
|
|
26
|
+
return response(400, 'Unexpected Host header.') unless valid_authority?(authority, Config.public_uri)
|
|
27
|
+
|
|
28
|
+
route(request)
|
|
29
|
+
rescue Worktree::AuthenticationError
|
|
30
|
+
response(422, DashboardView.checkout_authentication_error(request.path_info), type: 'text/html')
|
|
31
|
+
rescue ArgumentError, Worktree::CommandFailedError => e
|
|
32
|
+
response(422, e.message)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def route(request)
|
|
38
|
+
return index(request) if ['/', '/workspaces'].include?(request.path_info)
|
|
39
|
+
return create(request) if request.path_info == '/workspaces/create'
|
|
40
|
+
pr_match = %r{\A/pr/([1-9][0-9]*)\z}.match(request.path_info)
|
|
41
|
+
return pr_preview(request, pr_match[1]) if pr_match
|
|
42
|
+
|
|
43
|
+
workspace_route(request)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def workspace_route(request)
|
|
47
|
+
match = WORKSPACE_ROUTE.match(request.path_info)
|
|
48
|
+
return response(404, 'Not found. Visit /workspaces.') unless match
|
|
49
|
+
|
|
50
|
+
id, action = match.captures
|
|
51
|
+
return response(404, 'Unknown managed workspace') unless Worktree.new(id).exists?
|
|
52
|
+
|
|
53
|
+
action_request(request, id, action)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def pr_preview(request, number)
|
|
57
|
+
if safe?(request)
|
|
58
|
+
page = DashboardView.pull_request(number, @registry.list(pr: number))
|
|
59
|
+
return response(200, page, type: 'text/html', head: request.head?)
|
|
60
|
+
end
|
|
61
|
+
return response(405, 'Use GET, HEAD or POST') unless request.post?
|
|
62
|
+
return response(403, 'Creation requires the configured dashboard origin') unless same_origin?(request)
|
|
63
|
+
|
|
64
|
+
workspace = @registry.create(pr: number)
|
|
65
|
+
@coordinator.start(workspace.id)
|
|
66
|
+
redirect(Config.dashboard_url(workspace.id))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def index(request)
|
|
70
|
+
return response(405, 'Use GET or HEAD') unless safe?(request)
|
|
71
|
+
|
|
72
|
+
response(200, DashboardView.index(@registry.list), type: 'text/html', head: request.head?)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def action_request(request, id, action)
|
|
76
|
+
return update_environment(request, id) if action == 'environment'
|
|
77
|
+
return status_response(request, id, action) if action.nil? || action == 'status'
|
|
78
|
+
return response(405, 'Use POST') unless request.post?
|
|
79
|
+
return response(403, 'Actions require the configured dashboard origin') unless same_origin?(request)
|
|
80
|
+
|
|
81
|
+
perform_action(id, action)
|
|
82
|
+
redirect(Config.dashboard_url(id))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def status_response(request, id, action)
|
|
86
|
+
return response(405, 'Use GET or HEAD') unless safe?(request)
|
|
87
|
+
|
|
88
|
+
source = request.params.fetch('log', 'setup')
|
|
89
|
+
return response(400, 'Unknown workspace log') unless %w(setup backend).include?(source)
|
|
90
|
+
|
|
91
|
+
snapshot = @coordinator.snapshot(id, log: source)
|
|
92
|
+
body = action ? JSON.generate(snapshot) : DashboardView.render(snapshot)
|
|
93
|
+
response(200, body, type: action ? 'application/json' : 'text/html', head: request.head?)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def perform_action(id, action)
|
|
97
|
+
case action
|
|
98
|
+
when 'start' then @coordinator.start(id)
|
|
99
|
+
when 'prepare' then @coordinator.start(id, force: true)
|
|
100
|
+
when 'restart' then @coordinator.restart(id)
|
|
101
|
+
when 'stop' then stop(id)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def update_environment(request, id)
|
|
106
|
+
return response(405, 'Use POST') unless request.post?
|
|
107
|
+
return response(403, 'Actions require the configured dashboard origin') unless same_origin?(request)
|
|
108
|
+
|
|
109
|
+
updated = @coordinator.update_environment(id, **parse_environment_changes(request))
|
|
110
|
+
return response(409, 'Workspace is busy preparing, restarting, or running a command') unless updated
|
|
111
|
+
|
|
112
|
+
redirect(Config.dashboard_url(id))
|
|
113
|
+
rescue EnvironmentOverrides::Invalid => e
|
|
114
|
+
response(422, e.message)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def stop(id)
|
|
118
|
+
@registry.with_lock(id) do
|
|
119
|
+
Backend.new(id).stop!
|
|
120
|
+
StateStore.new(Config.state_file).set(id, 'status' => 'stopped', 'message' => 'Server stopped')
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def create(request)
|
|
125
|
+
return response(405, 'Use POST') unless request.post?
|
|
126
|
+
return response(403, 'Creation requires the configured dashboard origin') unless same_origin?(request)
|
|
127
|
+
|
|
128
|
+
params = request.POST
|
|
129
|
+
options = {}
|
|
130
|
+
%w(branch pr new_branch from).each { |key| options[key.to_sym] = params[key] unless params[key].to_s.empty? }
|
|
131
|
+
workspace = @registry.create(**options)
|
|
132
|
+
redirect(Config.dashboard_url(workspace.id))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def parse_environment_changes(request)
|
|
136
|
+
validate_environment_content_type!(request)
|
|
137
|
+
raw = environment_body(request)
|
|
138
|
+
params = Rack::Utils.parse_nested_query(raw)
|
|
139
|
+
{ set: normalize_set(params['set']), remove: normalize_remove(params['remove']) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def validate_environment_content_type!(request)
|
|
143
|
+
return if ['', 'application/x-www-form-urlencoded'].include?(request.media_type.to_s)
|
|
144
|
+
|
|
145
|
+
raise EnvironmentOverrides::Invalid, 'Environment updates must use form-encoded parameters'
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def environment_body(request)
|
|
149
|
+
limit = EnvironmentOverrides::MAX_BODY_BYTES
|
|
150
|
+
if request.content_length.to_i > limit
|
|
151
|
+
raise EnvironmentOverrides::Invalid,
|
|
152
|
+
'Environment update payload is too large'
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
raw = request.body.read(limit + 1).to_s
|
|
156
|
+
raise EnvironmentOverrides::Invalid, 'Environment update payload is too large' if raw.bytesize > limit
|
|
157
|
+
|
|
158
|
+
raw
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def normalize_set(values)
|
|
162
|
+
return {} if values.nil?
|
|
163
|
+
unless values.is_a?(Hash)
|
|
164
|
+
raise EnvironmentOverrides::Invalid,
|
|
165
|
+
'Environment updates must use set[NAME]=VALUE form fields'
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
values.transform_keys(&:to_s).transform_values(&:to_s)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def normalize_remove(values)
|
|
172
|
+
case values
|
|
173
|
+
when nil then []
|
|
174
|
+
when String then [values]
|
|
175
|
+
when Array then values.map(&:to_s)
|
|
176
|
+
else raise EnvironmentOverrides::Invalid, 'Environment removals must use remove[]=NAME form fields'
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def proxy(id, request)
|
|
181
|
+
return response(404, 'Unknown managed workspace') unless Worktree.new(id).exists?
|
|
182
|
+
|
|
183
|
+
snapshot = @coordinator.snapshot(id, include_log: false)
|
|
184
|
+
return unavailable(id, request) unless snapshot['status'] == 'ready' && !snapshot['active']
|
|
185
|
+
|
|
186
|
+
forward(id, request)
|
|
187
|
+
rescue IOError, SystemCallError, Timeout::Error => e
|
|
188
|
+
response(502, "Workspace backend unavailable: #{e.class}")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def unavailable(id, request)
|
|
192
|
+
return redirect(Config.dashboard_url(id)) if safe?(request)
|
|
193
|
+
|
|
194
|
+
response(503, "Workspace not ready. Visit #{Config.dashboard_url(id)}")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def forward(id, request)
|
|
198
|
+
uri = URI("http://127.0.0.1:#{Backend.new(id).port}#{request.fullpath}")
|
|
199
|
+
upstream = upstream_request(id, request, uri)
|
|
200
|
+
result = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 60) { |http| http.request(upstream) }
|
|
201
|
+
[result.code.to_i, response_headers(result),
|
|
202
|
+
request.head? ? [] : [result.body || '']]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def upstream_request(id, request, uri)
|
|
206
|
+
upstream = Net::HTTPGenericRequest.new(request.request_method, !safe?(request), !request.head?, uri.request_uri)
|
|
207
|
+
copy_request_headers(request, upstream)
|
|
208
|
+
forwarding_headers(id).each { |key, value| upstream[key] = value }
|
|
209
|
+
upstream.body = request.body.read unless safe?(request)
|
|
210
|
+
upstream
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def copy_request_headers(request, upstream)
|
|
214
|
+
request.each_header do |key, value|
|
|
215
|
+
next unless key.start_with?('HTTP_') || %w(CONTENT_TYPE CONTENT_LENGTH).include?(key)
|
|
216
|
+
next if key == 'HTTP_FORWARDED' || key.start_with?('HTTP_X_FORWARDED_')
|
|
217
|
+
|
|
218
|
+
upstream[key.delete_prefix('HTTP_').tr('_', '-')] = value
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def forwarding_headers(id)
|
|
223
|
+
{ 'Host' => Config.preview_host(id), 'X-Forwarded-Host' => Config.preview_host(id),
|
|
224
|
+
'X-Forwarded-Port' => Config.public_uri.port.to_s, 'X-Forwarded-Proto' => Config.public_uri.scheme }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def response_headers(result)
|
|
228
|
+
headers = result.each_header.to_h.reject { |key, _| %w(transfer-encoding connection).include?(key) }
|
|
229
|
+
cookies = result.get_fields('set-cookie')
|
|
230
|
+
headers['set-cookie'] = cookies if cookies
|
|
231
|
+
headers
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def safe?(request)
|
|
235
|
+
%w(GET HEAD).include?(request.request_method)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def same_origin?(request)
|
|
239
|
+
origin = request.get_header('HTTP_ORIGIN')
|
|
240
|
+
return origin == Config.public_origin if origin
|
|
241
|
+
|
|
242
|
+
uri = URI.parse(request.referer.to_s)
|
|
243
|
+
uri.is_a?(URI::HTTP) && uri.userinfo.nil? && "#{uri.scheme}://#{uri.authority}" == Config.public_origin
|
|
244
|
+
rescue URI::InvalidURIError
|
|
245
|
+
false
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def request_authority(request)
|
|
249
|
+
host = request.get_header('HTTP_HOST').to_s
|
|
250
|
+
return nil unless host.match?(/\A[a-zA-Z0-9.-]+(?::[0-9]+)?\z/)
|
|
251
|
+
|
|
252
|
+
URI.parse("#{Config.public_uri.scheme}://#{host}")
|
|
253
|
+
rescue URI::InvalidURIError
|
|
254
|
+
nil
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def valid_authority?(actual, expected)
|
|
258
|
+
actual.host.casecmp?(expected.host) && actual.port == expected.port
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def redirect(url)
|
|
262
|
+
[303, headers('text/plain').merge('location' => url), ["See #{url}\n"]]
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def response(status, body, type: 'text/plain', head: false)
|
|
266
|
+
[status, headers(type), head ? [] : [body]]
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def headers(type)
|
|
270
|
+
{ 'content-type' => "#{type}; charset=utf-8", 'cache-control' => 'no-store',
|
|
271
|
+
'x-content-type-options' => 'nosniff', 'x-frame-options' => 'DENY',
|
|
272
|
+
'content-security-policy' => "frame-ancestors 'none'" }
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
require 'pathname'
|
|
2
|
+
require 'digest'
|
|
3
|
+
|
|
4
|
+
module Workspaces
|
|
5
|
+
module Paths
|
|
6
|
+
def home
|
|
7
|
+
@home ||= if ENV['WORKSPACES_HOME']
|
|
8
|
+
Pathname.new(File.expand_path(ENV.fetch('WORKSPACES_HOME')))
|
|
9
|
+
else
|
|
10
|
+
Pathname.new(self::DEFAULT_HOME).join(Digest::SHA256.hexdigest(repo_root.realpath.to_s)[0, 16])
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def worktrees_dir(create: true)
|
|
15
|
+
directory('workspaces', create: create)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def logs_dir(create: true)
|
|
19
|
+
directory('logs', create: create)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def locks_dir(create: true)
|
|
23
|
+
directory('locks', create: create)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def state_file
|
|
27
|
+
home.join('state.json')
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def log_path(workspace_id, create: true)
|
|
31
|
+
setup_log_path(workspace_id, create: create)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def setup_log_path(workspace_id, create: true)
|
|
35
|
+
logs_dir(create: create).join("#{workspace_id}.setup.log")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def backend_log_path(workspace_id, create: true)
|
|
39
|
+
logs_dir(create: create).join("#{workspace_id}.backend.log")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def prepare_lock_path(workspace_id)
|
|
43
|
+
locks_dir(create: false).join("#{workspace_id}.prepare.lock")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def boot_lock_path(workspace_id)
|
|
47
|
+
locks_dir(create: false).join("#{workspace_id}.lock")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def repo_root
|
|
51
|
+
return Pathname.new(File.expand_path(ENV.fetch('WORKSPACES_REPO_ROOT'))) if ENV['WORKSPACES_REPO_ROOT']
|
|
52
|
+
|
|
53
|
+
Pathname.pwd.ascend.find { |directory| directory.join('.workspaces.yml').file? && directory.join('.git').exist? } ||
|
|
54
|
+
raise(ArgumentError, 'Run inside a Git repository or set WORKSPACES_REPO_ROOT')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def directory(name, create:)
|
|
60
|
+
home.join(name).tap { |path| path.mkpath if create }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
|
|
5
|
+
module Workspaces
|
|
6
|
+
# Finds a free local TCP port for a new PR backend, avoiding ports this tool already
|
|
7
|
+
# considers taken (per the state file) as well as ports something else on the box is using.
|
|
8
|
+
module PortAllocator
|
|
9
|
+
class NoFreePortError < StandardError; end
|
|
10
|
+
|
|
11
|
+
def self.allocate(range, taken_ports)
|
|
12
|
+
range.each do |port|
|
|
13
|
+
next if taken_ports.include?(port)
|
|
14
|
+
return port if free?(port)
|
|
15
|
+
end
|
|
16
|
+
raise NoFreePortError, "No free ports available in #{range}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.free?(port)
|
|
20
|
+
return false if listening?(port)
|
|
21
|
+
|
|
22
|
+
server = TCPServer.new('127.0.0.1', port)
|
|
23
|
+
server.close
|
|
24
|
+
true
|
|
25
|
+
rescue Errno::EADDRINUSE
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.listening?(port)
|
|
30
|
+
Socket.tcp('127.0.0.1', port, connect_timeout: 0.2) { true }
|
|
31
|
+
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Workspaces
|
|
2
|
+
class Presentation
|
|
3
|
+
TITLES = { 'idle' => 'Ready to start', 'stopped' => 'Server stopped', 'ready' => 'Workspace ready',
|
|
4
|
+
'preparing' => 'Running setup', 'error' => 'Setup failed',
|
|
5
|
+
'interrupted' => 'Setup interrupted' }.freeze
|
|
6
|
+
ACTIONS = { 'start' => 'Start workspace', 'prepare' => 'Rebuild & restart',
|
|
7
|
+
'restart' => 'Restart server', 'stop' => 'Stop server' }.freeze
|
|
8
|
+
|
|
9
|
+
def initialize(snapshot)
|
|
10
|
+
@snapshot = snapshot
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
{ 'title' => title, 'busy' => busy?, 'error' => error, 'steps' => steps,
|
|
15
|
+
'actions' => actions, 'ready' => status == 'ready' && !busy? }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def status
|
|
21
|
+
@snapshot.fetch('status', 'idle')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def busy?
|
|
25
|
+
@snapshot['active'] || status == 'preparing'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def title
|
|
29
|
+
return 'Workspace busy' if @snapshot['active'] && !busy_status?
|
|
30
|
+
return @snapshot['message'] || 'Running setup' if status == 'preparing'
|
|
31
|
+
|
|
32
|
+
TITLES.fetch(status, 'Workspace status')
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def busy_status?
|
|
36
|
+
status == 'preparing'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def error
|
|
40
|
+
return @snapshot['last_error'] unless status == 'interrupted'
|
|
41
|
+
|
|
42
|
+
'The setup process stopped before it finished. Retry to continue.'
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def steps
|
|
46
|
+
@snapshot.fetch('steps', []).map do |step|
|
|
47
|
+
step.merge('state' => status == 'interrupted' && step['state'] == 'active' ? 'failed' : step['state'])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def actions
|
|
52
|
+
return [] if busy?
|
|
53
|
+
return %w(prepare restart stop).map { |key| action(key, false) } if status == 'ready'
|
|
54
|
+
return [retry_action] if %w(error interrupted).include?(status)
|
|
55
|
+
|
|
56
|
+
[action('start', true)]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def retry_action
|
|
60
|
+
restart = @snapshot['operation'] == 'restart'
|
|
61
|
+
action(restart ? 'restart' : 'prepare', true).merge('label' => restart ? 'Retry restart' : 'Retry setup')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def action(key, primary)
|
|
65
|
+
{ 'key' => key, 'label' => ACTIONS.fetch(key), 'primary' => primary }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|