instabox 0.6.0 → 0.6.2
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.
- package/bin/instabox.mjs +2 -1
- package/package.json +1 -1
- package/skills/instabox/SKILL.md +44 -21
- package/skills/instabox-basic-setup/SKILL.md +57 -57
- package/skills/instabox-basic-setup/scripts/content.mjs +10 -5
- package/skills/instabox-basic-setup/scripts/install-files.py +82 -0
- package/skills/instabox-basic-setup/scripts/lib.mjs +61 -39
- package/skills/instabox-basic-setup/scripts/plan.mjs +248 -230
- package/skills/instabox-basic-setup/scripts/push.mjs +318 -151
- package/skills/instabox-basic-setup/scripts/scan.mjs +73 -46
- package/skills/instabox-move-repo/SKILL.md +22 -12
- package/skills/instabox-snapshots/SKILL.md +8 -2
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// node push.mjs --selection selection.json --dry-run # names, sizes, what it would do
|
|
6
|
-
// node push.mjs --selection selection.json
|
|
7
|
-
//
|
|
8
|
-
// A value is held in this process's memory between reading it and writing
|
|
9
|
-
// it to instabox's stdin; it is never printed, logged or put on a command
|
|
10
|
-
// line. Output: one line per Vault item with its name, kind, size and outcome.
|
|
11
|
-
//
|
|
12
|
-
// Every value is read and the totals checked against the Vault's limits
|
|
13
|
-
// before anything is written: over them, nothing is written (exit 3).
|
|
14
|
-
// An item that is in the Vault now but was not when the plan was written,
|
|
15
|
-
// and was not set by this skill, is left alone. The manifest
|
|
16
|
-
// (~/.config/instabox-basic-setup/pushed.json: names, kinds, paths; no
|
|
17
|
-
// values) records what this skill set, so a later run replaces those.
|
|
2
|
+
// Save reviewed org Secrets, select them for named sandboxes, and copy ordinary
|
|
3
|
+
// config files separately. Credential values are held only in memory/stdin.
|
|
18
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
19
6
|
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
20
|
-
import { dirname } from 'node:path';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
21
8
|
import { fileContent } from './content.mjs';
|
|
22
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
SECRET_MAX_ITEMS,
|
|
11
|
+
SECRET_MAX_TOTAL,
|
|
12
|
+
SECRET_MAX_VALUE,
|
|
13
|
+
FILE_MAX_VALUE,
|
|
14
|
+
FILE_MAX_TOTAL,
|
|
15
|
+
configPathOK,
|
|
16
|
+
deliveryPolicy,
|
|
17
|
+
human,
|
|
18
|
+
isMain,
|
|
19
|
+
run,
|
|
20
|
+
sameSecret,
|
|
21
|
+
secretIdentity,
|
|
22
|
+
secretNameOK,
|
|
23
|
+
validID,
|
|
24
|
+
} from './lib.mjs';
|
|
23
25
|
import { MANIFEST, readManifest } from './plan.mjs';
|
|
26
|
+
import { SHELL_RC } from './scan.mjs';
|
|
24
27
|
|
|
25
28
|
const shellFor = (file) => (/\/\.z[^/]*$/.test(file) && spawnSync('zsh', ['-c', 'true']).status === 0 ? 'zsh' : 'bash');
|
|
26
29
|
|
|
@@ -83,170 +86,334 @@ export function readValue(src, env = process.env, rc = {}) {
|
|
|
83
86
|
return { error: `unknown source ${src.type}` };
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
|
|
87
|
-
const
|
|
89
|
+
const INSTALL = readFileSync(new URL('./install-files.py', import.meta.url), 'utf8');
|
|
90
|
+
const quote = (s) => "'" + s.replaceAll("'", "'\"'\"'") + "'";
|
|
91
|
+
const unique = (values) => [...new Set(values)].sort();
|
|
88
92
|
|
|
89
|
-
function
|
|
93
|
+
function saveManifest(path, manifest) {
|
|
90
94
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
91
|
-
const
|
|
92
|
-
writeFileSync(
|
|
93
|
-
renameSync(
|
|
95
|
+
const temporary = `${path}.${randomBytes(8).toString('hex')}.tmp`;
|
|
96
|
+
writeFileSync(temporary, JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
97
|
+
renameSync(temporary, path);
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
|
|
100
|
+
function validSource(it, home) {
|
|
101
|
+
const s = it.source;
|
|
102
|
+
if (!s) return false;
|
|
103
|
+
if (s.type === 'cmd')
|
|
104
|
+
return JSON.stringify(s.argv) === JSON.stringify(['gh', 'auth', 'token', '--hostname', 'github.com']);
|
|
105
|
+
if (s.name !== it.name) return false;
|
|
106
|
+
return s.type === 'env' || (s.type === 'rc' && SHELL_RC.some((f) => join(home, f) === s.file));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function checkSelection(sel) {
|
|
110
|
+
if (sel.version !== 3 || Object.hasOwn(sel, 'selector'))
|
|
111
|
+
throw new Error('legacy selection format is retired; scan and approve a new org Secret plan');
|
|
112
|
+
if (
|
|
113
|
+
!validID(sel.orgId) ||
|
|
114
|
+
!Array.isArray(sel.sandboxes) ||
|
|
115
|
+
sel.sandboxes.some((id) => !validID(id) || !id.startsWith('m_')) ||
|
|
116
|
+
unique(sel.sandboxes).length !== sel.sandboxes.length
|
|
117
|
+
)
|
|
118
|
+
throw new Error('an explicit org ID and distinct sandbox IDs are required');
|
|
119
|
+
if (!Array.isArray(sel.items) || typeof sel.home !== 'string') throw new Error('invalid selection');
|
|
120
|
+
const ids = new Set(),
|
|
121
|
+
names = new Set(),
|
|
122
|
+
paths = new Set();
|
|
123
|
+
for (const it of sel.items) {
|
|
124
|
+
if (ids.has(it.id)) throw new Error('duplicate selection ID');
|
|
125
|
+
ids.add(it.id);
|
|
126
|
+
if (it.kind === 'env') {
|
|
127
|
+
if (!secretNameOK(it.name) || names.has(it.name) || !validSource(it, sel.home) || !Object.hasOwn(it, 'expected'))
|
|
128
|
+
throw new Error('invalid Secret selection or source');
|
|
129
|
+
names.add(it.name);
|
|
130
|
+
const policy = deliveryPolicy(it.delivery, it.hosts);
|
|
131
|
+
if (JSON.stringify(policy.hosts) !== JSON.stringify(it.hosts))
|
|
132
|
+
throw new Error('hosts must use the normalized policy from the approved plan');
|
|
133
|
+
} else if (it.kind === 'file') {
|
|
134
|
+
if (!sel.sandboxes.length || !Array.isArray(it.files))
|
|
135
|
+
throw new Error('config copying needs explicit sandbox IDs');
|
|
136
|
+
for (const f of it.files) {
|
|
137
|
+
if (!configPathOK(f.path) || paths.has(f.path) || !['text', 'raw', 'gitconfig'].includes(f.transform))
|
|
138
|
+
throw new Error('invalid or duplicate config target');
|
|
139
|
+
paths.add(f.path);
|
|
140
|
+
}
|
|
141
|
+
} else throw new Error('unknown selection kind');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function push(
|
|
146
|
+
sel,
|
|
147
|
+
{
|
|
148
|
+
instabox = 'instabox',
|
|
149
|
+
env = process.env,
|
|
150
|
+
dryRun = false,
|
|
151
|
+
manifestPath = MANIFEST,
|
|
152
|
+
log = (s) => console.log(s),
|
|
153
|
+
} = {},
|
|
154
|
+
) {
|
|
97
155
|
const results = [];
|
|
98
|
-
const fail = (
|
|
99
|
-
log(
|
|
100
|
-
return { ok: false, code
|
|
156
|
+
const fail = (message, code = 1, totals) => {
|
|
157
|
+
log(message);
|
|
158
|
+
return { ok: false, code, results, totals };
|
|
101
159
|
};
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
160
|
+
const json = (args) => {
|
|
161
|
+
const response = run([instabox, ...args, '--json'], { env });
|
|
162
|
+
if (!response.ok)
|
|
163
|
+
throw new Error(`${args.slice(0, 2).join(' ')} failed; check sign-in, permissions and the selected org/sandbox`);
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(response.stdout);
|
|
166
|
+
} catch {
|
|
167
|
+
throw new Error('CLI returned invalid metadata; update the CLI and retry');
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
let secrets, targets;
|
|
105
171
|
try {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
172
|
+
checkSelection(sel);
|
|
173
|
+
if (env.INSTABOX_API_KEY && sel.items.some((it) => it.kind === 'env'))
|
|
174
|
+
throw new Error('saving Secret values requires a signed-in org owner/admin session, not an API key');
|
|
175
|
+
const org = json(['org', 'get', sel.orgId]);
|
|
176
|
+
if (org.id !== sel.orgId) throw new Error('use the stable org ID, not a name');
|
|
177
|
+
if (sel.items.some((it) => it.kind === 'env') && !['owner', 'admin'].includes(org.role))
|
|
178
|
+
throw new Error('saving Secrets requires an org owner/admin sign-in');
|
|
179
|
+
secrets = json(['secrets', 'ls', '--org', sel.orgId]).items;
|
|
180
|
+
if (!Array.isArray(secrets) || secrets.some((s) => s.orgId !== sel.orgId))
|
|
181
|
+
throw new Error('Secret catalog belongs to another org');
|
|
182
|
+
for (const it of sel.items.filter((it) => it.kind === 'env')) {
|
|
183
|
+
const current = secrets.find((s) => s.name === it.name);
|
|
184
|
+
if (it.expected ? !sameSecret(it.expected, current) : current)
|
|
185
|
+
throw new Error(`${it.name} changed since the plan; regenerate and review before replacing it`);
|
|
186
|
+
}
|
|
187
|
+
targets = sel.sandboxes.map((id) => {
|
|
188
|
+
const box = json(['get', id]);
|
|
189
|
+
if (box.id !== id || box.orgId !== sel.orgId)
|
|
190
|
+
throw new Error('target sandbox does not belong to the selected org');
|
|
191
|
+
if (sel.items.some((it) => it.kind === 'file') && box.state !== 'running')
|
|
192
|
+
throw new Error(`${id}: start the sandbox before copying config files`);
|
|
193
|
+
const selection = json(['secrets', 'show', id]);
|
|
194
|
+
if (!Array.isArray(selection.envSecrets) || (selection.secrets !== 'all' && !Array.isArray(selection.secrets)))
|
|
195
|
+
throw new Error('sandbox returned invalid Secret selections');
|
|
196
|
+
return box;
|
|
197
|
+
});
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return fail(e.message);
|
|
109
200
|
}
|
|
110
|
-
let maxItems = VAULT_MAX_ITEMS;
|
|
111
|
-
let maxBytes = VAULT_MAX_TOTAL;
|
|
112
|
-
const usage = run([instabox, 'usage', '--json'], { env });
|
|
113
|
-
try {
|
|
114
|
-
const l = JSON.parse(usage.stdout).limits?.vault;
|
|
115
|
-
if (l?.maxItems) maxItems = l.maxItems;
|
|
116
|
-
if (l?.maxBytes) maxBytes = l.maxBytes;
|
|
117
|
-
} catch {}
|
|
118
|
-
|
|
119
|
-
const manifest = readManifest(manifestPath);
|
|
120
|
-
const ours = new Set(manifest.items.map((m) => m.name));
|
|
121
|
-
const byName = new Map(vault.map((v) => [v.name, v]));
|
|
122
|
-
const byPath = new Map(vault.filter((v) => v.path).map((v) => [v.path, v]));
|
|
123
|
-
const selector = sel.selector || {};
|
|
124
|
-
// One --selector per label: the CLI's flag takes key=value and repeats.
|
|
125
|
-
const selFlag = Object.entries(selector).flatMap(([k, v]) => ['--selector', `${k}=${v}`]);
|
|
126
201
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (
|
|
132
|
-
if (
|
|
133
|
-
|
|
202
|
+
let maxItems = SECRET_MAX_ITEMS,
|
|
203
|
+
maxBytes = SECRET_MAX_TOTAL;
|
|
204
|
+
try {
|
|
205
|
+
const limits = json(['usage']).limits?.vault;
|
|
206
|
+
if (limits?.maxItems) maxItems = limits.maxItems;
|
|
207
|
+
if (limits?.maxBytes) maxBytes = limits.maxBytes;
|
|
208
|
+
} catch {
|
|
209
|
+
return fail('could not read Secret limits; nothing written');
|
|
134
210
|
}
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
const
|
|
211
|
+
|
|
212
|
+
const tokens = [],
|
|
213
|
+
files = [];
|
|
214
|
+
const ghToken = sel.items.some((it) => it.kind === 'env' && it.name === 'GH_TOKEN' && it.delivery === 'env');
|
|
215
|
+
const rcNames = new Map(),
|
|
216
|
+
rcValues = new Map();
|
|
217
|
+
for (const it of sel.items)
|
|
218
|
+
if (it.kind === 'env' && it.source.type === 'rc') {
|
|
219
|
+
rcNames.set(it.source.file, [...(rcNames.get(it.source.file) || []), it.name]);
|
|
220
|
+
}
|
|
139
221
|
for (const it of sel.items) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
r.name = old.name;
|
|
161
|
-
}
|
|
162
|
-
const got = u.get();
|
|
163
|
-
const value = got.error ? null : got.value ?? got.text;
|
|
164
|
-
if (got.error || value === '' || value == null) {
|
|
165
|
-
r.status = 'failed';
|
|
166
|
-
r.detail = got.error || 'empty';
|
|
167
|
-
log(`failed ${u.name} (${u.kind}): ${r.detail}`);
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
const sent = u.kind === 'env' ? value.replace(/\n$/, '') : value;
|
|
171
|
-
r.bytes = Buffer.byteLength(sent);
|
|
172
|
-
if (r.bytes > VAULT_MAX_VALUE) {
|
|
173
|
-
r.status = 'failed';
|
|
174
|
-
r.detail = `${r.bytes} bytes: over the Vault's 64 KiB per item`;
|
|
175
|
-
log(`failed ${u.name} (${u.kind}): ${r.detail}`);
|
|
176
|
-
continue;
|
|
222
|
+
if (it.kind === 'env') {
|
|
223
|
+
if (it.source.type === 'rc' && !rcValues.has(it.source.file))
|
|
224
|
+
rcValues.set(it.source.file, readRc(it.source.file, rcNames.get(it.source.file), env));
|
|
225
|
+
const got = readValue(it.source, env, Object.fromEntries(rcValues));
|
|
226
|
+
const value = got.value;
|
|
227
|
+
if (
|
|
228
|
+
got.error ||
|
|
229
|
+
typeof value !== 'string' ||
|
|
230
|
+
value.includes('\0') ||
|
|
231
|
+
Buffer.byteLength(value) > SECRET_MAX_VALUE ||
|
|
232
|
+
(it.delivery === 'proxy' && /[\x00-\x08\x0a-\x1f\x7f]/.test(value))
|
|
233
|
+
)
|
|
234
|
+
return fail(`${it.name}: could not prepare a valid ${it.delivery} value; nothing written`);
|
|
235
|
+
tokens.push({ item: it, value });
|
|
236
|
+
} else {
|
|
237
|
+
for (const f of it.files) {
|
|
238
|
+
const got = fileContent(f, { home: sel.home, ghToken });
|
|
239
|
+
if (got.error || Buffer.byteLength(got.text) > FILE_MAX_VALUE)
|
|
240
|
+
return fail(`~/${f.path}: config could not be prepared; nothing written`);
|
|
241
|
+
files.push({ path: f.path, text: got.text });
|
|
177
242
|
}
|
|
178
|
-
plan.push({ r, u, old, sent });
|
|
179
243
|
}
|
|
180
244
|
}
|
|
245
|
+
const replaced = new Set(tokens.map((t) => t.item.name));
|
|
246
|
+
const staying = secrets.filter((s) => !replaced.has(s.name));
|
|
247
|
+
const totals = {
|
|
248
|
+
items: staying.length + tokens.length,
|
|
249
|
+
maxItems,
|
|
250
|
+
bytes:
|
|
251
|
+
staying.reduce((sum, s) => sum + (s.size || 0), 0) +
|
|
252
|
+
tokens.reduce((sum, t) => sum + Buffer.byteLength(t.value), 0),
|
|
253
|
+
maxBytes,
|
|
254
|
+
};
|
|
255
|
+
if (totals.items > maxItems || totals.bytes > maxBytes)
|
|
256
|
+
return fail(
|
|
257
|
+
`over the org Secret limits, nothing written: ${totals.items} items of ${maxItems}, ${human(totals.bytes)} of ${human(maxBytes)}`,
|
|
258
|
+
3,
|
|
259
|
+
totals,
|
|
260
|
+
);
|
|
261
|
+
const fileInput = JSON.stringify({ files });
|
|
262
|
+
if (
|
|
263
|
+
files.reduce((sum, f) => sum + Buffer.byteLength(f.text), 0) > FILE_MAX_TOTAL ||
|
|
264
|
+
Buffer.byteLength(fileInput) > 1024 * 1024
|
|
265
|
+
)
|
|
266
|
+
return fail('config copies exceed the transfer limit; nothing written', 3, totals);
|
|
181
267
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
268
|
+
const manifest = readManifest(manifestPath);
|
|
269
|
+
let ok = true;
|
|
270
|
+
for (const { item, value } of tokens) {
|
|
271
|
+
const r = {
|
|
272
|
+
name: item.name,
|
|
273
|
+
kind: 'secret',
|
|
274
|
+
delivery: item.delivery,
|
|
275
|
+
bytes: Buffer.byteLength(value),
|
|
276
|
+
status: dryRun ? 'would save' : 'failed',
|
|
277
|
+
};
|
|
278
|
+
results.push(r);
|
|
279
|
+
if (!dryRun) {
|
|
280
|
+
const args = [
|
|
281
|
+
instabox,
|
|
282
|
+
'secrets',
|
|
283
|
+
'set',
|
|
284
|
+
item.name,
|
|
285
|
+
'--org',
|
|
286
|
+
sel.orgId,
|
|
287
|
+
'--delivery',
|
|
288
|
+
item.delivery,
|
|
289
|
+
...item.hosts.flatMap((h) => ['--host', h]),
|
|
290
|
+
'--json',
|
|
291
|
+
];
|
|
292
|
+
const response = run(args, { env, input: value + '\n' });
|
|
293
|
+
try {
|
|
294
|
+
const saved = response.ok && JSON.parse(response.stdout);
|
|
295
|
+
if (
|
|
296
|
+
!saved ||
|
|
297
|
+
saved.name !== item.name ||
|
|
298
|
+
saved.orgId !== sel.orgId ||
|
|
299
|
+
!saved.id ||
|
|
300
|
+
!Number.isSafeInteger(saved.version) ||
|
|
301
|
+
saved.delivery !== item.delivery ||
|
|
302
|
+
JSON.stringify(deliveryPolicy(saved.delivery, saved.hosts || []).hosts) !== JSON.stringify(item.hosts)
|
|
303
|
+
)
|
|
304
|
+
throw new Error();
|
|
305
|
+
r.status = 'saved';
|
|
306
|
+
manifest.secrets = manifest.secrets.filter((m) => m.orgId !== sel.orgId || m.name !== item.name);
|
|
307
|
+
manifest.secrets.push(secretIdentity(saved));
|
|
308
|
+
} catch {
|
|
309
|
+
ok = false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
190
312
|
log(
|
|
191
|
-
|
|
192
|
-
`Biggest: ${big.map((p) => `${p.u.name} ${human(p.r.bytes)}`).join(', ')}. Uncheck some in the plan, or remove items you no longer need (instabox vault rm NAME).`,
|
|
313
|
+
`${r.status} ${item.name} (${item.delivery}${item.hosts.length ? ', ' + item.hosts.join(',') : ''}, ${r.bytes} bytes) in ${sel.orgId}`,
|
|
193
314
|
);
|
|
194
|
-
return { ok: false, code: 3, results, totals };
|
|
195
315
|
}
|
|
316
|
+
if (!dryRun && tokens.length) saveManifest(manifestPath, manifest);
|
|
317
|
+
if (!ok)
|
|
318
|
+
return fail(
|
|
319
|
+
'some Secrets could not be saved; sandbox selections and config files were not changed; saved Secrets remain in the org',
|
|
320
|
+
1,
|
|
321
|
+
totals,
|
|
322
|
+
);
|
|
196
323
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
for (const { r, u, sent } of plan) {
|
|
201
|
-
const what = `${u.name} (${u.kind}${u.path ? ` ~/${u.path}` : ''}, ${r.bytes} bytes) for ${where}`;
|
|
324
|
+
for (const box of targets) {
|
|
325
|
+
const result = { sandboxId: box.id, kind: 'sandbox', status: dryRun ? 'would configure' : 'failed' };
|
|
326
|
+
results.push(result);
|
|
202
327
|
if (dryRun) {
|
|
203
|
-
|
|
204
|
-
|
|
328
|
+
log(
|
|
329
|
+
`would update explicit Secret selections and copy ${files.length} config files to ${box.id}; existing selections are preserved`,
|
|
330
|
+
);
|
|
205
331
|
continue;
|
|
206
332
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
333
|
+
try {
|
|
334
|
+
if (tokens.length) {
|
|
335
|
+
const current = json(['secrets', 'show', box.id]);
|
|
336
|
+
const envNames = tokens.filter((t) => t.item.delivery === 'env').map((t) => t.item.name);
|
|
337
|
+
const proxyNames = tokens.filter((t) => t.item.delivery === 'proxy').map((t) => t.item.name);
|
|
338
|
+
const flags = [];
|
|
339
|
+
if (envNames.length) flags.push('--env-secrets', JSON.stringify(unique([...current.envSecrets, ...envNames])));
|
|
340
|
+
if (proxyNames.length && current.secrets !== 'all')
|
|
341
|
+
flags.push('--secrets', JSON.stringify(unique([...current.secrets, ...proxyNames])));
|
|
342
|
+
if (flags.length) json(['secrets', 'select', box.id, ...flags]);
|
|
343
|
+
if (box.state === 'running') {
|
|
344
|
+
const refreshed = json(['secrets', 'push', box.id]);
|
|
345
|
+
if (refreshed.guest?.state !== 'applied')
|
|
346
|
+
throw new Error('guest delivery is pending or failed; inspect secrets show and retry');
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
if (files.length) {
|
|
350
|
+
const response = run([instabox, 'exec', box.id, '--stdin', '--', 'python3 -c ' + quote(INSTALL)], {
|
|
351
|
+
env,
|
|
352
|
+
input: fileInput,
|
|
353
|
+
});
|
|
354
|
+
const marker = 'INSTABOX-BASIC-SETUP ';
|
|
355
|
+
const at = response.stdout.lastIndexOf(marker);
|
|
356
|
+
let copied;
|
|
357
|
+
try {
|
|
358
|
+
copied = JSON.parse(response.stdout.slice(at + marker.length).split('\n')[0]).files;
|
|
359
|
+
} catch {}
|
|
360
|
+
if (
|
|
361
|
+
at < 0 ||
|
|
362
|
+
!Array.isArray(copied) ||
|
|
363
|
+
copied.length !== files.length ||
|
|
364
|
+
new Set(copied.map((f) => f.path)).size !== files.length ||
|
|
365
|
+
copied.some((f) => !files.some((want) => want.path === f.path) || !['written', 'failed'].includes(f.status))
|
|
366
|
+
)
|
|
367
|
+
throw new Error('config copy returned no valid outcome');
|
|
368
|
+
for (const f of copied) {
|
|
369
|
+
const r = { sandboxId: box.id, kind: 'file', path: f.path, status: f.status };
|
|
370
|
+
results.push(r);
|
|
371
|
+
log(`${r.status} ${box.id}: ~/${f.path}`);
|
|
372
|
+
if (f.status === 'written' && !manifest.files.some((m) => m.sandboxId === box.id && m.path === f.path))
|
|
373
|
+
manifest.files.push({ sandboxId: box.id, path: f.path });
|
|
374
|
+
}
|
|
375
|
+
if (!response.ok || copied.some((f) => f.status !== 'written'))
|
|
376
|
+
throw new Error('some config files could not be copied (check existing paths and symlinks)');
|
|
377
|
+
}
|
|
378
|
+
result.status = 'configured';
|
|
379
|
+
log(
|
|
380
|
+
`configured ${box.id}${box.state === 'running' ? '; existing application processes may need a restart' : '; Secret delivery waits for the next start'}`,
|
|
381
|
+
);
|
|
382
|
+
} catch (e) {
|
|
217
383
|
ok = false;
|
|
218
|
-
|
|
219
|
-
r.detail = mask(p.stderr || p.stdout, sent);
|
|
220
|
-
log(`failed ${u.name} (${u.kind}): ${r.detail}`);
|
|
384
|
+
log(`failed ${box.id}: ${e.message}`);
|
|
221
385
|
}
|
|
222
386
|
}
|
|
223
|
-
if (!dryRun)
|
|
224
|
-
const set = new Map(manifest.items.map((m) => [m.name, m]));
|
|
225
|
-
for (const { r } of plan) if (r.status === 'set') set.set(r.name, { name: r.name, kind: r.kind, ...(r.path ? { path: r.path } : {}) });
|
|
226
|
-
// Forget names that are gone from the Vault (removed by hand since).
|
|
227
|
-
const live = new Set([...vault.map((v) => v.name), ...plan.filter((p) => p.r.status === 'set').map((p) => p.r.name)]);
|
|
228
|
-
writeManifest(manifestPath, [...set.values()].filter((m) => live.has(m.name)).sort((a, b) => (a.name < b.name ? -1 : 1)));
|
|
229
|
-
}
|
|
387
|
+
if (!dryRun && targets.length) saveManifest(manifestPath, manifest);
|
|
230
388
|
return { ok, code: ok ? 0 : 1, results, totals };
|
|
231
389
|
}
|
|
232
390
|
|
|
233
391
|
function main() {
|
|
234
|
-
const args = process.argv.slice(2)
|
|
235
|
-
|
|
236
|
-
if (
|
|
392
|
+
const args = process.argv.slice(2),
|
|
393
|
+
at = args.indexOf('--selection');
|
|
394
|
+
if (at < 0 || !args[at + 1]) {
|
|
237
395
|
process.stderr.write('usage: push.mjs --selection selection.json [--dry-run]\n');
|
|
238
396
|
process.exit(2);
|
|
239
397
|
}
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
398
|
+
const file = args[at + 1];
|
|
399
|
+
args.splice(at, 2);
|
|
400
|
+
if (args.some((arg) => arg !== '--dry-run')) {
|
|
401
|
+
process.stderr.write('unknown push arguments\n');
|
|
402
|
+
process.exit(2);
|
|
403
|
+
}
|
|
404
|
+
const selection = JSON.parse(readFileSync(file, 'utf8'));
|
|
405
|
+
const result = push(selection, {
|
|
406
|
+
dryRun: args.includes('--dry-run'),
|
|
407
|
+
instabox: process.env.INSTABOX_BIN || 'instabox',
|
|
408
|
+
});
|
|
409
|
+
if (result.totals)
|
|
410
|
+
console.log(
|
|
411
|
+
`Org Secrets after this: ${result.totals.items} of ${result.totals.maxItems}, ${human(result.totals.bytes)} of ${human(result.totals.maxBytes)}. Config files use no Secret quota.`,
|
|
412
|
+
);
|
|
413
|
+
if (selection.leftover?.length)
|
|
414
|
+
console.log(
|
|
415
|
+
`Previously saved and left unchanged: ${selection.leftover.join(' ')}. Remove only if intended: instabox secrets rm NAME --org ORG.`,
|
|
416
|
+
);
|
|
417
|
+
process.exit(result.code);
|
|
250
418
|
}
|
|
251
|
-
|
|
252
419
|
if (isMain(import.meta)) main();
|