workspaces 0.1.17 → 0.1.18
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/README.md +61 -6
- data/lib/workspaces/child_env.rb +6 -2
- data/lib/workspaces/cli.rb +18 -4
- data/lib/workspaces/coordinator.rb +20 -0
- data/lib/workspaces/dashboard/environment.js +196 -54
- data/lib/workspaces/dashboard/styles.css +32 -0
- data/lib/workspaces/dashboard/template.html.erb +14 -2
- data/lib/workspaces/dashboard_view.rb +69 -11
- data/lib/workspaces/environment_overrides.rb +103 -4
- data/lib/workspaces/front_door.rb +29 -3
- data/lib/workspaces/registry.rb +26 -0
- data/lib/workspaces/vault/file_vault.rb +174 -0
- data/lib/workspaces/vault.rb +137 -0
- data/lib/workspaces/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8a0a1c93490624ea0d8bb2fe77ddb8e7d1ad9bf53fe7700b6eec7245cc2e031a
|
|
4
|
+
data.tar.gz: 561ec874bbbe8d1c537066b612065c23db82caea34ca8d19cc894b66245be24d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7c9e26c91a93c37d8ea2391a0dd1c6ed3a163ba64c37deb2d73cae6d4688c33a7d5f9573ff98a8510789820d06e3f9af36dd78e86ba66b80a13435b11526ffd4
|
|
7
|
+
data.tar.gz: d0deac511f060614b66a41e793af6a4e3c7eb64e2150681dccd65c4910f0fffb5b74d213db865f38a3cb748adfc712bcfdf0a268328d4d9aea4d3375e23f2070
|
data/DESIGN.md
CHANGED
|
@@ -62,6 +62,7 @@ Restrained tooling UI: dark elevated panels, compact spacing rhythm, clear statu
|
|
|
62
62
|
- **Disclosure behavior**: the server renders the initial open state and the polling script re-applies it from `presentation.open`, but only when the state changes. A section the user opened or closed is left alone on later polls. Sections that are not meaningful in a state are hidden, never shown disabled.
|
|
63
63
|
- **Per-step re-run controls**: inside each step's accordion, small subtle **Re-run this step** and **Re-run from here** forms aimed at `/workspaces/ID/resume?only=KEY` and `?from=KEY`. The server decides which appear (`presentation.resume.step_modes`), so the page never offers something that would be refused; they are hidden while a run is in progress and are disabled with the other controls while busy.
|
|
64
64
|
- **Sleeping**: a workspace asleep is its own state (`sleeping`), distinct from stopped and failed. The lead reads "Your workspace is asleep", says files and data are kept and that opening the preview wakes it, and offers one **Wake workspace** button. A ready workspace shows one muted line, "Sleeps after N min without preview traffic", or "Kept awake" when exempt. **Keep awake** / **Allow sleep** are subtle workspace controls. A waking workspace is simply busy.
|
|
65
|
+
- **Environment rows**: the settings form is a list of rows, each a name input, a value input and a **Secret** checkbox, with **Add variable** and a per-row **Remove** (both revealed by script; without it the server-rendered rows still save). Rows post as `env[N][name|value|secret]`. A secret row's value is a password field that is never pre-filled; a stored secret shows its name, the placeholder "Stored. Enter a new value to replace it", and a locked checked checkbox (a hidden field carries it), so it can only be replaced or removed, not exposed. Ticking Secret on a new row switches its value to a password field. Presets replace only the visible rows and are matched against visible rows alone. A note under the rows shows why secrets are unavailable when the vault is not usable, and a line explains that secrets are write-only.
|
|
65
66
|
- **Action buttons**: start, prepare/rebuild, restart server, stop server, retry failed step, and refresh
|
|
66
67
|
- **Open workspace**: success-styled link available when the server is ready
|
|
67
68
|
- **Lifecycle controls**: hidden while busy (see state machine); helper text distinguishes rebuilding from server-only restart
|
data/README.md
CHANGED
|
@@ -242,6 +242,61 @@ temporarily stashed and restored (including staged state). On a restore conflict
|
|
|
242
242
|
the stash is retained for manual recovery. Updating the checkout does not run
|
|
243
243
|
setup or restart the already-running backend; use Rebuild & restart afterward.
|
|
244
244
|
|
|
245
|
+
### Environment variables and secrets
|
|
246
|
+
|
|
247
|
+
A workspace's settings are rows of **name** and **value**, with a **Secret**
|
|
248
|
+
checkbox on each. Add and remove rows as needed; **Save & restart** applies them.
|
|
249
|
+
|
|
250
|
+
A *secret* is an environment variable that is write-only. Its value goes to a
|
|
251
|
+
vault, is handed to the workspace's setup steps and server as an ordinary
|
|
252
|
+
environment variable, and is never shown again: not in the dashboard, the status
|
|
253
|
+
API, the logs (any output that contains it is filtered) or error messages. A saved
|
|
254
|
+
secret appears as its name with an empty, password-style field that says a value is
|
|
255
|
+
stored. Leave that field blank to keep the stored value, type a new one to replace
|
|
256
|
+
it, or remove the row to delete the secret. A stored secret cannot be turned back
|
|
257
|
+
into a visible variable. Names that look like secrets (`*_TOKEN`, `*_SECRET`,
|
|
258
|
+
`*_PASSWORD`, `*_API_KEY`, ...) cannot be saved as visible variables; tick **Secret**.
|
|
259
|
+
Presets only ever change visible variables.
|
|
260
|
+
|
|
261
|
+
Which vault is used is set where `serve` runs, with `WORKSPACES_VAULT` (default
|
|
262
|
+
`file`). The built-in `file` vault keeps one AES-256-GCM encrypted file per workspace
|
|
263
|
+
in `$WORKSPACES_HOME/vault/`, with a key derived per workspace from a master key you
|
|
264
|
+
provide:
|
|
265
|
+
|
|
266
|
+
```sh
|
|
267
|
+
export WORKSPACES_MASTER_KEY="$(openssl rand -base64 32)" # keep this; it cannot be recovered
|
|
268
|
+
bin/workspaces serve --secure
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Without `WORKSPACES_MASTER_KEY` the settings page says secrets are turned off, and
|
|
272
|
+
everything else works as before. With the wrong key a run fails with a plain reason
|
|
273
|
+
rather than starting without its secrets, and saving is refused so stored secrets are
|
|
274
|
+
never overwritten. The master key is read only by the front door: it is removed from
|
|
275
|
+
the environment of every step, server and `exec` command.
|
|
276
|
+
|
|
277
|
+
Removing a workspace (`remove`, or the dashboard's Remove button) deletes its secrets
|
|
278
|
+
too. The file vault is always asked to delete its own files, even if `WORKSPACES_VAULT`
|
|
279
|
+
has since been changed. A secret that cannot be deleted is reported rather than hidden:
|
|
280
|
+
`remove` exits non-zero and lists it under `secrets_left`, and the dashboard says the
|
|
281
|
+
workspace was removed but its secrets were not. Secrets live outside the workspace's
|
|
282
|
+
checkout, in `$WORKSPACES_HOME/vault/<workspace-id>.enc`, with no other record of the
|
|
283
|
+
link, so a workspace deleted some other way (by hand, or `git worktree remove`) leaves
|
|
284
|
+
its file behind. Clean those up with:
|
|
285
|
+
|
|
286
|
+
```sh
|
|
287
|
+
bin/workspaces purge-secrets --dry-run # list secrets whose workspace no longer exists
|
|
288
|
+
bin/workspaces purge-secrets # delete them
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
It refuses to run if the workspaces directory cannot be read, so an unmounted volume is
|
|
292
|
+
never mistaken for "every workspace is gone".
|
|
293
|
+
|
|
294
|
+
Another store can be added behind the same small interface (`available?`/`problem`,
|
|
295
|
+
`read(scope)`, `write(scope, entries)`, `purge(scope)`, and optionally `scopes` to
|
|
296
|
+
list what it holds so `purge-secrets` can find orphans): subclass
|
|
297
|
+
`Workspaces::Vault::Provider` and call `Workspaces::Vault.register('name', YourClass)`
|
|
298
|
+
from a file loaded with `serve --require`, then set `WORKSPACES_VAULT=name`.
|
|
299
|
+
|
|
245
300
|
### Editable environment presets
|
|
246
301
|
|
|
247
302
|
Recipes can define editable environment defaults and named presets in
|
|
@@ -258,20 +313,20 @@ environment_presets:
|
|
|
258
313
|
DEMO_MODE: "false"
|
|
259
314
|
```
|
|
260
315
|
|
|
261
|
-
Each preset maps editable variable names to values. In the workspace
|
|
262
|
-
choosing a preset
|
|
263
|
-
|
|
264
|
-
**Save & restart** to apply the draft. Presets are not applied automatically,
|
|
316
|
+
Each preset maps editable variable names to values. In the workspace settings,
|
|
317
|
+
choosing a preset replaces the visible variable rows rather than merging with
|
|
318
|
+
them (secret rows are left alone). Selection does not save or restart the
|
|
319
|
+
workspace; choose **Save & restart** to apply the draft. Presets are not applied automatically,
|
|
265
320
|
and defining them requires no JavaScript edits. Preset keys must be declared in
|
|
266
321
|
`default_editable_env`; recipes reject undeclared keys and secret-like values.
|
|
267
|
-
Hidden environment values are never exposed in the
|
|
322
|
+
Hidden environment values are never exposed in the settings or preset choices.
|
|
268
323
|
|
|
269
324
|
Use `create --branch main --preset Demo` to save a preset for a new, still-idle
|
|
270
325
|
workspace. `start ID --preset Demo` applies it before preparing a stopped
|
|
271
326
|
workspace; `restart ID --preset 'Demo off'` switches a running workspace and
|
|
272
327
|
restarts its server. `prepare ID --preset Demo` switches and rebuilds. An
|
|
273
328
|
unknown name is rejected without changing saved settings. Selecting a preset
|
|
274
|
-
replaces the editable settings (including custom
|
|
329
|
+
replaces the editable settings (including custom rows) but preserves
|
|
275
330
|
hidden legacy overrides. A running workspace requires `restart --preset` or
|
|
276
331
|
`prepare --preset`, not `start --preset`.
|
|
277
332
|
|
data/lib/workspaces/child_env.rb
CHANGED
|
@@ -2,6 +2,10 @@ module Workspaces
|
|
|
2
2
|
module ChildEnv
|
|
3
3
|
module_function
|
|
4
4
|
|
|
5
|
+
# Held by the front door alone. Anything a step, the server or `exec` starts must never see the
|
|
6
|
+
# key that opens the secret vault, however it was set.
|
|
7
|
+
WITHHELD = %w(WORKSPACES_MASTER_KEY).freeze
|
|
8
|
+
|
|
5
9
|
def for_workspace(workspace_path:, extra_env: {}, parent_env: ENV.to_h)
|
|
6
10
|
env = parent_env.to_h.transform_keys(&:to_s).dup
|
|
7
11
|
if defined?(::Bundler)
|
|
@@ -10,7 +14,7 @@ module Workspaces
|
|
|
10
14
|
clean.key?(name) ? env[name] = clean[name] : env.delete(name)
|
|
11
15
|
end
|
|
12
16
|
end
|
|
13
|
-
env.delete_if { |key, _| bundler_key?(key) }
|
|
17
|
+
env.delete_if { |key, _| bundler_key?(key) || WITHHELD.include?(key) }
|
|
14
18
|
|
|
15
19
|
gemfile = File.join(workspace_path.to_s, 'Gemfile')
|
|
16
20
|
env['BUNDLE_GEMFILE'] = gemfile
|
|
@@ -18,7 +22,7 @@ module Workspaces
|
|
|
18
22
|
|
|
19
23
|
extra_env.to_h.each do |name, value|
|
|
20
24
|
key = name.to_s
|
|
21
|
-
next if bundler_key?(key)
|
|
25
|
+
next if bundler_key?(key) || WITHHELD.include?(key)
|
|
22
26
|
|
|
23
27
|
if value.nil?
|
|
24
28
|
env.delete(key)
|
data/lib/workspaces/cli.rb
CHANGED
|
@@ -17,7 +17,7 @@ module Workspaces
|
|
|
17
17
|
COMMANDS = {
|
|
18
18
|
'serve' => :serve, 'create' => :create, 'list' => :list, 'show' => :show,
|
|
19
19
|
'start' => :lifecycle, 'prepare' => :lifecycle, 'restart' => :lifecycle,
|
|
20
|
-
'stop' => :stop, 'remove' => :remove, 'resume' => :resume, 'keep-awake' => :keep_awake, 'help' => :help
|
|
20
|
+
'stop' => :stop, 'remove' => :remove, 'purge-secrets' => :purge_secrets, 'resume' => :resume, 'keep-awake' => :keep_awake, 'help' => :help
|
|
21
21
|
}.freeze
|
|
22
22
|
def self.run(argv)
|
|
23
23
|
new.run(argv.dup)
|
|
@@ -88,6 +88,7 @@ module Workspaces
|
|
|
88
88
|
parser.on('--from REF') { |value| options[:from] = value }
|
|
89
89
|
parser.on('--json') { options[:json] = true }
|
|
90
90
|
parser.on('--force') { options[:force] = true }
|
|
91
|
+
parser.on('--dry-run') { options[:dry_run] = true }
|
|
91
92
|
parser.on('--off') { options[:off] = true }
|
|
92
93
|
end
|
|
93
94
|
|
|
@@ -207,8 +208,21 @@ module Workspaces
|
|
|
207
208
|
end
|
|
208
209
|
|
|
209
210
|
def remove(id, options)
|
|
210
|
-
registry.remove(id, force: options[:force])
|
|
211
|
-
|
|
211
|
+
leftover = registry.remove(id, force: options[:force])
|
|
212
|
+
leftover = [] unless leftover.is_a?(Array)
|
|
213
|
+
emit({ 'id' => id, 'removed' => true, 'branch_preserved' => true, 'secrets_left' => leftover }, options)
|
|
214
|
+
return if leftover.empty?
|
|
215
|
+
|
|
216
|
+
warn "The workspace was removed, but its secrets could not be: #{leftover.join('; ')}. " \
|
|
217
|
+
'Fix that, then run `workspaces purge-secrets`.'
|
|
218
|
+
raise ArgumentError, 'Secrets were left behind'
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Deletes stored secrets whose workspace no longer exists. `--dry-run` lists them without deleting.
|
|
222
|
+
def purge_secrets(_id, options)
|
|
223
|
+
result = registry.sweep_orphaned_secrets(dry_run: options[:dry_run])
|
|
224
|
+
emit(result.merge('dry_run' => !!options[:dry_run]), options)
|
|
225
|
+
raise ArgumentError, "Could not remove: #{result['problems'].join('; ')}" unless result['problems'].empty?
|
|
212
226
|
end
|
|
213
227
|
|
|
214
228
|
def execute(argv)
|
|
@@ -243,7 +257,7 @@ module Workspaces
|
|
|
243
257
|
'Usage: bin/workspaces serve [--require FILE] | create (--branch REF | --pr NUMBER | --new-branch NAME [--from REF]) [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
|
|
244
258
|
'list [--pr NUMBER] [--json] | show/start/prepare/restart ID [--preset NAME] [--set NAME=VALUE] [--unset NAME] [--json] | ' \
|
|
245
259
|
'resume ID [--from STEP | --only STEP] [--json] | stop ID | keep-awake ID [--off] | ' \
|
|
246
|
-
'exec ID -- COMMAND... | remove ID [--force]'
|
|
260
|
+
'exec ID -- COMMAND... | remove ID [--force] | purge-secrets [--dry-run]'
|
|
247
261
|
end
|
|
248
262
|
|
|
249
263
|
def help(*)
|
|
@@ -63,6 +63,17 @@ module Workspaces
|
|
|
63
63
|
})
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# Saves the settings form (rows of name / value / secret) and starts the workspace, restarting it
|
|
67
|
+
# if it is already up. See EnvironmentOverrides#replace_rows.
|
|
68
|
+
def replace_environment_rows(id, rows:)
|
|
69
|
+
restart = Backend.new(id).running? || sleeping?(id)
|
|
70
|
+
launch(id, restart: restart, force: true,
|
|
71
|
+
prelaunch: lambda { |_workspace|
|
|
72
|
+
defaults = Recipe.new.default_editable_env
|
|
73
|
+
EnvironmentOverrides.new(id).replace_rows(rows, defaults: defaults)
|
|
74
|
+
})
|
|
75
|
+
end
|
|
76
|
+
|
|
66
77
|
def replace_editable_environment(id, text:)
|
|
67
78
|
restart = Backend.new(id).running? || sleeping?(id)
|
|
68
79
|
launch(id, restart: restart, force: true,
|
|
@@ -259,6 +270,8 @@ module Workspaces
|
|
|
259
270
|
'log_tail' => include_log ? log_tail(id, log) : '', 'log_source' => log, 'port' => entry['port'],
|
|
260
271
|
'environment_keys' => environment_keys(id),
|
|
261
272
|
'editable_environment' => editable_environment(id),
|
|
273
|
+
'secret_names' => secret_names(id),
|
|
274
|
+
'vault' => Vault.status,
|
|
262
275
|
'environment_presets' => environment_presets
|
|
263
276
|
}
|
|
264
277
|
end
|
|
@@ -475,6 +488,13 @@ module Workspaces
|
|
|
475
488
|
[]
|
|
476
489
|
end
|
|
477
490
|
|
|
491
|
+
# Names only. A secret's value is never part of a snapshot, a status response or a page.
|
|
492
|
+
def secret_names(id)
|
|
493
|
+
EnvironmentOverrides.new(id).secret_names
|
|
494
|
+
rescue EnvironmentOverrides::Invalid
|
|
495
|
+
[]
|
|
496
|
+
end
|
|
497
|
+
|
|
478
498
|
def editable_environment(id)
|
|
479
499
|
EnvironmentOverrides.new(id).editable_values(Recipe.new.default_editable_env)
|
|
480
500
|
rescue Recipe::Invalid, EnvironmentOverrides::Invalid
|
|
@@ -5,14 +5,17 @@
|
|
|
5
5
|
const status = document.getElementById('environment-status');
|
|
6
6
|
const fieldset = form.querySelector('[data-environment-fieldset]');
|
|
7
7
|
const submit = form.querySelector('[data-env-submit]');
|
|
8
|
-
const
|
|
8
|
+
const rowsContainer = document.getElementById('env-rows');
|
|
9
|
+
const rowTemplate = document.getElementById('env-row-template');
|
|
10
|
+
const addButton = form.querySelector('[data-env-add-row]');
|
|
9
11
|
const presetButtons = Array.from(form.querySelectorAll('[data-environment-preset]'));
|
|
10
12
|
const presetGroup = form.querySelector('[data-preset-controls]');
|
|
11
|
-
const initialDraft = editor ? editor.value : '';
|
|
12
13
|
let lastAnnounced = '';
|
|
13
14
|
let activeBusy = document.getElementById('workspace').dataset.busy === 'true';
|
|
14
15
|
let presets = {};
|
|
15
16
|
let selectedPreset = null;
|
|
17
|
+
let nextIndex = 0;
|
|
18
|
+
let initialSignature = '';
|
|
16
19
|
|
|
17
20
|
try {
|
|
18
21
|
presets = JSON.parse(form.dataset.environmentPresets || '{}');
|
|
@@ -22,17 +25,60 @@
|
|
|
22
25
|
const hasPresets = Object.keys(presets).length > 0;
|
|
23
26
|
if (presetGroup && hasPresets) presetGroup.hidden = false;
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
// A preset is a list of { name, value }. A row's state is what the person typed. Secret rows are
|
|
29
|
+
// never part of any comparison, so a preset can match while secrets are present.
|
|
30
|
+
function rowElements() {
|
|
31
|
+
return rowsContainer ? Array.from(rowsContainer.querySelectorAll('[data-env-row]')) : [];
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// The value input is missing on a stored secret until Replace is clicked, so it can be null.
|
|
35
|
+
function fieldsOf(row) {
|
|
36
|
+
return {
|
|
37
|
+
name: row.querySelector('.env-row__name'),
|
|
38
|
+
value: row.querySelector('[data-env-value]'),
|
|
39
|
+
secret: row.querySelector('[data-env-secret]'),
|
|
40
|
+
remove: row.querySelector('[data-env-remove-row]'),
|
|
41
|
+
stored: row.querySelector('[data-env-stored]'),
|
|
42
|
+
replace: row.querySelector('[data-env-replace]')
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const valueOf = (fields) => (fields.value ? fields.value.value || '' : '');
|
|
47
|
+
|
|
48
|
+
// Keeps password managers out of every input this script creates: autocomplete is ignored on
|
|
49
|
+
// password fields, so each manager's own opt-out goes with it.
|
|
50
|
+
function noAutofill(input) {
|
|
51
|
+
input.autocomplete = 'off';
|
|
52
|
+
input.setAttribute('autocomplete', 'off');
|
|
53
|
+
input.setAttribute('data-1p-ignore', '');
|
|
54
|
+
input.setAttribute('data-lpignore', 'true');
|
|
55
|
+
input.setAttribute('data-bwignore', '');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function visibleEntries() {
|
|
59
|
+
return rowElements()
|
|
60
|
+
.map((row) => {
|
|
61
|
+
const fields = fieldsOf(row);
|
|
62
|
+
return {
|
|
63
|
+
name: (fields.name.value || '').trim(),
|
|
64
|
+
value: valueOf(fields),
|
|
65
|
+
secret: !!(fields.secret && fields.secret.checked)
|
|
66
|
+
};
|
|
67
|
+
})
|
|
68
|
+
.filter((entry) => !entry.secret && (entry.name || entry.value));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function signatureOf(entries) {
|
|
72
|
+
return JSON.stringify(
|
|
73
|
+
entries
|
|
74
|
+
.map((entry) => [entry.name, entry.value])
|
|
75
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function matchingPresetName() {
|
|
80
|
+
const current = signatureOf(visibleEntries());
|
|
81
|
+
return Object.keys(presets).find((name) => signatureOf(presets[name]) === current) || null;
|
|
36
82
|
}
|
|
37
83
|
|
|
38
84
|
function updatePresetButtons(nextSelected) {
|
|
@@ -69,15 +115,13 @@
|
|
|
69
115
|
if (fieldset) fieldset.disabled = activeBusy;
|
|
70
116
|
if (submit) submit.disabled = activeBusy;
|
|
71
117
|
presetButtons.forEach((button) => {
|
|
72
|
-
button.disabled = activeBusy;
|
|
118
|
+
button.disabled = activeBusy || !hasPresets;
|
|
73
119
|
});
|
|
74
120
|
}
|
|
75
121
|
|
|
76
122
|
function updateStatus(busy) {
|
|
77
123
|
if (busy)
|
|
78
|
-
setStatus(
|
|
79
|
-
'Workspace is busy. Environment updates are temporarily disabled.'
|
|
80
|
-
);
|
|
124
|
+
setStatus('Workspace is busy. Environment updates are temporarily disabled.');
|
|
81
125
|
else if (status.textContent === 'Workspace is busy. Environment updates are temporarily disabled.') {
|
|
82
126
|
status.textContent = '';
|
|
83
127
|
lastAnnounced = '';
|
|
@@ -86,75 +130,173 @@
|
|
|
86
130
|
|
|
87
131
|
function announceUnsaved(message) {
|
|
88
132
|
if (activeBusy) return;
|
|
89
|
-
const nextMessage =
|
|
133
|
+
const nextMessage =
|
|
134
|
+
message || 'Draft updated locally and not saved yet. Use ' + submitLabel() + ' to apply changes.';
|
|
90
135
|
status.textContent = nextMessage;
|
|
91
136
|
lastAnnounced = nextMessage;
|
|
92
137
|
}
|
|
93
138
|
|
|
94
|
-
function replaceDraft(value, message) {
|
|
95
|
-
if (!editor) return;
|
|
96
|
-
editor.value = value || '';
|
|
97
|
-
updatePresetButtons(matchingPresetNameForDraft(editor.value));
|
|
98
|
-
announceUnsaved(message);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
139
|
function clearStatus() {
|
|
102
140
|
if (activeBusy) return;
|
|
103
141
|
status.textContent = '';
|
|
104
142
|
lastAnnounced = '';
|
|
105
143
|
}
|
|
106
144
|
|
|
145
|
+
// The checkbox of a secret that is already stored is disabled (and a hidden field carries it), so a
|
|
146
|
+
// saved secret cannot be turned back into a visible variable. Ticking Secret on a new row switches
|
|
147
|
+
// its value to a password field; the value is write-only from then on.
|
|
148
|
+
function syncRow(row) {
|
|
149
|
+
const fields = fieldsOf(row);
|
|
150
|
+
const secret = !!(fields.secret && fields.secret.checked);
|
|
151
|
+
if (secret) row.setAttribute('data-secret', 'true');
|
|
152
|
+
else row.removeAttribute('data-secret');
|
|
153
|
+
if (!fields.value) return;
|
|
154
|
+
fields.value.type = secret ? 'password' : 'text';
|
|
155
|
+
noAutofill(fields.value);
|
|
156
|
+
if (row.hasAttribute('data-stored')) fields.value.placeholder = 'New value (leave blank to keep)';
|
|
157
|
+
else fields.value.placeholder = secret ? 'Value' : 'value';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Replace adds the value input to a stored secret. Until then the page has no password field for
|
|
161
|
+
// it at all.
|
|
162
|
+
function revealReplaceInput(row) {
|
|
163
|
+
const fields = fieldsOf(row);
|
|
164
|
+
if (fields.value || !fields.stored) return;
|
|
165
|
+
const input = document.createElement('input');
|
|
166
|
+
input.className = 'env-row__value';
|
|
167
|
+
input.name = (fields.name.name || '').replace(/\[name\]$/, '[value]');
|
|
168
|
+
input.type = 'password';
|
|
169
|
+
input.value = '';
|
|
170
|
+
input.placeholder = 'New value (leave blank to keep)';
|
|
171
|
+
input.setAttribute('aria-label', 'New value');
|
|
172
|
+
input.setAttribute('spellcheck', 'false');
|
|
173
|
+
input.setAttribute('autocapitalize', 'off');
|
|
174
|
+
input.setAttribute('data-env-value', '');
|
|
175
|
+
noAutofill(input);
|
|
176
|
+
fields.stored.parentNode.insertBefore(input, fields.stored);
|
|
177
|
+
fields.stored.parentNode.removeChild(fields.stored);
|
|
178
|
+
input.focus();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function bindRow(row) {
|
|
182
|
+
const fields = fieldsOf(row);
|
|
183
|
+
if (fields.remove) {
|
|
184
|
+
fields.remove.hidden = false;
|
|
185
|
+
fields.remove.addEventListener('click', () => {
|
|
186
|
+
if (activeBusy) return;
|
|
187
|
+
row.parentNode.removeChild(row);
|
|
188
|
+
draftChanged();
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (fields.replace) {
|
|
192
|
+
fields.replace.hidden = false;
|
|
193
|
+
fields.replace.addEventListener('click', () => {
|
|
194
|
+
if (activeBusy) return;
|
|
195
|
+
revealReplaceInput(row);
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
['input', 'change'].forEach((type) => {
|
|
199
|
+
row.addEventListener(type, (event) => {
|
|
200
|
+
if (event && event.target && event.target.matches && event.target.matches('[data-env-secret]'))
|
|
201
|
+
syncRow(row);
|
|
202
|
+
draftChanged();
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
syncRow(row);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function addRow(entry) {
|
|
209
|
+
if (!rowTemplate || !rowsContainer) return null;
|
|
210
|
+
const holder = document.createElement('div');
|
|
211
|
+
holder.innerHTML = rowTemplate.innerHTML.split('__INDEX__').join(String(nextIndex++));
|
|
212
|
+
const row = holder.querySelector('[data-env-row]');
|
|
213
|
+
if (!row) return null;
|
|
214
|
+
rowsContainer.append(row);
|
|
215
|
+
const fields = fieldsOf(row);
|
|
216
|
+
if (entry) {
|
|
217
|
+
fields.name.value = entry.name || '';
|
|
218
|
+
if (fields.value) fields.value.value = entry.value || '';
|
|
219
|
+
}
|
|
220
|
+
bindRow(row);
|
|
221
|
+
return row;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function draftChanged() {
|
|
225
|
+
updatePresetButtons(matchingPresetName());
|
|
226
|
+
const unchanged = rowElements().length === 0 ? false : currentSignature() === initialSignature;
|
|
227
|
+
if (unchanged) clearStatus();
|
|
228
|
+
else announceUnsaved();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function currentSignature() {
|
|
232
|
+
return JSON.stringify(
|
|
233
|
+
rowElements().map((row) => {
|
|
234
|
+
const fields = fieldsOf(row);
|
|
235
|
+
const secret = !!(fields.secret && fields.secret.checked);
|
|
236
|
+
// A secret's value is kept out of the signature, but whether one has been typed is not: a
|
|
237
|
+
// pending replacement is an unsaved change.
|
|
238
|
+
return [fields.name.value, secret ? '' : valueOf(fields), secret, secret && valueOf(fields) !== ''];
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Loading a preset replaces the visible variables only. Secrets are never touched by a preset.
|
|
244
|
+
function loadPreset(name) {
|
|
245
|
+
const entries = presets[name] || [];
|
|
246
|
+
rowElements().forEach((row) => {
|
|
247
|
+
const fields = fieldsOf(row);
|
|
248
|
+
const isSecret = !!(fields.secret && fields.secret.checked);
|
|
249
|
+
if (!isSecret) row.parentNode.removeChild(row);
|
|
250
|
+
});
|
|
251
|
+
entries.forEach((entry) => addRow(entry));
|
|
252
|
+
updatePresetButtons(name);
|
|
253
|
+
announceUnsaved('Preset loaded into draft only. Use ' + submitLabel() + ' to apply changes.');
|
|
254
|
+
}
|
|
255
|
+
|
|
107
256
|
form.addEventListener('submit', () => {
|
|
108
257
|
setStatus('Submitting environment settings…');
|
|
109
258
|
});
|
|
110
259
|
|
|
111
|
-
if (editor)
|
|
112
|
-
editor.addEventListener('input', () => {
|
|
113
|
-
if (editor.value === initialDraft) {
|
|
114
|
-
updatePresetButtons(matchingPresetNameForDraft(initialDraft));
|
|
115
|
-
clearStatus();
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
updatePresetButtons(matchingPresetNameForDraft(editor.value));
|
|
119
|
-
announceUnsaved();
|
|
120
|
-
});
|
|
121
|
-
|
|
122
260
|
presetButtons.forEach((button) => {
|
|
123
261
|
button.addEventListener('click', () => {
|
|
124
262
|
if (activeBusy) return;
|
|
125
263
|
const nextValue = button.dataset.environmentPreset;
|
|
126
264
|
if (!nextValue) return;
|
|
127
265
|
if (!Object.prototype.hasOwnProperty.call(presets, nextValue)) return;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
266
|
+
loadPreset(nextValue);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
if (addButton) {
|
|
271
|
+
addButton.hidden = false;
|
|
272
|
+
addButton.addEventListener('click', () => {
|
|
273
|
+
if (activeBusy) return;
|
|
274
|
+
const row = addRow(null);
|
|
275
|
+
if (row) fieldsOf(row).name.focus();
|
|
132
276
|
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Rows the server rendered already carry numbered names; new rows continue after the highest.
|
|
280
|
+
rowElements().forEach((row) => {
|
|
281
|
+
const match = /env\[(\d+)\]/.exec(fieldsOf(row).name.name || '');
|
|
282
|
+
if (match) nextIndex = Math.max(nextIndex, Number(match[1]) + 1);
|
|
283
|
+
bindRow(row);
|
|
133
284
|
});
|
|
285
|
+
initialSignature = currentSignature();
|
|
134
286
|
|
|
135
287
|
window.WorkspaceEnvironment = {
|
|
136
288
|
toggleBusy,
|
|
137
289
|
updateStatus,
|
|
138
290
|
setStatus,
|
|
139
291
|
setSubmitLabel,
|
|
292
|
+
// The rows are server-rendered and the person may be editing them, so a poll never rewrites
|
|
293
|
+
// them; it only keeps the selected preset in step when nothing has been touched.
|
|
140
294
|
syncFromSnapshot(snapshot) {
|
|
141
|
-
if (!snapshot || presetButtons.length === 0
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const expected = Object.keys(rendered)
|
|
145
|
-
.sort()
|
|
146
|
-
.map((name) => name + '=' + rendered[name])
|
|
147
|
-
.join('\n');
|
|
148
|
-
if (editor === document.activeElement) return;
|
|
149
|
-
if (editor.value !== expected) return;
|
|
150
|
-
updatePresetButtons(matchingPresetNameForDraft(expected));
|
|
295
|
+
if (!snapshot || presetButtons.length === 0) return;
|
|
296
|
+
if (rowsContainer && rowsContainer.contains(document.activeElement)) return;
|
|
297
|
+
updatePresetButtons(matchingPresetName());
|
|
151
298
|
}
|
|
152
299
|
};
|
|
153
|
-
updatePresetButtons(
|
|
154
|
-
if (!hasPresets) {
|
|
155
|
-
presetButtons.forEach((button) => {
|
|
156
|
-
button.disabled = true;
|
|
157
|
-
});
|
|
158
|
-
}
|
|
300
|
+
updatePresetButtons(matchingPresetName());
|
|
159
301
|
toggleBusy(activeBusy);
|
|
160
302
|
})();
|
|
@@ -386,6 +386,38 @@ h1 {
|
|
|
386
386
|
overflow: auto;
|
|
387
387
|
}
|
|
388
388
|
|
|
389
|
+
.env-rows { display: grid; gap: var(--space-2); }
|
|
390
|
+
|
|
391
|
+
.env-row {
|
|
392
|
+
display: grid;
|
|
393
|
+
grid-template-columns: minmax(8rem, 1fr) minmax(8rem, 2fr) auto auto;
|
|
394
|
+
gap: var(--space-2);
|
|
395
|
+
align-items: center;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.env-row input[type='text'],
|
|
399
|
+
.env-row input[type='password'],
|
|
400
|
+
.env-row .env-row__name {
|
|
401
|
+
min-inline-size: 0;
|
|
402
|
+
padding: var(--space-2) var(--space-3);
|
|
403
|
+
color: var(--text);
|
|
404
|
+
background: var(--bg);
|
|
405
|
+
border: 1px solid var(--border);
|
|
406
|
+
border-radius: var(--space-1);
|
|
407
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
.env-row[data-secret] .env-row__value { border-style: dashed; }
|
|
411
|
+
.env-row__stored { display: inline-flex; align-items: center; gap: var(--space-2); font-size: 13px; }
|
|
412
|
+
.env-row__secret { display: inline-flex; align-items: center; gap: var(--space-1); font-size: 13px; white-space: nowrap; }
|
|
413
|
+
.env-row-actions { display: flex; gap: var(--space-2); }
|
|
414
|
+
.env-vault-note { color: var(--warning); }
|
|
415
|
+
|
|
416
|
+
@media (max-width: 560px) {
|
|
417
|
+
.env-row { grid-template-columns: 1fr 1fr; }
|
|
418
|
+
.env-row .env-row__name, .env-row .env-row__value { grid-column: 1 / -1; }
|
|
419
|
+
}
|
|
420
|
+
|
|
389
421
|
.env-actions {
|
|
390
422
|
display: flex;
|
|
391
423
|
flex-wrap: wrap;
|
|
@@ -44,9 +44,21 @@
|
|
|
44
44
|
</div>
|
|
45
45
|
<% end %>
|
|
46
46
|
<div class="stack stack--tight">
|
|
47
|
-
<
|
|
48
|
-
<
|
|
47
|
+
<p class="env-input-label" id="env-rows-label">Environment variables</p>
|
|
48
|
+
<div class="env-rows" id="env-rows" role="group" aria-labelledby="env-rows-label" data-env-rows>
|
|
49
|
+
<% environment_rows.each_with_index do |row, index| %>
|
|
50
|
+
<%= env_row_html(index, row) %>
|
|
51
|
+
<% end %>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="env-row-actions">
|
|
54
|
+
<button type="button" class="button button--subtle button--small" data-env-add-row hidden>Add variable</button>
|
|
55
|
+
</div>
|
|
56
|
+
<% unless vault_status['available'] %>
|
|
57
|
+
<p class="muted env-vault-note" id="env-vault-note"><%= escape(vault_status['problem'] || 'Secrets are not available.') %></p>
|
|
58
|
+
<% end %>
|
|
59
|
+
<p class="muted env-secret-help">Secrets are write-only: once saved their values are never shown again. Leave a secret's value blank to keep what is stored.</p>
|
|
49
60
|
</div>
|
|
61
|
+
<template id="env-row-template"><%= env_row_html('__INDEX__', { 'name' => '', 'value' => '', 'secret' => false }) %></template>
|
|
50
62
|
</fieldset>
|
|
51
63
|
<div class="env-actions">
|
|
52
64
|
<button class="button button--primary" data-env-submit <%= 'disabled' if @presentation['busy'] %>><%= escape(@presentation['environment_submit']) %></button>
|
|
@@ -138,28 +138,86 @@ module Workspaces
|
|
|
138
138
|
keys.map(&:to_s).sort
|
|
139
139
|
end
|
|
140
140
|
|
|
141
|
-
|
|
141
|
+
# The rows the settings form starts with: visible variables with their values, then secrets by
|
|
142
|
+
# name only. A secret row never carries a value, so there is nothing to leak into the page.
|
|
143
|
+
def environment_rows
|
|
142
144
|
values = @snapshot['editable_environment']
|
|
143
|
-
|
|
145
|
+
plain = values.is_a?(Hash) ? values.sort.map { |name, value| { 'name' => name.to_s, 'value' => value.to_s, 'secret' => false } } : []
|
|
146
|
+
names = @snapshot['secret_names']
|
|
147
|
+
secrets = names.is_a?(Array) ? names.map(&:to_s).sort.map { |name| { 'name' => name, 'value' => '', 'secret' => true } } : []
|
|
148
|
+
plain + secrets
|
|
149
|
+
end
|
|
144
150
|
|
|
145
|
-
|
|
151
|
+
# What a preset loads: its rows in the same shape. Secrets are untouched by presets.
|
|
152
|
+
def environment_preset_rows
|
|
153
|
+
environment_presets_raw.transform_values do |entries|
|
|
154
|
+
entries.sort.map { |name, value| { 'name' => name.to_s, 'value' => value.to_s } }
|
|
155
|
+
end
|
|
146
156
|
end
|
|
147
157
|
|
|
148
|
-
|
|
158
|
+
# One row of the settings form. Plain rows show their value. Secret rows have a password field,
|
|
159
|
+
# never pre-filled, with a placeholder saying a value is stored. The secret checkbox is the only
|
|
160
|
+
# thing that decides which store a row goes to, and it is disabled for a stored secret so a
|
|
161
|
+
# saved secret cannot be quietly turned into a visible variable.
|
|
162
|
+
# Keeps password managers from offering to fill, save or generate anything for these inputs. No
|
|
163
|
+
# single attribute does it: `autocomplete="off"` is ignored on password fields, so each manager's
|
|
164
|
+
# own opt-out goes alongside it (1Password, LastPass, Bitwarden).
|
|
165
|
+
NO_AUTOFILL = 'autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore'
|
|
166
|
+
|
|
167
|
+
# One row of the settings form.
|
|
168
|
+
#
|
|
169
|
+
# A stored secret has no value input at all: just its name, a note, and a Replace button that
|
|
170
|
+
# adds the input when it is clicked. So the page holds no password field for a password manager
|
|
171
|
+
# to find until someone asks to replace one. (Without script, the input sits inside <noscript> so
|
|
172
|
+
# a secret can still be replaced.) A row posted with no value keeps what is stored.
|
|
173
|
+
#
|
|
174
|
+
# The secret checkbox is disabled for a stored secret, with a hidden field carrying it, so a
|
|
175
|
+
# saved secret cannot be quietly turned into a visible variable.
|
|
176
|
+
def env_row_html(index, row)
|
|
177
|
+
secret = row['secret']
|
|
178
|
+
stored = secret && !row['name'].to_s.empty?
|
|
179
|
+
<<~HTML.delete("\n")
|
|
180
|
+
<div class="env-row" data-env-row#{secret ? ' data-secret="true"' : ''}#{stored ? ' data-stored="true"' : ''}>
|
|
181
|
+
<input class="env-row__name" name="env[#{index}][name]" value="#{escape(row['name'])}" placeholder="NAME" aria-label="Variable name" spellcheck="false" #{NO_AUTOFILL} autocapitalize="off">
|
|
182
|
+
#{stored ? stored_value_html(index) : value_input_html(index, row)}
|
|
183
|
+
<label class="env-row__secret"><input type="checkbox" name="env[#{index}][secret]" value="1" data-env-secret#{secret ? ' checked' : ''}#{stored ? ' disabled' : ''}> Secret</label>
|
|
184
|
+
#{stored ? "<input type=\"hidden\" name=\"env[#{index}][secret]\" value=\"1\">" : ''}
|
|
185
|
+
<button type="button" class="button button--subtle button--small" data-env-remove-row aria-label="Remove variable" hidden>Remove</button>
|
|
186
|
+
</div>
|
|
187
|
+
HTML
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def value_input_html(index, row, placeholder: nil)
|
|
191
|
+
secret = row['secret']
|
|
192
|
+
placeholder ||= secret ? 'Value' : 'value'
|
|
193
|
+
%(<input class="env-row__value" name="env[#{index}][value]" type="#{secret ? 'password' : 'text'}" value="#{secret ? '' : escape(row['value'])}" placeholder="#{escape(placeholder)}" aria-label="Value" spellcheck="false" #{NO_AUTOFILL} autocapitalize="off" data-env-value>)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def stored_value_html(index)
|
|
197
|
+
'<span class="env-row__stored" data-env-stored><span class="muted">Stored. Write-only.</span> ' \
|
|
198
|
+
'<button type="button" class="button button--subtle button--small" data-env-replace hidden>Replace</button></span>' \
|
|
199
|
+
"<noscript>#{value_input_html(index, { 'secret' => true }, placeholder: 'New value (leave blank to keep)')}</noscript>"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def environment_presets_raw
|
|
149
203
|
presets = @snapshot['environment_presets']
|
|
150
204
|
return {} unless presets.is_a?(Hash)
|
|
151
205
|
|
|
152
|
-
presets.
|
|
153
|
-
|
|
154
|
-
next unless entries.is_a?(Hash)
|
|
206
|
+
presets.select { |name, entries| name.respond_to?(:to_s) && entries.is_a?(Hash) }.transform_keys(&:to_s)
|
|
207
|
+
end
|
|
155
208
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
209
|
+
def vault_status
|
|
210
|
+
vault = @snapshot['vault']
|
|
211
|
+
vault.is_a?(Hash) ? vault : { 'available' => false, 'problem' => nil }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# The preset names and values the buttons are built from.
|
|
215
|
+
def environment_presets
|
|
216
|
+
environment_presets_raw
|
|
159
217
|
end
|
|
160
218
|
|
|
161
219
|
def environment_presets_json
|
|
162
|
-
JSON.generate(
|
|
220
|
+
JSON.generate(environment_preset_rows)
|
|
163
221
|
end
|
|
164
222
|
|
|
165
223
|
# The lead's primary action. `open` is rendered separately as a link (it needs the
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'securerandom'
|
|
5
|
+
require_relative 'vault'
|
|
5
6
|
|
|
6
7
|
module Workspaces
|
|
7
8
|
class EnvironmentOverrides
|
|
@@ -13,6 +14,7 @@ module Workspaces
|
|
|
13
14
|
MAX_ENTRIES = 100
|
|
14
15
|
MAX_VALUE_BYTES = 8192
|
|
15
16
|
MAX_BODY_BYTES = 128 * 1024
|
|
17
|
+
MAX_ROWS = 200
|
|
16
18
|
|
|
17
19
|
def self.validate_entries!(entries)
|
|
18
20
|
raise Invalid, 'Environment overrides must be a mapping' unless entries.is_a?(Hash)
|
|
@@ -45,12 +47,26 @@ module Workspaces
|
|
|
45
47
|
@home = home
|
|
46
48
|
end
|
|
47
49
|
|
|
50
|
+
# Everything a step should see: saved variables and secrets. Raises Vault::Error when secrets
|
|
51
|
+
# exist but cannot be read, so a run fails with a reason instead of starting without them.
|
|
48
52
|
def load
|
|
53
|
+
load_plain.merge(secret_values)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The variables that are safe to show. No secrets, and no vault access.
|
|
57
|
+
def load_plain
|
|
49
58
|
load_file(file_path).merge(load_file(editable_file_path))
|
|
50
59
|
end
|
|
51
60
|
|
|
52
61
|
def keys
|
|
53
|
-
|
|
62
|
+
(load_plain.keys + secret_names).uniq.sort
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Names only, never values. Empty when the vault cannot be opened.
|
|
66
|
+
def secret_names
|
|
67
|
+
secret_values.keys.sort
|
|
68
|
+
rescue Vault::Error
|
|
69
|
+
[]
|
|
54
70
|
end
|
|
55
71
|
|
|
56
72
|
def editable_values(defaults)
|
|
@@ -72,7 +88,28 @@ module Workspaces
|
|
|
72
88
|
raise Invalid, 'Secret-like environment names cannot be stored in the visible editor'
|
|
73
89
|
end
|
|
74
90
|
persist_editable(entries, updated, defaults)
|
|
75
|
-
|
|
91
|
+
load_plain
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Saves the settings form: a list of rows, each { 'name', 'value', 'secret' }. Plain rows are
|
|
95
|
+
# stored as editable settings. Secret rows go to the vault and are write-only: a secret row
|
|
96
|
+
# with no value keeps the value already stored, and a secret that is no longer listed is removed.
|
|
97
|
+
#
|
|
98
|
+
# Nothing is changed unless every row is valid and the vault can be used. Messages name
|
|
99
|
+
# variables, never values.
|
|
100
|
+
def replace_rows(rows, defaults:)
|
|
101
|
+
raise Invalid, 'Environment rows must be a list' unless rows.is_a?(Array)
|
|
102
|
+
raise Invalid, "Environment rows are limited to #{MAX_ROWS}" if rows.length > MAX_ROWS
|
|
103
|
+
|
|
104
|
+
self.class.validate_entries!(defaults)
|
|
105
|
+
plain, secrets = split_rows(rows)
|
|
106
|
+
self.class.validate_entries!(plain)
|
|
107
|
+
self.class.validate_entries!(secrets.compact)
|
|
108
|
+
raise Invalid, 'Environment overrides are limited to 100 entries' if plain.length + secrets.length > MAX_ENTRIES
|
|
109
|
+
|
|
110
|
+
write_secrets(secrets)
|
|
111
|
+
persist_editable(plain, plain.reject { |name, value| defaults[name] == value }, defaults)
|
|
112
|
+
load_plain
|
|
76
113
|
end
|
|
77
114
|
|
|
78
115
|
def apply_preset(name, recipe: Recipe.new)
|
|
@@ -111,9 +148,12 @@ module Workspaces
|
|
|
111
148
|
updated
|
|
112
149
|
end
|
|
113
150
|
|
|
151
|
+
# Removes saved variables and secrets. Returns the secrets that could not be removed (see
|
|
152
|
+
# Vault.purge_everywhere), empty when everything went.
|
|
114
153
|
def clear!
|
|
115
154
|
delete_file!
|
|
116
155
|
delete_file!(editable_file_path)
|
|
156
|
+
Vault.purge_everywhere(workspace_id)
|
|
117
157
|
end
|
|
118
158
|
|
|
119
159
|
def scrub(value)
|
|
@@ -261,10 +301,69 @@ module Workspaces
|
|
|
261
301
|
raise Invalid, 'Environment storage does not allow symlinks' if path.symlink?
|
|
262
302
|
end
|
|
263
303
|
|
|
304
|
+
# Filtering still hides every secret that can be read, even when something else is wrong.
|
|
264
305
|
def scrub_values
|
|
265
|
-
|
|
266
|
-
|
|
306
|
+
values = [safely { load_plain.values }, safely { secret_values.values }].flatten
|
|
307
|
+
values.reject(&:empty?).uniq.sort_by { |value| -value.bytesize }
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def safely
|
|
311
|
+
yield
|
|
312
|
+
rescue Invalid, Vault::Error
|
|
267
313
|
[]
|
|
268
314
|
end
|
|
315
|
+
|
|
316
|
+
def secret_values
|
|
317
|
+
Vault.provider.read(workspace_id)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def split_rows(rows)
|
|
321
|
+
plain = {}
|
|
322
|
+
secrets = {}
|
|
323
|
+
rows.each do |row|
|
|
324
|
+
raise Invalid, 'Each environment row needs a name and a value' unless row.is_a?(Hash)
|
|
325
|
+
|
|
326
|
+
name = row['name'].to_s.strip
|
|
327
|
+
value = row['value'].to_s
|
|
328
|
+
next if name.empty? && value.empty?
|
|
329
|
+
|
|
330
|
+
raise Invalid, 'Every variable needs a name' if name.empty?
|
|
331
|
+
raise Invalid, "#{name} is listed more than once" if plain.key?(name) || secrets.key?(name)
|
|
332
|
+
|
|
333
|
+
if [true, '1', 'true', 'on'].include?(row['secret'])
|
|
334
|
+
validate_name!(name)
|
|
335
|
+
secrets[name] = value.empty? ? nil : value
|
|
336
|
+
else
|
|
337
|
+
validate_plain_row!(name, value)
|
|
338
|
+
plain[name] = value
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
[plain, secrets]
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def validate_plain_row!(name, value)
|
|
345
|
+
validate_name!(name)
|
|
346
|
+
validate_value!(value)
|
|
347
|
+
raise Invalid, "#{name} looks like a secret: tick Secret so it is stored write-only" if sensitive_name?(name)
|
|
348
|
+
raise Invalid, "#{name}: values must be a single line" if value.match?(/[\r\n]/)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# `submitted` maps each secret's name to its new value, or nil when the row was left blank. A blank
|
|
352
|
+
# row keeps the stored value. Existing secrets are never replaced blindly: if the vault cannot be
|
|
353
|
+
# read (wrong or missing key) an edit is refused rather than overwriting what is stored.
|
|
354
|
+
def write_secrets(submitted)
|
|
355
|
+
vault = Vault.provider
|
|
356
|
+
unless vault.available?
|
|
357
|
+
raise Vault::Unavailable, vault.problem unless submitted.empty?
|
|
358
|
+
|
|
359
|
+
return
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
stored = vault.read(workspace_id)
|
|
363
|
+
result = submitted.to_h do |name, value|
|
|
364
|
+
[name, value || stored.fetch(name) { raise Invalid, "#{name} is a new secret and needs a value" }]
|
|
365
|
+
end
|
|
366
|
+
vault.write(workspace_id, result) unless result == stored
|
|
367
|
+
end
|
|
269
368
|
end
|
|
270
369
|
end
|
|
@@ -148,7 +148,9 @@ module Workspaces
|
|
|
148
148
|
return response(403, 'Actions require the configured dashboard origin') unless same_origin?(request)
|
|
149
149
|
|
|
150
150
|
params = environment_params(request)
|
|
151
|
-
updated = if params.key?('
|
|
151
|
+
updated = if params.key?('env')
|
|
152
|
+
@coordinator.replace_environment_rows(id, rows: parse_environment_rows(params))
|
|
153
|
+
elsif params.key?('editable_env')
|
|
152
154
|
@coordinator.replace_editable_environment(id, text: parse_editable_environment(params))
|
|
153
155
|
else
|
|
154
156
|
@coordinator.update_environment(id, **parse_environment_changes(params))
|
|
@@ -158,6 +160,8 @@ module Workspaces
|
|
|
158
160
|
redirect(Config.dashboard_url(id))
|
|
159
161
|
rescue EnvironmentOverrides::Invalid => e
|
|
160
162
|
response(422, e.message)
|
|
163
|
+
rescue Vault::Error => e
|
|
164
|
+
response(422, e.message)
|
|
161
165
|
end
|
|
162
166
|
|
|
163
167
|
def remove(request, id)
|
|
@@ -167,9 +171,13 @@ module Workspaces
|
|
|
167
171
|
return response(413, 'Deletion confirmation is too large') if request.content_length.to_i > 512
|
|
168
172
|
return response(422, 'Type the exact workspace ID to confirm deletion') unless request.POST['confirm_id'] == id
|
|
169
173
|
|
|
170
|
-
@registry.remove(id, force: true)
|
|
174
|
+
leftover = @registry.remove(id, force: true)
|
|
171
175
|
@tracker.forget(id)
|
|
172
|
-
redirect("#{Config.public_origin}/workspaces")
|
|
176
|
+
return redirect("#{Config.public_origin}/workspaces") if !leftover.is_a?(Array) || leftover.empty?
|
|
177
|
+
|
|
178
|
+
# The workspace is gone, but its secrets are not, and saying "removed" would be untrue.
|
|
179
|
+
response(500, "The workspace was removed, but its stored secrets could not be: #{leftover.join('; ')}. " \
|
|
180
|
+
'Fix that, then run `workspaces purge-secrets`.')
|
|
173
181
|
rescue ArgumentError, Worktree::CommandFailedError, SystemCallError
|
|
174
182
|
response(409, 'Workspace was not removed. Check the server logs and retry when safe.')
|
|
175
183
|
end
|
|
@@ -204,6 +212,24 @@ module Workspaces
|
|
|
204
212
|
{ set: normalize_set(params['set']), remove: normalize_remove(params['remove']) }
|
|
205
213
|
end
|
|
206
214
|
|
|
215
|
+
# The form posts env[0][name], env[0][value] and env[0][secret] for each row. Rows are taken in
|
|
216
|
+
# the order of their numbers; anything that is not a mapping of strings is refused.
|
|
217
|
+
def parse_environment_rows(params)
|
|
218
|
+
rows = params['env']
|
|
219
|
+
raise EnvironmentOverrides::Invalid, 'Environment rows must be a numbered list' unless rows.is_a?(Hash)
|
|
220
|
+
unless rows.keys.all? { |key| key.to_s.match?(/\A\d{1,4}\z/) }
|
|
221
|
+
raise EnvironmentOverrides::Invalid, 'Environment rows must be a numbered list'
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
rows.sort_by { |key, _| key.to_i }.map do |_, row|
|
|
225
|
+
unless row.is_a?(Hash) && row.values.all?(String)
|
|
226
|
+
raise EnvironmentOverrides::Invalid, 'Each environment row needs a name and a value'
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
row.slice('name', 'value', 'secret')
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
207
233
|
def parse_editable_environment(params)
|
|
208
234
|
unless params['editable_env'].is_a?(String)
|
|
209
235
|
raise EnvironmentOverrides::Invalid, 'Environment editor requires one editable_env field'
|
data/lib/workspaces/registry.rb
CHANGED
|
@@ -41,6 +41,8 @@ module Workspaces
|
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Removes the workspace and its saved settings and secrets. Returns the secrets that could not be
|
|
45
|
+
# removed (empty when all went), so callers can say so rather than report a clean removal.
|
|
44
46
|
def remove(id, force: false)
|
|
45
47
|
with_lock(id) do |workspace|
|
|
46
48
|
raise ArgumentError, 'Workspace has uncommitted/untracked work' if !force && workspace.dirty?
|
|
@@ -48,9 +50,33 @@ module Workspaces
|
|
|
48
50
|
Backend.new(id).stop!
|
|
49
51
|
workspace.remove!(force: force)
|
|
50
52
|
StateStore.new(Config.state_file).delete(id)
|
|
53
|
+
EnvironmentOverrides.new(id).clear!
|
|
51
54
|
end
|
|
52
55
|
end
|
|
53
56
|
|
|
57
|
+
# Deletes stored secrets whose workspace no longer exists: removed by hand, with `git worktree
|
|
58
|
+
# remove`, or by a purge that failed. Returns { 'removed' => [...], 'kept' => [...],
|
|
59
|
+
# 'problems' => [...] }.
|
|
60
|
+
#
|
|
61
|
+
# It decides a workspace is gone only from the managed directory, and refuses to guess when that
|
|
62
|
+
# directory cannot be read, so a missing or unmounted volume is never mistaken for "everything
|
|
63
|
+
# was deleted". With dry_run it reports what it would remove and changes nothing.
|
|
64
|
+
def sweep_orphaned_secrets(dry_run: false)
|
|
65
|
+
root = Config.worktrees_dir(create: false)
|
|
66
|
+
unless root.directory? && !root.symlink?
|
|
67
|
+
raise ArgumentError, 'The workspaces directory cannot be read, so nothing was removed'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
live = root.children.select { |path| path.directory? && !path.symlink? }.map { |path| path.basename.to_s }
|
|
71
|
+
orphans = Vault.scopes - live
|
|
72
|
+
result = { 'removed' => [], 'kept' => live & Vault.scopes, 'problems' => [] }
|
|
73
|
+
orphans.each do |id|
|
|
74
|
+
problems = dry_run ? [] : Vault.purge_everywhere(id)
|
|
75
|
+
problems.empty? ? result['removed'] << id : result['problems'].concat(problems.map { |problem| "#{id}: #{problem}" })
|
|
76
|
+
end
|
|
77
|
+
result
|
|
78
|
+
end
|
|
79
|
+
|
|
54
80
|
def update_pr(id)
|
|
55
81
|
with_lock(id) do |workspace|
|
|
56
82
|
result = workspace.update_from_pr!
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require_relative '../config'
|
|
7
|
+
|
|
8
|
+
module Workspaces
|
|
9
|
+
module Vault
|
|
10
|
+
# An encrypted file per workspace under $WORKSPACES_HOME/vault, readable only with the master key
|
|
11
|
+
# in WORKSPACES_MASTER_KEY (32 random bytes, base64 encoded: `openssl rand -base64 32`).
|
|
12
|
+
#
|
|
13
|
+
# Each workspace gets its own AES-256-GCM key, derived from the master key with HKDF, and its id is
|
|
14
|
+
# bound into the ciphertext, so a file copied to another workspace's name does not decrypt. Names
|
|
15
|
+
# are encrypted along with values. The master key lives only in the environment of the process
|
|
16
|
+
# that runs `serve`; ChildEnv keeps it out of everything that process starts.
|
|
17
|
+
class FileVault < Provider
|
|
18
|
+
KEY_ENV = 'WORKSPACES_MASTER_KEY'
|
|
19
|
+
FORMAT_VERSION = 1
|
|
20
|
+
HKDF_SALT = 'workspaces-vault-v1'
|
|
21
|
+
GENERATE_HINT = 'openssl rand -base64 32'
|
|
22
|
+
|
|
23
|
+
def initialize(home: Config.home, env: ENV)
|
|
24
|
+
super()
|
|
25
|
+
@home = home
|
|
26
|
+
@env = env
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def name
|
|
30
|
+
'file'
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def problem
|
|
34
|
+
text = @env.fetch(KEY_ENV, '').to_s.strip
|
|
35
|
+
if text.empty?
|
|
36
|
+
return "Secrets are turned off. Set #{KEY_ENV} where `workspaces serve` runs " \
|
|
37
|
+
"(generate one with: #{GENERATE_HINT})."
|
|
38
|
+
end
|
|
39
|
+
return nil if master_key
|
|
40
|
+
|
|
41
|
+
"#{KEY_ENV} must be 32 random bytes, base64 encoded (generate one with: #{GENERATE_HINT})."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def read(scope)
|
|
45
|
+
path = file_for(scope)
|
|
46
|
+
return {} unless path.exist?
|
|
47
|
+
|
|
48
|
+
require_usable!
|
|
49
|
+
decrypt(scope, path.binread)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def write(scope, entries)
|
|
53
|
+
path = file_for(scope)
|
|
54
|
+
return purge(scope) if entries.empty?
|
|
55
|
+
|
|
56
|
+
require_usable!
|
|
57
|
+
ensure_directory!
|
|
58
|
+
store(path, encrypt(scope, entries))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def purge(scope)
|
|
62
|
+
path = file_for(scope)
|
|
63
|
+
File.unlink(path) if path.exist? || path.symlink?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The workspace ids that have a file here, found by name alone (no key needed). A symlinked
|
|
67
|
+
# or missing directory has none that can be trusted, so it lists nothing.
|
|
68
|
+
def scopes
|
|
69
|
+
return [] if directory.symlink? || !directory.directory?
|
|
70
|
+
|
|
71
|
+
directory.children.filter_map do |path|
|
|
72
|
+
next unless path.extname == '.enc' && !path.symlink? && path.file?
|
|
73
|
+
|
|
74
|
+
id = path.basename('.enc').to_s
|
|
75
|
+
id if id.match?(/\A[a-z0-9]+(?:-[a-z0-9]+)*\z/) && id.length <= 48
|
|
76
|
+
end.sort
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def require_usable!
|
|
82
|
+
reason = problem
|
|
83
|
+
raise Unavailable, reason if reason
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def master_key
|
|
87
|
+
decoded = decode64(@env.fetch(KEY_ENV, '').to_s.strip)
|
|
88
|
+
decoded.bytesize == 32 ? decoded : nil
|
|
89
|
+
rescue ArgumentError
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Strict base64 without the base64 gem, which is no longer a default gem from Ruby 3.4.
|
|
94
|
+
def encode64(bytes)
|
|
95
|
+
[bytes].pack('m0')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def decode64(text)
|
|
99
|
+
text.unpack1('m0')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def key_for(scope)
|
|
103
|
+
OpenSSL::KDF.hkdf(master_key, salt: HKDF_SALT, info: "workspace:#{scope}", length: 32, hash: 'SHA256')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def encrypt(scope, entries)
|
|
107
|
+
cipher = OpenSSL::Cipher.new('aes-256-gcm').encrypt
|
|
108
|
+
cipher.key = key_for(scope)
|
|
109
|
+
iv = cipher.random_iv
|
|
110
|
+
cipher.auth_data = scope
|
|
111
|
+
ciphertext = cipher.update(JSON.generate(entries)) + cipher.final
|
|
112
|
+
JSON.generate('v' => FORMAT_VERSION, 'iv' => encode64(iv),
|
|
113
|
+
'tag' => encode64(cipher.auth_tag), 'data' => encode64(ciphertext))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def decrypt(scope, document)
|
|
117
|
+
parsed = JSON.parse(document)
|
|
118
|
+
raise Unavailable, 'The stored secrets use an unknown format' unless parsed['v'] == FORMAT_VERSION
|
|
119
|
+
|
|
120
|
+
cipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
|
|
121
|
+
cipher.key = key_for(scope)
|
|
122
|
+
cipher.iv = decode64(parsed.fetch('iv'))
|
|
123
|
+
cipher.auth_tag = decode64(parsed.fetch('tag'))
|
|
124
|
+
cipher.auth_data = scope
|
|
125
|
+
entries = JSON.parse(cipher.update(decode64(parsed.fetch('data'))) + cipher.final)
|
|
126
|
+
raise Unavailable, 'The stored secrets are damaged' unless entries.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
entries
|
|
129
|
+
rescue OpenSSL::Cipher::CipherError, JSON::ParserError, KeyError, ArgumentError, TypeError
|
|
130
|
+
raise Unavailable, "The stored secrets could not be decrypted: wrong #{KEY_ENV}, or the file is damaged"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def directory
|
|
134
|
+
@home.join('vault')
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def file_for(scope)
|
|
138
|
+
Config.validate_id!(scope.to_s)
|
|
139
|
+
reject_symlink!(directory)
|
|
140
|
+
path = directory.join("#{scope}.enc")
|
|
141
|
+
reject_symlink!(path)
|
|
142
|
+
path
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def ensure_directory!
|
|
146
|
+
directory.mkpath unless directory.exist?
|
|
147
|
+
raise Unavailable, 'The secret vault path is not a directory' unless directory.directory?
|
|
148
|
+
|
|
149
|
+
File.chmod(0o700, directory)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def store(path, document)
|
|
153
|
+
temp = directory.join(".#{path.basename}.#{Process.pid}.#{SecureRandom.hex(6)}.tmp")
|
|
154
|
+
begin
|
|
155
|
+
File.open(temp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
156
|
+
file.write(document)
|
|
157
|
+
file.flush
|
|
158
|
+
file.fsync
|
|
159
|
+
end
|
|
160
|
+
File.rename(temp, path)
|
|
161
|
+
File.chmod(0o600, path)
|
|
162
|
+
ensure
|
|
163
|
+
File.unlink(temp) if temp.exist?
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def reject_symlink!(path)
|
|
168
|
+
raise Unavailable, 'The secret vault does not allow symlinks' if path.symlink?
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
register('file', FileVault)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Workspaces
|
|
4
|
+
# Where write-only secrets live. The dashboard can put a secret in and a workspace's steps can read it
|
|
5
|
+
# back out, but nothing here ever hands a value to the interface.
|
|
6
|
+
#
|
|
7
|
+
# A provider stores one set of name => value strings per scope (a workspace id) and is chosen with
|
|
8
|
+
# WORKSPACES_VAULT (default "file"). Register another with Vault.register(name, ProviderClass). The
|
|
9
|
+
# contract is small on purpose so that a remote store fits behind it:
|
|
10
|
+
#
|
|
11
|
+
# available? / problem whether it can be used, and if not, a plain-language reason that never
|
|
12
|
+
# contains key material
|
|
13
|
+
# read(scope) the whole set, {} when there is none; raises Unavailable when a set exists
|
|
14
|
+
# but cannot be read
|
|
15
|
+
# write(scope, entries) replaces the whole set; an empty hash removes it
|
|
16
|
+
# purge(scope) removes the set (used when a workspace is removed)
|
|
17
|
+
module Vault
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
class Unavailable < Error; end
|
|
20
|
+
|
|
21
|
+
DEFAULT_PROVIDER = 'file'
|
|
22
|
+
|
|
23
|
+
# The shape every provider follows. Providers inherit it so a missing method fails loudly.
|
|
24
|
+
class Provider
|
|
25
|
+
def name
|
|
26
|
+
raise NotImplementedError, "#{self.class} must name itself"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def problem
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def available?
|
|
34
|
+
problem.nil?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def read(_scope)
|
|
38
|
+
raise NotImplementedError, "#{self.class} must implement read"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write(_scope, _entries)
|
|
42
|
+
raise NotImplementedError, "#{self.class} must implement write"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def purge(_scope)
|
|
46
|
+
raise NotImplementedError, "#{self.class} must implement purge"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Every scope this provider holds secrets for. Used to find the ones whose workspace is gone, so
|
|
50
|
+
# a provider that cannot list its scopes returns nil and is simply never swept.
|
|
51
|
+
def scopes
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def status
|
|
56
|
+
{ 'provider' => name, 'available' => available?, 'problem' => problem }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Stands in for a provider name nobody registered, so a typo shows up as a clear message in the
|
|
61
|
+
# dashboard rather than an exception while rendering it.
|
|
62
|
+
class Unknown < Provider
|
|
63
|
+
def initialize(requested)
|
|
64
|
+
super()
|
|
65
|
+
@requested = requested.to_s[0, 40]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def name
|
|
69
|
+
@requested
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def problem
|
|
73
|
+
"Unknown secret vault provider #{@requested.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Nothing can be stored under an unknown provider, so there is nothing to read or remove; a typo
|
|
77
|
+
# in WORKSPACES_VAULT must not stop workspaces that have no secrets from running.
|
|
78
|
+
def read(_scope)
|
|
79
|
+
{}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def write(_scope, _entries)
|
|
83
|
+
raise Unavailable, problem
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def purge(_scope)
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def scopes
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
@providers = {}
|
|
96
|
+
|
|
97
|
+
class << self
|
|
98
|
+
def register(name, provider_class)
|
|
99
|
+
@providers[name.to_s] = provider_class
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def provider(name = ENV.fetch('WORKSPACES_VAULT', nil))
|
|
103
|
+
requested = name.to_s.strip
|
|
104
|
+
requested = DEFAULT_PROVIDER if requested.empty?
|
|
105
|
+
klass = @providers[requested]
|
|
106
|
+
klass ? klass.new : Unknown.new(requested)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def status
|
|
110
|
+
provider.status
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Removes a workspace's secrets, whatever WORKSPACES_VAULT says now. The built-in file vault is
|
|
114
|
+
# always asked, because its files stay on disk when the setting is changed or mistyped; the
|
|
115
|
+
# configured provider is asked as well when it is a different one. Returns the problems it
|
|
116
|
+
# could not clear, as plain sentences with no key material, so a caller can say so instead of
|
|
117
|
+
# reporting a removal that left secrets behind.
|
|
118
|
+
# Scopes held by the built-in file vault and the configured provider, for finding orphans.
|
|
119
|
+
def scopes
|
|
120
|
+
[@providers.fetch(DEFAULT_PROVIDER).new, provider].uniq(&:name).flat_map { |vault| vault.scopes || [] }.uniq.sort
|
|
121
|
+
rescue StandardError
|
|
122
|
+
[]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def purge_everywhere(scope)
|
|
126
|
+
providers = [@providers.fetch(DEFAULT_PROVIDER).new, provider]
|
|
127
|
+
providers.uniq(&:name).each_with_object([]) do |vault, problems|
|
|
128
|
+
vault.purge(scope)
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
problems << "#{vault.name}: #{e.message}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
require_relative 'vault/file_vault'
|
data/lib/workspaces/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: workspaces
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.18
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Workspaces contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|
|
@@ -102,6 +102,8 @@ files:
|
|
|
102
102
|
- lib/workspaces/step_log.rb
|
|
103
103
|
- lib/workspaces/tee_log.rb
|
|
104
104
|
- lib/workspaces/tls.rb
|
|
105
|
+
- lib/workspaces/vault.rb
|
|
106
|
+
- lib/workspaces/vault/file_vault.rb
|
|
105
107
|
- lib/workspaces/version.rb
|
|
106
108
|
- lib/workspaces/workflow.rb
|
|
107
109
|
- lib/workspaces/worktree.rb
|