workspaces 0.1.0 → 0.1.1
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 +4 -4
- data/DESIGN.md +1 -0
- data/lib/workspaces/coordinator.rb +17 -2
- data/lib/workspaces/dashboard/environment.js +4 -100
- data/lib/workspaces/dashboard/styles.css +15 -48
- data/lib/workspaces/dashboard/template.html.erb +7 -36
- data/lib/workspaces/dashboard_view.rb +7 -0
- data/lib/workspaces/environment_overrides.rb +106 -41
- data/lib/workspaces/front_door.rb +21 -3
- data/lib/workspaces/recipe.rb +29 -3
- data/lib/workspaces/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e22bcc91e0c7874f565156a5071aa07dae75e52eda5c9b5555f8d4a878f39aec
|
|
4
|
+
data.tar.gz: c72328bf47eb7aa1573221224cdc28f4f1e5f24e9539bf23adf8bfc970da7047
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 58edd2f1a515d3ee18a69bfe2fe600ef2b26799689e35d94b57ab87251f634864a9e8a7d12415bdf1c5608dc461480dc38caa29c9f7db4305f62685ce739ac3e
|
|
7
|
+
data.tar.gz: d0cd9666cbdb265c23719d2de5aec54a6215b6db2f986ecbe061c27aae6578de5a8145a81669a382322ebc2618d3481edb947599d9edff7d76a68193b247e5c1
|
data/DESIGN.md
CHANGED
|
@@ -52,6 +52,7 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
52
52
|
- **Bounded log panel**: monospace text, fixed max height, manual-scroll-safe updates
|
|
53
53
|
- **Log source switcher**: two native links inside one log panel, with current selection announced through `aria-current`; only the selected bounded log is loaded and refreshed. Setup is the default.
|
|
54
54
|
- **Preview destination**: a small muted URL in Workspace details, visible before readiness; the primary Open workspace action remains reserved for ready servers.
|
|
55
|
+
- **Editable environment**: labelled monospace multiline `NAME=value` control for non-secret recipe defaults and added workspace settings; busy state disables the fieldset and save button, values are escaped in HTML, other legacy overrides remain hidden. Omitted default names revert to recipe defaults; omitted added names are unset.
|
|
55
56
|
|
|
56
57
|
## Accessibility constraints
|
|
57
58
|
|
|
@@ -35,6 +35,15 @@ module Workspaces
|
|
|
35
35
|
})
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
def replace_editable_environment(id, text:)
|
|
39
|
+
restart = Backend.new(id).running?
|
|
40
|
+
launch(id, restart: restart, force: true,
|
|
41
|
+
prelaunch: lambda { |_workspace|
|
|
42
|
+
defaults = Recipe.new.default_editable_env
|
|
43
|
+
EnvironmentOverrides.new(id).replace_editable(text, defaults: defaults)
|
|
44
|
+
})
|
|
45
|
+
end
|
|
46
|
+
|
|
38
47
|
def wait(id)
|
|
39
48
|
workers_mutex.synchronize { workers[id] }&.value
|
|
40
49
|
snapshot(id)
|
|
@@ -51,7 +60,6 @@ module Workspaces
|
|
|
51
60
|
data.merge('presentation' => Presentation.new(data).to_h)
|
|
52
61
|
end
|
|
53
62
|
|
|
54
|
-
|
|
55
63
|
private
|
|
56
64
|
|
|
57
65
|
def resolve_status(id, status, busy)
|
|
@@ -71,7 +79,8 @@ module Workspaces
|
|
|
71
79
|
'operation' => entry['operation'], 'failed_phase' => failure_phase(entry, status),
|
|
72
80
|
'steps' => entry.fetch('steps', []),
|
|
73
81
|
'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
|
|
74
|
-
'environment_keys' => environment_keys(id)
|
|
82
|
+
'environment_keys' => environment_keys(id),
|
|
83
|
+
'editable_environment' => editable_environment(id)
|
|
75
84
|
}
|
|
76
85
|
end
|
|
77
86
|
|
|
@@ -192,6 +201,12 @@ module Workspaces
|
|
|
192
201
|
[]
|
|
193
202
|
end
|
|
194
203
|
|
|
204
|
+
def editable_environment(id)
|
|
205
|
+
EnvironmentOverrides.new(id).editable_values(Recipe.new.default_editable_env)
|
|
206
|
+
rescue Recipe::Invalid, EnvironmentOverrides::Invalid
|
|
207
|
+
{}
|
|
208
|
+
end
|
|
209
|
+
|
|
195
210
|
def update(id, attributes)
|
|
196
211
|
@state.set(id, attributes.merge('updated_at' => now))
|
|
197
212
|
end
|
|
@@ -2,18 +2,12 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
const form = document.getElementById('environment-form');
|
|
4
4
|
if (!form) return;
|
|
5
|
-
const list = form.querySelector('[data-env-add-list]');
|
|
6
|
-
const addButton = form.querySelector('[data-env-add-row]');
|
|
7
5
|
const status = document.getElementById('environment-status');
|
|
8
|
-
const namePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
9
|
-
let rowIndex = 0;
|
|
10
6
|
|
|
11
7
|
function toggleBusy(nextBusy) {
|
|
12
|
-
form
|
|
13
|
-
.
|
|
14
|
-
|
|
15
|
-
control.disabled = nextBusy;
|
|
16
|
-
});
|
|
8
|
+
form.querySelectorAll('fieldset, [data-env-submit]').forEach((control) => {
|
|
9
|
+
control.disabled = nextBusy;
|
|
10
|
+
});
|
|
17
11
|
}
|
|
18
12
|
|
|
19
13
|
function updateStatus(busy) {
|
|
@@ -22,98 +16,8 @@
|
|
|
22
16
|
'Workspace is busy. Environment updates are temporarily disabled.';
|
|
23
17
|
}
|
|
24
18
|
|
|
25
|
-
function syncReplace(toggle) {
|
|
26
|
-
const input = toggle
|
|
27
|
-
.closest('[data-env-existing]')
|
|
28
|
-
.querySelector('[data-replace-value]');
|
|
29
|
-
input.disabled = !toggle.checked;
|
|
30
|
-
if (!toggle.checked) input.value = '';
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function labelledInput(id, label, type) {
|
|
34
|
-
const wrap = document.createElement('p');
|
|
35
|
-
const caption = document.createElement('label');
|
|
36
|
-
caption.className = 'env-input-label';
|
|
37
|
-
caption.htmlFor = id;
|
|
38
|
-
caption.textContent = label;
|
|
39
|
-
const input = document.createElement('input');
|
|
40
|
-
input.id = id;
|
|
41
|
-
input.type = type;
|
|
42
|
-
input.autocomplete = 'off';
|
|
43
|
-
wrap.append(caption, input);
|
|
44
|
-
return [wrap, input];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function addVariableRow() {
|
|
48
|
-
const row = document.createElement('li');
|
|
49
|
-
row.className = 'env-add-item';
|
|
50
|
-
row.dataset.envAddRow = String(rowIndex);
|
|
51
|
-
const [keyWrap, key] = labelledInput(
|
|
52
|
-
'env-add-key-' + rowIndex,
|
|
53
|
-
'Variable name',
|
|
54
|
-
'text'
|
|
55
|
-
);
|
|
56
|
-
key.spellcheck = false;
|
|
57
|
-
key.dataset.envNewName = 'true';
|
|
58
|
-
key.pattern = '[A-Za-z_][A-Za-z0-9_]*';
|
|
59
|
-
key.title =
|
|
60
|
-
'Use letters, numbers, and underscores; must not start with a number.';
|
|
61
|
-
const [valueWrap, value] = labelledInput(
|
|
62
|
-
'env-add-value-' + rowIndex,
|
|
63
|
-
'Value',
|
|
64
|
-
'password'
|
|
65
|
-
);
|
|
66
|
-
value.dataset.envNewValue = 'true';
|
|
67
|
-
rowIndex += 1;
|
|
68
|
-
const fields = document.createElement('div');
|
|
69
|
-
fields.className = 'env-add-grid';
|
|
70
|
-
fields.append(keyWrap, valueWrap);
|
|
71
|
-
const actions = document.createElement('div');
|
|
72
|
-
actions.className = 'env-row-actions';
|
|
73
|
-
const remove = document.createElement('button');
|
|
74
|
-
remove.type = 'button';
|
|
75
|
-
remove.className = 'button button--subtle button--icon';
|
|
76
|
-
remove.textContent = 'Remove';
|
|
77
|
-
remove.setAttribute('aria-label', 'Remove variable row');
|
|
78
|
-
remove.addEventListener('click', () => row.remove());
|
|
79
|
-
actions.append(remove);
|
|
80
|
-
row.append(fields, actions);
|
|
81
|
-
list.append(row);
|
|
82
|
-
key.focus();
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function appendSetValue(row) {
|
|
86
|
-
const key = row.querySelector('[data-env-new-name]').value.trim();
|
|
87
|
-
const value = row.querySelector('[data-env-new-value]');
|
|
88
|
-
if (key && namePattern.test(key)) {
|
|
89
|
-
const hidden = document.createElement('input');
|
|
90
|
-
hidden.type = 'hidden';
|
|
91
|
-
hidden.dataset.generatedSet = 'true';
|
|
92
|
-
hidden.name = 'set[' + key + ']';
|
|
93
|
-
hidden.value = value.value;
|
|
94
|
-
form.append(hidden);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function prepareSubmit() {
|
|
99
|
-
form.querySelectorAll('[data-env-existing]').forEach((row) => {
|
|
100
|
-
const toggle = row.querySelector('[data-replace-toggle]');
|
|
101
|
-
if (!toggle.checked) row.querySelector('[data-replace-value]').value = '';
|
|
102
|
-
});
|
|
103
|
-
form
|
|
104
|
-
.querySelectorAll('[data-generated-set]')
|
|
105
|
-
.forEach((input) => input.remove());
|
|
106
|
-
form.querySelectorAll('li[data-env-add-row]').forEach(appendSetValue);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
form.querySelectorAll('[data-replace-toggle]').forEach((toggle) => {
|
|
110
|
-
toggle.addEventListener('change', () => syncReplace(toggle));
|
|
111
|
-
syncReplace(toggle);
|
|
112
|
-
});
|
|
113
|
-
addButton.addEventListener('click', addVariableRow);
|
|
114
19
|
form.addEventListener('submit', () => {
|
|
115
|
-
|
|
116
|
-
status.textContent = 'Submitting environment overrides…';
|
|
20
|
+
status.textContent = 'Submitting environment settings…';
|
|
117
21
|
});
|
|
118
22
|
window.WorkspaceEnvironment = { toggleBusy, updateStatus };
|
|
119
23
|
toggleBusy(document.getElementById('workspace').dataset.busy === 'true');
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
[hidden] { display: none !important; }
|
|
27
27
|
input { max-width: 100%; padding: var(--space-2); color: var(--text); background: var(--bg); border: 1px solid var(--border); border-radius: var(--space-1); }
|
|
28
28
|
input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
29
|
+
textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
29
30
|
summary { cursor: pointer; color: var(--muted); }
|
|
30
31
|
summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
|
|
31
32
|
.workspace-actions { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-block-start: var(--space-4); }
|
|
@@ -225,38 +226,6 @@ h1 {
|
|
|
225
226
|
padding-inline: var(--space-2);
|
|
226
227
|
}
|
|
227
228
|
|
|
228
|
-
.env-existing-list,
|
|
229
|
-
.env-add-list {
|
|
230
|
-
list-style: none;
|
|
231
|
-
margin: 0;
|
|
232
|
-
padding: 0;
|
|
233
|
-
display: grid;
|
|
234
|
-
gap: var(--space-4);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
.env-existing-item,
|
|
238
|
-
.env-add-item {
|
|
239
|
-
border: 1px solid var(--border);
|
|
240
|
-
border-radius: 10px;
|
|
241
|
-
padding: var(--space-3);
|
|
242
|
-
background: color-mix(in oklab, var(--bg) 65%, transparent);
|
|
243
|
-
display: grid;
|
|
244
|
-
gap: var(--space-3);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
.env-existing-name,
|
|
248
|
-
.env-add-grid {
|
|
249
|
-
display: grid;
|
|
250
|
-
gap: var(--space-2);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
.env-existing-actions {
|
|
254
|
-
display: flex;
|
|
255
|
-
flex-wrap: wrap;
|
|
256
|
-
gap: var(--space-4);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
.env-inline-label,
|
|
260
229
|
.env-input-label {
|
|
261
230
|
display: inline-flex;
|
|
262
231
|
align-items: center;
|
|
@@ -269,22 +238,20 @@ h1 {
|
|
|
269
238
|
margin-block-end: var(--space-1);
|
|
270
239
|
}
|
|
271
240
|
|
|
272
|
-
.env-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
min-inline-size: 2.25rem;
|
|
287
|
-
padding-inline: var(--space-2);
|
|
241
|
+
.env-fieldset textarea {
|
|
242
|
+
display: block;
|
|
243
|
+
inline-size: 100%;
|
|
244
|
+
min-block-size: 12rem;
|
|
245
|
+
resize: vertical;
|
|
246
|
+
padding: var(--space-3);
|
|
247
|
+
color: var(--text);
|
|
248
|
+
background: var(--bg);
|
|
249
|
+
border: 1px solid var(--border);
|
|
250
|
+
border-radius: var(--space-1);
|
|
251
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
252
|
+
line-height: 1.5;
|
|
253
|
+
white-space: pre;
|
|
254
|
+
overflow: auto;
|
|
288
255
|
}
|
|
289
256
|
|
|
290
257
|
.env-actions {
|
|
@@ -32,44 +32,15 @@
|
|
|
32
32
|
<pre id="log" tabindex="0" aria-label="<%= field('log_source') == 'backend' ? 'Backend' : 'Setup' %> log"><%= field('log_tail') %></pre>
|
|
33
33
|
</section>
|
|
34
34
|
<section class="status-panel env-panel" aria-labelledby="environment-overrides">
|
|
35
|
-
<h2 id="environment-overrides">
|
|
36
|
-
<p class="muted">
|
|
37
|
-
<p class="muted">
|
|
35
|
+
<h2 id="environment-overrides">Editable environment</h2>
|
|
36
|
+
<p class="muted">One NAME=value per line. These non-secret settings start from <code>default_editable_env</code> in the trusted recipe. Edit values to customize this workspace; remove a line to restore its default.</p>
|
|
37
|
+
<p class="muted">Do not enter secrets: editor values appear in the dashboard and status API. Add other non-secret names as needed. Previously saved values outside the editor remain hidden and unchanged.</p>
|
|
38
38
|
<form method="post" action="/workspaces/<%= @id %>/environment" id="environment-form" data-environment-form>
|
|
39
39
|
<fieldset class="env-fieldset" data-environment-fieldset>
|
|
40
|
-
<legend class="env-legend">
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
<ul class="env-existing-list">
|
|
45
|
-
<% environment_keys.each_with_index do |name, index| %>
|
|
46
|
-
<% key_id = "env-existing-#{index}" %>
|
|
47
|
-
<li class="env-existing-item" data-env-existing>
|
|
48
|
-
<div class="env-existing-name">
|
|
49
|
-
<label for="<%= key_id %>-replace-value"><code><%= escape(name) %></code></label>
|
|
50
|
-
<p class="muted">Saved value: <span aria-hidden="true">••••••••</span><span class="visually-hidden">Saved value masked</span></p>
|
|
51
|
-
</div>
|
|
52
|
-
<div class="env-existing-actions">
|
|
53
|
-
<label class="env-inline-label" for="<%= key_id %>-replace-toggle">
|
|
54
|
-
<input type="checkbox" id="<%= key_id %>-replace-toggle" data-replace-toggle aria-controls="<%= key_id %>-replace-value">
|
|
55
|
-
Replace
|
|
56
|
-
</label>
|
|
57
|
-
<label class="env-inline-label" for="<%= key_id %>-remove">
|
|
58
|
-
<input type="checkbox" id="<%= key_id %>-remove" name="remove[]" value="<%= escape(name) %>">
|
|
59
|
-
Remove
|
|
60
|
-
</label>
|
|
61
|
-
</div>
|
|
62
|
-
<label class="env-input-label" for="<%= key_id %>-replace-value">New value for <code><%= escape(name) %></code></label>
|
|
63
|
-
<input type="password" id="<%= key_id %>-replace-value" name="set[<%= escape(name) %>]" autocomplete="off" data-replace-value disabled>
|
|
64
|
-
</li>
|
|
65
|
-
<% end %>
|
|
66
|
-
</ul>
|
|
67
|
-
<% end %>
|
|
68
|
-
</fieldset>
|
|
69
|
-
<fieldset class="env-fieldset" data-environment-additions>
|
|
70
|
-
<legend class="env-legend">Add variables</legend>
|
|
71
|
-
<div class="env-add-list" data-env-add-list></div>
|
|
72
|
-
<button type="button" class="button button--subtle" data-env-add-row>Add variable</button>
|
|
40
|
+
<legend class="env-legend">Workspace settings</legend>
|
|
41
|
+
<label class="env-input-label" for="editable-env">Environment variables</label>
|
|
42
|
+
<textarea id="editable-env" name="editable_env" rows="8" spellcheck="false" autocomplete="off" autocapitalize="off" aria-describedby="editable-env-help"><%= escape(editable_environment_text) %></textarea>
|
|
43
|
+
<p class="muted" id="editable-env-help">Values may contain =. Blank lines are ignored. Names are case-sensitive and cannot repeat.</p>
|
|
73
44
|
</fieldset>
|
|
74
45
|
<div class="env-actions">
|
|
75
46
|
<button class="button button--primary" data-env-submit>Save & restart</button>
|
|
@@ -87,6 +87,13 @@ module Workspaces
|
|
|
87
87
|
keys.map(&:to_s).sort
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
def editable_environment_text
|
|
91
|
+
values = @snapshot['editable_environment']
|
|
92
|
+
return '' unless values.is_a?(Hash)
|
|
93
|
+
|
|
94
|
+
values.sort.map { |name, value| "#{name}=#{value}" }.join("\n")
|
|
95
|
+
end
|
|
96
|
+
|
|
90
97
|
def action_form(action)
|
|
91
98
|
style = action['primary'] ? 'button--primary' : 'button--subtle'
|
|
92
99
|
"<form method=\"post\" action=\"/workspaces/#{@id}/#{action.fetch('key')}\">" \
|
|
@@ -14,31 +14,69 @@ module Workspaces
|
|
|
14
14
|
MAX_VALUE_BYTES = 8192
|
|
15
15
|
MAX_BODY_BYTES = 128 * 1024
|
|
16
16
|
|
|
17
|
+
def self.validate_entries!(entries)
|
|
18
|
+
raise Invalid, 'Environment overrides must be a mapping' unless entries.is_a?(Hash)
|
|
19
|
+
raise Invalid, 'Environment overrides are limited to 100 entries' if entries.length > MAX_ENTRIES
|
|
20
|
+
|
|
21
|
+
entries.each_with_object({}) do |(name, value), normalized|
|
|
22
|
+
validate_name!(name)
|
|
23
|
+
validate_value!(value)
|
|
24
|
+
normalized[name] = value
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.validate_name!(name)
|
|
29
|
+
unless name.is_a?(String) && name.match?(NAME_PATTERN)
|
|
30
|
+
raise Invalid, 'Environment names must match /^[A-Za-z_][A-Za-z0-9_]*$/'
|
|
31
|
+
end
|
|
32
|
+
return unless BLOCKED_NAMES.include?(name) || BLOCKED_PREFIXES.any? { |prefix| name.start_with?(prefix) }
|
|
33
|
+
|
|
34
|
+
raise Invalid, 'Environment name is reserved for the workspace runner'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.validate_value!(value)
|
|
38
|
+
raise Invalid, 'Environment values must be strings' unless value.is_a?(String)
|
|
39
|
+
raise Invalid, 'Environment values must not contain NUL bytes' if value.include?("\0")
|
|
40
|
+
raise Invalid, 'Environment values must be 8192 bytes or smaller' if value.bytesize > MAX_VALUE_BYTES
|
|
41
|
+
end
|
|
42
|
+
|
|
17
43
|
def initialize(workspace_id, home: Config.home)
|
|
18
44
|
@workspace_id = Config.validate_id!(workspace_id.to_s)
|
|
19
45
|
@home = home
|
|
20
46
|
end
|
|
21
47
|
|
|
22
48
|
def load
|
|
23
|
-
|
|
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'
|
|
49
|
+
load_file(file_path).merge(load_file(editable_file_path))
|
|
33
50
|
end
|
|
34
51
|
|
|
35
52
|
def keys
|
|
36
53
|
load.keys.sort
|
|
37
54
|
end
|
|
38
55
|
|
|
56
|
+
def editable_values(defaults)
|
|
57
|
+
load_file(file_path)
|
|
58
|
+
defaults.merge(load_file(editable_file_path).reject { |name, _| sensitive_name?(name) })
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def replace_editable(text, defaults:)
|
|
62
|
+
raise Invalid, 'Environment editor must contain text' unless text.is_a?(String)
|
|
63
|
+
raise Invalid, 'Environment update payload is too large' if text.bytesize > MAX_BODY_BYTES
|
|
64
|
+
self.class.validate_entries!(defaults)
|
|
65
|
+
|
|
66
|
+
entries = parse_lines(text)
|
|
67
|
+
self.class.validate_entries!(entries)
|
|
68
|
+
|
|
69
|
+
updated = entries.reject { |name, value| defaults[name] == value }
|
|
70
|
+
if updated.keys.any? { |name| sensitive_name?(name) }
|
|
71
|
+
raise Invalid, 'Secret-like environment names cannot be stored in the visible editor'
|
|
72
|
+
end
|
|
73
|
+
persist_editable(entries, updated, defaults)
|
|
74
|
+
load
|
|
75
|
+
end
|
|
76
|
+
|
|
39
77
|
def apply_patch(set:, remove:)
|
|
40
78
|
validate_patch!(set, remove)
|
|
41
|
-
updated =
|
|
79
|
+
updated = load_file(file_path).merge(set)
|
|
42
80
|
remove.each { |name| updated.delete(name) }
|
|
43
81
|
validate_entries!(updated)
|
|
44
82
|
persist(updated)
|
|
@@ -47,6 +85,7 @@ module Workspaces
|
|
|
47
85
|
|
|
48
86
|
def clear!
|
|
49
87
|
delete_file!
|
|
88
|
+
delete_file!(editable_file_path)
|
|
50
89
|
end
|
|
51
90
|
|
|
52
91
|
def scrub(value)
|
|
@@ -61,6 +100,35 @@ module Workspaces
|
|
|
61
100
|
|
|
62
101
|
attr_reader :workspace_id, :home
|
|
63
102
|
|
|
103
|
+
def sensitive_name?(name)
|
|
104
|
+
name.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse_lines(text)
|
|
108
|
+
entries = {}
|
|
109
|
+
text.each_line.with_index(1) do |line, number|
|
|
110
|
+
line = line.delete_suffix("\n").delete_suffix("\r")
|
|
111
|
+
next if line.strip.empty?
|
|
112
|
+
|
|
113
|
+
name, separator, value = line.partition('=')
|
|
114
|
+
raise Invalid, "Line #{number} must be NAME=value" if separator.empty? || name != name.strip
|
|
115
|
+
raise Invalid, "Line #{number} repeats #{name}" if entries.key?(name)
|
|
116
|
+
raise Invalid, "Line #{number}: values must not contain carriage returns" if value.include?("\r")
|
|
117
|
+
|
|
118
|
+
entries[name] = value
|
|
119
|
+
end
|
|
120
|
+
entries
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def persist_editable(entries, updated, defaults)
|
|
124
|
+
legacy = load_file(file_path)
|
|
125
|
+
load_file(editable_file_path)
|
|
126
|
+
kept = legacy.reject { |name, _| defaults.key?(name) || entries.key?(name) }
|
|
127
|
+
validate_entries!(kept.merge(updated))
|
|
128
|
+
persist(kept)
|
|
129
|
+
persist(updated, path: editable_file_path)
|
|
130
|
+
end
|
|
131
|
+
|
|
64
132
|
def directory_path
|
|
65
133
|
home.join('environment')
|
|
66
134
|
end
|
|
@@ -69,6 +137,21 @@ module Workspaces
|
|
|
69
137
|
directory_path.join("#{workspace_id}.json")
|
|
70
138
|
end
|
|
71
139
|
|
|
140
|
+
def editable_file_path
|
|
141
|
+
directory_path.join("#{workspace_id}.editable.json")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def load_file(path)
|
|
145
|
+
reject_symlink!(directory_path)
|
|
146
|
+
reject_symlink!(path)
|
|
147
|
+
return {} unless path.exist?
|
|
148
|
+
|
|
149
|
+
parsed = JSON.parse(path.read)
|
|
150
|
+
validate_entries!(parsed)
|
|
151
|
+
rescue JSON::ParserError
|
|
152
|
+
raise Invalid, 'Stored environment overrides are invalid'
|
|
153
|
+
end
|
|
154
|
+
|
|
72
155
|
def validate_patch!(set, remove)
|
|
73
156
|
set_names = validate_set!(set)
|
|
74
157
|
remove_names = validate_remove!(remove)
|
|
@@ -95,45 +178,27 @@ module Workspaces
|
|
|
95
178
|
end
|
|
96
179
|
|
|
97
180
|
def validate_entries!(entries)
|
|
98
|
-
|
|
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
|
|
181
|
+
self.class.validate_entries!(entries)
|
|
106
182
|
end
|
|
107
183
|
|
|
108
184
|
def validate_name!(name)
|
|
109
|
-
|
|
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)
|
|
185
|
+
self.class.validate_name!(name)
|
|
113
186
|
end
|
|
114
187
|
|
|
115
188
|
def validate_value!(value)
|
|
116
|
-
|
|
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) }
|
|
189
|
+
self.class.validate_value!(value)
|
|
125
190
|
end
|
|
126
191
|
|
|
127
|
-
def persist(entries)
|
|
192
|
+
def persist(entries, path: file_path)
|
|
128
193
|
ensure_directory!
|
|
129
|
-
reject_symlink!(
|
|
130
|
-
return delete_file! if entries.empty?
|
|
194
|
+
reject_symlink!(path)
|
|
195
|
+
return delete_file!(path) if entries.empty?
|
|
131
196
|
|
|
132
197
|
temp_path = directory_path.join(".#{workspace_id}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
|
|
133
198
|
begin
|
|
134
199
|
write_temp_file(temp_path, entries)
|
|
135
|
-
File.rename(temp_path,
|
|
136
|
-
File.chmod(0o600,
|
|
200
|
+
File.rename(temp_path, path)
|
|
201
|
+
File.chmod(0o600, path)
|
|
137
202
|
ensure
|
|
138
203
|
File.unlink(temp_path) if temp_path.exist?
|
|
139
204
|
end
|
|
@@ -147,11 +212,11 @@ module Workspaces
|
|
|
147
212
|
end
|
|
148
213
|
end
|
|
149
214
|
|
|
150
|
-
def delete_file!
|
|
151
|
-
return unless
|
|
215
|
+
def delete_file!(path = file_path)
|
|
216
|
+
return unless path.exist? || path.symlink?
|
|
152
217
|
|
|
153
|
-
reject_symlink!(
|
|
154
|
-
File.unlink(
|
|
218
|
+
reject_symlink!(path)
|
|
219
|
+
File.unlink(path)
|
|
155
220
|
end
|
|
156
221
|
|
|
157
222
|
def ensure_directory!
|
|
@@ -106,7 +106,12 @@ module Workspaces
|
|
|
106
106
|
return response(405, 'Use POST') unless request.post?
|
|
107
107
|
return response(403, 'Actions require the configured dashboard origin') unless same_origin?(request)
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
params = environment_params(request)
|
|
110
|
+
updated = if params.key?('editable_env')
|
|
111
|
+
@coordinator.replace_editable_environment(id, text: parse_editable_environment(params))
|
|
112
|
+
else
|
|
113
|
+
@coordinator.update_environment(id, **parse_environment_changes(params))
|
|
114
|
+
end
|
|
110
115
|
return response(409, 'Workspace is busy preparing, restarting, or running a command') unless updated
|
|
111
116
|
|
|
112
117
|
redirect(Config.dashboard_url(id))
|
|
@@ -132,13 +137,26 @@ module Workspaces
|
|
|
132
137
|
redirect(Config.dashboard_url(workspace.id))
|
|
133
138
|
end
|
|
134
139
|
|
|
135
|
-
def
|
|
140
|
+
def environment_params(request)
|
|
136
141
|
validate_environment_content_type!(request)
|
|
137
142
|
raw = environment_body(request)
|
|
138
|
-
|
|
143
|
+
Rack::Utils.parse_nested_query(raw)
|
|
144
|
+
rescue Rack::Utils::ParameterTypeError => e
|
|
145
|
+
raise EnvironmentOverrides::Invalid, "Invalid environment form: #{e.class}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def parse_environment_changes(params)
|
|
139
149
|
{ set: normalize_set(params['set']), remove: normalize_remove(params['remove']) }
|
|
140
150
|
end
|
|
141
151
|
|
|
152
|
+
def parse_editable_environment(params)
|
|
153
|
+
unless params['editable_env'].is_a?(String)
|
|
154
|
+
raise EnvironmentOverrides::Invalid, 'Environment editor requires one editable_env field'
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
params.fetch('editable_env')
|
|
158
|
+
end
|
|
159
|
+
|
|
142
160
|
def validate_environment_content_type!(request)
|
|
143
161
|
return if ['', 'application/x-www-form-urlencoded'].include?(request.media_type.to_s)
|
|
144
162
|
|
data/lib/workspaces/recipe.rb
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
require 'yaml'
|
|
2
2
|
require_relative 'config'
|
|
3
|
+
require_relative 'environment_overrides'
|
|
3
4
|
|
|
4
5
|
module Workspaces
|
|
5
6
|
class Recipe
|
|
6
7
|
class Invalid < ArgumentError; end
|
|
7
8
|
RESERVED_ENV = %w(WORKSPACE_ID WORKSPACE_PATH WORKSPACE_REPO_ROOT WORKSPACE_PORT WORKSPACE_URL
|
|
8
9
|
WORKSPACE_HOST WORKSPACE_CACHE_DIR WORKSPACE_RUN_ID).freeze
|
|
9
|
-
attr_reader :steps, :environment
|
|
10
|
+
attr_reader :steps, :environment, :default_editable_env
|
|
10
11
|
|
|
11
12
|
def initialize(root: Config.repo_root)
|
|
12
13
|
@root = root
|
|
13
14
|
document = YAML.safe_load(root.join('.workspaces.yml').read, aliases: false)
|
|
14
15
|
validate_document!(document)
|
|
16
|
+
@default_editable_env = validate_editable_env!(document.fetch('default_editable_env', {}))
|
|
15
17
|
@environment = validate_env!(document.fetch('env', {}))
|
|
16
18
|
@steps = document.fetch('steps').map { |step| validate_step!(step) }
|
|
19
|
+
validate_editable_overlap!
|
|
17
20
|
validate_sequence!
|
|
18
21
|
rescue Errno::ENOENT
|
|
19
22
|
raise Invalid, 'Add .workspaces.yml to the source repository before starting a workspace'
|
|
@@ -33,7 +36,7 @@ module Workspaces
|
|
|
33
36
|
end
|
|
34
37
|
|
|
35
38
|
def env(step, context, overrides: {})
|
|
36
|
-
expanded = environment.merge(step.fetch('env', {})).transform_values do |value|
|
|
39
|
+
expanded = environment.merge(step.fetch('env', {})).merge(default_editable_env).transform_values do |value|
|
|
37
40
|
value.nil? ? nil : expand(value, context)
|
|
38
41
|
end
|
|
39
42
|
expanded.merge(overrides).merge(context)
|
|
@@ -46,7 +49,7 @@ module Workspaces
|
|
|
46
49
|
raise Invalid,
|
|
47
50
|
'Configuration must be a mapping with version: 1'
|
|
48
51
|
end
|
|
49
|
-
unknown_keys!(document, %w(version env steps))
|
|
52
|
+
unknown_keys!(document, %w(version env default_editable_env steps))
|
|
50
53
|
raise Invalid, 'steps must be a nonempty array' unless document['steps'].is_a?(Array) && !document['steps'].empty?
|
|
51
54
|
end
|
|
52
55
|
|
|
@@ -106,6 +109,29 @@ module Workspaces
|
|
|
106
109
|
values
|
|
107
110
|
end
|
|
108
111
|
|
|
112
|
+
def validate_editable_env!(values)
|
|
113
|
+
raise Invalid, 'default_editable_env must be a mapping' unless values.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
EnvironmentOverrides.validate_entries!(values)
|
|
116
|
+
values.each do |key, value|
|
|
117
|
+
raise Invalid, "#{key} is supplied by the workspace runner" if RESERVED_ENV.include?(key)
|
|
118
|
+
if key.match?(/(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY|CREDENTIAL)/i)
|
|
119
|
+
raise Invalid, 'Secret-like names cannot be declared in default_editable_env'
|
|
120
|
+
end
|
|
121
|
+
raise Invalid, 'Editable environment values must be single-line strings' if value.match?(/[\r\n]/)
|
|
122
|
+
end
|
|
123
|
+
values
|
|
124
|
+
rescue EnvironmentOverrides::Invalid => e
|
|
125
|
+
raise Invalid, e.message
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_editable_overlap!
|
|
129
|
+
names = default_editable_env.keys
|
|
130
|
+
return unless environment.keys.intersect?(names) || steps.any? { |step| step['env'].keys.intersect?(names) }
|
|
131
|
+
|
|
132
|
+
raise Invalid, 'default_editable_env names cannot also appear in env or step env'
|
|
133
|
+
end
|
|
134
|
+
|
|
109
135
|
def validate_env_name!(key)
|
|
110
136
|
return if key.is_a?(String) && key.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
111
137
|
|
data/lib/workspaces/version.rb
CHANGED