@pepys/ingest 0.0.1 → 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/README.md +2 -0
- package/package.json +4 -3
- package/src/index.ts +3 -8
- package/src/pull/make-pull-runner.ts +104 -0
- package/src/receive/create-ingest.ts +14 -1
- package/src/receive/make-receive-handler.ts +5 -1
- package/src/receive/resync-marks/collect-tied-entities.ts +3 -1
- package/src/receive/resync-marks/create-resync-marks.ts +23 -18
- package/src/sender-proof/imp-identity/create-imp-identity-sender-proof.ts +16 -32
- package/src/sender-proof/imp-identity/read-imp-identity-sender-proof.ts +35 -0
- package/src/sender-proof/imp-identity/read-imp-identity-trust.ts +11 -10
- package/src/sender-proof/imp-identity/types.ts +3 -32
- package/src/serve/serve-ingest.ts +31 -9
- package/src/serve/start-pull-timers.ts +69 -0
package/README.md
CHANGED
|
@@ -25,6 +25,8 @@ bun add @pepys/ingest
|
|
|
25
25
|
## Features
|
|
26
26
|
|
|
27
27
|
- `serveIngest` and `createIngest`: the receive routes of a deployment.
|
|
28
|
+
- The sender proof port, with the adapter that verifies a worker machine's identity token through
|
|
29
|
+
`@impz/client/identity`.
|
|
28
30
|
- The fact log port, with its S2 and memory adapters.
|
|
29
31
|
- The blob store port, with its Garage and local folder adapters.
|
|
30
32
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pepys/ingest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "pepys ingest: the receive routes, the fact log and the blob store",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@
|
|
25
|
-
"@pepys/
|
|
24
|
+
"@impz/client": "6.12.0",
|
|
25
|
+
"@pepys/kit": "0.1.1",
|
|
26
|
+
"@pepys/schema": "0.1.1",
|
|
26
27
|
"@s2-dev/streamstore": "0.30.0",
|
|
27
28
|
"@zgeoff/std": "0.4.3",
|
|
28
29
|
"tiny-invariant": "1.3.3",
|
package/src/index.ts
CHANGED
|
@@ -11,18 +11,13 @@ export { planAppends } from './fact-log/plan-appends.ts';
|
|
|
11
11
|
export { createS2FactLog } from './fact-log/s2/create-s2-fact-log.ts';
|
|
12
12
|
export { createIngest } from './receive/create-ingest.ts';
|
|
13
13
|
export type { Ingest, IngestOptions } from './receive/create-ingest.ts';
|
|
14
|
+
export type { PullPathName, PullRun, PullRunner } from './pull/make-pull-runner.ts';
|
|
14
15
|
export type { ResyncMarks } from './receive/resync-marks/create-resync-marks.ts';
|
|
15
16
|
export { createFakeSenderProof } from './sender-proof/fake/create-fake-sender-proof.ts';
|
|
16
17
|
export { createImpIdentitySenderProof } from './sender-proof/imp-identity/create-imp-identity-sender-proof.ts';
|
|
18
|
+
export { readImpIdentitySenderProof } from './sender-proof/imp-identity/read-imp-identity-sender-proof.ts';
|
|
17
19
|
export { readImpIdentityTrust } from './sender-proof/imp-identity/read-imp-identity-trust.ts';
|
|
18
|
-
|
|
19
|
-
export type {
|
|
20
|
-
ImpIdentityTrust,
|
|
21
|
-
ImpIdentityVerifier,
|
|
22
|
-
VerifiedImp,
|
|
23
|
-
VerifyOptions,
|
|
24
|
-
} from './sender-proof/imp-identity/types.ts';
|
|
25
|
-
|
|
20
|
+
export type { ImpIdentityTrust } from './sender-proof/imp-identity/types.ts';
|
|
26
21
|
export { noSenderProof } from './sender-proof/none/no-sender-proof.ts';
|
|
27
22
|
export { serveIngest } from './serve/serve-ingest.ts';
|
|
28
23
|
export type * from './source-port/types.ts';
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { CheckedDeployment, PullPath, Pulled, SourceDefinition } from '@pepys/kit';
|
|
2
|
+
import type { BatchAppender } from '../fact-log/make-batch-appender.ts';
|
|
3
|
+
import { findBatchProblem } from '../receive/find-batch-problem.ts';
|
|
4
|
+
import { planBatch } from '../receive/plan-batch.ts';
|
|
5
|
+
import type { ResyncMarks } from '../receive/resync-marks/create-resync-marks.ts';
|
|
6
|
+
import { writeBatch } from '../receive/write-batch.ts';
|
|
7
|
+
import type { StreamTurns } from '../receive/write-batch.ts';
|
|
8
|
+
|
|
9
|
+
export type PullPathName = 'live' | 'recovery' | 'backfill' | 'resync';
|
|
10
|
+
|
|
11
|
+
// `stored`: the batch is in the log, and the next run starts from `cursor`. `not-stored`: an append
|
|
12
|
+
// failed, so the next run starts from the same cursor. Any other answer is the pull's own.
|
|
13
|
+
export type PullRun =
|
|
14
|
+
| { kind: 'stored'; stored: number; cursor: string; isDone: boolean }
|
|
15
|
+
| { kind: 'not-stored' }
|
|
16
|
+
| { kind: 'refused'; reason: string }
|
|
17
|
+
| Exclude<Pulled, { kind: 'batch' }>;
|
|
18
|
+
|
|
19
|
+
export type PullRunner = (
|
|
20
|
+
source: string,
|
|
21
|
+
path: PullPathName,
|
|
22
|
+
cursor: string | null,
|
|
23
|
+
) => Promise<PullRun>;
|
|
24
|
+
|
|
25
|
+
interface PullRunnerDependencies {
|
|
26
|
+
deployment: CheckedDeployment;
|
|
27
|
+
|
|
28
|
+
// one value per name that a pull path's `env` lists
|
|
29
|
+
secrets: Readonly<Record<string, string>>;
|
|
30
|
+
append: BatchAppender;
|
|
31
|
+
turns: StreamTurns;
|
|
32
|
+
resyncMarks: ResyncMarks;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// One run of one pull path: the pull, the envelope check, the appends, and then, for a resync pull,
|
|
36
|
+
// the removal of every mark it was given. A pull's fact is noted in no mark: a resynced fact ties
|
|
37
|
+
// with the body it replaces, and would mark its entity again on every run.
|
|
38
|
+
export function makePullRunner(dependencies: PullRunnerDependencies): PullRunner {
|
|
39
|
+
return async (sourceName, pathName, cursor) => {
|
|
40
|
+
const source = dependencies.deployment.sources.find(
|
|
41
|
+
(candidate) => candidate.name === sourceName,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const path = source === undefined ? undefined : findPullPath(source, pathName);
|
|
45
|
+
|
|
46
|
+
if (source === undefined || path === undefined) {
|
|
47
|
+
return { kind: 'refused', reason: `the source ${sourceName} has no ${pathName} pull` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const marks = pathName === 'resync' ? dependencies.resyncMarks.list(source.name) : [];
|
|
51
|
+
const env: Record<string, string> = {};
|
|
52
|
+
|
|
53
|
+
for (const name of path.env ?? []) {
|
|
54
|
+
env[name] = dependencies.secrets[name] ?? '';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const pulled = await path.pull(cursor, { env, marks });
|
|
58
|
+
|
|
59
|
+
if (pulled.kind !== 'batch') {
|
|
60
|
+
return pulled;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const problem = findBatchProblem(source, pulled.batch);
|
|
64
|
+
|
|
65
|
+
if (problem !== undefined) {
|
|
66
|
+
return { kind: 'refused', reason: problem };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (pulled.batch.facts.some((pending) => pending.blobs.length > 0)) {
|
|
70
|
+
return { kind: 'refused', reason: 'ingest has no blob store' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const plan = planBatch(pulled.batch);
|
|
74
|
+
|
|
75
|
+
if (!plan.ok) {
|
|
76
|
+
return { kind: 'refused', reason: 'one fact is larger than one append of the log takes' };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const outcome = await writeBatch(plan.value, dependencies);
|
|
80
|
+
|
|
81
|
+
if (outcome !== 'stored') {
|
|
82
|
+
return outcome === 'not-stored'
|
|
83
|
+
? { kind: 'not-stored' }
|
|
84
|
+
: { kind: 'refused', reason: 'the log refused the batch' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
for (const mark of marks) {
|
|
88
|
+
dependencies.resyncMarks.remove(source.name, mark.entity);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
kind: 'stored',
|
|
93
|
+
stored: pulled.batch.facts.length,
|
|
94
|
+
cursor: pulled.cursor,
|
|
95
|
+
isDone: pulled.isDone,
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function findPullPath(source: SourceDefinition, pathName: PullPathName): PullPath | undefined {
|
|
101
|
+
const path = source[pathName];
|
|
102
|
+
|
|
103
|
+
return path?.kind === 'pull' ? path : undefined;
|
|
104
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { CheckedDeployment, SenderProof } from '@pepys/kit';
|
|
2
2
|
import type { FactLog } from '../fact-log/fact-log.ts';
|
|
3
3
|
import { makeBatchAppender } from '../fact-log/make-batch-appender.ts';
|
|
4
|
+
import { makePullRunner } from '../pull/make-pull-runner.ts';
|
|
5
|
+
import type { PullRunner } from '../pull/make-pull-runner.ts';
|
|
4
6
|
import { buildFailureAnswer } from './answers/build-failure-answer.ts';
|
|
5
7
|
import { makeIgnoredRouteHandler, makeReceiveHandler } from './make-receive-handler.ts';
|
|
6
8
|
import { createRecentBodies } from './recent-bodies/create-recent-bodies.ts';
|
|
@@ -11,7 +13,8 @@ import type { StreamTurns } from './write-batch.ts';
|
|
|
11
13
|
export interface IngestOptions {
|
|
12
14
|
deployment: CheckedDeployment;
|
|
13
15
|
|
|
14
|
-
//
|
|
16
|
+
// One value per `check.secretEnv` and per name in a pull path's `env`. A missing or empty secret
|
|
17
|
+
// refuses every delivery.
|
|
15
18
|
secrets: Readonly<Record<string, string>>;
|
|
16
19
|
log: FactLog;
|
|
17
20
|
senderProof: SenderProof;
|
|
@@ -25,6 +28,9 @@ export interface Ingest {
|
|
|
25
28
|
|
|
26
29
|
// what a source's resync pull reads
|
|
27
30
|
resyncMarks: ResyncMarks;
|
|
31
|
+
|
|
32
|
+
// one run of a pull path of the deployment, with its batch stored
|
|
33
|
+
runPull: PullRunner;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
const recentBodyCapacity = 10_000;
|
|
@@ -86,5 +92,12 @@ export function createIngest(options: IngestOptions): Ingest {
|
|
|
86
92
|
return answer;
|
|
87
93
|
},
|
|
88
94
|
resyncMarks: resync.marks,
|
|
95
|
+
runPull: makePullRunner({
|
|
96
|
+
deployment: options.deployment,
|
|
97
|
+
secrets: options.secrets,
|
|
98
|
+
append,
|
|
99
|
+
turns,
|
|
100
|
+
resyncMarks: resync.marks,
|
|
101
|
+
}),
|
|
89
102
|
};
|
|
90
103
|
}
|
|
@@ -137,7 +137,11 @@ export function makeReceiveHandler(
|
|
|
137
137
|
|
|
138
138
|
dependencies.marks?.noteStored(
|
|
139
139
|
digest,
|
|
140
|
-
facts.map((pending) => ({
|
|
140
|
+
facts.map((pending) => ({
|
|
141
|
+
entity: pending.fact.entity,
|
|
142
|
+
at: pending.fact.at,
|
|
143
|
+
locator: pending.locator,
|
|
144
|
+
})),
|
|
141
145
|
);
|
|
142
146
|
|
|
143
147
|
return buildSuccessAnswer(path, 'stored', facts.length);
|
|
@@ -3,11 +3,13 @@ import type { EntityID } from '@pepys/kit';
|
|
|
3
3
|
export interface LastBody {
|
|
4
4
|
digest: string;
|
|
5
5
|
at: string;
|
|
6
|
+
locator: string | undefined;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
|
-
interface StoredFactTime {
|
|
9
|
+
export interface StoredFactTime {
|
|
9
10
|
entity: EntityID;
|
|
10
11
|
at: string;
|
|
12
|
+
locator: string | undefined;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
// A new body ties with the last body stored for an entity when both have the same time and differ.
|
|
@@ -1,25 +1,27 @@
|
|
|
1
|
-
import type { EntityID } from '@pepys/kit';
|
|
1
|
+
import type { EntityID, ResyncMark } from '@pepys/kit';
|
|
2
2
|
import { collectReturnedEntities } from './collect-returned-entities.ts';
|
|
3
3
|
import { collectTiedEntities } from './collect-tied-entities.ts';
|
|
4
|
-
import type { LastBody } from './collect-tied-entities.ts';
|
|
4
|
+
import type { LastBody, StoredFactTime } from './collect-tied-entities.ts';
|
|
5
5
|
|
|
6
6
|
// The entities whose row may be behind the source, which a source's resync pull reads again. The
|
|
7
7
|
// pull reads the marks, and removes each one only after its fact is stored, so a failed pull keeps
|
|
8
8
|
// it. In memory only, so a restart loses them.
|
|
9
9
|
export interface ResyncMarks {
|
|
10
|
-
list: (source: string) => readonly
|
|
10
|
+
list: (source: string) => readonly ResyncMark[];
|
|
11
11
|
remove: (source: string, entity: EntityID) => void;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
// What the receive route of one source tells the marks.
|
|
15
15
|
export interface SourceMarks {
|
|
16
|
-
noteStored: (digest: string, facts: readonly
|
|
16
|
+
noteStored: (digest: string, facts: readonly StoredFactTime[]) => void;
|
|
17
17
|
noteCopy: (digest: string, entities: readonly EntityID[]) => void;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
interface SourceState {
|
|
21
21
|
lastBodies: Map<EntityID, LastBody>;
|
|
22
|
-
|
|
22
|
+
|
|
23
|
+
// each marked entity, with the locator of the last fact stored for it
|
|
24
|
+
marks: Map<EntityID, string | undefined>;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export interface ResyncMarkStore {
|
|
@@ -42,7 +44,7 @@ export function createResyncMarks(options: ResyncMarksOptions): ResyncMarkStore
|
|
|
42
44
|
return known;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
|
-
const state: SourceState = { lastBodies: new Map(), marks: new
|
|
47
|
+
const state: SourceState = { lastBodies: new Map(), marks: new Map() };
|
|
46
48
|
|
|
47
49
|
sources.set(source, state);
|
|
48
50
|
|
|
@@ -51,7 +53,8 @@ export function createResyncMarks(options: ResyncMarksOptions): ResyncMarkStore
|
|
|
51
53
|
|
|
52
54
|
return {
|
|
53
55
|
marks: {
|
|
54
|
-
list: (source) =>
|
|
56
|
+
list: (source) =>
|
|
57
|
+
[...(sources.get(source)?.marks ?? [])].map(([entity, locator]) => ({ entity, locator })),
|
|
55
58
|
remove: (source, entity) => {
|
|
56
59
|
sources.get(source)?.marks.delete(entity);
|
|
57
60
|
},
|
|
@@ -61,41 +64,43 @@ export function createResyncMarks(options: ResyncMarksOptions): ResyncMarkStore
|
|
|
61
64
|
|
|
62
65
|
return {
|
|
63
66
|
noteStored: (digest, facts) => {
|
|
64
|
-
|
|
67
|
+
const tied = collectTiedEntities(state.lastBodies, digest, facts);
|
|
65
68
|
|
|
66
69
|
for (const fact of facts) {
|
|
67
70
|
state.lastBodies.delete(fact.entity);
|
|
68
|
-
state.lastBodies.set(fact.entity, { digest, at: fact.at });
|
|
71
|
+
state.lastBodies.set(fact.entity, { digest, at: fact.at, locator: fact.locator });
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
addMarks(state, tied, options);
|
|
71
75
|
trimToCapacity(state.lastBodies, options.capacity);
|
|
72
76
|
},
|
|
73
77
|
noteCopy: (digest, entities) => {
|
|
74
|
-
addMarks(
|
|
75
|
-
state.marks,
|
|
76
|
-
collectReturnedEntities(state.lastBodies, digest, entities),
|
|
77
|
-
options,
|
|
78
|
-
);
|
|
78
|
+
addMarks(state, collectReturnedEntities(state.lastBodies, digest, entities), options);
|
|
79
79
|
},
|
|
80
80
|
};
|
|
81
81
|
},
|
|
82
82
|
};
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// The locator comes from the last body stored for the entity, which the caller has noted first.
|
|
85
86
|
function addMarks(
|
|
86
|
-
|
|
87
|
+
state: SourceState,
|
|
87
88
|
entities: readonly EntityID[],
|
|
88
89
|
options: ResyncMarksOptions,
|
|
89
90
|
): void {
|
|
90
91
|
for (const entity of entities) {
|
|
91
|
-
marks.
|
|
92
|
+
state.marks.delete(entity);
|
|
93
|
+
state.marks.set(entity, state.lastBodies.get(entity)?.locator);
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
trimToCapacity(marks, options.capacity);
|
|
96
|
+
trimToCapacity(state.marks, options.capacity);
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
// drops the oldest entries first: a Map and a Set keep insertion order
|
|
98
|
-
function trimToCapacity(
|
|
100
|
+
function trimToCapacity(
|
|
101
|
+
entries: Map<EntityID, LastBody> | Map<EntityID, string | undefined>,
|
|
102
|
+
capacity: number,
|
|
103
|
+
): void {
|
|
99
104
|
for (const key of entries.keys()) {
|
|
100
105
|
if (entries.size <= capacity) {
|
|
101
106
|
return;
|
|
@@ -1,45 +1,37 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { verifyImpIdentity } from '@impz/client/identity';
|
|
2
|
+
import type { SenderProof } from '@pepys/kit';
|
|
3
|
+
import type { ImpIdentityTrust } from './types.ts';
|
|
3
4
|
|
|
4
|
-
interface ImpIdentityOptions
|
|
5
|
-
verify:
|
|
6
|
-
trust: ImpIdentityTrust
|
|
5
|
+
interface ImpIdentityOptions {
|
|
6
|
+
verify: typeof verifyImpIdentity;
|
|
7
|
+
trust: ImpIdentityTrust;
|
|
7
8
|
now: () => number;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// Proves the sender from the signed identity token of a worker's machine. The issuer and the
|
|
15
|
-
// subject come only from the verified token, and every failure, a throw included, proves nothing.
|
|
16
|
-
// It never logs the token.
|
|
17
|
-
export function createImpIdentitySenderProof<Document>(
|
|
18
|
-
options: ImpIdentityOptions<Document>,
|
|
19
|
-
): SenderProof {
|
|
11
|
+
// Proves the sender from the signed identity token in the `imp-identity` header. The issuer and
|
|
12
|
+
// the subject come only from the verified token, and every failure, a throw included, proves
|
|
13
|
+
// nothing. It never logs the token.
|
|
14
|
+
export function createImpIdentitySenderProof(options: ImpIdentityOptions): SenderProof {
|
|
20
15
|
return {
|
|
21
16
|
prove: async (requirement, headers) => {
|
|
22
17
|
const documents = options.trust.issuers.get(requirement.issuersEnv) ?? [];
|
|
23
18
|
const audience = options.trust.audiences.get(requirement.audienceEnv);
|
|
24
|
-
const header = headers.get('imp-identity');
|
|
25
19
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
audience === undefined ||
|
|
29
|
-
header === null ||
|
|
30
|
-
!compactToken.test(header)
|
|
31
|
-
) {
|
|
20
|
+
// The verifier throws on an empty list of documents, so a path with none refuses here.
|
|
21
|
+
if (documents.length === 0 || audience === undefined) {
|
|
32
22
|
return null;
|
|
33
23
|
}
|
|
34
24
|
|
|
35
25
|
try {
|
|
36
|
-
const verified = await options.verify(
|
|
26
|
+
const verified = await options.verify(headers.get('imp-identity'), {
|
|
37
27
|
issuers: documents,
|
|
38
28
|
audience,
|
|
39
29
|
nowSeconds: Math.floor(options.now() / 1000),
|
|
40
30
|
});
|
|
41
31
|
|
|
42
|
-
return verified === null
|
|
32
|
+
return verified === null
|
|
33
|
+
? null
|
|
34
|
+
: { issuer: verified.issuer, subject: verified.id, name: verified.name };
|
|
43
35
|
} catch {
|
|
44
36
|
console.error('the identity verifier failed, so the delivery proves no sender');
|
|
45
37
|
|
|
@@ -48,11 +40,3 @@ export function createImpIdentitySenderProof<Document>(
|
|
|
48
40
|
},
|
|
49
41
|
};
|
|
50
42
|
}
|
|
51
|
-
|
|
52
|
-
function toSender(verified: VerifiedImp): Sender | null {
|
|
53
|
-
if (verified.issuer === '' || verified.id === '') {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return { issuer: verified.issuer, subject: verified.id, name: verified.name };
|
|
58
|
-
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { verifyImpIdentity } from '@impz/client/identity';
|
|
2
|
+
import type { Result, SenderProof, SourceDefinition } from '@pepys/kit';
|
|
3
|
+
import { createImpIdentitySenderProof } from './create-imp-identity-sender-proof.ts';
|
|
4
|
+
import { readImpIdentityTrust } from './read-imp-identity-trust.ts';
|
|
5
|
+
|
|
6
|
+
interface SenderProofReadOptions {
|
|
7
|
+
sources: readonly SourceDefinition[];
|
|
8
|
+
environment: Readonly<Record<string, string | undefined>>;
|
|
9
|
+
now: () => number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The sender proof that `serveIngest` runs: the trust of the deployment's environment and the
|
|
13
|
+
// verifier of `@impz/client/identity`. It fails only when a set folder cannot be read or holds an
|
|
14
|
+
// invalid document.
|
|
15
|
+
export async function readImpIdentitySenderProof(
|
|
16
|
+
options: SenderProofReadOptions,
|
|
17
|
+
): Promise<Result<SenderProof, string>> {
|
|
18
|
+
const trust = await readImpIdentityTrust({
|
|
19
|
+
sources: options.sources,
|
|
20
|
+
environment: options.environment,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!trust.ok) {
|
|
24
|
+
return trust;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
ok: true,
|
|
29
|
+
value: createImpIdentitySenderProof({
|
|
30
|
+
verify: verifyImpIdentity,
|
|
31
|
+
trust: trust.value,
|
|
32
|
+
now: options.now,
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
import { readdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { loadIdentityIssuers } from '@impz/client/identity';
|
|
4
|
+
import type { IdentityTrustDocument } from '@impz/client/identity';
|
|
3
5
|
import type { Result, SenderRequirement, SourceDefinition } from '@pepys/kit';
|
|
4
|
-
import type { ImpIdentityTrust
|
|
6
|
+
import type { ImpIdentityTrust } from './types.ts';
|
|
5
7
|
|
|
6
|
-
interface TrustReadOptions
|
|
8
|
+
interface TrustReadOptions {
|
|
7
9
|
sources: readonly SourceDefinition[];
|
|
8
10
|
|
|
9
11
|
// the environment of the ingest process; never a request
|
|
10
12
|
environment: Readonly<Record<string, string | undefined>>;
|
|
11
|
-
loadIssuers: ImpIdentityVerifier<Document>['loadIssuers'];
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
// An unset or empty variable, or an empty folder, leaves its paths with no entry, so they refuse
|
|
15
|
-
// every delivery. A folder that cannot be read, or a document the
|
|
16
|
+
// every delivery. A folder that cannot be read, or a document that the loader refuses, fails the
|
|
16
17
|
// start.
|
|
17
|
-
export async function readImpIdentityTrust
|
|
18
|
-
options: TrustReadOptions
|
|
19
|
-
): Promise<Result<ImpIdentityTrust
|
|
18
|
+
export async function readImpIdentityTrust(
|
|
19
|
+
options: TrustReadOptions,
|
|
20
|
+
): Promise<Result<ImpIdentityTrust, string>> {
|
|
20
21
|
const requirements = collectSenderRequirements(options.sources);
|
|
21
22
|
|
|
22
|
-
const issuers = new Map<string, readonly
|
|
23
|
+
const issuers = new Map<string, readonly IdentityTrustDocument[]>();
|
|
23
24
|
const audiences = new Map<string, string>();
|
|
24
25
|
|
|
25
26
|
for (const name of new Set(requirements.map((requirement) => requirement.audienceEnv))) {
|
|
@@ -48,9 +49,9 @@ export async function readImpIdentityTrust<Document>(
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
try {
|
|
51
|
-
issuers.set(name,
|
|
52
|
+
issuers.set(name, loadIdentityIssuers(documents.value));
|
|
52
53
|
} catch (error) {
|
|
53
|
-
const reason = error instanceof Error ? error.message : 'the
|
|
54
|
+
const reason = error instanceof Error ? error.message : 'the loader gave no reason';
|
|
54
55
|
|
|
55
56
|
return { ok: false, failure: `${name}: the trust documents are invalid: ${reason}` };
|
|
56
57
|
}
|
|
@@ -1,38 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
// `@impz/client/identity` entry of impz.
|
|
3
|
-
export interface VerifiedImp {
|
|
4
|
-
// the token's `iss`: the impd that signed it
|
|
5
|
-
readonly issuer: string;
|
|
6
|
-
|
|
7
|
-
// the token's `sub`: the imp's ID, unique on one impd only
|
|
8
|
-
readonly id: string;
|
|
9
|
-
|
|
10
|
-
// the imp's name, for logs; a name is free again after a destroy, so it is no key
|
|
11
|
-
readonly name: string;
|
|
12
|
-
}
|
|
1
|
+
import type { IdentityTrustDocument } from '@impz/client/identity';
|
|
13
2
|
|
|
14
3
|
// What the deployment trusts, read from its environment at start: the trust documents of each
|
|
15
4
|
// `issuersEnv` and the audience of each `audienceEnv` that its paths name. A name with no entry
|
|
16
5
|
// refuses every delivery on its paths.
|
|
17
|
-
export interface ImpIdentityTrust
|
|
18
|
-
readonly issuers: ReadonlyMap<string, readonly
|
|
6
|
+
export interface ImpIdentityTrust {
|
|
7
|
+
readonly issuers: ReadonlyMap<string, readonly IdentityTrustDocument[]>;
|
|
19
8
|
readonly audiences: ReadonlyMap<string, string>;
|
|
20
9
|
}
|
|
21
|
-
|
|
22
|
-
export interface VerifyOptions<Document> {
|
|
23
|
-
readonly issuers: readonly Document[];
|
|
24
|
-
readonly audience: string;
|
|
25
|
-
readonly nowSeconds: number;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
// The verifier of `@impz/client/identity`, which owns the token format and the trust document
|
|
29
|
-
// format. `Document` stays opaque to ingest.
|
|
30
|
-
export interface ImpIdentityVerifier<Document> {
|
|
31
|
-
// Answers null for a token to refuse: a bad signature, an issuer with no trust document, another
|
|
32
|
-
// audience, or a token out of its time. Throws on an empty or doubled list of documents.
|
|
33
|
-
verify: (header: string | null, options: VerifyOptions<Document>) => Promise<VerifiedImp | null>;
|
|
34
|
-
|
|
35
|
-
// Parses the documents a deployment pinned. Throws on an invalid document, on none, and on two
|
|
36
|
-
// with one issuer.
|
|
37
|
-
loadIssuers: (documents: readonly unknown[]) => Document[];
|
|
38
|
-
}
|
|
@@ -5,15 +5,17 @@ import { toTagged } from '@zgeoff/std';
|
|
|
5
5
|
import { readIngestConfig } from '../config/read-ingest-config.ts';
|
|
6
6
|
import { createS2FactLog } from '../fact-log/s2/create-s2-fact-log.ts';
|
|
7
7
|
import { createIngest } from '../receive/create-ingest.ts';
|
|
8
|
-
import {
|
|
8
|
+
import { readImpIdentitySenderProof } from '../sender-proof/imp-identity/read-imp-identity-sender-proof.ts';
|
|
9
|
+
import { startPullTimers } from './start-pull-timers.ts';
|
|
9
10
|
|
|
10
11
|
interface ServeIngestOptions {
|
|
11
12
|
deployment: Deployment;
|
|
12
13
|
environment: Readonly<Record<string, string | undefined>>;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
|
-
// The ingest process of a deployment. It exits 1 at start on a failed deployment check
|
|
16
|
-
// missing variable, so no route ever runs with an empty secret.
|
|
16
|
+
// The ingest process of a deployment. It exits 1 at start on a failed deployment check, on any
|
|
17
|
+
// missing variable, or on a trust folder it cannot load, so no route ever runs with an empty secret.
|
|
18
|
+
// A path whose trust documents or audience are unset starts, and refuses every delivery.
|
|
17
19
|
export async function serveIngest(options: ServeIngestOptions): Promise<void> {
|
|
18
20
|
const checked = checkDeployment(options.deployment);
|
|
19
21
|
|
|
@@ -30,15 +32,25 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
|
|
|
30
32
|
process.exit(1);
|
|
31
33
|
}
|
|
32
34
|
|
|
35
|
+
const senderProof = await readImpIdentitySenderProof({
|
|
36
|
+
sources,
|
|
37
|
+
environment: options.environment,
|
|
38
|
+
now: () => Date.now(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!senderProof.ok) {
|
|
42
|
+
console.error(senderProof.failure);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
33
46
|
// The S2 adapter creates the basin and each missing stream, so no setup command creates streams.
|
|
34
47
|
const log = await createS2FactLog({ ...config.value.s2, streams: collectStreams(sources) });
|
|
35
48
|
|
|
36
|
-
// No adapter verifies a sender yet, so a path that needs one refuses every delivery.
|
|
37
49
|
const ingest = createIngest({
|
|
38
50
|
deployment: checked.value,
|
|
39
51
|
secrets: config.value.secrets,
|
|
40
52
|
log,
|
|
41
|
-
senderProof:
|
|
53
|
+
senderProof: senderProof.value,
|
|
42
54
|
appendRetry: { maxAttempts: 5, retryDelayMs: 200 },
|
|
43
55
|
now: () => Date.now(),
|
|
44
56
|
});
|
|
@@ -47,6 +59,8 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
|
|
|
47
59
|
console.log(`route ${route.route}`, JSON.stringify(route));
|
|
48
60
|
}
|
|
49
61
|
|
|
62
|
+
const stopPulls = startPullTimers(sources, ingest.runPull);
|
|
63
|
+
|
|
50
64
|
const server = Bun.serve({
|
|
51
65
|
hostname: config.value.host,
|
|
52
66
|
port: config.value.port,
|
|
@@ -56,7 +70,11 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
|
|
|
56
70
|
console.log(`ingest listening on ${server.hostname}:${server.port}`);
|
|
57
71
|
|
|
58
72
|
const shutdown = makeShutdownHandler({
|
|
59
|
-
stop: () =>
|
|
73
|
+
stop: () => {
|
|
74
|
+
stopPulls();
|
|
75
|
+
|
|
76
|
+
return server.stop();
|
|
77
|
+
},
|
|
60
78
|
logError: (message, error) => {
|
|
61
79
|
console.error(message, error);
|
|
62
80
|
},
|
|
@@ -70,10 +88,14 @@ export async function serveIngest(options: ServeIngestOptions): Promise<void> {
|
|
|
70
88
|
}
|
|
71
89
|
}
|
|
72
90
|
|
|
91
|
+
// every receive path's secret, and every name a pull path's `env` lists
|
|
73
92
|
function collectSecretNames(sources: readonly SourceDefinition[]): string[] {
|
|
74
|
-
const names = sources.flatMap((source) =>
|
|
75
|
-
source.live?.kind === 'receive' ? [source.live.check.secretEnv] : [],
|
|
76
|
-
|
|
93
|
+
const names = sources.flatMap((source) => [
|
|
94
|
+
...(source.live?.kind === 'receive' ? [source.live.check.secretEnv] : []),
|
|
95
|
+
...[source.live, source.recovery, source.backfill, source.resync].flatMap((path) =>
|
|
96
|
+
path?.kind === 'pull' ? (path.env ?? []) : [],
|
|
97
|
+
),
|
|
98
|
+
]);
|
|
77
99
|
|
|
78
100
|
return [...new Set(names)];
|
|
79
101
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { SourceDefinition } from '@pepys/kit';
|
|
2
|
+
import { match } from 'ts-pattern';
|
|
3
|
+
import type { PullPathName, PullRun, PullRunner } from '../pull/make-pull-runner.ts';
|
|
4
|
+
|
|
5
|
+
const pullPathNames: readonly PullPathName[] = ['live', 'recovery', 'backfill', 'resync'];
|
|
6
|
+
|
|
7
|
+
// One timer per pull path with an interval. The cursor lives in memory, so a restart starts each
|
|
8
|
+
// pull from no cursor. A run never overlaps the one before it. Returns the function that stops them.
|
|
9
|
+
export function startPullTimers(
|
|
10
|
+
sources: readonly SourceDefinition[],
|
|
11
|
+
runPull: PullRunner,
|
|
12
|
+
): () => void {
|
|
13
|
+
const timers = sources.flatMap((source) =>
|
|
14
|
+
pullPathNames.flatMap((pathName) => {
|
|
15
|
+
const path = source[pathName];
|
|
16
|
+
|
|
17
|
+
if (path?.kind !== 'pull' || path.intervalMs === null) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let cursor: string | null = null;
|
|
22
|
+
let isRunning = false;
|
|
23
|
+
|
|
24
|
+
const runOnce = async (): Promise<void> => {
|
|
25
|
+
if (isRunning) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
isRunning = true;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const run = await runPull(source.name, pathName, cursor);
|
|
33
|
+
|
|
34
|
+
cursor = pickNextCursor(run, cursor);
|
|
35
|
+
|
|
36
|
+
if (run.kind !== 'stored') {
|
|
37
|
+
console.error(`the ${pathName} pull of ${source.name} did not store`, run);
|
|
38
|
+
}
|
|
39
|
+
} catch (error: unknown) {
|
|
40
|
+
console.error(`the ${pathName} pull of ${source.name} threw`, error);
|
|
41
|
+
} finally {
|
|
42
|
+
isRunning = false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return [
|
|
47
|
+
setInterval(() => {
|
|
48
|
+
void runOnce();
|
|
49
|
+
}, path.intervalMs),
|
|
50
|
+
];
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
for (const timer of timers) {
|
|
56
|
+
clearInterval(timer);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// An expired cursor starts the pull again from none; any other failure keeps the cursor.
|
|
62
|
+
function pickNextCursor(run: PullRun, cursor: string | null): string | null {
|
|
63
|
+
return match(run)
|
|
64
|
+
.with({ kind: 'stored' }, (stored) => stored.cursor)
|
|
65
|
+
.with({ kind: 'expired' }, () => null)
|
|
66
|
+
.with({ kind: 'not-stored' }, { kind: 'refused' }, { kind: 'unavailable' }, () => cursor)
|
|
67
|
+
.with({ kind: 'rejected' }, () => cursor)
|
|
68
|
+
.exhaustive();
|
|
69
|
+
}
|