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