@davidvornholt/standards 0.19.0 → 0.21.0
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/README.md +7 -5
- package/package.json +19 -1
- package/src/cli.ts +2 -2
- package/src/creds-add-cloudflare-commit.ts +2 -5
- package/src/creds-add-github.ts +82 -30
- package/src/creds-add-policy.ts +106 -26
- package/src/creds-add-preflight.ts +68 -0
- package/src/creds-add.ts +29 -20
- package/src/creds-args.ts +14 -0
- package/src/creds-cloudflare-account.ts +2 -2
- package/src/creds-cloudflare-id.ts +10 -0
- package/src/creds-cloudflare-zone.ts +40 -0
- package/src/creds-commands.ts +34 -11
- package/src/creds-github-app-api.ts +132 -0
- package/src/creds-github-apps.ts +107 -0
- package/src/creds-login-github-manifest.ts +38 -15
- package/src/creds-login-github.ts +78 -24
- package/src/creds-naming.ts +13 -0
- package/src/creds-plan-renew.ts +2 -5
- package/src/creds-plan-report.ts +87 -0
- package/src/creds-plan-run.ts +2 -11
- package/src/creds-plan-types.ts +19 -0
- package/src/creds-plan-unmanaged.ts +102 -0
- package/src/creds-plan.ts +25 -21
- package/src/creds-revoke.ts +162 -0
- package/src/creds-sops-value.ts +89 -0
- package/src/creds-sops.ts +0 -53
- package/src/creds-store-github.ts +121 -0
- package/src/creds-store-lock-holder.ts +105 -0
- package/src/creds-store-lock-inspection.ts +125 -0
- package/src/creds-store-lock-lease.ts +32 -0
- package/src/creds-store-lock.ts +28 -51
- package/src/creds-store.ts +14 -43
- package/src/dev-env-compose.ts +120 -0
- package/src/dev-env-destination-duplicates.ts +10 -10
- package/src/dev-env-destination-gitignore.ts +22 -0
- package/src/dev-env-destination.ts +52 -30
- package/src/dev-env-document.ts +19 -6
- package/src/dev-env-dotenv.ts +12 -3
- package/src/dev-env-reconciliation.ts +170 -0
- package/src/dev-env-secrets.ts +41 -0
- package/src/dev-env-transaction-recovery.ts +74 -11
- package/src/dev-env-transaction.ts +61 -15
- package/src/dev-env.ts +115 -63
- package/src/github-command-shared.ts +8 -1
- package/src/github-live-drift.ts +3 -3
- package/src/poller-closing-reference.ts +179 -0
- package/src/poller-fix-outcome.ts +9 -0
- package/src/poller-fix-publication.ts +5 -12
- package/src/poller-fix-run.ts +2 -3
- package/src/poller-fix-validation.ts +7 -8
- package/src/poller-outcome.ts +4 -1
- package/src/poller-protected-paths.ts +1 -25
- package/src/poller-protocol.ts +0 -42
- package/src/poller-review-publication.ts +2 -8
- package/src/poller-review-validation.ts +5 -12
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Proving that a stored secret matches the value that was just minted, without
|
|
2
|
+
// that value reaching a return type, a log line, or an error message. Callers
|
|
3
|
+
// destroy the new credential on a proven mismatch and keep it on an
|
|
4
|
+
// unverifiable read, so the two negative answers must stay distinct: anything
|
|
5
|
+
// that is not a leaf we can read as a scalar string is unverifiable.
|
|
6
|
+
|
|
7
|
+
import { isContainedSopsPath, parseSopsKeyPath } from './creds-sops-structure';
|
|
8
|
+
import { runSops } from './sops-exec';
|
|
9
|
+
|
|
10
|
+
export type SopsStoredValueVerification =
|
|
11
|
+
| { readonly ok: true; readonly matches: boolean }
|
|
12
|
+
| { readonly ok: false; readonly problem: string };
|
|
13
|
+
type SopsStoredValueInput = {
|
|
14
|
+
readonly consumer: string;
|
|
15
|
+
readonly rel: string;
|
|
16
|
+
readonly dottedPath: string;
|
|
17
|
+
readonly expectedValue: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Extracting a scalar leaf prints the stored value itself, with no quoting and
|
|
21
|
+
// no framing of any kind — probed against sops 3.13.0 and 3.13.3. Every byte of
|
|
22
|
+
// stdout is therefore value, which makes exact equality the only sound
|
|
23
|
+
// comparison: a trailing newline is a difference in the stored secret, not
|
|
24
|
+
// output framing.
|
|
25
|
+
//
|
|
26
|
+
// Once equality has been ruled out, output that parses as JSON is output we
|
|
27
|
+
// cannot read as this leaf's own value. It is a branch or list rather than a
|
|
28
|
+
// scalar; or a typed leaf, which a stored string could equally have produced;
|
|
29
|
+
// or a quoted rendering of exactly the value expected, which is either stray
|
|
30
|
+
// quoting in storage or framing from some sops we have not probed. None of
|
|
31
|
+
// those can be told apart from what was printed, and the wrong answer deletes a
|
|
32
|
+
// credential, so all of them stay unverifiable.
|
|
33
|
+
const isUnreadableOutput = (stdout: string, expectedValue: string): boolean => {
|
|
34
|
+
if (stdout === '') {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const parsed: unknown = JSON.parse(stdout);
|
|
39
|
+
return typeof parsed !== 'string' || parsed === expectedValue;
|
|
40
|
+
} catch {
|
|
41
|
+
// Not JSON at all, which is the normal case: an opaque token value, or a
|
|
42
|
+
// hex access key ID that reads as a malformed number.
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const verifySopsStoredValueWith = (
|
|
48
|
+
runner: typeof runSops,
|
|
49
|
+
input: SopsStoredValueInput,
|
|
50
|
+
): SopsStoredValueVerification => {
|
|
51
|
+
const { consumer, rel, dottedPath, expectedValue } = input;
|
|
52
|
+
const path = parseSopsKeyPath(dottedPath);
|
|
53
|
+
const problem = `could not verify stored SOPS value at ${dottedPath} in ${rel}`;
|
|
54
|
+
if (path === null || !isContainedSopsPath(consumer, rel, 'file')) {
|
|
55
|
+
return { ok: false, problem };
|
|
56
|
+
}
|
|
57
|
+
const extract = path
|
|
58
|
+
.map((segment) => `[${JSON.stringify(segment)}]`)
|
|
59
|
+
.join('');
|
|
60
|
+
const result = runner(
|
|
61
|
+
['decrypt', '--extract', extract, '--output-type', 'json', rel],
|
|
62
|
+
consumer,
|
|
63
|
+
);
|
|
64
|
+
if (result.status !== 0) {
|
|
65
|
+
return { ok: false, problem };
|
|
66
|
+
}
|
|
67
|
+
// Equality is decided before anything is classified, so a secret whose own
|
|
68
|
+
// text happens to be valid JSON — an all-digit token — is proven by the bytes
|
|
69
|
+
// rather than second-guessed by their shape.
|
|
70
|
+
if (result.stdout === expectedValue) {
|
|
71
|
+
return { ok: true, matches: true };
|
|
72
|
+
}
|
|
73
|
+
return isUnreadableOutput(result.stdout, expectedValue)
|
|
74
|
+
? { ok: false, problem }
|
|
75
|
+
: { ok: true, matches: false };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const verifySopsStoredValue = (
|
|
79
|
+
consumer: string,
|
|
80
|
+
rel: string,
|
|
81
|
+
dottedPath: string,
|
|
82
|
+
expectedValue: string,
|
|
83
|
+
): SopsStoredValueVerification =>
|
|
84
|
+
verifySopsStoredValueWith(runSops, {
|
|
85
|
+
consumer,
|
|
86
|
+
rel,
|
|
87
|
+
dottedPath,
|
|
88
|
+
expectedValue,
|
|
89
|
+
});
|
package/src/creds-sops.ts
CHANGED
|
@@ -15,7 +15,6 @@ import {
|
|
|
15
15
|
import {
|
|
16
16
|
inspectSopsStructure,
|
|
17
17
|
isContainedSopsPath,
|
|
18
|
-
parseSopsKeyPath,
|
|
19
18
|
type SopsShapeFailure,
|
|
20
19
|
} from './creds-sops-structure';
|
|
21
20
|
import { runSops } from './sops-exec';
|
|
@@ -36,15 +35,6 @@ export type SopsScalarDestinationResult = SopsScalarStructureResult | ReadError;
|
|
|
36
35
|
export type SopsWriteResult =
|
|
37
36
|
| { readonly ok: true }
|
|
38
37
|
| { readonly ok: false; readonly problem: string };
|
|
39
|
-
export type SopsStoredValueVerification =
|
|
40
|
-
| { readonly ok: true; readonly matches: boolean }
|
|
41
|
-
| { readonly ok: false; readonly problem: string };
|
|
42
|
-
type SopsStoredValueInput = {
|
|
43
|
-
readonly consumer: string;
|
|
44
|
-
readonly rel: string;
|
|
45
|
-
readonly dottedPath: string;
|
|
46
|
-
readonly expectedValue: string;
|
|
47
|
-
};
|
|
48
38
|
|
|
49
39
|
export const listEncryptedKeys = (text: string): EncryptedKeysReadResult => {
|
|
50
40
|
const result = inspectSopsStructure(text, true);
|
|
@@ -123,49 +113,6 @@ const editorCommand = (): string => {
|
|
|
123
113
|
return `"${process.execPath}" "${editor}"`;
|
|
124
114
|
};
|
|
125
115
|
|
|
126
|
-
export const verifySopsStoredValueWith = (
|
|
127
|
-
runner: typeof runSops,
|
|
128
|
-
input: SopsStoredValueInput,
|
|
129
|
-
): SopsStoredValueVerification => {
|
|
130
|
-
const { consumer, rel, dottedPath, expectedValue } = input;
|
|
131
|
-
const path = parseSopsKeyPath(dottedPath);
|
|
132
|
-
const problem = `could not verify stored SOPS value at ${dottedPath} in ${rel}`;
|
|
133
|
-
if (path === null || !isContainedSopsPath(consumer, rel, 'file')) {
|
|
134
|
-
return { ok: false, problem };
|
|
135
|
-
}
|
|
136
|
-
const extract = path
|
|
137
|
-
.map((segment) => `[${JSON.stringify(segment)}]`)
|
|
138
|
-
.join('');
|
|
139
|
-
const result = runner(
|
|
140
|
-
['decrypt', '--extract', extract, '--output-type', 'json', rel],
|
|
141
|
-
consumer,
|
|
142
|
-
);
|
|
143
|
-
if (result.status !== 0) {
|
|
144
|
-
return { ok: false, problem };
|
|
145
|
-
}
|
|
146
|
-
try {
|
|
147
|
-
const stored: unknown = JSON.parse(result.stdout);
|
|
148
|
-
return typeof stored === 'string'
|
|
149
|
-
? { ok: true, matches: stored === expectedValue }
|
|
150
|
-
: { ok: false, problem };
|
|
151
|
-
} catch {
|
|
152
|
-
return { ok: false, problem };
|
|
153
|
-
}
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
export const verifySopsStoredValue = (
|
|
157
|
-
consumer: string,
|
|
158
|
-
rel: string,
|
|
159
|
-
dottedPath: string,
|
|
160
|
-
expectedValue: string,
|
|
161
|
-
): SopsStoredValueVerification =>
|
|
162
|
-
verifySopsStoredValueWith(runSops, {
|
|
163
|
-
consumer,
|
|
164
|
-
rel,
|
|
165
|
-
dottedPath,
|
|
166
|
-
expectedValue,
|
|
167
|
-
});
|
|
168
|
-
|
|
169
116
|
export const setSopsValues = (
|
|
170
117
|
consumer: string,
|
|
171
118
|
rel: string,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
2
|
+
|
|
3
|
+
export type GithubBrokerApp = {
|
|
4
|
+
readonly owner: string | null;
|
|
5
|
+
readonly appId: number;
|
|
6
|
+
readonly slug: string;
|
|
7
|
+
readonly htmlUrl: string;
|
|
8
|
+
readonly clientId: string;
|
|
9
|
+
readonly privateKey: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const isGithubBrokerApp = (value: unknown): value is GithubBrokerApp =>
|
|
13
|
+
isRecord(value) &&
|
|
14
|
+
(value.owner === null || isNonEmptyString(value.owner)) &&
|
|
15
|
+
typeof value.appId === 'number' &&
|
|
16
|
+
Number.isInteger(value.appId) &&
|
|
17
|
+
isNonEmptyString(value.slug) &&
|
|
18
|
+
isNonEmptyString(value.htmlUrl) &&
|
|
19
|
+
isNonEmptyString(value.clientId) &&
|
|
20
|
+
isNonEmptyString(value.privateKey);
|
|
21
|
+
|
|
22
|
+
const parseGithubApp = (
|
|
23
|
+
raw: unknown,
|
|
24
|
+
owner: string | null,
|
|
25
|
+
): GithubBrokerApp => {
|
|
26
|
+
if (!isRecord(raw)) {
|
|
27
|
+
throw new Error('invalid github: run `standards creds login github`');
|
|
28
|
+
}
|
|
29
|
+
const app = {
|
|
30
|
+
owner,
|
|
31
|
+
appId: raw.app_id,
|
|
32
|
+
slug: raw.slug,
|
|
33
|
+
htmlUrl: raw.html_url,
|
|
34
|
+
clientId: raw.client_id,
|
|
35
|
+
privateKey: raw.private_key,
|
|
36
|
+
};
|
|
37
|
+
if (!isGithubBrokerApp(app)) {
|
|
38
|
+
throw new Error('invalid github: run `standards creds login github`');
|
|
39
|
+
}
|
|
40
|
+
return app;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const validateGithubApps = (
|
|
44
|
+
apps: ReadonlyArray<GithubBrokerApp>,
|
|
45
|
+
path: string,
|
|
46
|
+
): ReadonlyArray<GithubBrokerApp> => {
|
|
47
|
+
const owners = new Set<string>();
|
|
48
|
+
const appIds = new Set<number>();
|
|
49
|
+
for (const app of apps) {
|
|
50
|
+
if (!isGithubBrokerApp(app)) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`${path}: invalid GitHub App; run \`standards creds login github\``,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (app.owner === null) {
|
|
56
|
+
if (apps.length !== 1) {
|
|
57
|
+
throw new Error(
|
|
58
|
+
`${path}: the ownerless legacy GitHub App must be migrated before another App can be stored`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
const owner = app.owner.toLowerCase();
|
|
63
|
+
if (owners.has(owner)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`${path}: duplicate GitHub owner ${app.owner}; keep one broker App per account`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
owners.add(owner);
|
|
69
|
+
}
|
|
70
|
+
if (appIds.has(app.appId)) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`${path}: duplicate GitHub App id ${app.appId}; keep each App once`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
appIds.add(app.appId);
|
|
76
|
+
}
|
|
77
|
+
return apps;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const parseGithubApps = (
|
|
81
|
+
raw: unknown,
|
|
82
|
+
path: string,
|
|
83
|
+
): ReadonlyArray<GithubBrokerApp> => {
|
|
84
|
+
if (raw === undefined || raw === null) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
if (!Array.isArray(raw)) {
|
|
88
|
+
return [parseGithubApp(raw, null)];
|
|
89
|
+
}
|
|
90
|
+
const apps = raw.map((entry) => {
|
|
91
|
+
if (!(isRecord(entry) && isNonEmptyString(entry.owner))) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
'invalid github owner: run `standards creds login github`',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return parseGithubApp(entry, entry.owner);
|
|
97
|
+
});
|
|
98
|
+
return validateGithubApps(apps, path);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const appDocument = (
|
|
102
|
+
app: GithubBrokerApp,
|
|
103
|
+
): Readonly<Record<string, unknown>> => ({
|
|
104
|
+
...(app.owner === null ? {} : { owner: app.owner }),
|
|
105
|
+
// biome-ignore lint/style/useNamingConvention: The persisted broker YAML contract uses snake_case.
|
|
106
|
+
app_id: app.appId,
|
|
107
|
+
slug: app.slug,
|
|
108
|
+
// biome-ignore lint/style/useNamingConvention: The persisted broker YAML contract uses snake_case.
|
|
109
|
+
html_url: app.htmlUrl,
|
|
110
|
+
// biome-ignore lint/style/useNamingConvention: The persisted broker YAML contract uses snake_case.
|
|
111
|
+
client_id: app.clientId,
|
|
112
|
+
// biome-ignore lint/style/useNamingConvention: The persisted broker YAML contract uses snake_case.
|
|
113
|
+
private_key: app.privateKey,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export const githubStoreDocument = (
|
|
117
|
+
apps: ReadonlyArray<GithubBrokerApp>,
|
|
118
|
+
): unknown =>
|
|
119
|
+
apps.length === 1 && apps[0]?.owner === null
|
|
120
|
+
? appDocument(apps[0])
|
|
121
|
+
: apps.map(appDocument);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, rename, rmdir, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { Worker } from 'node:worker_threads';
|
|
5
|
+
import { startBrokerLockLease } from './creds-store-lock-lease';
|
|
6
|
+
import { isRecord } from './github-settings-parse';
|
|
7
|
+
|
|
8
|
+
const OWNER_ONLY_DIR_MODE = 0o700;
|
|
9
|
+
const OWNER_ONLY_FILE_MODE = 0o600;
|
|
10
|
+
const HOLDER_PREFIX = 'holder-';
|
|
11
|
+
const HOLDER_SUFFIX = '.json';
|
|
12
|
+
const LEASE_DIVISOR = 3;
|
|
13
|
+
// Both codes mean the lock is no longer this owner's to clean up. ENOENT: it is
|
|
14
|
+
// already gone. ENOTEMPTY: a contender published its own generation into the
|
|
15
|
+
// directory during the moment the unlink left it empty — renaming onto an empty
|
|
16
|
+
// directory succeeds, which is exactly how a waiter takes the lock. Releasing
|
|
17
|
+
// runs from a finally, so throwing here would replace the result of an
|
|
18
|
+
// operation that already succeeded.
|
|
19
|
+
const RELEASABLE_RMDIR_CODES = ['ENOENT', 'ENOTEMPTY'] as const;
|
|
20
|
+
|
|
21
|
+
export type HeldBrokerLock = {
|
|
22
|
+
readonly holderPath: string;
|
|
23
|
+
readonly lease: Worker;
|
|
24
|
+
readonly lockPath: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const isErrorCode = (error: unknown, code: string): boolean =>
|
|
28
|
+
isRecord(error) && error.code === code;
|
|
29
|
+
|
|
30
|
+
const cleanupCandidate = async (
|
|
31
|
+
candidatePath: string,
|
|
32
|
+
holderPath: string,
|
|
33
|
+
): Promise<void> => {
|
|
34
|
+
await unlink(holderPath).catch(() => undefined);
|
|
35
|
+
await rmdir(candidatePath).catch(() => undefined);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const tryAcquireBrokerLock = async (
|
|
39
|
+
lockPath: string,
|
|
40
|
+
staleMs: number,
|
|
41
|
+
): Promise<HeldBrokerLock | null> => {
|
|
42
|
+
const generation = randomUUID();
|
|
43
|
+
const candidatePath = `${lockPath}.pending-${generation}`;
|
|
44
|
+
const candidateHolderPath = join(
|
|
45
|
+
candidatePath,
|
|
46
|
+
`${HOLDER_PREFIX}${generation}${HOLDER_SUFFIX}`,
|
|
47
|
+
);
|
|
48
|
+
await mkdir(candidatePath, { mode: OWNER_ONLY_DIR_MODE });
|
|
49
|
+
try {
|
|
50
|
+
await writeFile(candidateHolderPath, JSON.stringify({ generation }), {
|
|
51
|
+
flag: 'wx',
|
|
52
|
+
mode: OWNER_ONLY_FILE_MODE,
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
await rename(candidatePath, lockPath);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (isErrorCode(error, 'EEXIST') || isErrorCode(error, 'ENOTEMPTY')) {
|
|
58
|
+
await cleanupCandidate(candidatePath, candidateHolderPath);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
await cleanupCandidate(candidatePath, candidateHolderPath);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
const holderPath = join(
|
|
68
|
+
lockPath,
|
|
69
|
+
`${HOLDER_PREFIX}${generation}${HOLDER_SUFFIX}`,
|
|
70
|
+
);
|
|
71
|
+
try {
|
|
72
|
+
const lease = startBrokerLockLease(
|
|
73
|
+
holderPath,
|
|
74
|
+
Math.max(1, Math.floor(staleMs / LEASE_DIVISOR)),
|
|
75
|
+
);
|
|
76
|
+
return { holderPath, lease, lockPath };
|
|
77
|
+
} catch (error) {
|
|
78
|
+
await unlink(holderPath).catch(() => undefined);
|
|
79
|
+
await rmdir(lockPath).catch(() => undefined);
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const releaseBrokerLock = async ({
|
|
85
|
+
holderPath,
|
|
86
|
+
lease,
|
|
87
|
+
lockPath,
|
|
88
|
+
}: HeldBrokerLock): Promise<void> => {
|
|
89
|
+
await lease.terminate();
|
|
90
|
+
try {
|
|
91
|
+
await unlink(holderPath);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (isErrorCode(error, 'ENOENT')) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
await rmdir(lockPath);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (!RELEASABLE_RMDIR_CODES.some((code) => isErrorCode(error, code))) {
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readdir, readFile, rmdir, stat, unlink } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isRecord } from './github-settings-parse';
|
|
4
|
+
|
|
5
|
+
const HOLDER_PREFIX = 'holder-';
|
|
6
|
+
const HOLDER_SUFFIX = '.json';
|
|
7
|
+
|
|
8
|
+
export type BrokerLockAvailability = 'blocked' | 'live' | 'retry';
|
|
9
|
+
|
|
10
|
+
const isErrorCode = (error: unknown, code: string): boolean =>
|
|
11
|
+
isRecord(error) && error.code === code;
|
|
12
|
+
|
|
13
|
+
type ExistingGeneration =
|
|
14
|
+
| { readonly kind: 'missing' }
|
|
15
|
+
| { readonly kind: 'unknown' }
|
|
16
|
+
| {
|
|
17
|
+
readonly entryPath: string | null;
|
|
18
|
+
readonly kind: 'incomplete';
|
|
19
|
+
readonly modifiedAt: number;
|
|
20
|
+
}
|
|
21
|
+
| {
|
|
22
|
+
readonly entryPath: string;
|
|
23
|
+
readonly kind: 'valid';
|
|
24
|
+
readonly modifiedAt: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const directoryModifiedAt = async (
|
|
28
|
+
lockPath: string,
|
|
29
|
+
): Promise<number | null> => {
|
|
30
|
+
try {
|
|
31
|
+
return (await stat(lockPath)).mtimeMs;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const readExistingGeneration = async (
|
|
38
|
+
lockPath: string,
|
|
39
|
+
): Promise<ExistingGeneration> => {
|
|
40
|
+
let names: ReadonlyArray<string>;
|
|
41
|
+
try {
|
|
42
|
+
names = await readdir(lockPath);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return isErrorCode(error, 'ENOENT')
|
|
45
|
+
? { kind: 'missing' }
|
|
46
|
+
: { kind: 'unknown' };
|
|
47
|
+
}
|
|
48
|
+
if (names.length === 0) {
|
|
49
|
+
const modifiedAt = await directoryModifiedAt(lockPath);
|
|
50
|
+
return modifiedAt === null
|
|
51
|
+
? { kind: 'missing' }
|
|
52
|
+
: { entryPath: null, kind: 'incomplete', modifiedAt };
|
|
53
|
+
}
|
|
54
|
+
const entryName = names.length === 1 ? names[0] : undefined;
|
|
55
|
+
if (entryName === undefined) {
|
|
56
|
+
return { kind: 'unknown' };
|
|
57
|
+
}
|
|
58
|
+
const entryPath = join(lockPath, entryName);
|
|
59
|
+
let modifiedAt: number;
|
|
60
|
+
try {
|
|
61
|
+
modifiedAt = (await stat(entryPath)).mtimeMs;
|
|
62
|
+
} catch {
|
|
63
|
+
return { kind: 'unknown' };
|
|
64
|
+
}
|
|
65
|
+
const incomplete = {
|
|
66
|
+
entryPath,
|
|
67
|
+
kind: 'incomplete' as const,
|
|
68
|
+
modifiedAt,
|
|
69
|
+
};
|
|
70
|
+
if (
|
|
71
|
+
!(entryName.startsWith(HOLDER_PREFIX) && entryName.endsWith(HOLDER_SUFFIX))
|
|
72
|
+
) {
|
|
73
|
+
return incomplete;
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const decoded: unknown = JSON.parse(await readFile(entryPath, 'utf8'));
|
|
77
|
+
const generation = entryName.slice(
|
|
78
|
+
HOLDER_PREFIX.length,
|
|
79
|
+
-HOLDER_SUFFIX.length,
|
|
80
|
+
);
|
|
81
|
+
return isRecord(decoded) && decoded.generation === generation
|
|
82
|
+
? { entryPath, kind: 'valid', modifiedAt }
|
|
83
|
+
: incomplete;
|
|
84
|
+
} catch {
|
|
85
|
+
return incomplete;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const removeObservedGeneration = async (
|
|
90
|
+
lockPath: string,
|
|
91
|
+
entryPath: string | null,
|
|
92
|
+
): Promise<boolean> => {
|
|
93
|
+
if (entryPath !== null) {
|
|
94
|
+
try {
|
|
95
|
+
await unlink(entryPath);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return isErrorCode(error, 'ENOENT');
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
await rmdir(lockPath);
|
|
102
|
+
return true;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return isErrorCode(error, 'ENOENT');
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
export const inspectBrokerLock = async (
|
|
109
|
+
lockPath: string,
|
|
110
|
+
staleMs: number,
|
|
111
|
+
): Promise<BrokerLockAvailability> => {
|
|
112
|
+
const generation = await readExistingGeneration(lockPath);
|
|
113
|
+
if (generation.kind === 'missing') {
|
|
114
|
+
return 'retry';
|
|
115
|
+
}
|
|
116
|
+
if (generation.kind === 'unknown') {
|
|
117
|
+
return 'blocked';
|
|
118
|
+
}
|
|
119
|
+
if (Date.now() - generation.modifiedAt < staleMs) {
|
|
120
|
+
return generation.kind === 'valid' ? 'live' : 'blocked';
|
|
121
|
+
}
|
|
122
|
+
return (await removeObservedGeneration(lockPath, generation.entryPath))
|
|
123
|
+
? 'retry'
|
|
124
|
+
: 'blocked';
|
|
125
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { utimes } from 'node:fs/promises';
|
|
2
|
+
import {
|
|
3
|
+
isMainThread,
|
|
4
|
+
parentPort,
|
|
5
|
+
Worker,
|
|
6
|
+
workerData,
|
|
7
|
+
} from 'node:worker_threads';
|
|
8
|
+
|
|
9
|
+
type LeaseWorkerData = {
|
|
10
|
+
readonly holderPath: string;
|
|
11
|
+
readonly intervalMs: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
if (!isMainThread) {
|
|
15
|
+
const { holderPath, intervalMs } = workerData as LeaseWorkerData;
|
|
16
|
+
const timer = setInterval(
|
|
17
|
+
() => utimes(holderPath, new Date(), new Date()).catch(() => undefined),
|
|
18
|
+
intervalMs,
|
|
19
|
+
);
|
|
20
|
+
parentPort?.on('close', () => clearInterval(timer));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const startBrokerLockLease = (
|
|
24
|
+
holderPath: string,
|
|
25
|
+
intervalMs: number,
|
|
26
|
+
): Worker => {
|
|
27
|
+
const lease = new Worker(import.meta.url, {
|
|
28
|
+
workerData: { holderPath, intervalMs } satisfies LeaseWorkerData,
|
|
29
|
+
});
|
|
30
|
+
lease.unref();
|
|
31
|
+
return lease;
|
|
32
|
+
};
|
package/src/creds-store-lock.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
// Broker store lock: a
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Broker store lock: a fully initialized generation is atomically renamed into
|
|
2
|
+
// place before its operation starts. A worker-thread lease remains live while
|
|
3
|
+
// synchronous SOPS/Nix blocks the main event loop. Cleanup unlinks the exact
|
|
4
|
+
// generation token before removing the directory, so an old owner cannot
|
|
5
|
+
// remove a replacement — and finding one there instead of an empty directory
|
|
6
|
+
// is a no-op, never a failure of the operation being unwound.
|
|
6
7
|
|
|
7
|
-
import { mkdir
|
|
8
|
+
import { mkdir } from 'node:fs/promises';
|
|
8
9
|
import { dirname } from 'node:path';
|
|
9
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type HeldBrokerLock,
|
|
12
|
+
releaseBrokerLock,
|
|
13
|
+
tryAcquireBrokerLock,
|
|
14
|
+
} from './creds-store-lock-holder';
|
|
15
|
+
import { inspectBrokerLock } from './creds-store-lock-inspection';
|
|
10
16
|
|
|
11
17
|
const OWNER_ONLY_DIR_MODE = 0o700;
|
|
12
18
|
|
|
@@ -23,59 +29,30 @@ const DEFAULT_STALE_MS = 30_000;
|
|
|
23
29
|
const sleep = (ms: number): Promise<void> =>
|
|
24
30
|
new Promise((resolve) => setTimeout(resolve, ms));
|
|
25
31
|
|
|
26
|
-
const tryAcquire = async (lockPath: string): Promise<boolean> => {
|
|
27
|
-
try {
|
|
28
|
-
await mkdir(lockPath, { mode: OWNER_ONLY_DIR_MODE });
|
|
29
|
-
return true;
|
|
30
|
-
} catch (error) {
|
|
31
|
-
if (isRecord(error) && error.code === 'EEXIST') {
|
|
32
|
-
return false;
|
|
33
|
-
}
|
|
34
|
-
throw error;
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
// True when the contender may immediately retry: the lock vanished, or it
|
|
39
|
-
// was stale and this process removed it (losing the removal race to another
|
|
40
|
-
// contender also lands here — the retry's mkdir arbitrates).
|
|
41
|
-
const breakIfStale = async (
|
|
42
|
-
lockPath: string,
|
|
43
|
-
staleMs: number,
|
|
44
|
-
): Promise<boolean> => {
|
|
45
|
-
try {
|
|
46
|
-
const info = await stat(lockPath);
|
|
47
|
-
if (Date.now() - info.mtimeMs < staleMs) {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
await rmdir(lockPath);
|
|
51
|
-
return true;
|
|
52
|
-
} catch {
|
|
53
|
-
return true;
|
|
54
|
-
}
|
|
55
|
-
};
|
|
56
|
-
|
|
57
32
|
// One acquisition attempt: acquired, or cleared a stale/vanished lock (retry
|
|
58
|
-
// immediately), or waited one retry slot
|
|
59
|
-
//
|
|
33
|
+
// immediately), or waited one retry slot. Proven-live generations do not
|
|
34
|
+
// consume the recovery deadline; incomplete or ambiguous generations do.
|
|
60
35
|
const attemptAcquire = async (
|
|
61
36
|
lockPath: string,
|
|
62
37
|
staleMs: number,
|
|
63
38
|
retryMs: number,
|
|
64
39
|
deadline: number,
|
|
65
|
-
): Promise<
|
|
66
|
-
|
|
67
|
-
|
|
40
|
+
): Promise<HeldBrokerLock | null> => {
|
|
41
|
+
const held = await tryAcquireBrokerLock(lockPath, staleMs);
|
|
42
|
+
if (held !== null) {
|
|
43
|
+
return held;
|
|
68
44
|
}
|
|
69
|
-
|
|
70
|
-
|
|
45
|
+
const availability = await inspectBrokerLock(lockPath, staleMs);
|
|
46
|
+
if (availability === 'retry') {
|
|
47
|
+
return null;
|
|
71
48
|
}
|
|
72
|
-
if (Date.now() >= deadline) {
|
|
49
|
+
if (availability === 'blocked' && Date.now() >= deadline) {
|
|
73
50
|
throw new Error(
|
|
74
51
|
`lock timeout: another creds process holds ${lockPath}; if none is running, remove that directory and retry`,
|
|
75
52
|
);
|
|
76
53
|
}
|
|
77
54
|
await sleep(retryMs);
|
|
78
|
-
return
|
|
55
|
+
return null;
|
|
79
56
|
};
|
|
80
57
|
|
|
81
58
|
export const withBrokerLock = async <T>(
|
|
@@ -89,14 +66,14 @@ export const withBrokerLock = async <T>(
|
|
|
89
66
|
const lockPath = `${path}.lock`;
|
|
90
67
|
await mkdir(dirname(path), { recursive: true, mode: OWNER_ONLY_DIR_MODE });
|
|
91
68
|
const deadline = Date.now() + timeoutMs;
|
|
92
|
-
let
|
|
93
|
-
while (
|
|
69
|
+
let held: HeldBrokerLock | null = null;
|
|
70
|
+
while (held === null) {
|
|
94
71
|
// biome-ignore lint/performance/noAwaitInLoops: lock acquisition retries are inherently sequential.
|
|
95
|
-
|
|
72
|
+
held = await attemptAcquire(lockPath, staleMs, retryMs, deadline);
|
|
96
73
|
}
|
|
97
74
|
try {
|
|
98
75
|
return await operation();
|
|
99
76
|
} finally {
|
|
100
|
-
await
|
|
77
|
+
await releaseBrokerLock(held);
|
|
101
78
|
}
|
|
102
79
|
};
|