@microck/canonfig 2.0.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/LICENSE +21 -0
- package/README.md +263 -0
- package/dist/agent/agent-resolution.errors.js +42 -0
- package/dist/agent/agent-resolution.layer.js +204 -0
- package/dist/agent/agent-resolution.service.js +2259 -0
- package/dist/agent/agent-resolution.types.js +1 -0
- package/dist/agent/controlled-executor.js +704 -0
- package/dist/agent/harness-adapters.js +85 -0
- package/dist/cli/cli.js +618 -0
- package/dist/cli/exit-codes.js +28 -0
- package/dist/cli/follower-commands.js +3 -0
- package/dist/cli/render.js +56 -0
- package/dist/cli/source-commands.js +5 -0
- package/dist/domain/brand.js +29 -0
- package/dist/domain/identity.js +31 -0
- package/dist/domain/npm-package-spec.js +186 -0
- package/dist/domain/profile.js +950 -0
- package/dist/domain/recipe-versions.js +297 -0
- package/dist/domain/resource.js +259 -0
- package/dist/domain/synchronization.js +346 -0
- package/dist/enrollment/enrollment.errors.js +43 -0
- package/dist/enrollment/enrollment.layer.js +724 -0
- package/dist/enrollment/enrollment.service.js +3 -0
- package/dist/enrollment/enrollment.types.js +59 -0
- package/dist/enrollment/follower-client.js +585 -0
- package/dist/enrollment/source-server.js +313 -0
- package/dist/machine/linux.layer.js +1183 -0
- package/dist/machine/machine-state.errors.js +52 -0
- package/dist/machine/machine-state.service.js +3 -0
- package/dist/machine/machine-state.types.js +1 -0
- package/dist/machine/macos.layer.js +470 -0
- package/dist/machine/windows.layer.js +879 -0
- package/dist/profile/discovery.js +740 -0
- package/dist/profile/profile-catalog.errors.js +50 -0
- package/dist/profile/profile-catalog.layer.js +20 -0
- package/dist/profile/profile-catalog.service.js +7 -0
- package/dist/profile/profile-codec.js +153 -0
- package/dist/profile/publication.js +298 -0
- package/dist/profile/tool-catalog.js +384 -0
- package/dist/runtime/doctor.js +306 -0
- package/dist/runtime/layers.js +706 -0
- package/dist/runtime/main.js +38 -0
- package/dist/schedule/linux-schedule.js +24 -0
- package/dist/schedule/macos-schedule.js +25 -0
- package/dist/schedule/schedule-manager.errors.js +17 -0
- package/dist/schedule/schedule-manager.layer.js +205 -0
- package/dist/schedule/schedule-manager.service.js +3 -0
- package/dist/schedule/schedule-manager.types.js +114 -0
- package/dist/schedule/windows-schedule.js +25 -0
- package/dist/state/state-repository.errors.js +55 -0
- package/dist/state/state-repository.layer.js +1507 -0
- package/dist/state/state-repository.service.js +3 -0
- package/dist/state/state-repository.types.js +1 -0
- package/dist/state/state-schema.js +298 -0
- package/dist/synchronization/config-codec.js +97 -0
- package/dist/synchronization/executor.js +700 -0
- package/dist/synchronization/follower-orchestration.js +939 -0
- package/dist/synchronization/follower-sync-config.js +81 -0
- package/dist/synchronization/npm-artifact.js +670 -0
- package/dist/synchronization/planner.js +378 -0
- package/dist/synchronization/recovery.js +397 -0
- package/dist/synchronization/resource-executors.js +1198 -0
- package/dist/synchronization/resource-plans.js +645 -0
- package/dist/synchronization/synchronization.errors.js +102 -0
- package/dist/synchronization/synchronization.layer.js +97 -0
- package/dist/synchronization/synchronization.service.js +11 -0
- package/dist/synchronization/synchronization.types.js +1 -0
- package/package.json +66 -0
|
@@ -0,0 +1,1507 @@
|
|
|
1
|
+
import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node";
|
|
2
|
+
import { Effect, Layer, Schema } from "effect";
|
|
3
|
+
import { SqlClient } from "effect/unstable/sql";
|
|
4
|
+
import { ActionId, BlobId, CertificateFingerprint, ContentDigest, CredentialReference, FollowerId, GroupName, ProfileRevisionId, ResourceId, RunId, } from "../domain/brand.js";
|
|
5
|
+
import { FollowerIdentity, SourceIdentity } from "../domain/identity.js";
|
|
6
|
+
import { ProfileRevisionSchema } from "../domain/profile.js";
|
|
7
|
+
import { AppliedResourceRecordSchema, DriftConflictSchema, SynchronizationPlanSchema, } from "../domain/synchronization.js";
|
|
8
|
+
import { FollowerSynchronizationConfiguration, LocalOverlayEntrySchema, } from "../synchronization/follower-sync-config.js";
|
|
9
|
+
import { SyncScheduleSchema } from "../schedule/schedule-manager.types.js";
|
|
10
|
+
import { ActionNotInPlanError, ActiveRunExistsError, EnrollmentStateConflictError, FollowerNotFoundError, InvalidRunTransitionError, RepositoryDecodeError, RepositorySqlError, RevisionImmutableError, RevisionNotFoundError, RunNotFoundError, } from "./state-repository.errors.js";
|
|
11
|
+
import { StateRepository } from "./state-repository.service.js";
|
|
12
|
+
import { stateMigrations } from "./state-schema.js";
|
|
13
|
+
const CountRow = Schema.Struct({ count: Schema.Number });
|
|
14
|
+
const RevisionJsonRow = Schema.Struct({ revision_json: Schema.String });
|
|
15
|
+
const RevisionBlobCandidateRow = Schema.Struct({
|
|
16
|
+
blob_id: ContentDigest,
|
|
17
|
+
revision_id: ProfileRevisionId,
|
|
18
|
+
resource_id: ResourceId,
|
|
19
|
+
});
|
|
20
|
+
const RunStatusRow = Schema.Struct({
|
|
21
|
+
follower_id: Schema.String,
|
|
22
|
+
status: Schema.String,
|
|
23
|
+
});
|
|
24
|
+
const ActiveRunRow = Schema.Struct({
|
|
25
|
+
id: RunId,
|
|
26
|
+
follower_id: FollowerId,
|
|
27
|
+
revision_id: ProfileRevisionId,
|
|
28
|
+
plan_json: Schema.String,
|
|
29
|
+
started_at: Schema.String,
|
|
30
|
+
});
|
|
31
|
+
const ActionJournalRow = Schema.Struct({
|
|
32
|
+
action_id: ActionId,
|
|
33
|
+
sequence: Schema.Number,
|
|
34
|
+
state: Schema.Literals(["pending", "running", "succeeded", "failed", "skipped"]),
|
|
35
|
+
recorded_at: Schema.String,
|
|
36
|
+
attempt: Schema.Number,
|
|
37
|
+
verification_json: Schema.NullOr(Schema.String),
|
|
38
|
+
rollback_reference: Schema.NullOr(Schema.String),
|
|
39
|
+
removed_resource_json: Schema.NullOr(Schema.String),
|
|
40
|
+
});
|
|
41
|
+
const DriftRow = Schema.Struct({
|
|
42
|
+
sequence: Schema.Number,
|
|
43
|
+
conflict_json: Schema.String,
|
|
44
|
+
recorded_at: Schema.String,
|
|
45
|
+
});
|
|
46
|
+
const AppliedResourceRow = Schema.Struct({
|
|
47
|
+
resource_id: ResourceId,
|
|
48
|
+
revision_id: ProfileRevisionId,
|
|
49
|
+
digest: ContentDigest,
|
|
50
|
+
applied_at: Schema.String,
|
|
51
|
+
owned_files_json: Schema.NullOr(Schema.String),
|
|
52
|
+
schedule_json: Schema.NullOr(Schema.String),
|
|
53
|
+
kind: Schema.NullOr(Schema.String),
|
|
54
|
+
policy: Schema.NullOr(Schema.String),
|
|
55
|
+
target: Schema.NullOr(Schema.String),
|
|
56
|
+
owned_keys_json: Schema.NullOr(Schema.String),
|
|
57
|
+
config_format: Schema.NullOr(Schema.String),
|
|
58
|
+
executable: Schema.NullOr(Schema.Number),
|
|
59
|
+
symlink_target: Schema.NullOr(Schema.String),
|
|
60
|
+
});
|
|
61
|
+
const OwnedFilesSchema = Schema.Array(Schema.Struct({
|
|
62
|
+
path: Schema.NonEmptyString,
|
|
63
|
+
digest: ContentDigest,
|
|
64
|
+
executable: Schema.optional(Schema.Boolean),
|
|
65
|
+
}));
|
|
66
|
+
const OwnedKeysSchema = Schema.Array(Schema.NonEmptyString);
|
|
67
|
+
const StoredScheduleSchema = SyncScheduleSchema;
|
|
68
|
+
const FollowerRow = Schema.Struct({
|
|
69
|
+
id: Schema.String,
|
|
70
|
+
name: Schema.String,
|
|
71
|
+
groups_json: Schema.String,
|
|
72
|
+
revoked: Schema.Number,
|
|
73
|
+
credential_reference: Schema.String,
|
|
74
|
+
enrolled_at: Schema.String,
|
|
75
|
+
});
|
|
76
|
+
const SourceIdentityRow = Schema.Struct({
|
|
77
|
+
key_id: Schema.String,
|
|
78
|
+
public_key_fingerprint: Schema.String,
|
|
79
|
+
});
|
|
80
|
+
const EnrollmentSourceRow = Schema.Struct({
|
|
81
|
+
key_id: Schema.String,
|
|
82
|
+
public_key_fingerprint: Schema.String,
|
|
83
|
+
signing_key_reference: CredentialReference,
|
|
84
|
+
tls_key_reference: CredentialReference,
|
|
85
|
+
tls_certificate_reference: CredentialReference,
|
|
86
|
+
tls_fingerprint: CertificateFingerprint,
|
|
87
|
+
});
|
|
88
|
+
const EnrollmentInvitationRow = Schema.Struct({
|
|
89
|
+
intended_source_fingerprint: CertificateFingerprint,
|
|
90
|
+
tls_fingerprint: CertificateFingerprint,
|
|
91
|
+
endpoint: Schema.String,
|
|
92
|
+
groups_json: Schema.String,
|
|
93
|
+
expires_at: Schema.String,
|
|
94
|
+
used_at: Schema.NullOr(Schema.String),
|
|
95
|
+
});
|
|
96
|
+
const FollowerCredentialRow = Schema.Struct({
|
|
97
|
+
id: Schema.String,
|
|
98
|
+
name: Schema.String,
|
|
99
|
+
groups_json: Schema.String,
|
|
100
|
+
revoked: Schema.Number,
|
|
101
|
+
credential_reference: CredentialReference,
|
|
102
|
+
enrolled_at: Schema.String,
|
|
103
|
+
credential_digest: ContentDigest,
|
|
104
|
+
});
|
|
105
|
+
const FollowerSynchronizationConfigurationRow = Schema.Struct({
|
|
106
|
+
configuration_json: Schema.String,
|
|
107
|
+
});
|
|
108
|
+
const PendingEnrollmentRow = Schema.Struct({
|
|
109
|
+
follower_id: FollowerId,
|
|
110
|
+
code_digest: ContentDigest,
|
|
111
|
+
credential_digest: ContentDigest,
|
|
112
|
+
credential_reference: CredentialReference,
|
|
113
|
+
follower_json: Schema.String,
|
|
114
|
+
});
|
|
115
|
+
const VerificationEvidenceSchema = Schema.Struct({
|
|
116
|
+
status: Schema.Literals(["passed", "failed", "not-run"]),
|
|
117
|
+
method: Schema.NonEmptyString,
|
|
118
|
+
observedDigest: Schema.optional(ContentDigest),
|
|
119
|
+
exitCode: Schema.optional(Schema.Int),
|
|
120
|
+
});
|
|
121
|
+
const sqlError = (operation) => (error) => new RepositorySqlError({ operation, message: String(error) });
|
|
122
|
+
const decodeError = (entity, id) => (error) => new RepositoryDecodeError({ entity, id, message: String(error) });
|
|
123
|
+
const decodeRows = (schema, rows, entity, id) => Schema.decodeUnknownEffect(Schema.Array(schema))(rows).pipe(Effect.mapError(decodeError(entity, id)));
|
|
124
|
+
const parseJson = (schema, text, entity, id) => Effect.try({
|
|
125
|
+
try: () => JSON.parse(text),
|
|
126
|
+
catch: (error) => new RepositoryDecodeError({
|
|
127
|
+
entity,
|
|
128
|
+
id,
|
|
129
|
+
message: String(error),
|
|
130
|
+
}),
|
|
131
|
+
}).pipe(Effect.flatMap(Schema.decodeUnknownEffect(schema)), Effect.mapError((error) => error instanceof RepositoryDecodeError
|
|
132
|
+
? error
|
|
133
|
+
: new RepositoryDecodeError({ entity, id, message: String(error) })));
|
|
134
|
+
const encodeJson = (value) => JSON.stringify(value);
|
|
135
|
+
const statusCount = Effect.fn("StateRepository.statusCount")(function* (sql, query, entity, id) {
|
|
136
|
+
const rows = yield* query.pipe(Effect.mapError(sqlError(`find ${entity}`)));
|
|
137
|
+
const decoded = yield* decodeRows(CountRow, rows, entity, id);
|
|
138
|
+
return decoded[0]?.count ?? 0;
|
|
139
|
+
});
|
|
140
|
+
const makeRepository = Effect.gen(function* () {
|
|
141
|
+
const sql = yield* SqlClient.SqlClient;
|
|
142
|
+
yield* SqliteMigrator.run({ loader: stateMigrations }).pipe(Effect.mapError((error) => new RepositorySqlError({
|
|
143
|
+
operation: "migrate state schema",
|
|
144
|
+
message: String(error),
|
|
145
|
+
})));
|
|
146
|
+
// A pending enrollment has not issued an active follower credential. Any
|
|
147
|
+
// process restart makes its remote outcome ambiguous, so fail closed by
|
|
148
|
+
// discarding the pending marker; the invitation remains unconsumed and can
|
|
149
|
+
// be safely retried because no active follower identity was issued.
|
|
150
|
+
yield* sql `DELETE FROM pending_enrollments`.pipe(Effect.mapError(sqlError("discard ambiguous pending enrollments")));
|
|
151
|
+
const saveSourceIdentity = Effect.fn("StateRepository.saveSourceIdentity")(function* (identity) {
|
|
152
|
+
yield* sql `
|
|
153
|
+
INSERT INTO source_identity (singleton, key_id, public_key_fingerprint)
|
|
154
|
+
VALUES (1, ${identity.keyId}, ${identity.publicKeyFingerprint})
|
|
155
|
+
ON CONFLICT(singleton) DO UPDATE SET
|
|
156
|
+
key_id = excluded.key_id,
|
|
157
|
+
public_key_fingerprint = excluded.public_key_fingerprint
|
|
158
|
+
`.pipe(Effect.mapError(sqlError("save source identity")));
|
|
159
|
+
});
|
|
160
|
+
const registerFollower = Effect.fn("StateRepository.registerFollower")(function* (input) {
|
|
161
|
+
const follower = input.follower;
|
|
162
|
+
yield* sql `
|
|
163
|
+
INSERT INTO followers (
|
|
164
|
+
id,
|
|
165
|
+
name,
|
|
166
|
+
groups_json,
|
|
167
|
+
revoked,
|
|
168
|
+
credential_reference,
|
|
169
|
+
enrolled_at
|
|
170
|
+
) VALUES (
|
|
171
|
+
${follower.id},
|
|
172
|
+
${follower.name},
|
|
173
|
+
${encodeJson([...follower.groups])},
|
|
174
|
+
${follower.revoked ? 1 : 0},
|
|
175
|
+
${follower.credentialReference},
|
|
176
|
+
${follower.enrolledAt}
|
|
177
|
+
)
|
|
178
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
179
|
+
name = excluded.name,
|
|
180
|
+
groups_json = excluded.groups_json,
|
|
181
|
+
revoked = excluded.revoked,
|
|
182
|
+
credential_reference = excluded.credential_reference,
|
|
183
|
+
enrolled_at = excluded.enrolled_at
|
|
184
|
+
`.pipe(Effect.mapError(sqlError("register follower")));
|
|
185
|
+
});
|
|
186
|
+
const saveFollowerSynchronizationConfiguration = Effect.fn("StateRepository.saveFollowerSynchronizationConfiguration")(function* (input) {
|
|
187
|
+
const configuration = yield* Schema.decodeUnknownEffect(FollowerSynchronizationConfiguration)(input.configuration).pipe(Effect.mapError(decodeError("follower synchronization configuration", input.configuration.follower.id)));
|
|
188
|
+
if (configuration.follower.credentialReference
|
|
189
|
+
!== configuration.credentialReference
|
|
190
|
+
|| configuration.source.signingFingerprint
|
|
191
|
+
!== input.sourceIdentity.publicKeyFingerprint) {
|
|
192
|
+
return yield* new RepositoryDecodeError({
|
|
193
|
+
entity: "follower synchronization configuration",
|
|
194
|
+
id: configuration.follower.id,
|
|
195
|
+
message: "configuration identities or credential references do not match",
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const transaction = Effect.gen(function* () {
|
|
199
|
+
yield* sql `
|
|
200
|
+
INSERT INTO followers (
|
|
201
|
+
id,
|
|
202
|
+
name,
|
|
203
|
+
groups_json,
|
|
204
|
+
revoked,
|
|
205
|
+
credential_reference,
|
|
206
|
+
enrolled_at
|
|
207
|
+
) VALUES (
|
|
208
|
+
${configuration.follower.id},
|
|
209
|
+
${configuration.follower.name},
|
|
210
|
+
${encodeJson([...configuration.follower.groups])},
|
|
211
|
+
${configuration.follower.revoked ? 1 : 0},
|
|
212
|
+
${configuration.credentialReference},
|
|
213
|
+
${configuration.follower.enrolledAt}
|
|
214
|
+
)
|
|
215
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
216
|
+
name = excluded.name,
|
|
217
|
+
groups_json = excluded.groups_json,
|
|
218
|
+
revoked = excluded.revoked,
|
|
219
|
+
credential_reference = excluded.credential_reference,
|
|
220
|
+
enrolled_at = excluded.enrolled_at
|
|
221
|
+
`;
|
|
222
|
+
yield* sql `
|
|
223
|
+
INSERT INTO source_identity (singleton, key_id, public_key_fingerprint)
|
|
224
|
+
VALUES (
|
|
225
|
+
1,
|
|
226
|
+
${input.sourceIdentity.keyId},
|
|
227
|
+
${input.sourceIdentity.publicKeyFingerprint}
|
|
228
|
+
)
|
|
229
|
+
ON CONFLICT(singleton) DO UPDATE SET
|
|
230
|
+
key_id = excluded.key_id,
|
|
231
|
+
public_key_fingerprint = excluded.public_key_fingerprint
|
|
232
|
+
`;
|
|
233
|
+
yield* sql `
|
|
234
|
+
INSERT INTO follower_sync_configuration (
|
|
235
|
+
singleton,
|
|
236
|
+
follower_id,
|
|
237
|
+
configuration_json,
|
|
238
|
+
updated_at
|
|
239
|
+
) VALUES (
|
|
240
|
+
1,
|
|
241
|
+
${configuration.follower.id},
|
|
242
|
+
${JSON.stringify(configuration)},
|
|
243
|
+
${configuration.updatedAt}
|
|
244
|
+
)
|
|
245
|
+
ON CONFLICT(singleton) DO UPDATE SET
|
|
246
|
+
follower_id = excluded.follower_id,
|
|
247
|
+
configuration_json = excluded.configuration_json,
|
|
248
|
+
updated_at = excluded.updated_at
|
|
249
|
+
`;
|
|
250
|
+
});
|
|
251
|
+
yield* sql.withTransaction(transaction).pipe(Effect.mapError(sqlError("save follower synchronization configuration")));
|
|
252
|
+
});
|
|
253
|
+
const getFollowerSynchronizationConfiguration = Effect.fn("StateRepository.getFollowerSynchronizationConfiguration")(function* () {
|
|
254
|
+
const rows = yield* sql `
|
|
255
|
+
SELECT configuration_json
|
|
256
|
+
FROM follower_sync_configuration
|
|
257
|
+
WHERE singleton = 1
|
|
258
|
+
`.pipe(Effect.mapError(sqlError("load follower synchronization configuration")));
|
|
259
|
+
const decoded = yield* decodeRows(FollowerSynchronizationConfigurationRow, rows, "follower synchronization configuration row", "1");
|
|
260
|
+
const row = decoded[0];
|
|
261
|
+
if (row === undefined)
|
|
262
|
+
return undefined;
|
|
263
|
+
return yield* parseJson(FollowerSynchronizationConfiguration, row.configuration_json, "follower synchronization configuration", "1");
|
|
264
|
+
});
|
|
265
|
+
const saveLocalOverlay = Effect.fn("StateRepository.saveLocalOverlay")(function* (input) {
|
|
266
|
+
const configuration = yield* getFollowerSynchronizationConfiguration();
|
|
267
|
+
if (configuration === undefined) {
|
|
268
|
+
return yield* new RepositoryDecodeError({
|
|
269
|
+
entity: "follower synchronization configuration",
|
|
270
|
+
id: "1",
|
|
271
|
+
message: "follower synchronization configuration is not enrolled",
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const entry = yield* Schema.decodeUnknownEffect(LocalOverlayEntrySchema)(input.entry).pipe(Effect.mapError(decodeError("local overlay", input.entry.resource)));
|
|
275
|
+
const localOverlay = [
|
|
276
|
+
...(configuration.localOverlay ?? []).filter((candidate) => candidate.resource !== entry.resource),
|
|
277
|
+
entry,
|
|
278
|
+
].sort((left, right) => left.resource.localeCompare(right.resource));
|
|
279
|
+
const nextConfiguration = yield* Schema.decodeUnknownEffect(FollowerSynchronizationConfiguration)({
|
|
280
|
+
...configuration,
|
|
281
|
+
localOverlay,
|
|
282
|
+
updatedAt: input.updatedAt,
|
|
283
|
+
}).pipe(Effect.mapError(decodeError("follower synchronization configuration", entry.resource)));
|
|
284
|
+
yield* sql `
|
|
285
|
+
UPDATE follower_sync_configuration
|
|
286
|
+
SET configuration_json = ${JSON.stringify(nextConfiguration)},
|
|
287
|
+
updated_at = ${nextConfiguration.updatedAt}
|
|
288
|
+
WHERE singleton = 1
|
|
289
|
+
`.pipe(Effect.mapError(sqlError("save local overlay")));
|
|
290
|
+
});
|
|
291
|
+
const removeLocalOverlay = Effect.fn("StateRepository.removeLocalOverlay")(function* (input) {
|
|
292
|
+
const configuration = yield* getFollowerSynchronizationConfiguration();
|
|
293
|
+
if (configuration === undefined) {
|
|
294
|
+
return yield* new RepositoryDecodeError({
|
|
295
|
+
entity: "follower synchronization configuration",
|
|
296
|
+
id: "1",
|
|
297
|
+
message: "follower synchronization configuration is not enrolled",
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
const localOverlay = (configuration.localOverlay ?? [])
|
|
301
|
+
.filter((entry) => entry.resource !== input.resource);
|
|
302
|
+
const nextConfiguration = yield* Schema.decodeUnknownEffect(FollowerSynchronizationConfiguration)({
|
|
303
|
+
...configuration,
|
|
304
|
+
localOverlay,
|
|
305
|
+
updatedAt: input.updatedAt,
|
|
306
|
+
}).pipe(Effect.mapError(decodeError("follower synchronization configuration", input.resource)));
|
|
307
|
+
yield* sql `
|
|
308
|
+
UPDATE follower_sync_configuration
|
|
309
|
+
SET configuration_json = ${JSON.stringify(nextConfiguration)},
|
|
310
|
+
updated_at = ${nextConfiguration.updatedAt}
|
|
311
|
+
WHERE singleton = 1
|
|
312
|
+
`.pipe(Effect.mapError(sqlError("remove local overlay")));
|
|
313
|
+
});
|
|
314
|
+
const listLocalOverlays = Effect.fn("StateRepository.listLocalOverlays")(function* () {
|
|
315
|
+
const configuration = yield* getFollowerSynchronizationConfiguration();
|
|
316
|
+
return configuration?.localOverlay ?? [];
|
|
317
|
+
});
|
|
318
|
+
const saveEnrollmentSource = Effect.fn("StateRepository.saveEnrollmentSource")(function* (source) {
|
|
319
|
+
const transaction = Effect.gen(function* () {
|
|
320
|
+
yield* sql `
|
|
321
|
+
INSERT INTO source_identity (singleton, key_id, public_key_fingerprint)
|
|
322
|
+
VALUES (
|
|
323
|
+
1,
|
|
324
|
+
${source.identity.keyId},
|
|
325
|
+
${source.identity.publicKeyFingerprint}
|
|
326
|
+
)
|
|
327
|
+
ON CONFLICT(singleton) DO UPDATE SET
|
|
328
|
+
key_id = excluded.key_id,
|
|
329
|
+
public_key_fingerprint = excluded.public_key_fingerprint
|
|
330
|
+
`;
|
|
331
|
+
yield* sql `
|
|
332
|
+
INSERT INTO enrollment_source (
|
|
333
|
+
singleton,
|
|
334
|
+
signing_key_reference,
|
|
335
|
+
tls_key_reference,
|
|
336
|
+
tls_certificate_reference,
|
|
337
|
+
tls_fingerprint
|
|
338
|
+
) VALUES (
|
|
339
|
+
1,
|
|
340
|
+
${source.signingKeyReference},
|
|
341
|
+
${source.tlsKeyReference},
|
|
342
|
+
${source.tlsCertificateReference},
|
|
343
|
+
${source.tlsFingerprint}
|
|
344
|
+
)
|
|
345
|
+
ON CONFLICT(singleton) DO UPDATE SET
|
|
346
|
+
signing_key_reference = excluded.signing_key_reference,
|
|
347
|
+
tls_key_reference = excluded.tls_key_reference,
|
|
348
|
+
tls_certificate_reference = excluded.tls_certificate_reference,
|
|
349
|
+
tls_fingerprint = excluded.tls_fingerprint
|
|
350
|
+
`;
|
|
351
|
+
});
|
|
352
|
+
yield* sql.withTransaction(transaction).pipe(Effect.mapError(sqlError("save enrollment source")));
|
|
353
|
+
});
|
|
354
|
+
const getEnrollmentSource = Effect.fn("StateRepository.getEnrollmentSource")(function* () {
|
|
355
|
+
const rows = yield* sql `
|
|
356
|
+
SELECT
|
|
357
|
+
source_identity.key_id,
|
|
358
|
+
source_identity.public_key_fingerprint,
|
|
359
|
+
enrollment_source.signing_key_reference,
|
|
360
|
+
enrollment_source.tls_key_reference,
|
|
361
|
+
enrollment_source.tls_certificate_reference,
|
|
362
|
+
enrollment_source.tls_fingerprint
|
|
363
|
+
FROM enrollment_source
|
|
364
|
+
INNER JOIN source_identity ON source_identity.singleton = enrollment_source.singleton
|
|
365
|
+
WHERE enrollment_source.singleton = 1
|
|
366
|
+
`.pipe(Effect.mapError(sqlError("load enrollment source")));
|
|
367
|
+
const decoded = yield* decodeRows(EnrollmentSourceRow, rows, "enrollment source", "1");
|
|
368
|
+
const row = decoded[0];
|
|
369
|
+
if (row === undefined)
|
|
370
|
+
return undefined;
|
|
371
|
+
const identity = yield* Schema.decodeUnknownEffect(SourceIdentity)({
|
|
372
|
+
keyId: row.key_id,
|
|
373
|
+
publicKeyFingerprint: row.public_key_fingerprint,
|
|
374
|
+
}).pipe(Effect.mapError(decodeError("source identity", "1")));
|
|
375
|
+
return {
|
|
376
|
+
identity,
|
|
377
|
+
signingKeyReference: row.signing_key_reference,
|
|
378
|
+
tlsKeyReference: row.tls_key_reference,
|
|
379
|
+
tlsCertificateReference: row.tls_certificate_reference,
|
|
380
|
+
tlsFingerprint: row.tls_fingerprint,
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
const createEnrollmentInvitation = Effect.fn("StateRepository.createEnrollmentInvitation")(function* (input) {
|
|
384
|
+
yield* sql `
|
|
385
|
+
INSERT INTO enrollment_invitations (
|
|
386
|
+
code_digest,
|
|
387
|
+
nonce_digest,
|
|
388
|
+
intended_source_fingerprint,
|
|
389
|
+
tls_fingerprint,
|
|
390
|
+
endpoint,
|
|
391
|
+
groups_json,
|
|
392
|
+
expires_at,
|
|
393
|
+
used_at
|
|
394
|
+
) VALUES (
|
|
395
|
+
${input.codeDigest},
|
|
396
|
+
${input.nonceDigest},
|
|
397
|
+
${input.intendedSourceFingerprint},
|
|
398
|
+
${input.tlsFingerprint},
|
|
399
|
+
${input.endpoint},
|
|
400
|
+
${encodeJson([...input.groups])},
|
|
401
|
+
${input.expiresAt},
|
|
402
|
+
NULL
|
|
403
|
+
)
|
|
404
|
+
`.pipe(Effect.mapError(sqlError("create enrollment invitation")));
|
|
405
|
+
});
|
|
406
|
+
const findEnrollmentInvitation = Effect.fn("StateRepository.findEnrollmentInvitation")(function* (codeDigest) {
|
|
407
|
+
const rows = yield* sql `
|
|
408
|
+
SELECT
|
|
409
|
+
intended_source_fingerprint,
|
|
410
|
+
tls_fingerprint,
|
|
411
|
+
endpoint,
|
|
412
|
+
groups_json,
|
|
413
|
+
expires_at,
|
|
414
|
+
used_at
|
|
415
|
+
FROM enrollment_invitations
|
|
416
|
+
WHERE code_digest = ${codeDigest}
|
|
417
|
+
`.pipe(Effect.mapError(sqlError("find enrollment invitation")));
|
|
418
|
+
const decoded = yield* decodeRows(EnrollmentInvitationRow, rows, "enrollment invitation", codeDigest);
|
|
419
|
+
const row = decoded[0];
|
|
420
|
+
if (row === undefined)
|
|
421
|
+
return undefined;
|
|
422
|
+
const groups = yield* parseJson(Schema.Array(GroupName), row.groups_json, "enrollment invitation groups", codeDigest);
|
|
423
|
+
const storedInvitation = {
|
|
424
|
+
intendedSourceFingerprint: row.intended_source_fingerprint,
|
|
425
|
+
tlsFingerprint: row.tls_fingerprint,
|
|
426
|
+
endpoint: row.endpoint,
|
|
427
|
+
groups,
|
|
428
|
+
expiresAt: row.expires_at,
|
|
429
|
+
};
|
|
430
|
+
if (row.used_at !== null) {
|
|
431
|
+
return { ...storedInvitation, usedAt: row.used_at };
|
|
432
|
+
}
|
|
433
|
+
return storedInvitation;
|
|
434
|
+
});
|
|
435
|
+
const consumeEnrollmentInvitation = Effect.fn("StateRepository.consumeEnrollmentInvitation")(function* (input) {
|
|
436
|
+
const transaction = Effect.gen(function* () {
|
|
437
|
+
const rows = yield* sql `
|
|
438
|
+
SELECT
|
|
439
|
+
intended_source_fingerprint,
|
|
440
|
+
tls_fingerprint,
|
|
441
|
+
endpoint,
|
|
442
|
+
groups_json,
|
|
443
|
+
expires_at,
|
|
444
|
+
used_at
|
|
445
|
+
FROM enrollment_invitations
|
|
446
|
+
WHERE code_digest = ${input.codeDigest}
|
|
447
|
+
`;
|
|
448
|
+
const invitations = yield* decodeRows(EnrollmentInvitationRow, rows, "enrollment invitation", input.codeDigest);
|
|
449
|
+
const invitation = invitations[0];
|
|
450
|
+
if (invitation === undefined) {
|
|
451
|
+
return yield* new EnrollmentStateConflictError({
|
|
452
|
+
reason: "invitation-not-found",
|
|
453
|
+
message: "the invitation is unknown",
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
if (invitation.used_at !== null) {
|
|
457
|
+
return yield* new EnrollmentStateConflictError({
|
|
458
|
+
reason: "invitation-used",
|
|
459
|
+
message: "the invitation was already consumed",
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
if (Date.parse(invitation.expires_at) <= Date.parse(input.consumedAt)) {
|
|
463
|
+
return yield* new EnrollmentStateConflictError({
|
|
464
|
+
reason: "invitation-expired",
|
|
465
|
+
message: "the invitation has expired",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
if (invitation.intended_source_fingerprint !== input.intendedSourceFingerprint
|
|
469
|
+
|| invitation.tls_fingerprint !== input.tlsFingerprint) {
|
|
470
|
+
return yield* new EnrollmentStateConflictError({
|
|
471
|
+
reason: "invitation-mismatch",
|
|
472
|
+
message: "the invitation is not valid for this source",
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
const nonceRows = yield* sql `
|
|
476
|
+
SELECT count(*) AS count
|
|
477
|
+
FROM enrollment_invitations
|
|
478
|
+
WHERE code_digest = ${input.codeDigest}
|
|
479
|
+
AND nonce_digest = ${input.nonceDigest}
|
|
480
|
+
`;
|
|
481
|
+
const nonceCount = yield* decodeRows(CountRow, nonceRows, "enrollment invitation nonce", input.codeDigest);
|
|
482
|
+
if ((nonceCount[0]?.count ?? 0) !== 1) {
|
|
483
|
+
return yield* new EnrollmentStateConflictError({
|
|
484
|
+
reason: "invitation-mismatch",
|
|
485
|
+
message: "the invitation nonce is invalid",
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
const pendingRows = yield* sql `
|
|
489
|
+
SELECT count(*) AS count
|
|
490
|
+
FROM pending_enrollments
|
|
491
|
+
WHERE code_digest = ${input.codeDigest}
|
|
492
|
+
`;
|
|
493
|
+
const pendingCount = yield* decodeRows(CountRow, pendingRows, "pending enrollment", input.codeDigest);
|
|
494
|
+
if ((pendingCount[0]?.count ?? 0) !== 0) {
|
|
495
|
+
return yield* new EnrollmentStateConflictError({
|
|
496
|
+
reason: "invitation-used",
|
|
497
|
+
message: "the invitation is already pending finalization",
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
const identityRows = yield* sql `
|
|
501
|
+
SELECT revoked
|
|
502
|
+
FROM followers
|
|
503
|
+
WHERE id = ${input.follower.id}
|
|
504
|
+
`;
|
|
505
|
+
const identities = yield* decodeRows(Schema.Struct({ revoked: Schema.Number }), identityRows, "follower identity", input.follower.id);
|
|
506
|
+
const existingIdentity = identities[0];
|
|
507
|
+
if (existingIdentity !== undefined && existingIdentity.revoked !== 1) {
|
|
508
|
+
return yield* new EnrollmentStateConflictError({
|
|
509
|
+
reason: "follower-identity-conflict",
|
|
510
|
+
message: "the follower identity is already enrolled",
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
const invitationGroups = yield* parseJson(Schema.Array(GroupName), invitation.groups_json, "enrollment invitation groups", input.codeDigest);
|
|
514
|
+
const requestedGroups = [...input.follower.groups];
|
|
515
|
+
if (requestedGroups.length !== invitationGroups.length
|
|
516
|
+
|| requestedGroups.some((group, index) => group !== invitationGroups[index])
|
|
517
|
+
|| input.follower.revoked) {
|
|
518
|
+
return yield* new EnrollmentStateConflictError({
|
|
519
|
+
reason: "invitation-mismatch",
|
|
520
|
+
message: "the follower enrollment does not match the invitation",
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
const credentialRows = yield* sql `
|
|
524
|
+
SELECT count(*) AS count
|
|
525
|
+
FROM follower_credentials
|
|
526
|
+
WHERE credential_digest = ${input.credentialDigest}
|
|
527
|
+
`;
|
|
528
|
+
const credentialCount = yield* decodeRows(CountRow, credentialRows, "follower credential", input.follower.id);
|
|
529
|
+
if ((credentialCount[0]?.count ?? 0) !== 0) {
|
|
530
|
+
return yield* new EnrollmentStateConflictError({
|
|
531
|
+
reason: "credential-conflict",
|
|
532
|
+
message: "the follower credential is already assigned",
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
yield* sql `
|
|
536
|
+
INSERT INTO pending_enrollments (
|
|
537
|
+
follower_id,
|
|
538
|
+
code_digest,
|
|
539
|
+
credential_digest,
|
|
540
|
+
credential_reference,
|
|
541
|
+
follower_json,
|
|
542
|
+
created_at
|
|
543
|
+
) VALUES (
|
|
544
|
+
${input.follower.id},
|
|
545
|
+
${input.codeDigest},
|
|
546
|
+
${input.credentialDigest},
|
|
547
|
+
${input.credentialReference},
|
|
548
|
+
${JSON.stringify(input.follower)},
|
|
549
|
+
${input.consumedAt}
|
|
550
|
+
)
|
|
551
|
+
`;
|
|
552
|
+
});
|
|
553
|
+
yield* sql.withTransaction(transaction).pipe(Effect.mapError((error) => error instanceof EnrollmentStateConflictError
|
|
554
|
+
? error
|
|
555
|
+
: error instanceof RepositoryDecodeError
|
|
556
|
+
? error
|
|
557
|
+
: error instanceof RepositorySqlError
|
|
558
|
+
? error
|
|
559
|
+
: sqlError("consume enrollment invitation")(error)));
|
|
560
|
+
});
|
|
561
|
+
const finalizeEnrollment = Effect.fn("StateRepository.finalizeEnrollment")(function* (input) {
|
|
562
|
+
const transaction = Effect.gen(function* () {
|
|
563
|
+
const pendingRows = yield* sql `
|
|
564
|
+
SELECT
|
|
565
|
+
follower_id,
|
|
566
|
+
code_digest,
|
|
567
|
+
credential_digest,
|
|
568
|
+
credential_reference,
|
|
569
|
+
follower_json
|
|
570
|
+
FROM pending_enrollments
|
|
571
|
+
WHERE follower_id = ${input.follower}
|
|
572
|
+
AND credential_digest = ${input.credentialDigest}
|
|
573
|
+
`;
|
|
574
|
+
const pending = (yield* decodeRows(PendingEnrollmentRow, pendingRows, "pending enrollment", input.follower))[0];
|
|
575
|
+
if (pending === undefined) {
|
|
576
|
+
const activeRows = yield* sql `
|
|
577
|
+
SELECT count(*) AS count
|
|
578
|
+
FROM followers
|
|
579
|
+
INNER JOIN follower_credentials
|
|
580
|
+
ON follower_credentials.follower_id = followers.id
|
|
581
|
+
WHERE followers.id = ${input.follower}
|
|
582
|
+
AND followers.revoked = 0
|
|
583
|
+
AND follower_credentials.credential_digest = ${input.credentialDigest}
|
|
584
|
+
AND follower_credentials.credential_reference = ${input.credentialReference}
|
|
585
|
+
`;
|
|
586
|
+
const active = yield* decodeRows(CountRow, activeRows, "follower enrollment", input.follower);
|
|
587
|
+
if ((active[0]?.count ?? 0) === 1)
|
|
588
|
+
return;
|
|
589
|
+
return yield* new EnrollmentStateConflictError({
|
|
590
|
+
reason: "credential-conflict",
|
|
591
|
+
message: "the pending follower enrollment is unavailable",
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
if (pending.credential_reference !== input.credentialReference) {
|
|
595
|
+
return yield* new EnrollmentStateConflictError({
|
|
596
|
+
reason: "credential-conflict",
|
|
597
|
+
message: "the follower credential reference does not match",
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
const follower = yield* parseJson(FollowerIdentity, pending.follower_json, "pending follower identity", input.follower);
|
|
601
|
+
const identityRows = yield* sql `
|
|
602
|
+
SELECT revoked
|
|
603
|
+
FROM followers
|
|
604
|
+
WHERE id = ${input.follower}
|
|
605
|
+
`;
|
|
606
|
+
const identities = yield* decodeRows(Schema.Struct({ revoked: Schema.Number }), identityRows, "follower identity", input.follower);
|
|
607
|
+
if (identities[0] !== undefined && identities[0].revoked !== 1) {
|
|
608
|
+
return yield* new EnrollmentStateConflictError({
|
|
609
|
+
reason: "follower-identity-conflict",
|
|
610
|
+
message: "the follower identity is already enrolled",
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
const invitationRows = yield* sql `
|
|
614
|
+
SELECT used_at, expires_at
|
|
615
|
+
FROM enrollment_invitations
|
|
616
|
+
WHERE code_digest = ${pending.code_digest}
|
|
617
|
+
`;
|
|
618
|
+
const invitation = (yield* decodeRows(Schema.Struct({
|
|
619
|
+
used_at: Schema.NullOr(Schema.String),
|
|
620
|
+
expires_at: Schema.String,
|
|
621
|
+
}), invitationRows, "enrollment invitation", pending.code_digest))[0];
|
|
622
|
+
if (invitation === undefined || invitation.used_at !== null) {
|
|
623
|
+
return yield* new EnrollmentStateConflictError({
|
|
624
|
+
reason: "invitation-used",
|
|
625
|
+
message: "the enrollment invitation is no longer available",
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
if (Date.parse(invitation.expires_at) <= Date.now()) {
|
|
629
|
+
return yield* new EnrollmentStateConflictError({
|
|
630
|
+
reason: "invitation-expired",
|
|
631
|
+
message: "the enrollment invitation has expired",
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
if (identities[0] === undefined) {
|
|
635
|
+
yield* sql `
|
|
636
|
+
INSERT INTO followers (
|
|
637
|
+
id,
|
|
638
|
+
name,
|
|
639
|
+
groups_json,
|
|
640
|
+
revoked,
|
|
641
|
+
credential_reference,
|
|
642
|
+
enrolled_at
|
|
643
|
+
) VALUES (
|
|
644
|
+
${follower.id},
|
|
645
|
+
${follower.name},
|
|
646
|
+
${encodeJson([...follower.groups])},
|
|
647
|
+
0,
|
|
648
|
+
${input.credentialReference},
|
|
649
|
+
${follower.enrolledAt}
|
|
650
|
+
)
|
|
651
|
+
`;
|
|
652
|
+
yield* sql `
|
|
653
|
+
INSERT INTO follower_credentials (
|
|
654
|
+
follower_id,
|
|
655
|
+
credential_digest,
|
|
656
|
+
credential_reference
|
|
657
|
+
) VALUES (
|
|
658
|
+
${follower.id},
|
|
659
|
+
${input.credentialDigest},
|
|
660
|
+
${input.credentialReference}
|
|
661
|
+
)
|
|
662
|
+
`;
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
yield* sql `
|
|
666
|
+
UPDATE followers
|
|
667
|
+
SET
|
|
668
|
+
name = ${follower.name},
|
|
669
|
+
groups_json = ${encodeJson([...follower.groups])},
|
|
670
|
+
revoked = 0,
|
|
671
|
+
credential_reference = ${input.credentialReference},
|
|
672
|
+
enrolled_at = ${follower.enrolledAt}
|
|
673
|
+
WHERE id = ${follower.id}
|
|
674
|
+
`;
|
|
675
|
+
yield* sql `
|
|
676
|
+
UPDATE follower_credentials
|
|
677
|
+
SET
|
|
678
|
+
credential_digest = ${input.credentialDigest},
|
|
679
|
+
credential_reference = ${input.credentialReference}
|
|
680
|
+
WHERE follower_id = ${follower.id}
|
|
681
|
+
`;
|
|
682
|
+
const credentialUpdated = yield* statusCount(sql, sql `
|
|
683
|
+
SELECT count(*) AS count
|
|
684
|
+
FROM follower_credentials
|
|
685
|
+
WHERE follower_id = ${follower.id}
|
|
686
|
+
`, "follower credential", follower.id);
|
|
687
|
+
if (credentialUpdated !== 1) {
|
|
688
|
+
return yield* new EnrollmentStateConflictError({
|
|
689
|
+
reason: "follower-identity-conflict",
|
|
690
|
+
message: "the revoked follower credential is unavailable",
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
yield* sql `
|
|
695
|
+
UPDATE enrollment_invitations
|
|
696
|
+
SET used_at = ${new Date().toISOString()}
|
|
697
|
+
WHERE code_digest = ${pending.code_digest} AND used_at IS NULL
|
|
698
|
+
`;
|
|
699
|
+
const changedRows = yield* sql `SELECT changes() AS count`;
|
|
700
|
+
const changed = yield* decodeRows(CountRow, changedRows, "enrollment invitation", pending.code_digest);
|
|
701
|
+
if ((changed[0]?.count ?? 0) !== 1) {
|
|
702
|
+
return yield* new EnrollmentStateConflictError({
|
|
703
|
+
reason: "invitation-used",
|
|
704
|
+
message: "the enrollment invitation was consumed concurrently",
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
yield* sql `
|
|
708
|
+
DELETE FROM pending_enrollments
|
|
709
|
+
WHERE follower_id = ${pending.follower_id}
|
|
710
|
+
AND credential_digest = ${pending.credential_digest}
|
|
711
|
+
`;
|
|
712
|
+
});
|
|
713
|
+
yield* sql.withTransaction(transaction).pipe(Effect.mapError((error) => error instanceof EnrollmentStateConflictError
|
|
714
|
+
? error
|
|
715
|
+
: error instanceof RepositoryDecodeError
|
|
716
|
+
? error
|
|
717
|
+
: error instanceof RepositorySqlError
|
|
718
|
+
? error
|
|
719
|
+
: sqlError("finalize enrollment")(error)));
|
|
720
|
+
});
|
|
721
|
+
const cancelPendingEnrollment = Effect.fn("StateRepository.cancelPendingEnrollment")(function* (input) {
|
|
722
|
+
yield* sql `
|
|
723
|
+
DELETE FROM pending_enrollments
|
|
724
|
+
WHERE credential_digest = ${input.credentialDigest}
|
|
725
|
+
`.pipe(Effect.mapError(sqlError("cancel pending enrollment")));
|
|
726
|
+
});
|
|
727
|
+
const listPendingEnrollments = Effect.fn("StateRepository.listPendingEnrollments")(function* () {
|
|
728
|
+
const rows = yield* sql `
|
|
729
|
+
SELECT follower_id, code_digest, credential_digest, credential_reference, follower_json
|
|
730
|
+
FROM pending_enrollments
|
|
731
|
+
ORDER BY created_at, follower_id
|
|
732
|
+
`.pipe(Effect.mapError(sqlError("list pending enrollments")));
|
|
733
|
+
const decoded = yield* decodeRows(PendingEnrollmentRow, rows, "pending enrollment", "all");
|
|
734
|
+
return yield* Effect.forEach(decoded, (row) => parseJson(FollowerIdentity, row.follower_json, "pending follower identity", row.follower_id)
|
|
735
|
+
.pipe(Effect.as({
|
|
736
|
+
follower: row.follower_id,
|
|
737
|
+
codeDigest: row.code_digest,
|
|
738
|
+
credentialDigest: row.credential_digest,
|
|
739
|
+
credentialReference: row.credential_reference,
|
|
740
|
+
})));
|
|
741
|
+
});
|
|
742
|
+
const decodeFollowerCredential = Effect.fn("StateRepository.decodeFollowerCredential")(function* (row) {
|
|
743
|
+
const groups = yield* parseJson(Schema.Array(GroupName), row.groups_json, "follower groups", row.id);
|
|
744
|
+
const follower = yield* Schema.decodeUnknownEffect(FollowerIdentity)({
|
|
745
|
+
id: row.id,
|
|
746
|
+
name: row.name,
|
|
747
|
+
groups,
|
|
748
|
+
revoked: row.revoked === 1,
|
|
749
|
+
credentialReference: row.credential_reference,
|
|
750
|
+
enrolledAt: row.enrolled_at,
|
|
751
|
+
}).pipe(Effect.mapError(decodeError("follower", row.id)));
|
|
752
|
+
return {
|
|
753
|
+
follower,
|
|
754
|
+
credentialDigest: row.credential_digest,
|
|
755
|
+
credentialReference: row.credential_reference,
|
|
756
|
+
};
|
|
757
|
+
});
|
|
758
|
+
const findFollowerCredential = Effect.fn("StateRepository.findFollowerCredential")(function* (credentialDigest) {
|
|
759
|
+
const rows = yield* sql `
|
|
760
|
+
SELECT
|
|
761
|
+
followers.id,
|
|
762
|
+
followers.name,
|
|
763
|
+
followers.groups_json,
|
|
764
|
+
followers.revoked,
|
|
765
|
+
followers.credential_reference,
|
|
766
|
+
followers.enrolled_at,
|
|
767
|
+
follower_credentials.credential_digest
|
|
768
|
+
FROM follower_credentials
|
|
769
|
+
INNER JOIN followers ON followers.id = follower_credentials.follower_id
|
|
770
|
+
WHERE follower_credentials.credential_digest = ${credentialDigest}
|
|
771
|
+
`.pipe(Effect.mapError(sqlError("find follower credential")));
|
|
772
|
+
const decoded = yield* decodeRows(FollowerCredentialRow, rows, "follower credential", credentialDigest);
|
|
773
|
+
const row = decoded[0];
|
|
774
|
+
return row === undefined ? undefined : yield* decodeFollowerCredential(row);
|
|
775
|
+
});
|
|
776
|
+
const getFollowerCredential = Effect.fn("StateRepository.getFollowerCredential")(function* (follower) {
|
|
777
|
+
const rows = yield* sql `
|
|
778
|
+
SELECT
|
|
779
|
+
followers.id,
|
|
780
|
+
followers.name,
|
|
781
|
+
followers.groups_json,
|
|
782
|
+
followers.revoked,
|
|
783
|
+
followers.credential_reference,
|
|
784
|
+
followers.enrolled_at,
|
|
785
|
+
follower_credentials.credential_digest
|
|
786
|
+
FROM follower_credentials
|
|
787
|
+
INNER JOIN followers ON followers.id = follower_credentials.follower_id
|
|
788
|
+
WHERE follower_credentials.follower_id = ${follower}
|
|
789
|
+
`.pipe(Effect.mapError(sqlError("get follower credential")));
|
|
790
|
+
const decoded = yield* decodeRows(FollowerCredentialRow, rows, "follower credential", follower);
|
|
791
|
+
const row = decoded[0];
|
|
792
|
+
if (row === undefined)
|
|
793
|
+
return yield* new FollowerNotFoundError({ follower });
|
|
794
|
+
return yield* decodeFollowerCredential(row);
|
|
795
|
+
});
|
|
796
|
+
const revokeFollower = Effect.fn("StateRepository.revokeFollower")(function* (follower) {
|
|
797
|
+
const count = yield* statusCount(sql, sql `SELECT count(*) AS count FROM followers WHERE id = ${follower}`, "follower", follower);
|
|
798
|
+
if (count === 0)
|
|
799
|
+
return yield* new FollowerNotFoundError({ follower });
|
|
800
|
+
yield* sql `UPDATE followers SET revoked = 1 WHERE id = ${follower}`.pipe(Effect.mapError(sqlError("revoke follower")));
|
|
801
|
+
});
|
|
802
|
+
const updateFollowerGroups = Effect.fn("StateRepository.updateFollowerGroups")(function* (follower, groups) {
|
|
803
|
+
const count = yield* statusCount(sql, sql `SELECT count(*) AS count FROM followers WHERE id = ${follower}`, "follower", follower);
|
|
804
|
+
if (count === 0)
|
|
805
|
+
return yield* new FollowerNotFoundError({ follower });
|
|
806
|
+
yield* sql `
|
|
807
|
+
UPDATE followers
|
|
808
|
+
SET groups_json = ${encodeJson([...groups])}
|
|
809
|
+
WHERE id = ${follower}
|
|
810
|
+
`.pipe(Effect.mapError(sqlError("update follower groups")));
|
|
811
|
+
});
|
|
812
|
+
const publishRevision = Effect.fn("StateRepository.publishRevision")(function* (input) {
|
|
813
|
+
const revision = input.revision;
|
|
814
|
+
const encoded = encodeJson(revision);
|
|
815
|
+
const transaction = Effect.gen(function* () {
|
|
816
|
+
const existingRows = yield* sql `
|
|
817
|
+
SELECT revision_json
|
|
818
|
+
FROM profile_revisions
|
|
819
|
+
WHERE id = ${revision.id}
|
|
820
|
+
`;
|
|
821
|
+
const existing = yield* decodeRows(RevisionJsonRow, existingRows, "profile revision row", revision.id);
|
|
822
|
+
if (existing.length > 0) {
|
|
823
|
+
const stored = yield* parseJson(ProfileRevisionSchema, existing[0].revision_json, "profile revision", revision.id);
|
|
824
|
+
if (encodeJson(stored) === encoded)
|
|
825
|
+
return;
|
|
826
|
+
return yield* new RevisionImmutableError({
|
|
827
|
+
revision: revision.id,
|
|
828
|
+
message: "the revision id already names different immutable content",
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
yield* sql `
|
|
832
|
+
INSERT INTO profile_revisions (
|
|
833
|
+
id,
|
|
834
|
+
profile_id,
|
|
835
|
+
sequence,
|
|
836
|
+
canonical_bytes,
|
|
837
|
+
digest,
|
|
838
|
+
signature,
|
|
839
|
+
published_at,
|
|
840
|
+
revision_json
|
|
841
|
+
) VALUES (
|
|
842
|
+
${revision.id},
|
|
843
|
+
${revision.profileId},
|
|
844
|
+
${revision.sequence},
|
|
845
|
+
${revision.canonicalBytes},
|
|
846
|
+
${revision.digest},
|
|
847
|
+
${revision.signature},
|
|
848
|
+
${revision.publishedAt},
|
|
849
|
+
${encoded}
|
|
850
|
+
)
|
|
851
|
+
`;
|
|
852
|
+
for (const resource of revision.resources) {
|
|
853
|
+
for (const blob of resource.blobs) {
|
|
854
|
+
yield* sql `
|
|
855
|
+
INSERT OR IGNORE INTO profile_revision_blobs (
|
|
856
|
+
blob_id,
|
|
857
|
+
revision_id,
|
|
858
|
+
resource_id
|
|
859
|
+
) VALUES (
|
|
860
|
+
${Schema.decodeUnknownSync(BlobId)(blob)},
|
|
861
|
+
${revision.id},
|
|
862
|
+
${resource.id}
|
|
863
|
+
)
|
|
864
|
+
`;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
});
|
|
868
|
+
yield* sql.withTransaction(transaction).pipe(Effect.catchTag("SqlError", (error) => Effect.fail(error.reason._tag === "UniqueViolation"
|
|
869
|
+
? new RevisionImmutableError({
|
|
870
|
+
revision: revision.id,
|
|
871
|
+
message: `profile sequence is already published: ${error.reason.constraint}`,
|
|
872
|
+
})
|
|
873
|
+
: sqlError("publish profile revision")(error))));
|
|
874
|
+
});
|
|
875
|
+
const getRevision = Effect.fn("StateRepository.getRevision")(function* (revision) {
|
|
876
|
+
const rows = yield* sql `
|
|
877
|
+
SELECT revision_json
|
|
878
|
+
FROM profile_revisions
|
|
879
|
+
WHERE id = ${revision}
|
|
880
|
+
`.pipe(Effect.mapError(sqlError("load profile revision")));
|
|
881
|
+
const decoded = yield* decodeRows(RevisionJsonRow, rows, "profile revision row", revision);
|
|
882
|
+
const row = decoded[0];
|
|
883
|
+
if (row === undefined)
|
|
884
|
+
return yield* new RevisionNotFoundError({ revision });
|
|
885
|
+
return yield* parseJson(ProfileRevisionSchema, row.revision_json, "profile revision", revision);
|
|
886
|
+
});
|
|
887
|
+
const findRevision = Effect.fn("StateRepository.findRevision")(function* (revision) {
|
|
888
|
+
const rows = yield* sql `
|
|
889
|
+
SELECT revision_json
|
|
890
|
+
FROM profile_revisions
|
|
891
|
+
WHERE id = ${revision}
|
|
892
|
+
`.pipe(Effect.mapError(sqlError("find profile revision")));
|
|
893
|
+
const decoded = yield* decodeRows(RevisionJsonRow, rows, "profile revision row", revision);
|
|
894
|
+
const row = decoded[0];
|
|
895
|
+
if (row === undefined)
|
|
896
|
+
return undefined;
|
|
897
|
+
return yield* parseJson(ProfileRevisionSchema, row.revision_json, "profile revision", revision);
|
|
898
|
+
});
|
|
899
|
+
const getLatestRevision = Effect.fn("StateRepository.getLatestRevision")(function* (profile) {
|
|
900
|
+
const rows = yield* sql `
|
|
901
|
+
SELECT revision_json
|
|
902
|
+
FROM profile_revisions
|
|
903
|
+
WHERE profile_id = ${profile}
|
|
904
|
+
ORDER BY sequence DESC
|
|
905
|
+
LIMIT 1
|
|
906
|
+
`.pipe(Effect.mapError(sqlError("load latest profile revision")));
|
|
907
|
+
const decoded = yield* decodeRows(RevisionJsonRow, rows, "profile revision row", profile);
|
|
908
|
+
const row = decoded[0];
|
|
909
|
+
if (row === undefined)
|
|
910
|
+
return undefined;
|
|
911
|
+
return yield* parseJson(ProfileRevisionSchema, row.revision_json, "profile revision", profile);
|
|
912
|
+
});
|
|
913
|
+
const listRevisions = Effect.fn("StateRepository.listRevisions")(function* () {
|
|
914
|
+
const rows = yield* sql `
|
|
915
|
+
SELECT revision_json
|
|
916
|
+
FROM profile_revisions
|
|
917
|
+
ORDER BY profile_id, sequence
|
|
918
|
+
`.pipe(Effect.mapError(sqlError("list profile revisions")));
|
|
919
|
+
const decoded = yield* decodeRows(RevisionJsonRow, rows, "profile revision row", "all");
|
|
920
|
+
const revisions = [];
|
|
921
|
+
for (const row of decoded) {
|
|
922
|
+
revisions.push(yield* parseJson(ProfileRevisionSchema, row.revision_json, "profile revision", "all"));
|
|
923
|
+
}
|
|
924
|
+
return revisions;
|
|
925
|
+
});
|
|
926
|
+
const listRevisionBlobCandidates = Effect.fn("StateRepository.listRevisionBlobCandidates")(function* (blob) {
|
|
927
|
+
const rows = yield* sql `
|
|
928
|
+
SELECT
|
|
929
|
+
profile_revision_blobs.blob_id,
|
|
930
|
+
profile_revision_blobs.revision_id,
|
|
931
|
+
profile_revision_blobs.resource_id
|
|
932
|
+
FROM profile_revision_blobs
|
|
933
|
+
WHERE profile_revision_blobs.blob_id = ${blob}
|
|
934
|
+
ORDER BY profile_revision_blobs.revision_id, profile_revision_blobs.resource_id
|
|
935
|
+
`.pipe(Effect.mapError(sqlError("list profile revision blob candidates")));
|
|
936
|
+
const decoded = yield* decodeRows(RevisionBlobCandidateRow, rows, "profile revision blob candidates", blob);
|
|
937
|
+
return decoded.map((row) => ({
|
|
938
|
+
blob: row.blob_id,
|
|
939
|
+
revision: row.revision_id,
|
|
940
|
+
resource: row.resource_id,
|
|
941
|
+
}));
|
|
942
|
+
});
|
|
943
|
+
const loadAppliedResources = Effect.fn("StateRepository.loadAppliedResources")(function* (follower) {
|
|
944
|
+
const rows = yield* sql `
|
|
945
|
+
SELECT resource_id, revision_id, digest, applied_at, owned_files_json, schedule_json,
|
|
946
|
+
kind, policy, target, owned_keys_json, config_format
|
|
947
|
+
, executable, symlink_target
|
|
948
|
+
FROM applied_resources
|
|
949
|
+
WHERE follower_id = ${follower}
|
|
950
|
+
ORDER BY resource_id
|
|
951
|
+
`.pipe(Effect.mapError(sqlError("load applied resources")));
|
|
952
|
+
const stored = yield* decodeRows(AppliedResourceRow, rows, "applied resources", follower);
|
|
953
|
+
return yield* Effect.forEach(stored, (row) => Effect.gen(function* () {
|
|
954
|
+
const ownedFiles = row.owned_files_json === null
|
|
955
|
+
? undefined
|
|
956
|
+
: yield* parseJson(OwnedFilesSchema, row.owned_files_json, "applied resource owned files", row.resource_id);
|
|
957
|
+
const schedule = row.schedule_json === null
|
|
958
|
+
? undefined
|
|
959
|
+
: yield* parseJson(StoredScheduleSchema, row.schedule_json, "applied resource schedule", row.resource_id);
|
|
960
|
+
const ownedKeys = row.owned_keys_json === null
|
|
961
|
+
? undefined
|
|
962
|
+
: yield* parseJson(OwnedKeysSchema, row.owned_keys_json, "applied resource owned keys", row.resource_id);
|
|
963
|
+
return yield* Schema.decodeUnknownEffect(AppliedResourceRecordSchema)({
|
|
964
|
+
resource: row.resource_id,
|
|
965
|
+
revision: row.revision_id,
|
|
966
|
+
digest: row.digest,
|
|
967
|
+
appliedAt: row.applied_at,
|
|
968
|
+
kind: row.kind ?? undefined,
|
|
969
|
+
policy: row.policy ?? undefined,
|
|
970
|
+
target: row.target ?? undefined,
|
|
971
|
+
executable: row.executable === null
|
|
972
|
+
? undefined
|
|
973
|
+
: row.executable === 1,
|
|
974
|
+
symlinkTo: row.symlink_target ?? undefined,
|
|
975
|
+
ownedFiles,
|
|
976
|
+
ownedKeys,
|
|
977
|
+
configFormat: row.config_format ?? undefined,
|
|
978
|
+
schedule,
|
|
979
|
+
}).pipe(Effect.mapError(decodeError("applied resource", row.resource_id)));
|
|
980
|
+
}));
|
|
981
|
+
});
|
|
982
|
+
const startRun = Effect.fn("StateRepository.startRun")(function* (input) {
|
|
983
|
+
const transaction = Effect.gen(function* () {
|
|
984
|
+
const followerCount = yield* statusCount(sql, sql `SELECT COUNT(*) AS count FROM followers WHERE id = ${input.follower}`, "follower", input.follower);
|
|
985
|
+
if (followerCount === 0) {
|
|
986
|
+
return yield* new FollowerNotFoundError({ follower: input.follower });
|
|
987
|
+
}
|
|
988
|
+
const revisionCount = yield* statusCount(sql, sql `SELECT COUNT(*) AS count FROM profile_revisions WHERE id = ${input.revision}`, "profile revision", input.revision);
|
|
989
|
+
if (revisionCount === 0) {
|
|
990
|
+
return yield* new RevisionNotFoundError({ revision: input.revision });
|
|
991
|
+
}
|
|
992
|
+
const recoverableRunCount = yield* statusCount(sql, sql `
|
|
993
|
+
SELECT COUNT(*) AS count
|
|
994
|
+
FROM synchronization_runs
|
|
995
|
+
WHERE follower_id = ${input.follower}
|
|
996
|
+
AND status IN ('applying', 'Interrupted')
|
|
997
|
+
`, "recoverable synchronization run", input.follower);
|
|
998
|
+
if (recoverableRunCount > 0) {
|
|
999
|
+
return yield* new ActiveRunExistsError({ follower: input.follower });
|
|
1000
|
+
}
|
|
1001
|
+
yield* sql `
|
|
1002
|
+
INSERT INTO synchronization_runs (
|
|
1003
|
+
id,
|
|
1004
|
+
follower_id,
|
|
1005
|
+
revision_id,
|
|
1006
|
+
status,
|
|
1007
|
+
plan_json,
|
|
1008
|
+
started_at
|
|
1009
|
+
) VALUES (
|
|
1010
|
+
${input.id},
|
|
1011
|
+
${input.follower},
|
|
1012
|
+
${input.revision},
|
|
1013
|
+
'applying',
|
|
1014
|
+
${encodeJson(input.plan)},
|
|
1015
|
+
${input.startedAt}
|
|
1016
|
+
)
|
|
1017
|
+
`;
|
|
1018
|
+
for (let ordinal = 0; ordinal < input.plan.actions.length; ordinal += 1) {
|
|
1019
|
+
const action = input.plan.actions[ordinal];
|
|
1020
|
+
yield* sql `
|
|
1021
|
+
INSERT INTO run_actions (run_id, action_id, plan_ordinal)
|
|
1022
|
+
VALUES (${input.id}, ${action.id}, ${ordinal})
|
|
1023
|
+
`;
|
|
1024
|
+
yield* sql `
|
|
1025
|
+
INSERT INTO action_journal (
|
|
1026
|
+
run_id,
|
|
1027
|
+
action_id,
|
|
1028
|
+
sequence,
|
|
1029
|
+
state,
|
|
1030
|
+
recorded_at,
|
|
1031
|
+
attempt
|
|
1032
|
+
) VALUES (
|
|
1033
|
+
${input.id},
|
|
1034
|
+
${action.id},
|
|
1035
|
+
${ordinal},
|
|
1036
|
+
'pending',
|
|
1037
|
+
${input.startedAt},
|
|
1038
|
+
0
|
|
1039
|
+
)
|
|
1040
|
+
`;
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
yield* sql.withTransaction(transaction).pipe(Effect.catchTag("SqlError", (error) => {
|
|
1044
|
+
if (error.reason._tag === "UniqueViolation"
|
|
1045
|
+
&& error.reason.constraint.includes("synchronization_runs.follower_id")) {
|
|
1046
|
+
return Effect.fail(new ActiveRunExistsError({ follower: input.follower }));
|
|
1047
|
+
}
|
|
1048
|
+
return Effect.fail(sqlError("start synchronization run")(error));
|
|
1049
|
+
}));
|
|
1050
|
+
});
|
|
1051
|
+
const loadRunStatus = Effect.fn("StateRepository.loadRunStatus")(function* (run) {
|
|
1052
|
+
const rows = yield* sql `
|
|
1053
|
+
SELECT follower_id, status
|
|
1054
|
+
FROM synchronization_runs
|
|
1055
|
+
WHERE id = ${run}
|
|
1056
|
+
`.pipe(Effect.mapError(sqlError("load synchronization run")));
|
|
1057
|
+
const decoded = yield* decodeRows(RunStatusRow, rows, "synchronization run", run);
|
|
1058
|
+
const row = decoded[0];
|
|
1059
|
+
if (row === undefined)
|
|
1060
|
+
return yield* new RunNotFoundError({ run });
|
|
1061
|
+
return row;
|
|
1062
|
+
});
|
|
1063
|
+
const journalAction = Effect.fn("StateRepository.journalAction")(function* (input) {
|
|
1064
|
+
const transaction = Effect.gen(function* () {
|
|
1065
|
+
const run = yield* loadRunStatus(input.run);
|
|
1066
|
+
if (run.status !== "applying" && run.status !== "Interrupted") {
|
|
1067
|
+
return yield* new InvalidRunTransitionError({
|
|
1068
|
+
run: input.run,
|
|
1069
|
+
message: `cannot journal an action while run status is ${run.status}`,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
const actionCount = yield* statusCount(sql, sql `
|
|
1073
|
+
SELECT COUNT(*) AS count
|
|
1074
|
+
FROM run_actions
|
|
1075
|
+
WHERE run_id = ${input.run} AND action_id = ${input.action}
|
|
1076
|
+
`, "planned action", input.action);
|
|
1077
|
+
if (actionCount === 0) {
|
|
1078
|
+
return yield* new ActionNotInPlanError({
|
|
1079
|
+
run: input.run,
|
|
1080
|
+
action: input.action,
|
|
1081
|
+
});
|
|
1082
|
+
}
|
|
1083
|
+
const sequenceRows = yield* sql `
|
|
1084
|
+
SELECT COALESCE(MAX(sequence), -1) + 1 AS count
|
|
1085
|
+
FROM action_journal
|
|
1086
|
+
WHERE run_id = ${input.run}
|
|
1087
|
+
`;
|
|
1088
|
+
const sequences = yield* decodeRows(CountRow, sequenceRows, "action journal sequence", input.run);
|
|
1089
|
+
const sequence = sequences[0]?.count ?? 0;
|
|
1090
|
+
yield* sql `
|
|
1091
|
+
INSERT INTO action_journal (
|
|
1092
|
+
run_id,
|
|
1093
|
+
action_id,
|
|
1094
|
+
sequence,
|
|
1095
|
+
state,
|
|
1096
|
+
recorded_at,
|
|
1097
|
+
attempt,
|
|
1098
|
+
verification_json,
|
|
1099
|
+
rollback_reference,
|
|
1100
|
+
removed_resource_json
|
|
1101
|
+
) VALUES (
|
|
1102
|
+
${input.run},
|
|
1103
|
+
${input.action},
|
|
1104
|
+
${sequence},
|
|
1105
|
+
${input.state},
|
|
1106
|
+
${input.recordedAt},
|
|
1107
|
+
${input.attempt},
|
|
1108
|
+
${input.verification === undefined ? null : encodeJson(input.verification)},
|
|
1109
|
+
${input.rollbackReference ?? null},
|
|
1110
|
+
${input.removedResourceRecord === undefined
|
|
1111
|
+
? null
|
|
1112
|
+
: encodeJson(input.removedResourceRecord)}
|
|
1113
|
+
)
|
|
1114
|
+
`;
|
|
1115
|
+
const removedResource = input.removedResourceRecord?.resource
|
|
1116
|
+
?? input.removedResource;
|
|
1117
|
+
if (removedResource !== undefined) {
|
|
1118
|
+
yield* sql `
|
|
1119
|
+
DELETE FROM applied_resources
|
|
1120
|
+
WHERE follower_id = ${run.follower_id}
|
|
1121
|
+
AND resource_id = ${removedResource}
|
|
1122
|
+
`;
|
|
1123
|
+
}
|
|
1124
|
+
if (input.appliedResource !== undefined) {
|
|
1125
|
+
const record = input.appliedResource;
|
|
1126
|
+
yield* sql `
|
|
1127
|
+
INSERT INTO applied_resources (
|
|
1128
|
+
follower_id,
|
|
1129
|
+
resource_id,
|
|
1130
|
+
revision_id,
|
|
1131
|
+
digest,
|
|
1132
|
+
applied_at,
|
|
1133
|
+
owned_files_json,
|
|
1134
|
+
schedule_json,
|
|
1135
|
+
kind,
|
|
1136
|
+
policy,
|
|
1137
|
+
target,
|
|
1138
|
+
owned_keys_json,
|
|
1139
|
+
config_format,
|
|
1140
|
+
executable,
|
|
1141
|
+
symlink_target
|
|
1142
|
+
) VALUES (
|
|
1143
|
+
${run.follower_id},
|
|
1144
|
+
${record.resource},
|
|
1145
|
+
${record.revision},
|
|
1146
|
+
${record.digest},
|
|
1147
|
+
${record.appliedAt},
|
|
1148
|
+
${record.ownedFiles === undefined
|
|
1149
|
+
? null
|
|
1150
|
+
: encodeJson(JSON.parse(JSON.stringify(record.ownedFiles)))}
|
|
1151
|
+
, ${record.schedule === undefined
|
|
1152
|
+
? null
|
|
1153
|
+
: encodeJson(JSON.parse(JSON.stringify(record.schedule)))}
|
|
1154
|
+
, ${record.kind ?? null}
|
|
1155
|
+
, ${record.policy ?? null}
|
|
1156
|
+
, ${record.target ?? null}
|
|
1157
|
+
, ${record.ownedKeys === undefined
|
|
1158
|
+
? null
|
|
1159
|
+
: encodeJson(JSON.parse(JSON.stringify(record.ownedKeys)))}
|
|
1160
|
+
, ${record.configFormat ?? null}
|
|
1161
|
+
, ${record.executable === undefined
|
|
1162
|
+
? null
|
|
1163
|
+
: record.executable ? 1 : 0}
|
|
1164
|
+
, ${record.symlinkTo ?? null}
|
|
1165
|
+
)
|
|
1166
|
+
ON CONFLICT(follower_id, resource_id) DO UPDATE SET
|
|
1167
|
+
revision_id = excluded.revision_id,
|
|
1168
|
+
digest = excluded.digest,
|
|
1169
|
+
applied_at = excluded.applied_at,
|
|
1170
|
+
owned_files_json = excluded.owned_files_json
|
|
1171
|
+
, schedule_json = excluded.schedule_json
|
|
1172
|
+
, kind = excluded.kind
|
|
1173
|
+
, policy = excluded.policy
|
|
1174
|
+
, target = excluded.target
|
|
1175
|
+
, owned_keys_json = excluded.owned_keys_json
|
|
1176
|
+
, config_format = excluded.config_format
|
|
1177
|
+
, executable = excluded.executable
|
|
1178
|
+
, symlink_target = excluded.symlink_target
|
|
1179
|
+
`;
|
|
1180
|
+
}
|
|
1181
|
+
});
|
|
1182
|
+
yield* sql.withTransaction(transaction).pipe(Effect.mapError((error) => "_tag" in error && error._tag !== "SqlError"
|
|
1183
|
+
? error
|
|
1184
|
+
: sqlError("journal synchronization action")(error)));
|
|
1185
|
+
});
|
|
1186
|
+
const recordDrift = Effect.fn("StateRepository.recordDrift")(function* (input) {
|
|
1187
|
+
const transaction = Effect.gen(function* () {
|
|
1188
|
+
yield* loadRunStatus(input.run);
|
|
1189
|
+
const sequenceRows = yield* sql `
|
|
1190
|
+
SELECT COALESCE(MAX(sequence), -1) + 1 AS count
|
|
1191
|
+
FROM drift_records
|
|
1192
|
+
WHERE run_id = ${input.run}
|
|
1193
|
+
`;
|
|
1194
|
+
const sequences = yield* decodeRows(CountRow, sequenceRows, "drift sequence", input.run);
|
|
1195
|
+
yield* sql `
|
|
1196
|
+
INSERT INTO drift_records (
|
|
1197
|
+
run_id,
|
|
1198
|
+
sequence,
|
|
1199
|
+
conflict_json,
|
|
1200
|
+
recorded_at
|
|
1201
|
+
) VALUES (
|
|
1202
|
+
${input.run},
|
|
1203
|
+
${sequences[0]?.count ?? 0},
|
|
1204
|
+
${encodeJson(input.conflict)},
|
|
1205
|
+
${input.recordedAt}
|
|
1206
|
+
)
|
|
1207
|
+
`;
|
|
1208
|
+
});
|
|
1209
|
+
yield* sql.withTransaction(transaction).pipe(Effect.mapError((error) => "_tag" in error && error._tag !== "SqlError"
|
|
1210
|
+
? error
|
|
1211
|
+
: sqlError("record follower drift")(error)));
|
|
1212
|
+
});
|
|
1213
|
+
const completeRun = Effect.fn("StateRepository.completeRun")(function* (input) {
|
|
1214
|
+
const transaction = Effect.gen(function* () {
|
|
1215
|
+
const run = yield* loadRunStatus(input.run);
|
|
1216
|
+
if (run.status !== "applying" && run.status !== "Interrupted") {
|
|
1217
|
+
return yield* new InvalidRunTransitionError({
|
|
1218
|
+
run: input.run,
|
|
1219
|
+
message: `run is already ${run.status}`,
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
if (input.outcome.run !== input.run) {
|
|
1223
|
+
return yield* new InvalidRunTransitionError({
|
|
1224
|
+
run: input.run,
|
|
1225
|
+
message: `outcome belongs to run ${input.outcome.run}`,
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
yield* sql `
|
|
1229
|
+
UPDATE synchronization_runs
|
|
1230
|
+
SET
|
|
1231
|
+
status = ${input.outcome.outcome},
|
|
1232
|
+
completed_at = ${input.completedAt},
|
|
1233
|
+
outcome_json = ${encodeJson(input.outcome)}
|
|
1234
|
+
WHERE id = ${input.run}
|
|
1235
|
+
`;
|
|
1236
|
+
for (const resource of input.removedResources ?? []) {
|
|
1237
|
+
yield* sql `
|
|
1238
|
+
DELETE FROM applied_resources
|
|
1239
|
+
WHERE follower_id = ${run.follower_id}
|
|
1240
|
+
AND resource_id = ${resource}
|
|
1241
|
+
`;
|
|
1242
|
+
}
|
|
1243
|
+
for (const record of input.appliedResources) {
|
|
1244
|
+
yield* sql `
|
|
1245
|
+
INSERT INTO applied_resources (
|
|
1246
|
+
follower_id,
|
|
1247
|
+
resource_id,
|
|
1248
|
+
revision_id,
|
|
1249
|
+
digest,
|
|
1250
|
+
applied_at,
|
|
1251
|
+
owned_files_json,
|
|
1252
|
+
schedule_json,
|
|
1253
|
+
kind,
|
|
1254
|
+
policy,
|
|
1255
|
+
target,
|
|
1256
|
+
owned_keys_json,
|
|
1257
|
+
config_format,
|
|
1258
|
+
executable,
|
|
1259
|
+
symlink_target
|
|
1260
|
+
) VALUES (
|
|
1261
|
+
${run.follower_id},
|
|
1262
|
+
${record.resource},
|
|
1263
|
+
${record.revision},
|
|
1264
|
+
${record.digest},
|
|
1265
|
+
${record.appliedAt},
|
|
1266
|
+
${record.ownedFiles === undefined
|
|
1267
|
+
? null
|
|
1268
|
+
: encodeJson(JSON.parse(JSON.stringify(record.ownedFiles)))}
|
|
1269
|
+
, ${record.schedule === undefined
|
|
1270
|
+
? null
|
|
1271
|
+
: encodeJson(JSON.parse(JSON.stringify(record.schedule)))}
|
|
1272
|
+
, ${record.kind ?? null}
|
|
1273
|
+
, ${record.policy ?? null}
|
|
1274
|
+
, ${record.target ?? null}
|
|
1275
|
+
, ${record.ownedKeys === undefined
|
|
1276
|
+
? null
|
|
1277
|
+
: encodeJson(JSON.parse(JSON.stringify(record.ownedKeys)))}
|
|
1278
|
+
, ${record.configFormat ?? null}
|
|
1279
|
+
, ${record.executable === undefined
|
|
1280
|
+
? null
|
|
1281
|
+
: record.executable ? 1 : 0}
|
|
1282
|
+
, ${record.symlinkTo ?? null}
|
|
1283
|
+
)
|
|
1284
|
+
ON CONFLICT(follower_id, resource_id) DO UPDATE SET
|
|
1285
|
+
revision_id = excluded.revision_id,
|
|
1286
|
+
digest = excluded.digest,
|
|
1287
|
+
applied_at = excluded.applied_at,
|
|
1288
|
+
owned_files_json = excluded.owned_files_json
|
|
1289
|
+
, schedule_json = excluded.schedule_json
|
|
1290
|
+
, kind = excluded.kind
|
|
1291
|
+
, policy = excluded.policy
|
|
1292
|
+
, target = excluded.target
|
|
1293
|
+
, owned_keys_json = excluded.owned_keys_json
|
|
1294
|
+
, config_format = excluded.config_format
|
|
1295
|
+
, executable = excluded.executable
|
|
1296
|
+
, symlink_target = excluded.symlink_target
|
|
1297
|
+
`;
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
yield* sql.withTransaction(transaction).pipe(Effect.mapError((error) => "_tag" in error && error._tag !== "SqlError"
|
|
1301
|
+
? error
|
|
1302
|
+
: sqlError("complete synchronization run")(error)));
|
|
1303
|
+
});
|
|
1304
|
+
const loadRecovery = Effect.fn("StateRepository.loadRecovery")(function* (follower) {
|
|
1305
|
+
const runRows = yield* sql `
|
|
1306
|
+
SELECT id, follower_id, revision_id, plan_json, started_at
|
|
1307
|
+
FROM synchronization_runs
|
|
1308
|
+
WHERE follower_id = ${follower}
|
|
1309
|
+
AND status IN ('applying', 'Interrupted')
|
|
1310
|
+
ORDER BY started_at DESC
|
|
1311
|
+
LIMIT 1
|
|
1312
|
+
`.pipe(Effect.mapError(sqlError("load active synchronization run")));
|
|
1313
|
+
const runs = yield* decodeRows(ActiveRunRow, runRows, "active synchronization run", follower);
|
|
1314
|
+
const row = runs[0];
|
|
1315
|
+
if (row === undefined)
|
|
1316
|
+
return undefined;
|
|
1317
|
+
const plan = yield* parseJson(SynchronizationPlanSchema, row.plan_json, "synchronization plan", row.id);
|
|
1318
|
+
const journalRows = yield* sql `
|
|
1319
|
+
SELECT
|
|
1320
|
+
action_id,
|
|
1321
|
+
sequence,
|
|
1322
|
+
state,
|
|
1323
|
+
recorded_at,
|
|
1324
|
+
attempt,
|
|
1325
|
+
verification_json,
|
|
1326
|
+
rollback_reference,
|
|
1327
|
+
removed_resource_json
|
|
1328
|
+
FROM action_journal
|
|
1329
|
+
WHERE run_id = ${row.id}
|
|
1330
|
+
ORDER BY sequence
|
|
1331
|
+
`.pipe(Effect.mapError(sqlError("load action journal")));
|
|
1332
|
+
const journal = yield* decodeRows(ActionJournalRow, journalRows, "action journal", row.id);
|
|
1333
|
+
const actions = [];
|
|
1334
|
+
for (const event of journal) {
|
|
1335
|
+
let verification;
|
|
1336
|
+
if (event.verification_json !== null) {
|
|
1337
|
+
verification = yield* parseJson(VerificationEvidenceSchema, event.verification_json, "action verification", `${row.id}:${event.sequence}`);
|
|
1338
|
+
}
|
|
1339
|
+
const base = {
|
|
1340
|
+
action: event.action_id,
|
|
1341
|
+
ordinal: event.sequence,
|
|
1342
|
+
state: event.state,
|
|
1343
|
+
recordedAt: event.recorded_at,
|
|
1344
|
+
attempt: event.attempt,
|
|
1345
|
+
};
|
|
1346
|
+
const rollbackReference = event.rollback_reference;
|
|
1347
|
+
const removedResource = event.removed_resource_json === null
|
|
1348
|
+
? undefined
|
|
1349
|
+
: yield* parseJson(AppliedResourceRecordSchema, event.removed_resource_json, "removed resource ownership", `${row.id}:${event.sequence}`);
|
|
1350
|
+
const pushAction = (action) => {
|
|
1351
|
+
if (removedResource === undefined) {
|
|
1352
|
+
actions.push(action);
|
|
1353
|
+
}
|
|
1354
|
+
else {
|
|
1355
|
+
actions.push({ ...action, removedResource });
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
if (verification === undefined) {
|
|
1359
|
+
if (rollbackReference === null) {
|
|
1360
|
+
pushAction(base);
|
|
1361
|
+
}
|
|
1362
|
+
else {
|
|
1363
|
+
pushAction({
|
|
1364
|
+
...base,
|
|
1365
|
+
rollbackReference,
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
else if (rollbackReference === null) {
|
|
1370
|
+
pushAction({
|
|
1371
|
+
...base,
|
|
1372
|
+
verification,
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
else {
|
|
1376
|
+
pushAction({
|
|
1377
|
+
...base,
|
|
1378
|
+
verification,
|
|
1379
|
+
rollbackReference,
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
const driftRows = yield* sql `
|
|
1384
|
+
SELECT sequence, conflict_json, recorded_at
|
|
1385
|
+
FROM drift_records
|
|
1386
|
+
WHERE run_id = ${row.id}
|
|
1387
|
+
ORDER BY sequence
|
|
1388
|
+
`.pipe(Effect.mapError(sqlError("load drift records")));
|
|
1389
|
+
const encodedDrift = yield* decodeRows(DriftRow, driftRows, "drift records", row.id);
|
|
1390
|
+
const drift = [];
|
|
1391
|
+
for (const stored of encodedDrift) {
|
|
1392
|
+
const conflict = yield* parseJson(DriftConflictSchema, stored.conflict_json, "drift conflict", `${row.id}:${stored.sequence}`);
|
|
1393
|
+
drift.push({
|
|
1394
|
+
ordinal: stored.sequence,
|
|
1395
|
+
conflict,
|
|
1396
|
+
recordedAt: stored.recorded_at,
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
const appliedResources = yield* loadAppliedResources(follower);
|
|
1400
|
+
const removedResources = [
|
|
1401
|
+
...new Map(actions
|
|
1402
|
+
.flatMap((event) => event.removedResource === undefined
|
|
1403
|
+
? []
|
|
1404
|
+
: [[event.removedResource.resource, event.removedResource]])).values(),
|
|
1405
|
+
].sort((left, right) => left.resource.localeCompare(right.resource));
|
|
1406
|
+
return {
|
|
1407
|
+
run: {
|
|
1408
|
+
id: row.id,
|
|
1409
|
+
follower: row.follower_id,
|
|
1410
|
+
revision: row.revision_id,
|
|
1411
|
+
startedAt: row.started_at,
|
|
1412
|
+
plan,
|
|
1413
|
+
},
|
|
1414
|
+
actions,
|
|
1415
|
+
drift,
|
|
1416
|
+
appliedResources,
|
|
1417
|
+
removedResources,
|
|
1418
|
+
};
|
|
1419
|
+
});
|
|
1420
|
+
const loadState = Effect.fn("StateRepository.loadState")(function* (follower) {
|
|
1421
|
+
const followerRows = yield* sql `
|
|
1422
|
+
SELECT
|
|
1423
|
+
id,
|
|
1424
|
+
name,
|
|
1425
|
+
groups_json,
|
|
1426
|
+
revoked,
|
|
1427
|
+
credential_reference,
|
|
1428
|
+
enrolled_at
|
|
1429
|
+
FROM followers
|
|
1430
|
+
WHERE id = ${follower}
|
|
1431
|
+
`.pipe(Effect.mapError(sqlError("load follower state")));
|
|
1432
|
+
const followers = yield* decodeRows(FollowerRow, followerRows, "follower", follower);
|
|
1433
|
+
const storedFollower = followers[0];
|
|
1434
|
+
if (storedFollower === undefined) {
|
|
1435
|
+
return yield* new FollowerNotFoundError({ follower });
|
|
1436
|
+
}
|
|
1437
|
+
const groups = yield* parseJson(Schema.Array(Schema.String), storedFollower.groups_json, "follower groups", follower);
|
|
1438
|
+
const decodedFollower = yield* Schema.decodeUnknownEffect(FollowerIdentity)({
|
|
1439
|
+
id: storedFollower.id,
|
|
1440
|
+
name: storedFollower.name,
|
|
1441
|
+
groups,
|
|
1442
|
+
revoked: storedFollower.revoked === 1,
|
|
1443
|
+
credentialReference: storedFollower.credential_reference,
|
|
1444
|
+
enrolledAt: storedFollower.enrolled_at,
|
|
1445
|
+
}).pipe(Effect.mapError(decodeError("follower", follower)));
|
|
1446
|
+
const identityRows = yield* sql `
|
|
1447
|
+
SELECT key_id, public_key_fingerprint
|
|
1448
|
+
FROM source_identity
|
|
1449
|
+
WHERE singleton = 1
|
|
1450
|
+
`.pipe(Effect.mapError(sqlError("load source identity")));
|
|
1451
|
+
const identities = yield* decodeRows(SourceIdentityRow, identityRows, "source identity", "1");
|
|
1452
|
+
const storedIdentity = identities[0];
|
|
1453
|
+
const sourceIdentity = storedIdentity === undefined
|
|
1454
|
+
? undefined
|
|
1455
|
+
: yield* Schema.decodeUnknownEffect(SourceIdentity)({
|
|
1456
|
+
keyId: storedIdentity.key_id,
|
|
1457
|
+
publicKeyFingerprint: storedIdentity.public_key_fingerprint,
|
|
1458
|
+
}).pipe(Effect.mapError(decodeError("source identity", "1")));
|
|
1459
|
+
const activeRecovery = yield* loadRecovery(follower);
|
|
1460
|
+
if (sourceIdentity === undefined && activeRecovery === undefined) {
|
|
1461
|
+
return { follower: decodedFollower };
|
|
1462
|
+
}
|
|
1463
|
+
if (sourceIdentity === undefined) {
|
|
1464
|
+
return { follower: decodedFollower, activeRecovery };
|
|
1465
|
+
}
|
|
1466
|
+
if (activeRecovery === undefined) {
|
|
1467
|
+
return { follower: decodedFollower, sourceIdentity };
|
|
1468
|
+
}
|
|
1469
|
+
return { follower: decodedFollower, sourceIdentity, activeRecovery };
|
|
1470
|
+
});
|
|
1471
|
+
return StateRepository.of({
|
|
1472
|
+
saveSourceIdentity,
|
|
1473
|
+
registerFollower,
|
|
1474
|
+
saveFollowerSynchronizationConfiguration,
|
|
1475
|
+
getFollowerSynchronizationConfiguration,
|
|
1476
|
+
saveLocalOverlay,
|
|
1477
|
+
removeLocalOverlay,
|
|
1478
|
+
listLocalOverlays,
|
|
1479
|
+
saveEnrollmentSource,
|
|
1480
|
+
getEnrollmentSource,
|
|
1481
|
+
createEnrollmentInvitation,
|
|
1482
|
+
findEnrollmentInvitation,
|
|
1483
|
+
consumeEnrollmentInvitation,
|
|
1484
|
+
finalizeEnrollment,
|
|
1485
|
+
cancelPendingEnrollment,
|
|
1486
|
+
listPendingEnrollments,
|
|
1487
|
+
findFollowerCredential,
|
|
1488
|
+
getFollowerCredential,
|
|
1489
|
+
revokeFollower,
|
|
1490
|
+
updateFollowerGroups,
|
|
1491
|
+
publishRevision,
|
|
1492
|
+
getRevision,
|
|
1493
|
+
findRevision,
|
|
1494
|
+
getLatestRevision,
|
|
1495
|
+
listRevisions,
|
|
1496
|
+
listRevisionBlobCandidates,
|
|
1497
|
+
loadAppliedResources,
|
|
1498
|
+
startRun,
|
|
1499
|
+
journalAction,
|
|
1500
|
+
recordDrift,
|
|
1501
|
+
completeRun,
|
|
1502
|
+
loadRecovery,
|
|
1503
|
+
loadState,
|
|
1504
|
+
});
|
|
1505
|
+
});
|
|
1506
|
+
export const StateRepositoryLive = Layer.effect(StateRepository, makeRepository);
|
|
1507
|
+
export const stateRepositoryLayer = (filename) => StateRepositoryLive.pipe(Layer.provide(SqliteClient.layer({ filename })));
|