sliftutils 1.9.7 → 1.9.9

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/index.d.ts CHANGED
@@ -1182,7 +1182,7 @@ declare module "sliftutils/storage/BulkDatabase2/BulkDatabaseBase" {
1182
1182
  export type BulkDatabase2Config = {
1183
1183
  maxTriggerThrottleMs?: number;
1184
1184
  };
1185
- export type MergeSkipReason = "mergeInFlight" | "tabLockHeld" | "fileLockHeld" | "nothingToMerge";
1185
+ export type MergeSkipReason = "mergeInFlight" | "tabLockHeld" | "fileLockHeld" | "fileLockUnwritable" | "nothingToMerge";
1186
1186
  export type MergeAttemptResult = {
1187
1187
  merged: boolean;
1188
1188
  skipReason?: MergeSkipReason;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sliftutils",
3
- "version": "1.9.7",
3
+ "version": "1.9.9",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -247,7 +247,6 @@ async function pollSource(repoURL: string) {
247
247
  // Said when the file is written, and only if it came out different. A commit that does not
248
248
  // touch the keys is not worth telling anyone about.
249
249
  addChangeReason(`The key repo \`${repoURL}\` moved to \`${result.remoteSha.slice(0, 12)}\`.`);
250
- sourceState(repoURL).lastSha = result.remoteSha;
251
250
  return true;
252
251
  }
253
252
 
@@ -71,12 +71,14 @@ export async function currentBranch(config: { repoPath: string; keyPath: string
71
71
  for good: it sits in a stash if it is ever wanted. */
72
72
  export async function setGitRef(config: { repoPath: string; gitRef: string; keyPath: string }) {
73
73
  let { repoPath, gitRef, keyPath } = config;
74
- // Staging the untracked files first is what lets the stash carry them off, so the reset lands
75
- // on a genuinely clean tree.
76
- await runGit({ args: ["add", "--all"], cwd: repoPath, keyPath, allowFailure: true });
77
- // A checkout with nothing to stash makes git stash exit non-zero, which is harmless here - the
78
- // reset below is what actually forces the tree.
79
- await runGit({ args: ["stash"], cwd: repoPath, keyPath, allowFailure: true });
74
+ let dirty = (await runGit({ args: ["status", "--porcelain"], cwd: repoPath, keyPath })).stdout.trim();
75
+ if (dirty) {
76
+ console.log(`[pid ${process.pid}] Stashing local changes in ${repoPath} before resetting it to ${gitRef}:\n${dirty.slice(0, MAX_ERROR_BODY_LENGTH)}`);
77
+ // Staging the untracked files first is what lets the stash carry them off, so the reset lands
78
+ // on a genuinely clean tree.
79
+ await runGit({ args: ["add", "--all"], cwd: repoPath, keyPath, allowFailure: true });
80
+ await runGit({ args: ["stash"], cwd: repoPath, keyPath, allowFailure: true });
81
+ }
80
82
  await runGit({ args: ["fetch", "--prune", "origin"], cwd: repoPath, keyPath });
81
83
  await runGit({ args: ["reset", "--hard", gitRef], cwd: repoPath, keyPath });
82
84
  // Drops objects nothing references any more, so a file committed by mistake cannot bloat the
@@ -140,5 +142,6 @@ export async function syncRepo(repoURL: string, options?: { forceUpdate?: boolea
140
142
  });
141
143
  historyRewritten = ancestry.status !== 0;
142
144
  }
145
+ sourceState(repoURL).lastSha = remoteSha;
143
146
  return { changed: true, historyRewritten, remoteSha, previousSha };
144
147
  }
@@ -8,6 +8,7 @@ import { spawnPromise } from "../helpers/spawn";
8
8
 
9
9
  export const UNREVOKES_DIR = "unrevoked";
10
10
  const REVOCATIONS_DIR = "revocations";
11
+ const MAX_LOGGED_CONTENTS = 500;
11
12
 
12
13
  /** One revocation event. Either an ssh key used from an address it is not allowed from, or a
13
14
  machine that talked to us from one. Both are the same shape - an identity and an address - and
@@ -38,25 +39,20 @@ export function revocationIdentity(revocation: Revocation) {
38
39
  return revocation.fingerprint || revocation.machineId || "";
39
40
  }
40
41
 
41
- /** Every revocation the revoke repo lists. Cloned read only into a temp directory.
42
-
43
- A person running this has their own access to the repo, so their credentials are what is used.
44
- On a machine that is already a portsecure host there may be no such credentials, but the source
45
- deploy key is right there, and the key derived from it is one that repo definitely accepts. */
46
- export async function readRemoteRevocations(config: { sourceURL: string }) {
42
+ async function cloneRevokeRepo(config: { sourceURL: string }) {
47
43
  let { sourceURL } = config;
48
44
  let temporaryDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "unrevoke-"));
49
45
  let repoPath = path.join(temporaryDirectory, "repo");
50
- let cloneArgs = ["clone", "--depth", "1", revokeRepoURL(sourceURL), repoPath];
46
+ let gitConfig: string[] = [];
51
47
  let sourceKey = await findSourceKey(sourceURL);
52
48
  if (sourceKey) {
53
49
  let derived = deriveRevokeKey(await fs.readFile(sourceKey, "utf8"));
54
50
  let derivedKeyPath = path.join(temporaryDirectory, "key");
55
51
  await fs.writeFile(derivedKeyPath, derived.privateKeyFile, { mode: 0o600 });
56
52
  let sshCommand = `ssh -i ${derivedKeyPath} -o IdentitiesOnly=yes -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
57
- cloneArgs = ["-c", `core.sshCommand=${sshCommand}`, ...cloneArgs];
53
+ gitConfig = ["-c", `core.sshCommand=${sshCommand}`];
58
54
  }
59
- let clone = await spawnPromise({ command: "git", args: cloneArgs });
55
+ let clone = await spawnPromise({ command: "git", args: [...gitConfig, "clone", "--depth", "1", revokeRepoURL(sourceURL), repoPath] });
60
56
  if (clone.status !== 0) {
61
57
  await fs.rm(temporaryDirectory, { recursive: true, force: true });
62
58
  throw new Error(
@@ -64,18 +60,63 @@ export async function readRemoteRevocations(config: { sourceURL: string }) {
64
60
  + `${(clone.stdout + clone.stderr).trim()}`
65
61
  );
66
62
  }
67
- let revocations: Revocation[] = [];
68
- let directory = path.join(repoPath, REVOCATIONS_DIR);
69
- let names: string[] = [];
63
+ return { temporaryDirectory, repoPath, gitConfig };
64
+ }
65
+
66
+ async function listRevocationFiles(repoPath: string) {
70
67
  try {
71
- names = (await fs.readdir(directory)).sort();
68
+ return (await fs.readdir(path.join(repoPath, REVOCATIONS_DIR))).filter(name => name.endsWith(".json")).sort();
72
69
  } catch (e) {
73
70
  // No revocations directory at all just means nothing has ever been revoked.
71
+ return [];
74
72
  }
75
- for (let name of names) {
76
- if (!name.endsWith(".json")) {
77
- continue;
73
+ }
74
+
75
+ /** Deletes the revocation files in the revoke repo that are not JSON at all, and pushes that.
76
+ Nothing is lost: a file that does not parse names no key and no address, so every machine
77
+ already ignores it, and all it does is print the same complaint on every read forever.
78
+
79
+ Judged on a fresh clone, never on a machine's own checkout, so a file only goes if the repo
80
+ itself holds it broken. */
81
+ export async function removeUnreadableRevocations(config: { sourceURL: string }) {
82
+ let { sourceURL } = config;
83
+ let { temporaryDirectory, repoPath, gitConfig } = await cloneRevokeRepo({ sourceURL });
84
+ try {
85
+ let removed: string[] = [];
86
+ for (let name of await listRevocationFiles(repoPath)) {
87
+ let relativePath = `${REVOCATIONS_DIR}/${name}`;
88
+ let contents = await fs.readFile(path.join(repoPath, REVOCATIONS_DIR, name), "utf8");
89
+ try {
90
+ JSON.parse(contents);
91
+ continue;
92
+ } catch (e) {
93
+ console.log(`Removing unreadable revocation ${relativePath} from ${revokeRepoURL(sourceURL)}, ${contents.length} characters: ${JSON.stringify(contents.slice(0, MAX_LOGGED_CONTENTS))}. ${e}`);
94
+ }
95
+ await fs.rm(path.join(repoPath, REVOCATIONS_DIR, name));
96
+ removed.push(relativePath);
78
97
  }
98
+ if (!removed.length) return;
99
+ let git = async (args: string[]) => {
100
+ let result = await spawnPromise({ command: "git", args: [...gitConfig, ...args], cwd: repoPath });
101
+ if (result.status !== 0) {
102
+ throw new Error(`Expected git ${args.join(" ")} to succeed in a clone of ${revokeRepoURL(sourceURL)}, exited ${result.status}.\n${(result.stdout + result.stderr).trim()}`);
103
+ }
104
+ };
105
+ await git(["add", "-A"]);
106
+ await git(["-c", "user.email=portsecure@localhost", "-c", "user.name=portsecure", "commit", "-m", `remove ${removed.length} unreadable revocation(s)`]);
107
+ await git(["push", "origin", "HEAD"]);
108
+ console.log(`Removed ${removed.length} unreadable revocation(s) from ${revokeRepoURL(sourceURL)}: ${removed.join(", ")}`);
109
+ } finally {
110
+ await fs.rm(temporaryDirectory, { recursive: true, force: true });
111
+ }
112
+ }
113
+
114
+ export async function readRemoteRevocations(config: { sourceURL: string }) {
115
+ let { sourceURL } = config;
116
+ let { temporaryDirectory, repoPath } = await cloneRevokeRepo({ sourceURL });
117
+ let revocations: Revocation[] = [];
118
+ let directory = path.join(repoPath, REVOCATIONS_DIR);
119
+ for (let name of await listRevocationFiles(repoPath)) {
79
120
  try {
80
121
  revocations.push(JSON.parse(await fs.readFile(path.join(directory, name), "utf8")));
81
122
  } catch (e) {
@@ -6,13 +6,13 @@ import { revokeRepoURL } from "./revokeSource";
6
6
  import { signRepo } from "../signedFiles/signFiles";
7
7
  import { getMachines, setMachines } from "../machines/machines";
8
8
  import { resolveKeysRepo } from "./keysRepo";
9
- import { readRemoteRevocations, readUnrevokedInRepo, Revocation, revocationIdentity, revocationIP, stillRevokedBy, UNREVOKES_DIR } from "./unrevoke";
9
+ import { readRemoteRevocations, readUnrevokedInRepo, removeUnreadableRevocations,Revocation, revocationIdentity, revocationIP, stillRevokedBy, UNREVOKES_DIR } from "./unrevoke";
10
10
 
11
11
  const GIT_KEYWORD = "git";
12
12
  const COMMIT_MESSAGE = "unrevoke keys";
13
13
  const USAGE = `Usage: yarn unrevoke [keys-repo] [${GIT_KEYWORD}]
14
14
 
15
- Run this in a keys repo, or name one. It reads that repo's revoke repo and writes one unrevoke file
15
+ Run this in a keys repo, or name one. It deletes any revocation file in that repo's revoke repo that is not valid JSON, pushing that straight away. It reads the revoke repo and writes one unrevoke file
16
16
  naming every revocation in it, so the keys are accepted again as each machine picks it up. It signs
17
17
  the result, and with ${GIT_KEYWORD} it commits and pushes it too.
18
18
 
@@ -98,6 +98,12 @@ async function main() {
98
98
  }
99
99
  let { repoPath, sourceURL: originURL } = await resolveKeysRepo(positional[0]);
100
100
 
101
+ try {
102
+ await removeUnreadableRevocations({ sourceURL: originURL });
103
+ } catch (e) {
104
+ console.log(`Could not remove the unreadable revocations from ${revokeRepoURL(originURL)}, unrevoking anyway.`, (e as Error).stack ?? e);
105
+ }
106
+
101
107
  let revocations = await readRemoteRevocations({ sourceURL: originURL });
102
108
  if (!revocations.length) {
103
109
  console.log(`${revokeRepoURL(originURL)} lists no revocations, so there is nothing to undo.`);
@@ -16,6 +16,7 @@ import { describeHost, runOverSSH, writeRemoteFile } from "../helpers/remoteSSH"
16
16
  import { lazy } from "socket-function/src/caching";
17
17
  import { runInfinitePoll, runInSerial } from "socket-function/src/batching";
18
18
  import { spawnPromise } from "../helpers/spawn";
19
+ import { formatTime } from "socket-function/src/formatting/format";
19
20
 
20
21
  // Which machines this system talks to, kept in the same repo as the ssh keys. That repo is already
21
22
  // signed, already distributed to every machine, and already has somewhere to record a rejection,
@@ -26,6 +27,9 @@ const REVOCATION_REASON = "a machine talked to us from an unapproved IP";
26
27
  // The revoke repo is pulled at most this often. A check happens per request, and a request must
27
28
  // not cost a round trip to github.
28
29
  const REVOKE_SYNC_INTERVAL = 60 * 1000;
30
+ const SLOW_REVOKE_SYNC_TIME = 10 * 1000;
31
+ const REVOKE_SYNC_RETRY_INTERVAL = 5 * 60 * 1000;
32
+ const ACCEPTED_SYNC_WAIT = 5 * 1000;
29
33
  // And the machine list is re-read from the checkout on this interval, in the background. The
30
34
  // checkout itself only moves when the daemon pulls it, which is on the same sort of interval, so
31
35
  // reading more often than this would only re-read the same bytes.
@@ -235,19 +239,56 @@ async function readMachines(repoPath: string) {
235
239
  return machines;
236
240
  }
237
241
 
238
- let lastRevokeSync = 0;
242
+ let nextRevokeSyncTime = 0;
243
+ let revokeSyncFailing = false;
244
+ let revokeSyncInFlight: Promise<boolean> | undefined;
239
245
 
240
246
  /** Pulls the keys repo and its revoke repo, at most once a REVOKE_SYNC_INTERVAL, because this is
241
247
  asked per request. Both, since either changing changes the answer: the keys repo carries the
242
248
  machines and unrevokes, the revoke repo carries the revocations. A sync that fails leaves the
243
249
  checkouts we already have. */
244
- async function syncRevocations(sourceURL: string) {
245
- if (Date.now() - lastRevokeSync < REVOKE_SYNC_INTERVAL) {
246
- return;
250
+ function startRevocationSync(sourceURL: string) {
251
+ if (revokeSyncInFlight || Date.now() < nextRevokeSyncTime) {
252
+ return revokeSyncInFlight;
247
253
  }
248
- lastRevokeSync = Date.now();
249
- await syncRepo(sourceURL).catch(e => console.log(`Could not sync ${sourceURL}, using the checkout already here. ${e}`));
250
- await syncRepoFiles(revokeRepo(sourceURL)).catch(e => console.log(`Could not sync ${revokeRepoURL(sourceURL)}, using the revocations already absorbed. ${e}`));
254
+ nextRevokeSyncTime = Date.now() + REVOKE_SYNC_INTERVAL;
255
+ revokeSyncInFlight = (async () => {
256
+ let start = Date.now();
257
+ let succeeded = true;
258
+ await syncRepo(sourceURL).catch(e => {
259
+ succeeded = false;
260
+ console.log(`[pid ${process.pid}] Could not sync ${sourceURL}, using the checkout already here. ${(e as Error).stack ?? e}`);
261
+ });
262
+ await syncRepoFiles(revokeRepo(sourceURL)).catch(e => {
263
+ succeeded = false;
264
+ console.log(`[pid ${process.pid}] Could not sync ${revokeRepoURL(sourceURL)}, using the revocations already absorbed. ${(e as Error).stack ?? e}`);
265
+ });
266
+ let elapsed = Date.now() - start;
267
+ if (elapsed > SLOW_REVOKE_SYNC_TIME) {
268
+ console.log(`[pid ${process.pid}] Syncing ${sourceURL} and ${revokeRepoURL(sourceURL)} took ${formatTime(elapsed)}, over ${formatTime(SLOW_REVOKE_SYNC_TIME)}`);
269
+ }
270
+ if (!succeeded) {
271
+ nextRevokeSyncTime = Date.now() + REVOKE_SYNC_RETRY_INTERVAL;
272
+ if (!revokeSyncFailing) {
273
+ console.log(`[pid ${process.pid}] Syncing ${sourceURL} failed, retrying every ${formatTime(REVOKE_SYNC_RETRY_INTERVAL)} instead of every ${formatTime(REVOKE_SYNC_INTERVAL)} until it works`);
274
+ }
275
+ revokeSyncFailing = true;
276
+ return false;
277
+ }
278
+ if (revokeSyncFailing) {
279
+ console.log(`[pid ${process.pid}] Syncing ${sourceURL} works again, back to every ${formatTime(REVOKE_SYNC_INTERVAL)}`);
280
+ }
281
+ revokeSyncFailing = false;
282
+ try {
283
+ trustedMachines.set(Promise.resolve(await readTrustedMachines()));
284
+ } catch (e) {
285
+ console.log(`[pid ${process.pid}] Could not re-read the trusted machines after syncing, keeping the ones we have. ${(e as Error).stack ?? e}`);
286
+ }
287
+ return true;
288
+ })().finally(() => {
289
+ revokeSyncInFlight = undefined;
290
+ });
291
+ return revokeSyncInFlight;
251
292
  }
252
293
 
253
294
  /** What to run to trust a machine that has just been refused.
@@ -390,12 +431,34 @@ export async function isMachineAccepted(config: {
390
431
  // there is nothing to gain from looking again. Any other rejection might just be a stale view -
391
432
  // a machine added seconds ago that our cached list has not caught up to - so it is worth one
392
433
  // fresh look, which is what the retry below does.
393
- let evaluate = async (): Promise<{ verdict: MachineVerdict; froze: boolean }> => {
394
- if (LOOPBACK.includes(ip) && !!domain && machineId === getOwnMachineId(domain)) {
395
- return { verdict: { accepted: true, reason: "" }, froze: false };
434
+ if (LOOPBACK.includes(ip) && !!domain && machineId === getOwnMachineId(domain)) {
435
+ return { accepted: true, reason: "" };
436
+ }
437
+ let { sourceURL } = await keysRepo();
438
+ let syncing = startRevocationSync(sourceURL);
439
+ if (syncing) {
440
+ let checkouts = await Promise.all([sourceRepoPath(sourceURL), revokeRepoPath(sourceURL)].map(repoPath =>
441
+ fs.access(path.join(repoPath, ".git")).then(() => true, () => false)
442
+ ));
443
+ let acceptedNow = false;
444
+ if (checkouts.every(exists => exists)) {
445
+ let listed = (await getTrustedMachines()).find(entry => entry.machineId === machineId);
446
+ acceptedNow = !!listed && (listed.ips.includes(ip) || LOOPBACK.includes(ip));
447
+ }
448
+ if (!acceptedNow) {
449
+ await syncing;
450
+ } else {
451
+ let synced = await Promise.race([
452
+ syncing,
453
+ new Promise<boolean>(resolve => setTimeout(() => resolve(false), ACCEPTED_SYNC_WAIT)),
454
+ ]);
455
+ if (!synced) {
456
+ return { accepted: true, reason: "" };
457
+ }
396
458
  }
397
- let { sourceURL } = await keysRepo();
398
- await syncRevocations(sourceURL);
459
+ }
460
+
461
+ let evaluate = async (): Promise<{ verdict: MachineVerdict; froze: boolean }> => {
399
462
  // The list has revocations already applied - readSignedRepo drops frozen machines - so a
400
463
  // machine in it is trusted, and a machine missing from it is either unknown or frozen.
401
464
  let machine = (await getTrustedMachines()).find(entry => entry.machineId === machineId);
@@ -456,7 +519,7 @@ export async function isMachineAccepted(config: {
456
519
  // final.
457
520
  if (Date.now() - lastMachinesInvalidation > MACHINES_INVALIDATION_INTERVAL) {
458
521
  lastMachinesInvalidation = Date.now();
459
- let { sourceURL } = await keysRepo();
522
+ await revokeSyncInFlight;
460
523
  await syncRepo(sourceURL, { forceUpdate: true }).catch(e => console.log(`Could not force update ${sourceURL}. ${e}`));
461
524
  trustedMachines.reset();
462
525
  }
@@ -28,7 +28,7 @@ export type StorageFactory = (path: string) => Promise<FileStorage>;
28
28
  export type BulkDatabase2Config = {
29
29
  maxTriggerThrottleMs?: number;
30
30
  };
31
- export type MergeSkipReason = "mergeInFlight" | "tabLockHeld" | "fileLockHeld" | "nothingToMerge";
31
+ export type MergeSkipReason = "mergeInFlight" | "tabLockHeld" | "fileLockHeld" | "fileLockUnwritable" | "nothingToMerge";
32
32
  export type MergeAttemptResult = {
33
33
  merged: boolean;
34
34
  skipReason?: MergeSkipReason;
@@ -111,6 +111,7 @@ export type MergeSkipReason =
111
111
  | "tabLockHeld"
112
112
  // Another process holds the cross-process .merge-lock file (or won the settle race for it).
113
113
  | "fileLockHeld"
114
+ | "fileLockUnwritable"
114
115
  // compact() only: the collection has no files on disk.
115
116
  | "nothingToMerge";
116
117
 
@@ -689,7 +690,7 @@ export class BulkDatabaseBase<T extends { key: string }> {
689
690
  try {
690
691
  await this.tryMergeThrottled();
691
692
  } catch (e) {
692
- console.warn(`${this.name}: background merge failed: ${(e as Error).message}`);
693
+ console.warn(`${this.name}: background merge failed:`, (e as Error).stack ?? e);
693
694
  }
694
695
  }
695
696
 
@@ -714,7 +715,14 @@ export class BulkDatabaseBase<T extends { key: string }> {
714
715
  if (this.mergeInFlight) return await this.mergeSkip("mergeInFlight");
715
716
  if (!tryAcquireMergeLock(this.name, writerId)) return await this.mergeSkip("tabLockHeld");
716
717
  const storage = await this.storage();
717
- const haveFileLock = await tryAcquireMergeFileLock(storage, writerId);
718
+ let haveFileLock: boolean;
719
+ try {
720
+ haveFileLock = await tryAcquireMergeFileLock(storage, writerId);
721
+ } catch (e) {
722
+ releaseMergeLock(this.name, writerId);
723
+ console.warn(`${blue(this.name)} ${magenta("merge")} skipped: fileLockUnwritable, writer ${writerId} could not write .merge-lock:`, (e as Error).stack ?? e);
724
+ return { merged: false, skipReason: "fileLockUnwritable" };
725
+ }
718
726
  if (!haveFileLock) {
719
727
  releaseMergeLock(this.name, writerId);
720
728
  return await this.mergeSkip("fileLockHeld");