agmsg-cloud 0.1.0-rc.8 → 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.
- package/dist/scripts/version-output.js +65 -0
- package/dist/src/api.js +85 -3
- package/dist/src/commands/connect.js +14 -7
- package/dist/src/commands/login.js +120 -3
- package/dist/src/commands/logout.js +38 -7
- package/dist/src/commands/request.js +53 -3
- package/dist/src/commands/sync.js +12 -1
- package/dist/src/commands/vault.js +345 -78
- package/dist/src/commands/watch.js +25 -7
- package/dist/src/config.js +11 -2
- package/dist/src/credentials.js +149 -17
- package/dist/src/data-plane.js +67 -0
- package/dist/src/index.js +45 -24
- package/dist/src/oss.js +30 -1
- package/dist/src/preflight.js +140 -15
- package/dist/src/recovery-key.js +11 -16
- package/dist/src/self-install.js +152 -0
- package/dist/src/slot-advice.js +7 -19
- package/dist/src/vault-inventory.js +118 -0
- package/dist/src/vault-placement.js +94 -0
- package/package.json +3 -2
|
@@ -2,12 +2,17 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { CourierClient } from '../api.js';
|
|
5
|
-
import { connectedTeams, keyHandoff, remoteBinding, unlockAuthenticatedBundle } from '../oss.js';
|
|
5
|
+
import { connectedTeams, keyHandoff, remoteBinding, runAllowingFailure, unlockAuthenticatedBundle, } from '../oss.js';
|
|
6
|
+
import { cmdPull } from './pull.js';
|
|
7
|
+
import { dataPlaneIdentity } from '../data-plane.js';
|
|
8
|
+
import { originOf, readCredential } from '../credentials.js';
|
|
6
9
|
import { NEEDS, ensurePreflight, preflight } from '../preflight.js';
|
|
7
10
|
import { generateRecoveryKey, normalizeRecoveryKey, promptRecoveryKey, showRecoveryKey, } from '../recovery-key.js';
|
|
8
11
|
import { appendVaultVersionWithVdk, createVault, openVaultWithVdk, readAccountVault, vdkFromRecoveryKey, } from '../vault-protocol.js';
|
|
9
12
|
import { openDeviceSlot, saveDeviceSlot } from '../device-slot.js';
|
|
10
13
|
import { adviseOnSlot, renderSlotAdvice } from '../slot-advice.js';
|
|
14
|
+
import { inventoryLines, inventoryOf, } from '../vault-inventory.js';
|
|
15
|
+
import { placementLines } from '../vault-placement.js';
|
|
11
16
|
import { emptyContainer, parseContainer, selectTeam, serializeContainer, upsertTeamIfMoved, } from '../vault-container.js';
|
|
12
17
|
// The two recovery commands: resolve the team, obtain the bundle from the OSS
|
|
13
18
|
// side, get the recovery key from the terminal, and hand off to
|
|
@@ -52,7 +57,13 @@ export function slotAddress(identity, vaultId, generation) {
|
|
|
52
57
|
* the same place; what differs is what the person is told, and that judgement
|
|
53
58
|
* lives in slot-advice.ts rather than here.
|
|
54
59
|
*/
|
|
55
|
-
async function vdkForVault(identity, version,
|
|
60
|
+
async function vdkForVault(identity, version,
|
|
61
|
+
/**
|
|
62
|
+
* Optional: `restore` with no team has none to name, and the advice below is
|
|
63
|
+
* the only thing that used it. `setupCommand` already carries the no-team
|
|
64
|
+
* form, so the remedy stays executable either way.
|
|
65
|
+
*/
|
|
66
|
+
team) {
|
|
56
67
|
const address = slotAddress(identity, version.vault_id, version.recovery_generation);
|
|
57
68
|
const opened = await openDeviceSlot(address);
|
|
58
69
|
if (opened.ok) {
|
|
@@ -61,7 +72,7 @@ async function vdkForVault(identity, version, team) {
|
|
|
61
72
|
// does not fit. A check here would be a third place that has to agree.
|
|
62
73
|
return { vdk: opened.vdk, usedRecoveryKey: false };
|
|
63
74
|
}
|
|
64
|
-
const advice = renderSlotAdvice(adviseOnSlot(opened
|
|
75
|
+
const advice = renderSlotAdvice(adviseOnSlot(opened));
|
|
65
76
|
if (advice)
|
|
66
77
|
process.stdout.write(advice);
|
|
67
78
|
const recoveryKey = normalizeRecoveryKey(await promptRecoveryKey('Recovery key: '));
|
|
@@ -126,19 +137,11 @@ function vaultTeamKey(serverInstanceId, teamId) {
|
|
|
126
137
|
* `recovery setup` — put the keys of every team THIS MACHINE reports into the
|
|
127
138
|
* account's vault.
|
|
128
139
|
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* NOT "every team on the account", and the difference is load-bearing until
|
|
136
|
-
* fujibee/agmsg#650 lands. The set comes from `remote.sh status --json`, which
|
|
137
|
-
* drops a team it could not read and exits 0 — so an active team that has never
|
|
138
|
-
* been backed up can be missed with nothing said. Every sentence describing this
|
|
139
|
-
* command, here and in `--help` and in the README, stops at what the machine
|
|
140
|
-
* reported for exactly that reason. Widening them is the change that closes
|
|
141
|
-
* #182, and it comes after #650, not before.
|
|
140
|
+
* The vault holds one recovery key for the whole account, so "set up recovery"
|
|
141
|
+
* is an account-level act. The old optional team argument existed while
|
|
142
|
+
* fujibee/agmsg#650 could silently omit an unreadable team. That producer now
|
|
143
|
+
* distinguishes the failure, so this command has one account-wide form and a
|
|
144
|
+
* short enumeration is refused rather than presented as a complete backup.
|
|
142
145
|
*
|
|
143
146
|
* IDEMPOTENT. A run where no team's epochs have moved writes nothing and leaves
|
|
144
147
|
* the revision where it was. `setup` that appended an identical version every
|
|
@@ -146,7 +149,7 @@ function vaultTeamKey(serverInstanceId, teamId) {
|
|
|
146
149
|
* cost is not storage, it is that a version history exists to show when
|
|
147
150
|
* something changed, and identical versions make it unreadable.
|
|
148
151
|
*/
|
|
149
|
-
export async function cmdVaultPut(config
|
|
152
|
+
export async function cmdVaultPut(config) {
|
|
150
153
|
// Same check connect gets. Without it a machine with no agmsg install runs
|
|
151
154
|
// straight into `bash exited 127: …/remote.sh: No such file or directory` —
|
|
152
155
|
// the same absence connect reports as a named checklist.
|
|
@@ -162,13 +165,11 @@ export async function cmdVaultPut(config, args) {
|
|
|
162
165
|
// disconnected team's ids are a leftover, and a vault entry
|
|
163
166
|
// under one records a backup against a remote this machine is no
|
|
164
167
|
// longer bound to.
|
|
165
|
-
const observed =
|
|
166
|
-
const targets =
|
|
167
|
-
? observed.filter((t) => t.state === 'active')
|
|
168
|
-
: [{ team: args.team, ...(await remoteBinding(config.scriptsDir, args.team)) }];
|
|
168
|
+
const observed = await connectedTeams(config.scriptsDir);
|
|
169
|
+
const targets = observed.filter((t) => t.state === 'active');
|
|
169
170
|
if (targets.length === 0) {
|
|
170
171
|
throw new Error('no team on this machine is connected to the hosted service, so there are no keys to back up.\n' +
|
|
171
|
-
'Run `agmsg-cloud connect <team>` first
|
|
172
|
+
'Run `agmsg-cloud connect <team>` first.');
|
|
172
173
|
}
|
|
173
174
|
// The account's vault, not this team's: one vault, one recovery key. A second
|
|
174
175
|
// team finds the vault that already exists and is added to it.
|
|
@@ -192,7 +193,7 @@ export async function cmdVaultPut(config, args) {
|
|
|
192
193
|
// X" was true when a team had its own vault; saying it now would teach
|
|
193
194
|
// someone to expect a different key per team, which is the belief the
|
|
194
195
|
// account vault exists to remove.
|
|
195
|
-
const obtained = await vdkForVault(identity, version,
|
|
196
|
+
const obtained = await vdkForVault(identity, version, targets[0].team);
|
|
196
197
|
vdk = obtained.vdk;
|
|
197
198
|
askedForTheKey = obtained.usedRecoveryKey;
|
|
198
199
|
// Open before writing. The container has to be read to add a team to it,
|
|
@@ -229,35 +230,15 @@ export async function cmdVaultPut(config, args) {
|
|
|
229
230
|
// becomes safe only when the slot is durable before the vault is created,
|
|
230
231
|
// and that ordering is a separate change with its own failure to think
|
|
231
232
|
// through (a slot addressed to a vault that was never made).
|
|
232
|
-
await showRecoveryKey(minted
|
|
233
|
+
await showRecoveryKey(minted);
|
|
233
234
|
container = emptyContainer();
|
|
234
235
|
}
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
// "could not be read" either — same message, same exit code. Measured, and
|
|
240
|
-
// filed as fujibee/agmsg#650.
|
|
241
|
-
//
|
|
242
|
-
// The first version of this REFUSED when the vault held a team the run did
|
|
243
|
-
// not see. That was wrong, and the reason is worth keeping: a team
|
|
244
|
-
// deliberately disconnected here disappears from the enumeration too, and the
|
|
245
|
-
// vault keeps its entry forever by design — so the refusal fired every run,
|
|
246
|
-
// for the one reason that is not a problem, with no way out. Refusing on a
|
|
247
|
-
// distinction the system cannot draw treats "unknown" as "wrong".
|
|
248
|
-
//
|
|
249
|
-
// So it reports instead. What was not seen is put on the screen by name and
|
|
250
|
-
// the judgement goes back to the operator, who is the only party that knows
|
|
251
|
-
// whether they disconnected it. That leaves a real gap — nobody who does not
|
|
252
|
-
// read the output is protected — and the gap is smaller than a command that
|
|
253
|
-
// cannot be run.
|
|
254
|
-
//
|
|
255
|
-
// When #650 lands this can become a refusal again, because by then a team
|
|
256
|
-
// that could not be read will say so.
|
|
236
|
+
// agmsg#650 made unreadable status fail instead of silently disappearing, so
|
|
237
|
+
// `observed` is now a fail-closed account-wide enumeration. An old vault row
|
|
238
|
+
// can still be absent because this machine has no current binding for it;
|
|
239
|
+
// those rows remain untouched and are named below rather than deleted.
|
|
257
240
|
const seenThisRun = new Set(observed.map((t) => vaultTeamKey(t.serverInstanceId, t.teamId)));
|
|
258
|
-
const unseen =
|
|
259
|
-
? container.teams.filter((e) => !seenThisRun.has(vaultTeamKey(e.server_instance_id, e.team_id)))
|
|
260
|
-
: [];
|
|
241
|
+
const unseen = container.teams.filter((e) => !seenThisRun.has(vaultTeamKey(e.server_instance_id, e.team_id)));
|
|
261
242
|
// Seen, and deliberately not filed. Kept apart from `unseen` because they are
|
|
262
243
|
// different facts: this machine HEARD about these, and the ones above it did
|
|
263
244
|
// not hear about at all.
|
|
@@ -286,10 +267,6 @@ export async function cmdVaultPut(config, args) {
|
|
|
286
267
|
// "Covered".
|
|
287
268
|
const coverage = (written, unchanged, stranded) => {
|
|
288
269
|
const group = (title, members) => members.length === 0 ? [] : [`\n ${title}\n`, ...members.map((m) => ` ${m}\n`)];
|
|
289
|
-
// The first two hold for either form — a run that names one team still has
|
|
290
|
-
// to say what it did with it. The rest are about the ENUMERATION, so they
|
|
291
|
-
// only mean anything when the enumeration is what chose the set.
|
|
292
|
-
const enumerated = args.team === undefined;
|
|
293
270
|
const lines = [
|
|
294
271
|
...group('Backed up in this revision:', written),
|
|
295
272
|
...group('Already current, nothing to write:', unchanged),
|
|
@@ -299,26 +276,17 @@ export async function cmdVaultPut(config, args) {
|
|
|
299
276
|
// that needs acting on — and the reason is what says which team's state
|
|
300
277
|
// to look at, rather than leaving "something failed" for them to locate.
|
|
301
278
|
...group('Recorded as connected here, but this machine could not open them:', stranded.map((f) => `${f.team} — ${f.reason}`)),
|
|
302
|
-
...(
|
|
303
|
-
? []
|
|
304
|
-
: group('Disconnected on this machine, left as they are:', disconnected.map((t) => t.team))),
|
|
279
|
+
...group('Disconnected on this machine, left as they are:', disconnected.map((t) => t.team)),
|
|
305
280
|
// Ids, because a team that never reported has no local name here to
|
|
306
281
|
// print — the same absence that put it on this list. Said plainly rather
|
|
307
282
|
// than dressed up as an instruction: this machine does not know whether
|
|
308
283
|
// these were disconnected on purpose, and the operator does.
|
|
309
|
-
...(
|
|
310
|
-
? []
|
|
311
|
-
: group('In the vault, but not reported by this machine on this run:', unseen.map((e) => `${e.team_id} (server ${e.server_instance_id})`))),
|
|
284
|
+
...group('In the vault, but not reported by this machine on this run:', unseen.map((e) => `${e.team_id} (server ${e.server_instance_id})`)),
|
|
312
285
|
];
|
|
313
286
|
if (unseen.length > 0) {
|
|
314
|
-
lines.push('\n Those entries are untouched. If you disconnected them here, nothing is\n', ' wrong.
|
|
315
|
-
}
|
|
316
|
-
// Said only by the form that DERIVED its set. A run given a team covered
|
|
317
|
-
// what it was told to; claiming it covered what the store reported would
|
|
318
|
-
// be a statement about an enumeration that never ran.
|
|
319
|
-
if (enumerated) {
|
|
320
|
-
lines.push("\n That is every team this machine's store reported. A team connected only\n", ' on another machine is backed up by running this there.\n');
|
|
287
|
+
lines.push('\n Those entries are untouched. If you disconnected them here, nothing is\n', ' wrong. This machine has no current binding to re-check for those ids.\n');
|
|
321
288
|
}
|
|
289
|
+
lines.push("\n That is every team this machine's store reported. A team connected only\n", ' on another machine is backed up by running this there.\n');
|
|
322
290
|
return lines.join('');
|
|
323
291
|
};
|
|
324
292
|
const scratch = mkdtempSync(join(tmpdir(), 'agmsg-cloud-'));
|
|
@@ -487,17 +455,14 @@ export async function cmdVaultPut(config, args) {
|
|
|
487
455
|
//
|
|
488
456
|
// Discarding the key is still right and still first: it opens nothing,
|
|
489
457
|
// and someone who files it will trust it for years. What changed is the
|
|
490
|
-
// second half — a bare retry is only the answer
|
|
491
|
-
//
|
|
492
|
-
//
|
|
493
|
-
// one thing that lets an account with a broken leftover back up the team
|
|
494
|
-
// actually in use.
|
|
458
|
+
// second half — a bare retry is only the answer after the named cause is
|
|
459
|
+
// repaired. There is no per-team escape now: recovery setup is
|
|
460
|
+
// account-wide and refuses an incomplete backup (#182).
|
|
495
461
|
process.stdout.write('\nThe backup did NOT complete, so the recovery key above was never used\n' +
|
|
496
462
|
'and opens nothing. Discard it.\n\n' +
|
|
497
|
-
'Re-running
|
|
498
|
-
'
|
|
499
|
-
'
|
|
500
|
-
' agmsg-cloud recovery setup <team>\n\n');
|
|
463
|
+
'Re-running before fixing the cause above will stop at the same place and\n' +
|
|
464
|
+
'cost you another key. Repair that team first, then run:\n\n' +
|
|
465
|
+
' agmsg-cloud recovery setup\n\n');
|
|
501
466
|
}
|
|
502
467
|
throw err;
|
|
503
468
|
}
|
|
@@ -530,9 +495,37 @@ export function bundleKeyIds(bundle) {
|
|
|
530
495
|
'in the vault without them');
|
|
531
496
|
}
|
|
532
497
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
498
|
+
/**
|
|
499
|
+
* `agmsg-cloud recovery restore [team]` — open the account vault, and either
|
|
500
|
+
* unlock one named team from it or say what is in it.
|
|
501
|
+
*
|
|
502
|
+
* WITH A TEAM this is unchanged: resolve the local binding, open, select that
|
|
503
|
+
* team's entry, unlock. That path works on a machine that already has the team,
|
|
504
|
+
* and it is the one people use.
|
|
505
|
+
*
|
|
506
|
+
* WITHOUT ONE is the disaster path this command is named for, and it did not
|
|
507
|
+
* work at all. The binding lookup ran BEFORE the vault was ever read, so a
|
|
508
|
+
* machine that had lost everything got
|
|
509
|
+
*
|
|
510
|
+
* agmsg: team 'x' has never been connected
|
|
511
|
+
*
|
|
512
|
+
* and stopped — measured against a stub endpoint that logs every request, which
|
|
513
|
+
* received nothing. The vault was never opened, so nothing could say what was
|
|
514
|
+
* in it, and a person with no local teams had no name to give that would have
|
|
515
|
+
* helped. Knowing the name was never the missing thing.
|
|
516
|
+
*
|
|
517
|
+
* So with no team the order is inverted: authenticate, open the vault, ask the
|
|
518
|
+
* control plane for names, and place what can be named — #342b. The order is
|
|
519
|
+
* forced, not chosen: `remote.sh unlock` requires the team to exist locally
|
|
520
|
+
* already (`remote.sh:1126`), so the bind (`pull`) comes before the unlock, and
|
|
521
|
+
* the vault has no names to bind with, so the names come before both.
|
|
522
|
+
*
|
|
523
|
+
* The team argument is kept and becomes "place just this one". Removing it is a
|
|
524
|
+
* decision available now that everything is placeable; it is not made here.
|
|
525
|
+
*/
|
|
526
|
+
export async function cmdVaultRestore(config, args = {}) {
|
|
527
|
+
if (args.team === undefined)
|
|
528
|
+
return placeFromVault(config);
|
|
536
529
|
ensurePreflight(preflight(config.scriptsDir, NEEDS.vaultRestore));
|
|
537
530
|
const client = new CourierClient(config);
|
|
538
531
|
const binding = await remoteBinding(config.scriptsDir, args.team);
|
|
@@ -569,3 +562,277 @@ export async function cmdVaultRestore(config, args) {
|
|
|
569
562
|
await keepSlot(slotAddress(identity, version.vault_id, version.recovery_generation), vdk);
|
|
570
563
|
}
|
|
571
564
|
}
|
|
565
|
+
/**
|
|
566
|
+
* The no-team form: open the account vault and say what is in it.
|
|
567
|
+
*
|
|
568
|
+
* NO TEAM-LOCAL OSS STATE IS READ: no remote binding, no `remote.sh` preflight,
|
|
569
|
+
* no unlock. Those are the reads that fail on the machine this command exists
|
|
570
|
+
* to rescue — it has no teams, so every one of them ends the command before the
|
|
571
|
+
* vault is reached. `NEEDS.vaultRestore` requires `remote.sh` for the same
|
|
572
|
+
* reason it is skipped here: refusing to LIST a backup because the tool that
|
|
573
|
+
* PLACES one is missing is the same defect one layer out.
|
|
574
|
+
*
|
|
575
|
+
* IT DOES READ THIS MACHINE'S SECURE STORE, and that is deliberate.
|
|
576
|
+
* `vdkForVault` opens the device slot first; a machine that holds one is not
|
|
577
|
+
* asked for the recovery key, and a machine without one falls back to the
|
|
578
|
+
* recovery-key wrap. A replacement machine has no slot and types the key, which
|
|
579
|
+
* is the disaster path working as designed — the slot is an optimisation for
|
|
580
|
+
* machines that have already proven they hold this vault's key, not a
|
|
581
|
+
* prerequisite. Saying "nothing local" here was wider than what is true, and
|
|
582
|
+
* the check that was supposed to back it up did not look (review P1-artifact).
|
|
583
|
+
*
|
|
584
|
+
* The names come from the control plane, not from the vault, because the vault
|
|
585
|
+
* has none. That request uses the same credential the vault read just used, so
|
|
586
|
+
* it cannot introduce a new way to be unauthenticated — and if it fails, the
|
|
587
|
+
* inventory still reports every entry, as unnamed. A failure to name is not a
|
|
588
|
+
* failure to find.
|
|
589
|
+
*/
|
|
590
|
+
async function placeFromVault(config) {
|
|
591
|
+
const client = new CourierClient(config);
|
|
592
|
+
const { identity, version } = await readAccountVault(client);
|
|
593
|
+
if (!version) {
|
|
594
|
+
throw new Error('this account has no recovery vault yet — nothing to restore from');
|
|
595
|
+
}
|
|
596
|
+
const { vdk, usedRecoveryKey } = await vdkForVault(identity, version);
|
|
597
|
+
const opened = openVaultWithVdk(identity, version, vdk);
|
|
598
|
+
const container = parseContainer(opened.content);
|
|
599
|
+
// The slot is kept HERE, before any placing, and that moved.
|
|
600
|
+
//
|
|
601
|
+
// While this command only reported, saving at the end cost nothing. Now it
|
|
602
|
+
// spawns a `pull` and an `unlock` per team, and any of those can fail — so
|
|
603
|
+
// saving afterwards would mean a run that placed four teams and tripped on
|
|
604
|
+
// the fifth asks for the recovery key again next time. The moment this
|
|
605
|
+
// machine has proven it holds the vault's key is the moment the vault opened,
|
|
606
|
+
// which is here.
|
|
607
|
+
if (usedRecoveryKey) {
|
|
608
|
+
await keepSlot(slotAddress(identity, version.vault_id, version.recovery_generation), vdk);
|
|
609
|
+
}
|
|
610
|
+
const inv = inventoryOf(container, await serverNames(client));
|
|
611
|
+
for (const line of inventoryLines(inv, opened.revision)) {
|
|
612
|
+
process.stdout.write(`${line}\n`);
|
|
613
|
+
}
|
|
614
|
+
if (!inv.namesKnown) {
|
|
615
|
+
// Nothing is attempted, and the reason is the missing question rather than
|
|
616
|
+
// anything about the entries. Both `pull` and `unlock` are addressed by
|
|
617
|
+
// name; with no names there is no argument to give them.
|
|
618
|
+
process.stdout.write('nothing was placed: placing needs the names, and the server could not be asked\n');
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (inv.named.length === 0) {
|
|
622
|
+
process.stdout.write('nothing here can be placed: none of these entries has a name\n');
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
// Required only now. Listing a backup does not need the tool that places one
|
|
626
|
+
// — refusing to say what is in the vault because `remote.sh` is missing is
|
|
627
|
+
// the same defect one layer out — so the gate sits after the report and
|
|
628
|
+
// before the first spawn.
|
|
629
|
+
ensurePreflight(preflight(config.scriptsDir, NEEDS.vaultRestore));
|
|
630
|
+
const present = await teamsAlreadyHere(config.scriptsDir);
|
|
631
|
+
// WHICH SERVER, asked once, BEFORE anything is pulled.
|
|
632
|
+
//
|
|
633
|
+
// This was a post-pull check and that was not enough (review P1-1). A pull
|
|
634
|
+
// creates a local team under the person's name and gives it history, and the
|
|
635
|
+
// history is what makes the next pull refuse — measured. So discovering
|
|
636
|
+
// afterwards that the entry belonged to another server left a team that
|
|
637
|
+
// could not be replaced by the right one, with no way to free the name
|
|
638
|
+
// (#446). A restore that builds its own permanent obstacle is worse than one
|
|
639
|
+
// that places nothing.
|
|
640
|
+
//
|
|
641
|
+
// Only entries whose own instance matches are pulled at all. If the server
|
|
642
|
+
// cannot be asked, nothing is pulled: an unanswered question is not a match.
|
|
643
|
+
const endpoint = await endpointInstance(config, inv.named);
|
|
644
|
+
const results = [];
|
|
645
|
+
for (const named of inv.named) {
|
|
646
|
+
results.push(await placeOne(config, named, present, endpoint));
|
|
647
|
+
}
|
|
648
|
+
for (const line of placementLines(results, inv.total)) {
|
|
649
|
+
process.stdout.write(`${line}\n`);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* The control plane's answer, or the fact that it had none.
|
|
654
|
+
*
|
|
655
|
+
* A failure to name is not a failure to find: the person is one step from
|
|
656
|
+
* having lost everything, and "here is what your backup holds, by id" is worth
|
|
657
|
+
* more than a stack trace. But it is not reported as an empty answer either —
|
|
658
|
+
* see `ServerNames`, which exists so this cannot be flattened into "the server
|
|
659
|
+
* mentioned none of them", which reads as "your credential cannot reach any of
|
|
660
|
+
* this".
|
|
661
|
+
*/
|
|
662
|
+
async function serverNames(client) {
|
|
663
|
+
try {
|
|
664
|
+
return { asked: true, teams: await client.listTeams() };
|
|
665
|
+
}
|
|
666
|
+
catch (err) {
|
|
667
|
+
process.stdout.write(`could not ask the server for team names (${err instanceof Error ? err.message : String(err)})\n`);
|
|
668
|
+
return { asked: false };
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
async function endpointInstance(config, named) {
|
|
672
|
+
if (named.length === 0)
|
|
673
|
+
return { known: false, why: 'there was nothing to place' };
|
|
674
|
+
const credential = readCredential(originOf(config.baseUrl));
|
|
675
|
+
if (!credential) {
|
|
676
|
+
return {
|
|
677
|
+
known: false,
|
|
678
|
+
why: `no stored credential for ${originOf(config.baseUrl)} — run \`agmsg-cloud login\` on this machine first`,
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
try {
|
|
682
|
+
// No team id is passed, and that is the point: naming one reaches a route
|
|
683
|
+
// that CLAIMS an unclaimed id on the hosted gateway. See `data-plane.ts`.
|
|
684
|
+
const { serverInstanceId } = await dataPlaneIdentity(credential.capabilityUrl);
|
|
685
|
+
return { known: true, serverInstanceId };
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
return { known: false, why: err instanceof Error ? err.message : String(err) };
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const remoteKey = (serverInstanceId, teamId) => `${serverInstanceId}${teamId}`;
|
|
692
|
+
/**
|
|
693
|
+
* What this machine already holds, asked once.
|
|
694
|
+
*
|
|
695
|
+
* One `remote.sh status --json` for the whole loop rather than one per entry:
|
|
696
|
+
* the answer is a list, and asking per team would spawn a process per backup to
|
|
697
|
+
* learn what a single call already said.
|
|
698
|
+
*
|
|
699
|
+
* BOTH DIRECTIONS ARE KEPT, because the loop asks two different questions of
|
|
700
|
+
* the same answer — "is this backup already here (under any name)?" and "is
|
|
701
|
+
* this name taken by something else?" — and a single map answers only one.
|
|
702
|
+
*
|
|
703
|
+
* A team is matched on the PAIR. The same team id on a different instance is a
|
|
704
|
+
* different team, which is why the vault entry binds both.
|
|
705
|
+
*/
|
|
706
|
+
async function teamsAlreadyHere(scriptsDir) {
|
|
707
|
+
const byRemote = new Map();
|
|
708
|
+
const byName = new Map();
|
|
709
|
+
for (const t of await connectedTeams(scriptsDir)) {
|
|
710
|
+
// `disconnected` still counts as HERE — the question is whether the data is
|
|
711
|
+
// on this machine, and a re-pull would be refused for exactly that reason.
|
|
712
|
+
// But it does not count as unlockable: MEASURED, `remote.sh unlock` on a
|
|
713
|
+
// disconnected team exits 1 with `connected team binding is invalid or
|
|
714
|
+
// disconnected`, so the state is carried rather than flattened.
|
|
715
|
+
byRemote.set(remoteKey(t.serverInstanceId, t.teamId), {
|
|
716
|
+
team: t.team,
|
|
717
|
+
live: t.state === 'active',
|
|
718
|
+
});
|
|
719
|
+
byName.set(t.team, { teamId: t.teamId, instance: t.serverInstanceId });
|
|
720
|
+
}
|
|
721
|
+
return { byRemote, byName };
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Place one entry: bind it if it is not here, then import its key.
|
|
725
|
+
*
|
|
726
|
+
* THE ORDER IS MEASURED, not assumed. `remote.sh pull` refuses a team with any
|
|
727
|
+
* local history BEFORE it compares team ids, so on a second run every team the
|
|
728
|
+
* first run placed comes back as `already has history; pull clones into an
|
|
729
|
+
* empty team` — a sentence that is right for `pull` and says nothing about a
|
|
730
|
+
* restore. So "is it already here" is decided from `remote.sh status --json`,
|
|
731
|
+
* which answers with both ids while the team is still locked, and `pull` is
|
|
732
|
+
* never called for a team that would be refused for that reason.
|
|
733
|
+
*
|
|
734
|
+
* `unlock` is safe to repeat: the OSS suite pins a second unlock with the same
|
|
735
|
+
* bundle at `imported 0 envelope(s)` and exit 0. It restarts that team's sync
|
|
736
|
+
* engine each time, so it is called once per entry and never in a retry.
|
|
737
|
+
*/
|
|
738
|
+
async function placeOne(config, { entry, team }, present, endpoint) {
|
|
739
|
+
// Under whatever local name it already has, which need not be the name the
|
|
740
|
+
// server uses now. Matching on the name instead would pull a second copy of a
|
|
741
|
+
// team this machine already holds.
|
|
742
|
+
const here = present.byRemote.get(remoteKey(entry.server_instance_id, entry.team_id));
|
|
743
|
+
const holder = present.byName.get(team);
|
|
744
|
+
if (here === undefined && holder !== undefined) {
|
|
745
|
+
return {
|
|
746
|
+
kind: 'name-held',
|
|
747
|
+
team,
|
|
748
|
+
teamId: entry.team_id,
|
|
749
|
+
instance: entry.server_instance_id,
|
|
750
|
+
localTeamId: holder.teamId,
|
|
751
|
+
localInstance: holder.instance,
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
if (here !== undefined && !here.live) {
|
|
755
|
+
// Not attempted: the unlock is known to refuse a disconnected binding, and
|
|
756
|
+
// reporting a call we knew would fail teaches the reader nothing about
|
|
757
|
+
// their own situation.
|
|
758
|
+
return { kind: 'disconnected', team: here.team, teamId: entry.team_id };
|
|
759
|
+
}
|
|
760
|
+
if (here === undefined) {
|
|
761
|
+
// BEFORE the pull, because the pull is what cannot be taken back.
|
|
762
|
+
if (!endpoint.known) {
|
|
763
|
+
return { kind: 'server-unknown', team, teamId: entry.team_id, why: endpoint.why };
|
|
764
|
+
}
|
|
765
|
+
if (endpoint.serverInstanceId !== entry.server_instance_id) {
|
|
766
|
+
return {
|
|
767
|
+
kind: 'wrong-instance',
|
|
768
|
+
team,
|
|
769
|
+
teamId: entry.team_id,
|
|
770
|
+
wanted: entry.server_instance_id,
|
|
771
|
+
got: endpoint.serverInstanceId,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
const refused = await pullTeam(config, team, entry.team_id);
|
|
775
|
+
if (refused !== null)
|
|
776
|
+
return { kind: 'refused', team, teamId: entry.team_id, said: refused };
|
|
777
|
+
// THE PAIR IS CONFIRMED BEFORE THE KEY GOES IN. `listTeams` carries no
|
|
778
|
+
// server instance and the control plane cannot supply one — `team_id` is
|
|
779
|
+
// the primary key of `edge_team_claims` — so a name resolved there does not
|
|
780
|
+
// establish that the team it names lives on the server this backup came
|
|
781
|
+
// from. The binding the pull just wrote does, and it is read now because
|
|
782
|
+
// the NEXT step is the one that would install this server's key material
|
|
783
|
+
// into a different server's team.
|
|
784
|
+
const bound = await remoteBinding(config.scriptsDir, team);
|
|
785
|
+
if (bound.serverInstanceId !== entry.server_instance_id ||
|
|
786
|
+
bound.teamId !== entry.team_id) {
|
|
787
|
+
return {
|
|
788
|
+
kind: 'wrong-instance',
|
|
789
|
+
team,
|
|
790
|
+
teamId: entry.team_id,
|
|
791
|
+
wanted: entry.server_instance_id,
|
|
792
|
+
got: bound.serverInstanceId,
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
const localName = here?.team ?? team;
|
|
797
|
+
const failed = await unlockTeam(config, localName, entry.bundle);
|
|
798
|
+
if (failed !== null)
|
|
799
|
+
return { kind: 'locked', team: localName, teamId: entry.team_id, said: failed };
|
|
800
|
+
return here === undefined
|
|
801
|
+
? { kind: 'placed', team: localName, teamId: entry.team_id }
|
|
802
|
+
: { kind: 'already', team: localName, teamId: entry.team_id };
|
|
803
|
+
}
|
|
804
|
+
/** Returns what `remote.sh` said when it refused, or null when the pull worked. */
|
|
805
|
+
async function pullTeam(config, team, teamId) {
|
|
806
|
+
let said = '';
|
|
807
|
+
// Piped rather than inherited: this is a loop, and each team's output belongs
|
|
808
|
+
// under that team in the report. What comes back is printed as it arrived —
|
|
809
|
+
// the OSS side is the one that knows why it refused, and a paraphrase here is
|
|
810
|
+
// a second copy of its wording that goes stale.
|
|
811
|
+
const runner = async (command, args) => {
|
|
812
|
+
const r = await runAllowingFailure(command, [...args]);
|
|
813
|
+
said = [r.stdout.trim(), r.stderr.trim()].filter((s) => s !== '').join('\n');
|
|
814
|
+
return r.code;
|
|
815
|
+
};
|
|
816
|
+
try {
|
|
817
|
+
await cmdPull(config, { team, teamId, runner, nextStepsFromCaller: true });
|
|
818
|
+
return null;
|
|
819
|
+
}
|
|
820
|
+
catch (err) {
|
|
821
|
+
// `said` is empty when the failure was this side's — an unresolvable
|
|
822
|
+
// credential, say — and then our own message is the only account there is.
|
|
823
|
+
return said !== '' ? said : err instanceof Error ? err.message : String(err);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
/** Returns what failed, or null when the key imported. */
|
|
827
|
+
async function unlockTeam(config, team, bundle) {
|
|
828
|
+
try {
|
|
829
|
+
// Straight from the vault's bytes into the OSS import, with no file in
|
|
830
|
+
// between — the same reason the one-team path does it this way: a path can
|
|
831
|
+
// be made to point at other bytes after we vouched for these ones.
|
|
832
|
+
await unlockAuthenticatedBundle(config.scriptsDir, team, Buffer.from(bundle, 'base64'));
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
catch (err) {
|
|
836
|
+
return err instanceof Error ? err.message : String(err);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
@@ -10,6 +10,30 @@ import { shellArg } from '../shell-arg.js';
|
|
|
10
10
|
// not revealed until an approver has committed. Anything shown here would have
|
|
11
11
|
// to be invented, and a code that appears before the ceremony has run is exactly
|
|
12
12
|
// the thing a user must never learn to accept.
|
|
13
|
+
/**
|
|
14
|
+
* The line one waiting request is announced with.
|
|
15
|
+
*
|
|
16
|
+
* Split out so it can be read by a test. `cmdWatch` writes straight to
|
|
17
|
+
* `process.stdout` and takes no sink, so the only other way to check what it
|
|
18
|
+
* says is a global spy on the stream — which this suite has already paid for
|
|
19
|
+
* once: a spy installed for one file leaked into three others and timed them
|
|
20
|
+
* out. A pure function is the seam that costs nothing.
|
|
21
|
+
*/
|
|
22
|
+
export function watchLine(req) {
|
|
23
|
+
return (
|
|
24
|
+
// The id is the server's, not ours, and it lands in an argument
|
|
25
|
+
// position of a line written to be pasted. Quoted for that reason
|
|
26
|
+
// rather than because a UUID needs it: what makes a value safe here is
|
|
27
|
+
// where it goes, not what it happens to contain today.
|
|
28
|
+
//
|
|
29
|
+
// ONCE, not twice. The id opened this line as well until #329 — the
|
|
30
|
+
// same value, in a position where nothing can be done with it, three
|
|
31
|
+
// words before the position where it is the argument to paste. This is
|
|
32
|
+
// the one place in the CLI where a record id passes #275's test, and it
|
|
33
|
+
// passes it by being part of the next step rather than by being shown.
|
|
34
|
+
`enrollment request "${req.label}" — run \`agmsg-cloud approve <team> ${shellArg(req.id)}\` ` +
|
|
35
|
+
`to compare codes with them (expires ${req.expires_at})\n`);
|
|
36
|
+
}
|
|
13
37
|
export async function cmdWatch(config, args, deps = {}) {
|
|
14
38
|
const client = new CourierClient(config);
|
|
15
39
|
const interval = args.intervalMs ?? 15_000;
|
|
@@ -32,13 +56,7 @@ export async function cmdWatch(config, args, deps = {}) {
|
|
|
32
56
|
if (seen.has(req.id))
|
|
33
57
|
continue;
|
|
34
58
|
seen.add(req.id);
|
|
35
|
-
process.stdout.write(
|
|
36
|
-
// The id is the server's, not ours, and it lands in an argument
|
|
37
|
-
// position of a line written to be pasted. Quoted for that reason
|
|
38
|
-
// rather than because a UUID needs it: what makes a value safe here is
|
|
39
|
-
// where it goes, not what it happens to contain today.
|
|
40
|
-
`enrollment request ${req.id} "${req.label}" — run \`agmsg-cloud approve <team> ${shellArg(req.id)}\` ` +
|
|
41
|
-
`to compare codes with them (expires ${req.expires_at})\n`);
|
|
59
|
+
process.stdout.write(watchLine(req));
|
|
42
60
|
}
|
|
43
61
|
if (deps.signal?.aborted)
|
|
44
62
|
break;
|
package/dist/src/config.js
CHANGED
|
@@ -80,7 +80,11 @@ export function defaultScriptsDir(home = homedir()) {
|
|
|
80
80
|
* review again). One body, so there is nothing to keep in step.
|
|
81
81
|
*/
|
|
82
82
|
function resolveCredential(env) {
|
|
83
|
-
|
|
83
|
+
const viaEnv = fromEnv(env);
|
|
84
|
+
if (viaEnv)
|
|
85
|
+
return { ...viaEnv, from: 'env' };
|
|
86
|
+
const stored = readCredential(null, env);
|
|
87
|
+
return stored ? { ...stored, from: 'stored' } : null;
|
|
84
88
|
}
|
|
85
89
|
/**
|
|
86
90
|
* Whether this machine has a credential to act with — `loadConfig`'s own
|
|
@@ -99,5 +103,10 @@ export function loadConfig(env = process.env) {
|
|
|
99
103
|
if (!resolved) {
|
|
100
104
|
throw new Error('not signed in on this machine — run `agmsg-cloud login --endpoint <url>` first');
|
|
101
105
|
}
|
|
102
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
baseUrl: resolved.endpoint,
|
|
108
|
+
secret: resolved.secret,
|
|
109
|
+
scriptsDir: resolveScriptsDir(env),
|
|
110
|
+
credentialFrom: resolved.from,
|
|
111
|
+
};
|
|
103
112
|
}
|