@sanoma-ai/workflows 0.10.3 → 0.10.6
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/CLOUD-IDENTITY.md +128 -17
- package/README.md +15 -4
- package/dist/apply.d.ts +11 -4
- package/dist/apply.js +126 -35
- package/dist/apply.js.map +1 -1
- package/dist/call.js +1 -1
- package/dist/call.js.map +1 -1
- package/dist/cloud-identity.d.ts +5 -1
- package/dist/cloud-identity.js +13 -77
- package/dist/cloud-identity.js.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.js +3 -2
- package/dist/config.js.map +1 -1
- package/dist/define.d.ts +9 -2
- package/dist/define.js +1 -0
- package/dist/define.js.map +1 -1
- package/dist/dns.d.ts +20 -0
- package/dist/dns.js +26 -0
- package/dist/dns.js.map +1 -0
- package/dist/drift.d.ts +4 -2
- package/dist/drift.js +13 -4
- package/dist/drift.js.map +1 -1
- package/dist/identity-broker.d.ts +14 -0
- package/dist/identity-broker.js +84 -0
- package/dist/identity-broker.js.map +1 -0
- package/dist/identity-provisioning.d.ts +29 -0
- package/dist/identity-provisioning.js +178 -0
- package/dist/identity-provisioning.js.map +1 -0
- package/dist/identity-service.d.ts +25 -0
- package/dist/identity-service.js +148 -0
- package/dist/identity-service.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/op.d.ts +15 -8
- package/dist/op.js.map +1 -1
- package/dist/provisioning-identity.d.ts +27 -0
- package/dist/provisioning-identity.js +81 -0
- package/dist/provisioning-identity.js.map +1 -0
- package/dist/resource.d.ts +67 -6
- package/dist/resource.js +95 -9
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +3 -3
- package/dist/resources.js +6 -5
- package/dist/resources.js.map +1 -1
- package/dist/runtime-ops.d.ts +60 -1
- package/dist/runtime-ops.js +73 -1
- package/dist/runtime-ops.js.map +1 -1
- package/dist/scenario.d.ts +6 -3
- package/dist/scenario.js +13 -1
- package/dist/scenario.js.map +1 -1
- package/oxlint.json +1 -0
- package/package.json +5 -1
- package/src/apply.ts +165 -47
- package/src/call.ts +2 -2
- package/src/cloud-identity.ts +16 -76
- package/src/config.ts +6 -2
- package/src/define.ts +10 -2
- package/src/dns.ts +29 -0
- package/src/drift.ts +13 -4
- package/src/identity-broker.ts +94 -0
- package/src/identity-provisioning.ts +208 -0
- package/src/identity-service.ts +179 -0
- package/src/index.ts +2 -0
- package/src/op.ts +13 -3
- package/src/provisioning-identity.ts +106 -0
- package/src/resource.ts +147 -15
- package/src/resources.ts +6 -5
- package/src/runtime-ops.ts +79 -0
- package/src/scenario.ts +24 -6
package/src/apply.ts
CHANGED
|
@@ -24,9 +24,13 @@ import {
|
|
|
24
24
|
afterCreateStuck,
|
|
25
25
|
fieldPathOf,
|
|
26
26
|
holderOf,
|
|
27
|
+
type Missing,
|
|
27
28
|
type PlanAction,
|
|
28
29
|
type Resource,
|
|
30
|
+
same,
|
|
31
|
+
type Unfilled,
|
|
29
32
|
valueAt,
|
|
33
|
+
withFrom,
|
|
30
34
|
} from "./resource.ts";
|
|
31
35
|
import type { ResourceProblem } from "./resources.ts";
|
|
32
36
|
import { type APPLY_WORKFLOW, errorMessage } from "./shared.ts";
|
|
@@ -60,13 +64,14 @@ export type ApplyInput = z.infer<typeof ApplyInput>;
|
|
|
60
64
|
/**
|
|
61
65
|
* One declared resource, or orphaned binding, by what apply does with it: `create` it (as the
|
|
62
66
|
* vendor plans: `planned`, the paths `unknown` until it exists), `update` or `replace` its bound
|
|
63
|
-
* object (the declared `fields` that differ, the paths that force the replace
|
|
67
|
+
* object (the declared `fields` that differ, the paths that force the replace, any `unknown` until
|
|
68
|
+
* it is done: a `from` field whose resource changes first), `bind` it to an
|
|
64
69
|
* object its identity `found` or the input `given`, ask about a `gone` object or an `orphan`ed
|
|
65
70
|
* binding (unless `answer`ed), or `none`.
|
|
66
71
|
*/
|
|
67
72
|
export type PlanStep = { id: string; vendor: string; type: string; connection?: ConnectionRef } & (
|
|
68
73
|
| { action: "create"; planned: Record<string, unknown>; unknown: string[] }
|
|
69
|
-
| { action: "update" | "replace"; binding: string; fields: DriftField[]; replace: string[] }
|
|
74
|
+
| { action: "update" | "replace"; binding: string; fields: DriftField[]; replace: string[]; unknown?: string[] }
|
|
70
75
|
| { action: "bind"; binding: string; source: "found" | "given" }
|
|
71
76
|
| { action: "gone"; binding: string; answer?: PlanAnswer }
|
|
72
77
|
| { action: "orphan"; key: string; binding: string; answer?: PlanAnswer }
|
|
@@ -183,22 +188,40 @@ function inDependencyOrder<T extends { id: string; refs: Record<string, string>
|
|
|
183
188
|
return out;
|
|
184
189
|
}
|
|
185
190
|
|
|
191
|
+
/** What a run knows of the declared resources as it goes, by declared id, which `desiredOf` reads. */
|
|
192
|
+
interface Known {
|
|
193
|
+
/** The lockfile's bindings, as the run has changed them so far. */
|
|
194
|
+
bindings: Bindings;
|
|
195
|
+
/** The clone URL and commit a host wanted runs. */
|
|
196
|
+
hosts: ReadonlyMap<string, { source: string; revision: string }>;
|
|
197
|
+
/** Where each host answers, as its state says. */
|
|
198
|
+
urls: ReadonlyMap<string, string>;
|
|
199
|
+
/**
|
|
200
|
+
* A resource's state as found or as its step left it, to read a `from` reference's path in;
|
|
201
|
+
* undefined while it has none known there.
|
|
202
|
+
*/
|
|
203
|
+
stateOf: (named: string, from: string) => State | undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
186
206
|
/**
|
|
187
207
|
* What a change operation is given of a declaration: its fields, where it names a resource the
|
|
188
208
|
* vendor may hold by another field (`by`, as a repository's node id), that resource's binding id
|
|
189
|
-
* when it has one, made in this run or before; a
|
|
190
|
-
*
|
|
209
|
+
* when it has one, made in this run or before; where a `from` reference names one, that
|
|
210
|
+
* resource's state at its path, or while it has none there, `null` (`unknown`) or nothing
|
|
211
|
+
* (`drop`), as `missing` says, with those references as `unfilled`; a wakeup's `host`,
|
|
212
|
+
* the URL its host answers at (`urls`, by the host's declared id) once its state says.
|
|
191
213
|
*/
|
|
192
214
|
function desiredOf(
|
|
193
215
|
r: Declaration,
|
|
194
216
|
types: ReadonlyMap<string, Resource>,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
217
|
+
known: Known,
|
|
218
|
+
missing: Missing | ((named: string, from: string) => Missing),
|
|
219
|
+
): { desired: State; unfilled: Unfilled[] } {
|
|
220
|
+
const { bindings, hosts, urls, stateOf } = known;
|
|
199
221
|
const { fields, host, wakeup } = types.get(`${r.vendor}.${r.type}`)!;
|
|
200
222
|
const { references = {} } = fields;
|
|
201
|
-
const
|
|
223
|
+
const filled = withFrom(references, r.desired, r.refs, stateOf, missing);
|
|
224
|
+
const out = filled.desired === r.desired ? structuredClone(r.desired) : filled.desired;
|
|
202
225
|
for (const [at, target] of Object.entries(r.refs)) {
|
|
203
226
|
const bound = bindings[target]?.id;
|
|
204
227
|
const place = holderOf(out, at);
|
|
@@ -220,9 +243,16 @@ function desiredOf(
|
|
|
220
243
|
if (place) place.holder[place.key] = value;
|
|
221
244
|
}
|
|
222
245
|
}
|
|
223
|
-
return out;
|
|
246
|
+
return { desired: out, unfilled: filled.unfilled };
|
|
224
247
|
}
|
|
225
248
|
|
|
249
|
+
/** Whether two dotted paths name the same field, or one a field inside the other. */
|
|
250
|
+
const overlaps = (a: string, b: string) => a === b || a.startsWith(`${b}.`) || b.startsWith(`${a}.`);
|
|
251
|
+
|
|
252
|
+
/** Why a step cannot write a field a `from` reference takes: its resource has no state, or nothing at the path. */
|
|
253
|
+
const waitingFor = ({ at, named, from, stated }: Unfilled) =>
|
|
254
|
+
`its ${at} come from ${named}, ${stated ? `whose state has no ${from} yet` : "which has no state yet"}`;
|
|
255
|
+
|
|
226
256
|
/** Why apply cannot `what` (create, update, delete) an object of a type. */
|
|
227
257
|
const cannotChange = (type: Pick<Resource, "vendor" | "type" | "writable"> | undefined, what: string) =>
|
|
228
258
|
type?.writable
|
|
@@ -292,6 +322,9 @@ function decide(
|
|
|
292
322
|
return result.status === "drifted" ? { step, problem: cannot("update") } : { step };
|
|
293
323
|
}
|
|
294
324
|
|
|
325
|
+
/** Whether apply makes a declared resource: one unbound (`absent`) or `gone`. */
|
|
326
|
+
const creates = (result: DriftResult) => result.status === "absent" || result.status === "gone";
|
|
327
|
+
|
|
295
328
|
/** The step a `plan` answer makes of a declared resource: a create for one unbound or gone, else an update, a replace or none. */
|
|
296
329
|
function fromPlan(
|
|
297
330
|
r: Declaration,
|
|
@@ -299,12 +332,21 @@ function fromPlan(
|
|
|
299
332
|
p: { action: PlanAction; planned?: State; unknown: string[]; replace: string[] },
|
|
300
333
|
): { step?: PlanStep; problem?: ResourceProblem } {
|
|
301
334
|
const head = { id: r.id, vendor: r.vendor, type: r.type };
|
|
302
|
-
const creating = result
|
|
335
|
+
const creating = creates(result);
|
|
303
336
|
if (creating && p.action === "create") {
|
|
304
337
|
return { step: { ...head, action: "create", planned: p.planned ?? {}, unknown: p.unknown } };
|
|
305
338
|
}
|
|
306
339
|
if (!creating && (p.action === "update" || p.action === "replace")) {
|
|
307
|
-
return {
|
|
340
|
+
return {
|
|
341
|
+
step: {
|
|
342
|
+
...head,
|
|
343
|
+
action: p.action,
|
|
344
|
+
binding: result.binding!,
|
|
345
|
+
fields: result.fields,
|
|
346
|
+
replace: p.replace,
|
|
347
|
+
...(p.unknown.length && { unknown: p.unknown }),
|
|
348
|
+
},
|
|
349
|
+
};
|
|
308
350
|
}
|
|
309
351
|
if (!creating && p.action === "none") return { step: { ...head, action: "none", binding: result.binding! } };
|
|
310
352
|
return { problem: problemOf(r.id, `the vendor plans to ${p.action} it, which apply does not do here`) };
|
|
@@ -432,9 +474,14 @@ function messageOf(step: PlanStep, before: Binding | undefined, after: Binding |
|
|
|
432
474
|
* and right after one that changes a binding, the lockfile written and committed
|
|
433
475
|
* (`steps.write`), so an object made is never left unbound. An update is given the plan's action
|
|
434
476
|
* and replace (`expect`), and refuses (`plan_changed`) when the vendor now plans otherwise; a
|
|
435
|
-
* reference to a resource made in this run is given its new binding,
|
|
436
|
-
*
|
|
437
|
-
*
|
|
477
|
+
* reference to a resource made in this run is given its new binding, a `from` reference that
|
|
478
|
+
* resource's state at its path as an earlier step left it (planned as `null` while this apply
|
|
479
|
+
* changes it there, or a create's while it has none there, so the change is planned and approved
|
|
480
|
+
* up front, and the step planned again once the value is known: done without a write when it
|
|
481
|
+
* plans `none`, else expecting the fresh plan; a step whose resource still has none there, its
|
|
482
|
+
* step failed or not done, fails before its driver is called), and a wakeup's `host` the URL its host's state gives
|
|
483
|
+
* (`outputs(state).url`), found or made in this run. A failed step is reported and the run
|
|
484
|
+
* goes on; a failure that ends the run (`isRunControlError`) ends it.
|
|
438
485
|
*/
|
|
439
486
|
export function applyWorkflow(
|
|
440
487
|
types: ReadonlyMap<string, Resource>,
|
|
@@ -499,15 +546,21 @@ export function applyWorkflow(
|
|
|
499
546
|
}
|
|
500
547
|
}
|
|
501
548
|
}
|
|
502
|
-
//
|
|
549
|
+
// Each declared resource's state, found now or made below, for a `from` reference to it, and
|
|
550
|
+
// where each declared host answers, as its state says: a wakeup is given it.
|
|
551
|
+
const states = new Map<string, State>();
|
|
503
552
|
const urls = new Map<string, string>();
|
|
504
|
-
|
|
553
|
+
// The resources whose step to change them has started and not left a state yet.
|
|
554
|
+
const unsettled = new Set<string>();
|
|
555
|
+
const keep = (r: Declaration, state: State) => {
|
|
556
|
+
states.set(r.id, state);
|
|
557
|
+
unsettled.delete(r.id);
|
|
505
558
|
const url = types.get(`${r.vendor}.${r.type}`)?.host?.outputs(state).url;
|
|
506
559
|
if (url) urls.set(r.id, url);
|
|
507
560
|
};
|
|
508
561
|
for (const resource of resources) {
|
|
509
562
|
const now = found.get(resource.id);
|
|
510
|
-
if (now && "state" in now)
|
|
563
|
+
if (now && "state" in now) keep(resource, now.state);
|
|
511
564
|
}
|
|
512
565
|
// A host wanted runs the commit its ref names now, resolved before planning: a merge is a change
|
|
513
566
|
// to it. One bound by the input needs none.
|
|
@@ -534,32 +587,69 @@ export function applyWorkflow(
|
|
|
534
587
|
}
|
|
535
588
|
}
|
|
536
589
|
const drift = new Map(compare(types, resources, found, revisions).map((result) => [result.id, result]));
|
|
537
|
-
|
|
538
|
-
|
|
590
|
+
// Each declaration's fields as planned, each after the ones it names. A `from` field whose
|
|
591
|
+
// value this apply changes is planned unknown (`null`), so the change it makes is in the plan,
|
|
592
|
+
// and the approval: its resource created or replaced, or updated where the update's plan
|
|
593
|
+
// leaves the path unknown or plans another value there. One whose resource has no state
|
|
594
|
+
// there is `null` in a create's plan, and left out of a bound object's, as drift does, and
|
|
595
|
+
// run so.
|
|
596
|
+
const desired = new Map<string, State>();
|
|
597
|
+
// For each resource a step changes: whether the change touches a path of its state.
|
|
598
|
+
const touches = new Map<string, (from: string) => boolean>();
|
|
599
|
+
// For each step, the `from` fields planned left out: run so, not waited for.
|
|
600
|
+
const dropped = new Map<string, Set<string>>();
|
|
601
|
+
// The steps planned with a `from` field `null`: planned again at their step, once known.
|
|
602
|
+
const awaited = new Set<string>();
|
|
539
603
|
const planned: PlanStep[] = [];
|
|
604
|
+
const add = (step: PlanStep, after?: State) => {
|
|
605
|
+
planned.push(step);
|
|
606
|
+
const before = states.get(step.id);
|
|
607
|
+
if (runOf(step) === "create" || step.action === "replace") touches.set(step.id, () => true);
|
|
608
|
+
else if (step.action === "update")
|
|
609
|
+
touches.set(
|
|
610
|
+
step.id,
|
|
611
|
+
(from) =>
|
|
612
|
+
(step.unknown ?? []).some((path) => overlaps(path, from)) ||
|
|
613
|
+
after === undefined ||
|
|
614
|
+
!same(valueAt(after, from), valueAt(before, from)),
|
|
615
|
+
);
|
|
616
|
+
};
|
|
540
617
|
for (const resource of inDependencyOrder(resources).filter((r) => wanted(r.id))) {
|
|
541
618
|
const result = drift.get(resource.id)!;
|
|
619
|
+
const changesAt = (named: string, from: string) => touches.get(named)?.(from) ?? false;
|
|
620
|
+
const missing = (named: string, from: string): Missing =>
|
|
621
|
+
creates(result) || changesAt(named, from) ? "unknown" : "drop";
|
|
622
|
+
const stateOf = (named: string, from: string) => (changesAt(named, from) ? undefined : states.get(named));
|
|
623
|
+
const { desired: fields, unfilled } = desiredOf(
|
|
624
|
+
resource,
|
|
625
|
+
types,
|
|
626
|
+
{ bindings: locked, hosts, urls, stateOf },
|
|
627
|
+
missing,
|
|
628
|
+
);
|
|
629
|
+
desired.set(resource.id, fields);
|
|
630
|
+
const left = unfilled.filter((u) => missing(u.named, u.from) === "drop").map((u) => u.at);
|
|
631
|
+
if (left.length) dropped.set(resource.id, new Set(left));
|
|
632
|
+
if (left.length < unfilled.length) awaited.add(resource.id);
|
|
542
633
|
const unresolved = revisions.get(resource.id);
|
|
543
634
|
// A host whose ref could not be resolved has no commit to run (compare says so only for one it found).
|
|
544
635
|
const next =
|
|
545
636
|
unresolved && "error" in unresolved && result.status !== "error"
|
|
546
637
|
? { problem: problemOf(resource.id, unresolved.error) }
|
|
547
|
-
: decide(resource, result, found.get(resource.id)!, input,
|
|
638
|
+
: decide(resource, result, found.get(resource.id)!, input, fields, types, usable);
|
|
548
639
|
if (next.ask !== undefined) {
|
|
549
640
|
try {
|
|
550
|
-
const
|
|
551
|
-
resource,
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
);
|
|
555
|
-
if (answer.step) planned.push(answer.step);
|
|
641
|
+
const answered = await ctx[resource.vendor]![resource.type]!.plan(next.ask, {
|
|
642
|
+
connection: resource.connection,
|
|
643
|
+
});
|
|
644
|
+
const answer = fromPlan(resource, result, answered);
|
|
645
|
+
if (answer.step) add(answer.step, answered.planned);
|
|
556
646
|
if (answer.problem) problems.push(answer.problem);
|
|
557
647
|
} catch (err) {
|
|
558
648
|
if (isRunControlError(err)) throw err;
|
|
559
649
|
problems.push(problemOf(resource.id, `could not be planned: ${errorMessage(err)}`));
|
|
560
650
|
}
|
|
561
651
|
}
|
|
562
|
-
if (next.step)
|
|
652
|
+
if (next.step) add(next.step);
|
|
563
653
|
if (next.problem) problems.push(next.problem);
|
|
564
654
|
}
|
|
565
655
|
const orphaned = orphansOf(orphans, input, wanted, usable, types);
|
|
@@ -579,47 +669,74 @@ export function applyWorkflow(
|
|
|
579
669
|
}
|
|
580
670
|
|
|
581
671
|
const bindings: Bindings = { ...locked };
|
|
672
|
+
// A resource whose step failed or waited has no state known where its change touches it: a
|
|
673
|
+
// `from` reference there fails, never given the state before; elsewhere the state stands.
|
|
674
|
+
const settled = (named: string, from: string) =>
|
|
675
|
+
unsettled.has(named) && touches.get(named)?.(from) ? undefined : states.get(named);
|
|
676
|
+
const known: Known = { bindings, hosts, urls, stateOf: settled };
|
|
582
677
|
const results: ApplyResult[] = [];
|
|
583
678
|
let written = false;
|
|
584
679
|
let revision: string | undefined;
|
|
585
680
|
for (const step of plan.steps) {
|
|
586
|
-
const
|
|
681
|
+
const declared = declarations.get(step.id);
|
|
682
|
+
const binding = "binding" in step ? step.binding : "";
|
|
683
|
+
const run = runOf(step);
|
|
684
|
+
const writing = run === "create" || run === "update";
|
|
685
|
+
// For a step that writes them, its fields as the run knows them now, a `from` field from
|
|
686
|
+
// its resource's state as an earlier step left it. One not known yet is left out, so a
|
|
687
|
+
// driver is never given `null`: planned unknown, the step fails, its driver not called;
|
|
688
|
+
// planned left out (its resource had no state there and no change), it runs without.
|
|
689
|
+
const filled = declared && writing ? desiredOf(declared, types, known, "drop") : undefined;
|
|
690
|
+
const unfilled = filled?.unfilled.find((u) => !dropped.get(step.id)?.has(u.at));
|
|
691
|
+
const waiting = unfilled && waitingFor(unfilled);
|
|
692
|
+
// Unsettled until the step leaves its new state (`keep`). Its URL stays: a host answers
|
|
693
|
+
// where it did until a change moves it.
|
|
694
|
+
if (writing) unsettled.add(step.id);
|
|
695
|
+
const todo = waiting === undefined ? run : "none";
|
|
587
696
|
const key = keyOf(step);
|
|
588
697
|
const type = `${step.vendor}.${step.type}`;
|
|
589
698
|
const before = bindings[key];
|
|
590
|
-
const binding = "binding" in step ? step.binding : "";
|
|
591
|
-
const declared = declarations.get(step.id);
|
|
592
699
|
const spec = types.get(type);
|
|
593
700
|
let state: State | undefined;
|
|
594
701
|
try {
|
|
595
702
|
if (todo === "create") {
|
|
596
703
|
const made = await ctx[step.vendor]![step.type]!.create(
|
|
597
|
-
{
|
|
598
|
-
desired: desiredOf(declared!, types, bindings, hosts, urls),
|
|
599
|
-
},
|
|
704
|
+
{ desired: filled!.desired },
|
|
600
705
|
{ connection: step.connection },
|
|
601
706
|
);
|
|
602
707
|
bindings[key] = { type, id: made.id, ...(step.connection && { connection: step.connection }) };
|
|
603
708
|
state = made.state;
|
|
604
709
|
} else if (todo === "update" && (step.action === "update" || step.action === "replace")) {
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
710
|
+
// A step planned with a `from` field unknown (its resource changed first) is planned again with its fields as now known: the approval covered the change in kind,
|
|
711
|
+
// and the vendor now says which. Nothing to write, it is done; else the update expects
|
|
712
|
+
// the fresh plan, an action other than an update or replace refused as changed.
|
|
713
|
+
let expect: Expect = { action: step.action, replace: step.replace };
|
|
714
|
+
let unchanged = false;
|
|
715
|
+
if (awaited.has(step.id)) {
|
|
716
|
+
const fresh = await ctx[step.vendor]![step.type]!.plan(
|
|
717
|
+
{ id: binding, desired: filled!.desired },
|
|
718
|
+
{ connection: step.connection },
|
|
719
|
+
);
|
|
720
|
+
unchanged = fresh.action === "none";
|
|
721
|
+
if (fresh.action === "update" || fresh.action === "replace")
|
|
722
|
+
expect = { action: fresh.action, replace: fresh.replace };
|
|
723
|
+
}
|
|
724
|
+
if (unchanged) unsettled.delete(step.id);
|
|
725
|
+
else {
|
|
726
|
+
const changed = await ctx[step.vendor]![step.type]!.update(
|
|
727
|
+
{ id: binding, desired: filled!.desired, expect },
|
|
728
|
+
{ connection: step.connection },
|
|
729
|
+
);
|
|
730
|
+
bindings[key] = { type, id: changed.id, ...(step.connection && { connection: step.connection }) };
|
|
731
|
+
state = changed.state;
|
|
732
|
+
}
|
|
616
733
|
} else if (todo === "delete") {
|
|
617
734
|
await ctx[step.vendor]![step.type]!.delete({ id: binding }, { connection: step.connection });
|
|
618
735
|
delete bindings[key];
|
|
619
736
|
} else if (todo === "bind")
|
|
620
737
|
bindings[key] = { type, id: binding, ...(step.connection && { connection: step.connection }) };
|
|
621
738
|
else if (todo === "forget") delete bindings[key];
|
|
622
|
-
if (state && declared)
|
|
739
|
+
if (state && declared) keep(declared, state);
|
|
623
740
|
const after = bindings[key];
|
|
624
741
|
if (after?.id !== before?.id) {
|
|
625
742
|
const wrote = await steps.write(key, after, messageOf(step, before, after));
|
|
@@ -630,7 +747,8 @@ export function applyWorkflow(
|
|
|
630
747
|
// id, again every few seconds until its output passes `until`; one that never does fails
|
|
631
748
|
// the step, and the rest are not called. The loop `callAfterCreate` has, inline: a
|
|
632
749
|
// function defined here may not close over `ctx`.
|
|
633
|
-
|
|
750
|
+
// A step waiting on a `from` reference ran as `none`, and fails here with why.
|
|
751
|
+
let stuck = waiting;
|
|
634
752
|
for (const hook of todo === "create" ? (spec?.afterCreate ?? []) : []) {
|
|
635
753
|
if (stuck === undefined) {
|
|
636
754
|
const [vendor = "", group = "", name = ""] = hook.op.split(".");
|
package/src/call.ts
CHANGED
|
@@ -199,8 +199,8 @@ export function buildCtx(wf: WorkflowDefinition<any, any>, run: Run): any {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
/** Who started the run, as `ctx.startedBy` shows it: a plain frozen record, whose absent fields read as undefined. */
|
|
202
|
-
const startedByOf = ({ id, email, subject }: Principal): StartedBy =>
|
|
203
|
-
Object.freeze({ id, ...(email && { email }), ...(subject && { subject }) });
|
|
202
|
+
const startedByOf = ({ id, email, github, subject }: Principal): StartedBy =>
|
|
203
|
+
Object.freeze({ id, ...(email && { email }), ...(github && { github }), ...(subject && { subject }) });
|
|
204
204
|
|
|
205
205
|
/** `members`, each object in it included, refusing any member not in it (`strict`), with `plain`'s as they are. */
|
|
206
206
|
function guarded(
|
package/src/cloud-identity.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import { Compute, OAuth2Client } from "google-auth-library";
|
|
3
3
|
import type { Db } from "./db.ts";
|
|
4
|
+
import { enrollmentBroker } from "./identity-broker.ts";
|
|
5
|
+
export { provisioningIdentityBroker, attachedGoogleIdTokenIssuer } from "./provisioning-identity.ts";
|
|
6
|
+
export type { ProvisioningIdentity, ProvisioningIdToken } from "./provisioning-identity.ts";
|
|
7
|
+
|
|
8
|
+
export { postgresIdentityProvisioning, IdentityProvisioningError } from "./identity-provisioning.ts";
|
|
9
|
+
export type { IdentityProvisioning, DeploymentIdentity, EnrollIdentity } from "./identity-provisioning.ts";
|
|
4
10
|
|
|
5
11
|
const LIFETIME = 600_000;
|
|
6
12
|
const SCOPE = "https://www.googleapis.com/auth/cloud-platform";
|
|
@@ -15,7 +21,7 @@ export class CloudIdentityError extends Error {
|
|
|
15
21
|
}
|
|
16
22
|
}
|
|
17
23
|
|
|
18
|
-
/** Provisioning only. Deliver
|
|
24
|
+
/** Provisioning only. Deliver through deployment secrets; persist only its hash in the enrollment database. */
|
|
19
25
|
export function createDeploymentCredential(): { credential: string; hash: string } {
|
|
20
26
|
const credential = randomBytes(32).toString("base64url");
|
|
21
27
|
return { credential, hash: credentialHash(credential) };
|
|
@@ -103,12 +109,6 @@ export interface IdentityBrokerOptions {
|
|
|
103
109
|
/** Must durably record the decision without token values. A failure prevents delivery. */
|
|
104
110
|
audit(event: IdentityAudit): Promise<void>;
|
|
105
111
|
}
|
|
106
|
-
const validEnrollment = (e: IdentityEnrollment | undefined): e is IdentityEnrollment =>
|
|
107
|
-
!!e &&
|
|
108
|
-
[e.deployment, e.company, e.environment].every((v) => typeof v === "string" && v.length > 0 && v.length <= 256) &&
|
|
109
|
-
ACCOUNT.test(e.serviceAccount) &&
|
|
110
|
-
Number.isSafeInteger(e.generation) &&
|
|
111
|
-
e.generation > 0;
|
|
112
112
|
const validToken = (token: CloudAccessToken, now: number) =>
|
|
113
113
|
typeof token?.accessToken === "string" &&
|
|
114
114
|
token.accessToken.length > 0 &&
|
|
@@ -117,77 +117,17 @@ const validToken = (token: CloudAccessToken, now: number) =>
|
|
|
117
117
|
Number.isFinite(token.expiresAt) &&
|
|
118
118
|
token.expiresAt > now &&
|
|
119
119
|
token.expiresAt <= now + LIFETIME + 30_000;
|
|
120
|
-
const reply = (status: number, body: unknown) =>
|
|
121
|
-
Response.json(body, {
|
|
122
|
-
status,
|
|
123
|
-
headers: { "Cache-Control": "no-store", Pragma: "no-cache" },
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
// Node/edge adapters can represent an empty POST as a stream. Reject actual bytes,
|
|
127
|
-
// without buffering attacker-controlled input or waiting indefinitely for its end.
|
|
128
|
-
async function hasBody(request: Request): Promise<boolean> {
|
|
129
|
-
if (!request.body) return false;
|
|
130
|
-
const reader = request.body.getReader();
|
|
131
|
-
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
132
|
-
try {
|
|
133
|
-
const first = await Promise.race([
|
|
134
|
-
reader.read(),
|
|
135
|
-
new Promise<never>((_, reject) => {
|
|
136
|
-
timeout = setTimeout(() => reject(new CloudIdentityError()), 5_000);
|
|
137
|
-
}),
|
|
138
|
-
]);
|
|
139
|
-
return !first.done;
|
|
140
|
-
} finally {
|
|
141
|
-
clearTimeout(timeout);
|
|
142
|
-
void reader.cancel().catch(() => {});
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
120
|
/** POST /token with a deployment bearer credential and no query or body. No enrollment endpoint. */
|
|
147
121
|
export function identityBroker(options: IdentityBrokerOptions): (request: Request) => Promise<Response> {
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
enrollment = hash ? await options.enrollments.active(hash) : undefined;
|
|
158
|
-
if (!validEnrollment(enrollment)) {
|
|
159
|
-
await options.audit({ result: "denied" });
|
|
160
|
-
return reply(401, { error: "unauthorized" });
|
|
161
|
-
}
|
|
162
|
-
// Copy authoritative values before any awaits; caller fields never select a target or lifetime.
|
|
163
|
-
enrollment = {
|
|
164
|
-
deployment: enrollment.deployment,
|
|
165
|
-
company: enrollment.company,
|
|
166
|
-
environment: enrollment.environment,
|
|
167
|
-
serviceAccount: enrollment.serviceAccount,
|
|
168
|
-
generation: enrollment.generation,
|
|
169
|
-
};
|
|
170
|
-
const token = await options.issue(enrollment.serviceAccount);
|
|
171
|
-
if (!validToken(token, Date.now())) throw new CloudIdentityError();
|
|
172
|
-
const current = await options.enrollments.active(hash!);
|
|
173
|
-
if (
|
|
174
|
-
!current ||
|
|
175
|
-
(Object.keys(enrollment) as (keyof IdentityEnrollment)[]).some((key) => current[key] !== enrollment![key])
|
|
176
|
-
) {
|
|
177
|
-
await options.audit({ result: "denied", enrollment });
|
|
178
|
-
return reply(401, { error: "unauthorized" });
|
|
179
|
-
}
|
|
180
|
-
await options.audit({ result: "issued", enrollment, expiresAt: token.expiresAt });
|
|
181
|
-
return reply(200, { accessToken: token.accessToken, expiresAt: token.expiresAt });
|
|
182
|
-
} catch {
|
|
183
|
-
try {
|
|
184
|
-
await options.audit({ result: "unavailable", ...(enrollment && { enrollment }) });
|
|
185
|
-
} catch {
|
|
186
|
-
/* fail closed */
|
|
187
|
-
}
|
|
188
|
-
return reply(503, { error: "unavailable" });
|
|
189
|
-
}
|
|
190
|
-
};
|
|
122
|
+
return enrollmentBroker({
|
|
123
|
+
...options,
|
|
124
|
+
path: "/token",
|
|
125
|
+
method: "POST",
|
|
126
|
+
accept: () => true,
|
|
127
|
+
issue: (enrollment) => options.issue(enrollment.serviceAccount),
|
|
128
|
+
valid: validToken,
|
|
129
|
+
body: (token) => ({ accessToken: token.accessToken, expiresAt: token.expiresAt }),
|
|
130
|
+
});
|
|
191
131
|
}
|
|
192
132
|
|
|
193
133
|
/** Cloud Run attached identity only: no ADC environment file or service-account private key fallback. */
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { configuredSecrets } from "./credentials.ts";
|
|
2
|
+
import type { IdentityProvisioning } from "./identity-provisioning.ts";
|
|
2
3
|
import { secretsForInput } from "./secret.ts";
|
|
3
4
|
import { isRunControlError } from "./errors.ts";
|
|
4
5
|
import { currentRun } from "./run.ts";
|
|
@@ -52,7 +53,7 @@ import {
|
|
|
52
53
|
} from "./op.ts";
|
|
53
54
|
import { type CedarPolicySource, policyAt } from "./cedar.ts";
|
|
54
55
|
import type { Policy } from "./policy.ts";
|
|
55
|
-
import { type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
|
|
56
|
+
import { checkFromRefs, type Resource, resourceTypesOf, withoutWriteOnly } from "./resource.ts";
|
|
56
57
|
import { CODE_MAIL, loginCodeWorkflow } from "./login-code.ts";
|
|
57
58
|
import {
|
|
58
59
|
APPLY_WORKFLOW,
|
|
@@ -107,6 +108,8 @@ export interface SanomaConfig {
|
|
|
107
108
|
ledger?: LedgerStore;
|
|
108
109
|
/** Credential storage and explicit bindings. Omitted: encrypted Postgres, no environment fallback. */
|
|
109
110
|
secrets?: SecretConfiguration;
|
|
111
|
+
/** Trusted control plane only: separate broker enrollment writer. Absent in company runtimes. */
|
|
112
|
+
identityProvisioning?: IdentityProvisioning;
|
|
110
113
|
connections?: readonly ConnectionDefinition[];
|
|
111
114
|
connectionRules?: readonly ConnectionRule[];
|
|
112
115
|
/** Postgres for the runtime. Defaults to `SANOMA_DATABASE_URL`, then `DEFAULT_DATABASE_URL`. */
|
|
@@ -349,6 +352,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
349
352
|
]);
|
|
350
353
|
checkWakeupOps(types, ops);
|
|
351
354
|
checkDatabaseOps(types, ops);
|
|
355
|
+
checkFromRefs(types);
|
|
352
356
|
const driversByVendor = new Map([...makersOf(running, "driver")].map(([vendor, make]) => [vendor, make()]));
|
|
353
357
|
const remotes = new Map([...makersOf(config.connectors, "remote")].map(([vendor, make]) => [vendor, make()]));
|
|
354
358
|
const drivers = indexDrivers(
|
|
@@ -357,7 +361,7 @@ export function resolveConfig(config: SanomaConfig): ResolvedConfig {
|
|
|
357
361
|
changeDriver(remotes),
|
|
358
362
|
connectionDriver(connections),
|
|
359
363
|
appsListDriver,
|
|
360
|
-
runtimeDriver({ tokens: tokenStore(() => connectionPool.db()) }),
|
|
364
|
+
runtimeDriver({ tokens: tokenStore(() => connectionPool.db()), identities: config.identityProvisioning }),
|
|
361
365
|
...(hostTyped ? [sourceDriver] : []),
|
|
362
366
|
...(dataStore ? [collectionsDriver(declared, dataStore)] : []),
|
|
363
367
|
],
|
package/src/define.ts
CHANGED
|
@@ -23,6 +23,11 @@ export interface Principal {
|
|
|
23
23
|
* passes on. Admission keeps it for a visitor alone; a run's `ctx.startedBy.email`.
|
|
24
24
|
*/
|
|
25
25
|
email?: string;
|
|
26
|
+
/**
|
|
27
|
+
* For a visitor: the GitHub login their site's login verified (`LoginHalf.verify`: signed in
|
|
28
|
+
* with GitHub, or linked it), kept as `email` is; a run's `ctx.startedBy.github`.
|
|
29
|
+
*/
|
|
30
|
+
github?: string;
|
|
26
31
|
/**
|
|
27
32
|
* For an api token (`token:<id>`): the subject its row acts for, which `SanomaClient.start`
|
|
28
33
|
* reads from the row, never from the caller; a run's `ctx.startedBy.subject`.
|
|
@@ -35,17 +40,20 @@ export const Principal: z.ZodType<Principal> = z.object({
|
|
|
35
40
|
groups: z.array(z.string()).optional(),
|
|
36
41
|
onBehalfOf: z.string().regex(/\S/, "onBehalfOf must not be blank").optional(),
|
|
37
42
|
email: z.string().optional(),
|
|
43
|
+
github: z.string().optional(),
|
|
38
44
|
subject: z.string().optional(),
|
|
39
45
|
});
|
|
40
46
|
|
|
41
47
|
/**
|
|
42
48
|
* Who started a run, as its workflow sees it (`ctx.startedBy`): the principal's id (`alice`,
|
|
43
|
-
* `visitor:<login>:<subject>`, `token:<id>`), with the email a visitor's login
|
|
44
|
-
* an api token the subject its row acts for, so a workflow can tell whose a
|
|
49
|
+
* `visitor:<login>:<subject>`, `token:<id>`), with the email and GitHub login a visitor's login
|
|
50
|
+
* verified, and for an api token the subject its row acts for, so a workflow can tell whose a
|
|
51
|
+
* record is.
|
|
45
52
|
*/
|
|
46
53
|
export interface StartedBy {
|
|
47
54
|
readonly id: string;
|
|
48
55
|
readonly email?: string;
|
|
56
|
+
readonly github?: string;
|
|
49
57
|
readonly subject?: string;
|
|
50
58
|
}
|
|
51
59
|
|
package/src/dns.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A DNS record as one resource's state gives it and another's input takes it, through a `from`
|
|
5
|
+
* reference (`FieldReference.from`): the records a sending domain asks for (`resend.domain`'s
|
|
6
|
+
* `dns_records`), which a zone's record set writes (`cloudflare.records`' `records`).
|
|
7
|
+
*/
|
|
8
|
+
export const dnsRecord = z
|
|
9
|
+
.object({
|
|
10
|
+
type: z.enum(["TXT", "MX", "CNAME", "CAA"]),
|
|
11
|
+
/** The full name, lower case, with no trailing dot: `resend._domainkey.example.com`. */
|
|
12
|
+
name: z
|
|
13
|
+
.string()
|
|
14
|
+
.refine((name) => name === name.toLowerCase(), "a record's name is lower case")
|
|
15
|
+
.refine((name) => !name.endsWith("."), "a record's name has no trailing dot"),
|
|
16
|
+
/** As the zone holds it: a TXT record's text unquoted. */
|
|
17
|
+
content: z.string(),
|
|
18
|
+
/** An MX record's priority. */
|
|
19
|
+
priority: z.number().int().optional(),
|
|
20
|
+
/** In seconds; 1 is automatic, the zone's default. */
|
|
21
|
+
ttl: z.number().int().optional(),
|
|
22
|
+
})
|
|
23
|
+
.refine(({ type, content }) => type !== "TXT" || !/^".*"$/s.test(content), {
|
|
24
|
+
message: "a TXT record's content is its text, not wrapped in quotes",
|
|
25
|
+
path: ["content"],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** One DNS record: see `dnsRecord`. */
|
|
29
|
+
export type DnsRecord = z.infer<typeof dnsRecord>;
|
package/src/drift.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
|
|
3
3
|
import { isRunControlError } from "./errors.ts";
|
|
4
4
|
import type { Op } from "./op.ts";
|
|
5
|
-
import { type DriftField, diffDeclared, fieldPathOf, type Resource, valueAt } from "./resource.ts";
|
|
5
|
+
import { type DriftField, diffDeclared, fieldPathOf, type Resource, valueAt, withFrom } from "./resource.ts";
|
|
6
6
|
import type { DeclaredResource, ResourceProblem } from "./resources.ts";
|
|
7
7
|
import { type DRIFT_WORKFLOW, errorMessage } from "./shared.ts";
|
|
8
8
|
import { SOURCE_RESOLVE, type sourceOps } from "./source.ts";
|
|
@@ -128,8 +128,10 @@ export const observed = (out: { gone: boolean; state?: State }): Found =>
|
|
|
128
128
|
* Each declared resource against what its import `found`: `error`, `absent` (unbound, with the
|
|
129
129
|
* binding id of what its identity found as `found`), `gone`, else the state and the declared
|
|
130
130
|
* fields compared by `diffDeclared`, a reference against the state of the resource it names
|
|
131
|
-
* (so GitHub's node id for a repository counts as its name)
|
|
132
|
-
*
|
|
131
|
+
* (so GitHub's node id for a repository counts as its name), and a `from` reference's field
|
|
132
|
+
* against that resource's state at its path, left out while that resource is absent, gone or
|
|
133
|
+
* unreadable (its own row says so). A host with a `revisions` entry also compares its revision
|
|
134
|
+
* against the commit its ref names now (or is that entry's `error`).
|
|
133
135
|
*/
|
|
134
136
|
export function compare(
|
|
135
137
|
types: ReadonlyMap<string, Resource>,
|
|
@@ -137,6 +139,12 @@ export function compare(
|
|
|
137
139
|
found: ReadonlyMap<string, Found>,
|
|
138
140
|
revisions: ReadonlyMap<string, Revision> = new Map(),
|
|
139
141
|
): DriftResult[] {
|
|
142
|
+
const bound = new Set(resources.filter((r) => r.binding !== undefined).map((r) => r.id));
|
|
143
|
+
// A resource's state as a `from` reference takes it: only a bound one's, read in this run.
|
|
144
|
+
const stateOf = (target: string) => {
|
|
145
|
+
const now = found.get(target);
|
|
146
|
+
return bound.has(target) && now && "state" in now ? now.state : undefined;
|
|
147
|
+
};
|
|
140
148
|
return resources.map(({ id, vendor, type, name, binding, desired, refs }): DriftResult => {
|
|
141
149
|
const row = { id, type, vendor, name, ...(binding !== undefined && { binding }) };
|
|
142
150
|
const now = found.get(id)!;
|
|
@@ -158,7 +166,8 @@ export function compare(
|
|
|
158
166
|
const named = found.get(target);
|
|
159
167
|
if (named && "state" in named) accept[at] = by.map((field) => named.state[field]).filter((v) => v != null);
|
|
160
168
|
}
|
|
161
|
-
const
|
|
169
|
+
const declared = withFrom(resource.fields.references, desired, refs, stateOf, "drop").desired;
|
|
170
|
+
const fields = diffDeclared(resource, now.state, declared, accept);
|
|
162
171
|
const resolved = revisions.get(id);
|
|
163
172
|
if (resolved && "error" in resolved) return { ...row, status: "error", fields: [], error: resolved.error };
|
|
164
173
|
// A host whose state does not say which commit it runs is not compared.
|