@sanoma-ai/workflows 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -21
- package/dist/apply-steps.d.ts +6 -0
- package/dist/apply-steps.js +93 -0
- package/dist/apply-steps.js.map +1 -0
- package/dist/apply.d.ts +197 -0
- package/dist/apply.js +470 -0
- package/dist/apply.js.map +1 -0
- package/dist/call.d.ts +4 -0
- package/dist/call.js +19 -4
- package/dist/call.js.map +1 -1
- package/dist/client.d.ts +38 -2
- package/dist/client.js +67 -10
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +3 -3
- package/dist/config.js +33 -16
- package/dist/config.js.map +1 -1
- package/dist/credentials.d.ts +25 -0
- package/dist/credentials.js +73 -5
- package/dist/credentials.js.map +1 -1
- package/dist/datafile.d.ts +11 -2
- package/dist/datafile.js +56 -2
- package/dist/datafile.js.map +1 -1
- package/dist/describe.d.ts +11 -5
- package/dist/describe.js +13 -5
- package/dist/describe.js.map +1 -1
- package/dist/drift.d.ts +64 -12
- package/dist/drift.js +118 -41
- package/dist/drift.js.map +1 -1
- package/dist/expectations.d.ts +2 -2
- package/dist/expectations.js +28 -3
- package/dist/expectations.js.map +1 -1
- package/dist/host.d.ts +56 -0
- package/dist/host.js +45 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/op.d.ts +3 -0
- package/dist/op.js.map +1 -1
- package/dist/resource.d.ts +188 -22
- package/dist/resource.js +173 -28
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +18 -3
- package/dist/resources.js +53 -6
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +14 -0
- package/dist/run.js +6 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +5 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +12 -3
- package/dist/scenario.js +33 -8
- package/dist/scenario.js.map +1 -1
- package/dist/secret.d.ts +42 -0
- package/dist/secret.js +58 -0
- package/dist/secret.js.map +1 -0
- package/dist/shared.d.ts +8 -0
- package/dist/shared.js +7 -0
- package/dist/shared.js.map +1 -1
- package/dist/source.d.ts +58 -0
- package/dist/source.js +54 -0
- package/dist/source.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker.d.ts +3 -0
- package/dist/worker.js +13 -4
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/package.json +6 -1
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/call.ts +19 -4
- package/src/client.ts +83 -13
- package/src/config.ts +48 -25
- package/src/credentials.ts +90 -5
- package/src/datafile.ts +76 -4
- package/src/describe.ts +36 -16
- package/src/drift.ts +155 -45
- package/src/expectations.ts +30 -4
- package/src/host.ts +96 -0
- package/src/index.ts +24 -1
- package/src/lockfile.ts +95 -0
- package/src/op.ts +3 -0
- package/src/resource.ts +270 -47
- package/src/resources.ts +63 -8
- package/src/run.ts +15 -0
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +50 -13
- package/src/secret.ts +92 -0
- package/src/shared.ts +10 -0
- package/src/source.ts +82 -0
- package/src/version.ts +1 -1
- package/src/worker.ts +16 -5
- package/src/writer.ts +225 -0
package/src/describe.ts
CHANGED
|
@@ -2,13 +2,13 @@ import type { z } from "zod";
|
|
|
2
2
|
import { type LintProblem, readSource, type SourceFile } from "./ast.ts";
|
|
3
3
|
import { resolveConfig, type SanomaConfig } from "./config.ts";
|
|
4
4
|
import { type Builtin, jsonSchemaOf, opIdsOf, type Use } from "./define.ts";
|
|
5
|
-
import { DRIFT_WORKFLOW } from "./drift.ts";
|
|
6
5
|
import { lintWorkflow } from "./lint.ts";
|
|
7
6
|
import { type Effect, VENDOR, type VendorInfo } from "./op.ts";
|
|
8
7
|
import { type Outline, outlineWithSource } from "./outline.ts";
|
|
9
8
|
import { allowAll, policyOpOf } from "./policy.ts";
|
|
10
9
|
import { type ResourceFields, resourceTypesOf } from "./resource.ts";
|
|
11
|
-
import { type DeclaredResource, readDataFiles, type ResourceProblem } from "./resources.ts";
|
|
10
|
+
import { type DataFiles, type DeclaredResource, readDataFiles, type ResourceProblem } from "./resources.ts";
|
|
11
|
+
import { BUILT_IN_WORKFLOWS } from "./shared.ts";
|
|
12
12
|
|
|
13
13
|
// `@sanoma-ai/workflows/describe`: what a UI renders from, apart from the main entry.
|
|
14
14
|
export { outlineWorkflow, type Outline, type OutlineNode, type Span } from "./outline.ts";
|
|
@@ -21,6 +21,21 @@ export {
|
|
|
21
21
|
type DeclaredResource,
|
|
22
22
|
type ResourceProblem,
|
|
23
23
|
} from "./resources.ts";
|
|
24
|
+
// The writer: setting a resource's fields in its data file, and rendering a new one.
|
|
25
|
+
export { formatOptions, renderDataFile, setFields, type DataFileContent } from "./writer.ts";
|
|
26
|
+
export { Reference, type DataValue } from "./datafile.ts";
|
|
27
|
+
export {
|
|
28
|
+
LOCKFILE,
|
|
29
|
+
readLockfile,
|
|
30
|
+
serializeLockfile,
|
|
31
|
+
writeLockfile,
|
|
32
|
+
diffLockfiles,
|
|
33
|
+
type Binding,
|
|
34
|
+
type Bindings,
|
|
35
|
+
type Rebind,
|
|
36
|
+
} from "./lockfile.ts";
|
|
37
|
+
// The apply workflow's plan and report, which the app renders.
|
|
38
|
+
export type { ApplyInput, ApplyPlan, ApplyReport, ApplyResult, PlanAnswer, PlanStep } from "./apply.ts";
|
|
24
39
|
|
|
25
40
|
/** What a workflow is, read from its definition: enough to draw a start form and show what it may call. */
|
|
26
41
|
export interface WorkflowEntry {
|
|
@@ -28,7 +43,7 @@ export interface WorkflowEntry {
|
|
|
28
43
|
title?: string;
|
|
29
44
|
trigger: "manual";
|
|
30
45
|
/**
|
|
31
|
-
* Set for a workflow the runtime adds itself, `drift`, rather than the config: a label. It
|
|
46
|
+
* Set for a workflow the runtime adds itself, `drift` or `apply`, rather than the config: a label. It
|
|
32
47
|
* starts like any other.
|
|
33
48
|
*/
|
|
34
49
|
builtin?: true;
|
|
@@ -96,8 +111,8 @@ export interface ResourceTypeEntry {
|
|
|
96
111
|
vendor: string;
|
|
97
112
|
type: string;
|
|
98
113
|
title: string;
|
|
99
|
-
/** How a declared resource's
|
|
100
|
-
identity
|
|
114
|
+
/** How a declared resource's identity is made, such as `name` or `repository_id:pattern`; absent when it is found only by its binding. */
|
|
115
|
+
identity?: string;
|
|
101
116
|
/** Its flagged fields, by dotted path. */
|
|
102
117
|
fields: ResourceFields;
|
|
103
118
|
/**
|
|
@@ -105,7 +120,7 @@ export interface ResourceTypeEntry {
|
|
|
105
120
|
* `sanoma:resource-type/<id>`: the operations' contracts `$ref` it instead of repeating it.
|
|
106
121
|
*/
|
|
107
122
|
schema: Record<string, unknown>;
|
|
108
|
-
/** Its operations' ids: `<id>.import` and `<id>.read
|
|
123
|
+
/** Its operations' ids, sorted: `<id>.import` and `<id>.read`, and for a writable type its change operations. */
|
|
109
124
|
ops: string[];
|
|
110
125
|
}
|
|
111
126
|
|
|
@@ -129,6 +144,8 @@ export interface ConfigDescription {
|
|
|
129
144
|
* less any with a problem and any that names one.
|
|
130
145
|
*/
|
|
131
146
|
resources: DeclaredResource[];
|
|
147
|
+
/** Each binding in the lockfile (`LOCKFILE`) whose key no data file declares, as `readDataFiles` reports it. */
|
|
148
|
+
orphans: DataFiles["orphans"];
|
|
132
149
|
/** What is wrong in the data files (`readDataFiles`), each at its file, relative to the root, line and column. A workflow's are on its entry. */
|
|
133
150
|
problems: ResourceProblem[];
|
|
134
151
|
/** `defined` is false for `allowAll`. */
|
|
@@ -155,7 +172,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
155
172
|
// Each resource type's state is described once, on its entry, and referenced from its operations.
|
|
156
173
|
const refs = new Map<z.ZodType, string>([...resources].map(([id, r]) => [r.schema, resourceTypeUri(id)]));
|
|
157
174
|
const resourceTypes: ResourceTypeEntry[] = [...resources]
|
|
158
|
-
.map(([id, { vendor, type, title, identity, fields, schema }]) => ({
|
|
175
|
+
.map(([id, { vendor, type, title, identity, fields, schema, ops }]) => ({
|
|
159
176
|
id,
|
|
160
177
|
vendor,
|
|
161
178
|
type,
|
|
@@ -163,7 +180,9 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
163
180
|
identity,
|
|
164
181
|
fields,
|
|
165
182
|
schema: { $id: resourceTypeUri(id), ...toJsonSchema(schema, `${id} state`, "output") },
|
|
166
|
-
ops:
|
|
183
|
+
ops: Object.keys(ops)
|
|
184
|
+
.map((name) => `${id}.${name}`)
|
|
185
|
+
.toSorted(),
|
|
167
186
|
}))
|
|
168
187
|
.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
169
188
|
|
|
@@ -184,18 +203,18 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
184
203
|
const { outline, source, found } = outlineWithSource(wf, file);
|
|
185
204
|
// What the lint does not report: the file unread, or no workflow of this name in it.
|
|
186
205
|
const own = "error" in outline && !found && (typeof file === "string" || !file.problems.length);
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
206
|
+
const builtin = BUILT_IN_WORKFLOWS.has(wf.name);
|
|
207
|
+
const problems = builtin
|
|
208
|
+
? []
|
|
209
|
+
: [
|
|
210
|
+
...(typeof file === "string" ? [] : lint(file)),
|
|
211
|
+
...(own ? [{ line: 1, column: 1, message: outline.error }] : []),
|
|
212
|
+
];
|
|
194
213
|
return {
|
|
195
214
|
name: wf.name,
|
|
196
215
|
title: wf.title,
|
|
197
216
|
trigger: wf.trigger,
|
|
198
|
-
...(
|
|
217
|
+
...(builtin && { builtin: true as const }),
|
|
199
218
|
input: toJsonSchema(wf.input, `${wf.name} input`, "input"),
|
|
200
219
|
ops: opIdsOf(wf),
|
|
201
220
|
builtins: (wf.uses as Use[]).filter((u): u is Builtin => typeof u === "string"),
|
|
@@ -216,6 +235,7 @@ export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
|
216
235
|
vendors,
|
|
217
236
|
resourceTypes,
|
|
218
237
|
resources: dataFiles.resources,
|
|
238
|
+
orphans: dataFiles.orphans,
|
|
219
239
|
problems: dataFiles.problems,
|
|
220
240
|
policy: {
|
|
221
241
|
defined: resolved.policy !== allowAll,
|
package/src/drift.ts
CHANGED
|
@@ -2,9 +2,10 @@ 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, type Resource } from "./resource.ts";
|
|
5
|
+
import { type DriftField, diffDeclared, fieldPathOf, type Resource, valueAt } from "./resource.ts";
|
|
6
6
|
import type { DeclaredResource, ResourceProblem } from "./resources.ts";
|
|
7
7
|
import { type DRIFT_WORKFLOW, errorMessage } from "./shared.ts";
|
|
8
|
+
import { SOURCE_RESOLVE, type sourceOps } from "./source.ts";
|
|
8
9
|
|
|
9
10
|
export type { DriftField } from "./resource.ts";
|
|
10
11
|
export { DRIFT_WORKFLOW } from "./shared.ts";
|
|
@@ -14,11 +15,12 @@ export { DRIFT_WORKFLOW } from "./shared.ts";
|
|
|
14
15
|
// vendor call goes through `ctx`: the policy sees each (effect `read`) and the ledger records it.
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
* How a declared resource compares: `clean` when every declared field
|
|
18
|
-
* `drifted` when one does not, `gone` when the vendor
|
|
19
|
-
*
|
|
18
|
+
* How a declared resource compares: `clean` when every declared field of its bound object holds
|
|
19
|
+
* its declared value, `drifted` when one does not, `gone` when the vendor no longer has its bound
|
|
20
|
+
* object, `absent` when it is bound to none (whether or not its identity finds one: `found`), and
|
|
21
|
+
* `error` when it could not be read (the vendor's error, a policy denial, or no driver for its type).
|
|
20
22
|
*/
|
|
21
|
-
export type DriftStatus = "clean" | "drifted" | "gone" | "error";
|
|
23
|
+
export type DriftStatus = "clean" | "drifted" | "gone" | "absent" | "error";
|
|
22
24
|
|
|
23
25
|
/** One declared resource in a drift report. */
|
|
24
26
|
export interface DriftResult {
|
|
@@ -27,9 +29,13 @@ export interface DriftResult {
|
|
|
27
29
|
/** Its type within the vendor, such as `repository`. */
|
|
28
30
|
type: string;
|
|
29
31
|
vendor: string;
|
|
30
|
-
/**
|
|
32
|
+
/** What people call it: its identity, else its export's name. */
|
|
31
33
|
name: string;
|
|
32
34
|
status: DriftStatus;
|
|
35
|
+
/** The vendor's id of the object the lockfile binds it to; absent when unbound. */
|
|
36
|
+
binding?: string;
|
|
37
|
+
/** For `absent`: the binding id of the object its identity found, which apply may bind it to. */
|
|
38
|
+
found?: string;
|
|
33
39
|
/** The declared fields that differ; empty unless `drifted`. */
|
|
34
40
|
fields: DriftField[];
|
|
35
41
|
/** Why it could not be read, for `error`. */
|
|
@@ -59,7 +65,7 @@ export const DriftInput = z.object({}).strict();
|
|
|
59
65
|
* write-only values, and the problems.
|
|
60
66
|
*/
|
|
61
67
|
export interface DriftDeclared {
|
|
62
|
-
resources: Pick<DeclaredResource, "id" | "vendor" | "type" | "name" | "desired" | "refs">[];
|
|
68
|
+
resources: Pick<DeclaredResource, "id" | "vendor" | "type" | "name" | "identity" | "binding" | "desired" | "refs">[];
|
|
63
69
|
problems: ResourceProblem[];
|
|
64
70
|
}
|
|
65
71
|
|
|
@@ -67,9 +73,99 @@ type State = Record<string, unknown>;
|
|
|
67
73
|
|
|
68
74
|
// A resource type's import, as `ctx` gives it: by vendor and type, both only known per run.
|
|
69
75
|
type ImportOp = Op<string, string, "import", { id: string }, { id: string; gone: boolean; state?: State }>;
|
|
76
|
+
// The built-in `sanoma.source.resolve`, which a host's check calls for the commit its ref names now.
|
|
77
|
+
export type ResolveOp = typeof sourceOps.source.resolve;
|
|
78
|
+
|
|
79
|
+
/** A clone URL (a scheme's, or scp-like `user@host:path`): what a `by` field of a host's source holds, told from an id. */
|
|
80
|
+
const CLONE_URL = /^((https?|ssh|git|file):\/\/|[\w.-]+@[\w.-]+:)/;
|
|
70
81
|
|
|
71
82
|
/** What one import found: the object's state, that it is gone, or why it could not be read. */
|
|
72
|
-
type Found = { state: State } | { gone: true } | { error: string };
|
|
83
|
+
export type Found = { state: State } | { gone: true } | { error: string };
|
|
84
|
+
/** What a host's ref names now, or why it could not be resolved. */
|
|
85
|
+
export type Revision = { revision: string } | { error: string };
|
|
86
|
+
|
|
87
|
+
// What drift and apply share. Observing a resource is one `ctx` call, which a workflow makes in
|
|
88
|
+
// its own `run` (the outline draws only those): `observeBy` says what each import is called with,
|
|
89
|
+
// `observed` reads its answer, and `compare` turns what every import found into drift's results.
|
|
90
|
+
|
|
91
|
+
/** The `import` ops of the types among `ops` with a driver, by type, sorted: what observing them uses. */
|
|
92
|
+
export function importsOf(
|
|
93
|
+
types: ReadonlyMap<string, Resource>,
|
|
94
|
+
ops: ReadonlyMap<string, Op>,
|
|
95
|
+
drivers: ReadonlyMap<string, unknown>,
|
|
96
|
+
): ImportOp[] {
|
|
97
|
+
return [...types.keys()].toSorted().flatMap((type) => {
|
|
98
|
+
const op = ops.get(`${type}.import`);
|
|
99
|
+
return op && drivers.has(op.id) ? [op as ImportOp] : [];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What a declared resource is observed by: the import id (`by`, default its binding, else its
|
|
105
|
+
* identity), or without one what it is found to be: `gone` for none, `error` when its type is
|
|
106
|
+
* not the connectors' or has no import among `imports`.
|
|
107
|
+
*/
|
|
108
|
+
export function observeBy(
|
|
109
|
+
{ vendor, type, binding, identity }: Pick<DeclaredResource, "vendor" | "type" | "binding" | "identity">,
|
|
110
|
+
types: ReadonlyMap<string, Resource>,
|
|
111
|
+
imports: readonly Pick<Op, "id">[],
|
|
112
|
+
by = binding ?? identity,
|
|
113
|
+
): { id: string } | Found {
|
|
114
|
+
const key = `${vendor}.${type}`;
|
|
115
|
+
if (!types.has(key)) return { error: `the config's connectors declare no resource type ${key}` };
|
|
116
|
+
if (!imports.some((op) => op.id === `${key}.import`)) return { error: `no driver for ${key}.import` };
|
|
117
|
+
return by === undefined ? { gone: true } : { id: by };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** What an import's answer found. */
|
|
121
|
+
export const observed = (out: { gone: boolean; state?: State }): Found =>
|
|
122
|
+
out.gone || !out.state ? { gone: true } : { state: out.state };
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Each declared resource against what its import `found`: `error`, `absent` (unbound, with the
|
|
126
|
+
* binding id of what its identity found as `found`), `gone`, else the state and the declared
|
|
127
|
+
* fields compared by `diffDeclared`, a reference against the state of the resource it names
|
|
128
|
+
* (so GitHub's node id for a repository counts as its name). A host with a `revisions` entry
|
|
129
|
+
* also compares its revision against the commit its ref names now (or is that entry's `error`).
|
|
130
|
+
*/
|
|
131
|
+
export function compare(
|
|
132
|
+
types: ReadonlyMap<string, Resource>,
|
|
133
|
+
resources: DriftDeclared["resources"],
|
|
134
|
+
found: ReadonlyMap<string, Found>,
|
|
135
|
+
revisions: ReadonlyMap<string, Revision> = new Map(),
|
|
136
|
+
): DriftResult[] {
|
|
137
|
+
return resources.map(({ id, vendor, type, name, binding, desired, refs }): DriftResult => {
|
|
138
|
+
const row = { id, type, vendor, name, ...(binding !== undefined && { binding }) };
|
|
139
|
+
const now = found.get(id)!;
|
|
140
|
+
if ("error" in now) return { ...row, status: "error", fields: [], error: now.error };
|
|
141
|
+
const resource = types.get(`${vendor}.${type}`)!;
|
|
142
|
+
if (binding === undefined) {
|
|
143
|
+
return {
|
|
144
|
+
...row,
|
|
145
|
+
status: "absent",
|
|
146
|
+
fields: [],
|
|
147
|
+
...("state" in now && { found: resource.bindingId(now.state) }),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if ("gone" in now) return { ...row, status: "gone", fields: [] };
|
|
151
|
+
// Each reference, by where it is: the values the vendor may hold there for its resource.
|
|
152
|
+
const accept: Record<string, unknown[]> = {};
|
|
153
|
+
for (const [at, target] of Object.entries(refs)) {
|
|
154
|
+
const by = resource.fields.references?.[fieldPathOf(at)]?.by ?? [];
|
|
155
|
+
const named = found.get(target);
|
|
156
|
+
if (named && "state" in named) accept[at] = by.map((field) => named.state[field]).filter((v) => v != null);
|
|
157
|
+
}
|
|
158
|
+
const fields = diffDeclared(resource, now.state, desired, accept);
|
|
159
|
+
const resolved = revisions.get(id);
|
|
160
|
+
if (resolved && "error" in resolved) return { ...row, status: "error", fields: [], error: resolved.error };
|
|
161
|
+
// A host whose state does not say which commit it runs is not compared.
|
|
162
|
+
const actual = resource.host?.outputs(now.state).revision;
|
|
163
|
+
if (resolved && actual !== undefined && actual !== resolved.revision) {
|
|
164
|
+
fields.push({ path: resource.host!.revision, desired: resolved.revision, actual });
|
|
165
|
+
}
|
|
166
|
+
return { ...row, status: fields.length ? "drifted" : "clean", fields };
|
|
167
|
+
});
|
|
168
|
+
}
|
|
73
169
|
|
|
74
170
|
/**
|
|
75
171
|
* The drift workflow for a config: it `uses` the `import` of every resource type the connectors
|
|
@@ -77,11 +173,13 @@ type Found = { state: State } | { gone: true } | { error: string };
|
|
|
77
173
|
* files. `resolveConfig` adds it to a config whose connectors declare resource types, with
|
|
78
174
|
* `declared`, the step that reads the data files.
|
|
79
175
|
*
|
|
80
|
-
* A run reads the data files (`declared`, recorded as a step, so a replay checks
|
|
81
|
-
* then imports each resource by its
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
176
|
+
* A run reads the data files and the lockfile (`declared`, recorded as a step, so a replay checks
|
|
177
|
+
* the same list), then imports each resource by its binding, else by its identity, in the files'
|
|
178
|
+
* order: one vendor call each, none for a resource with neither. A bound resource whose import
|
|
179
|
+
* says gone is `gone`; an unbound one is `absent`, with the binding id of what its identity found
|
|
180
|
+
* as `found`. Else the state and the declared fields are compared by `diffDeclared`, a reference
|
|
181
|
+
* against the state of the resource it names, read in the same run (so GitHub's node id for a
|
|
182
|
+
* repository counts as its name). A failed call is that resource's
|
|
85
183
|
* `error`, and the run goes on to the next; a failure that ends the run (`isRunControlError`)
|
|
86
184
|
* ends it.
|
|
87
185
|
*/
|
|
@@ -90,16 +188,10 @@ export function driftWorkflow(
|
|
|
90
188
|
ops: ReadonlyMap<string, Op>,
|
|
91
189
|
drivers: ReadonlyMap<string, unknown>,
|
|
92
190
|
declared: () => Promise<DriftDeclared>,
|
|
93
|
-
): WorkflowDefinition<readonly ImportOp[], typeof DriftInput> {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
});
|
|
98
|
-
// Why a resource of each type cannot be read, if it cannot.
|
|
99
|
-
const unreadable = new Map<string, string>();
|
|
100
|
-
for (const type of types.keys()) {
|
|
101
|
-
if (!uses.some((op) => op.id === `${type}.import`)) unreadable.set(type, `no driver for ${type}.import`);
|
|
102
|
-
}
|
|
191
|
+
): WorkflowDefinition<readonly (ImportOp | ResolveOp)[], typeof DriftInput> {
|
|
192
|
+
const imports = importsOf(types, ops, drivers);
|
|
193
|
+
const resolve = ops.get(SOURCE_RESOLVE) as ResolveOp | undefined;
|
|
194
|
+
const uses = [...imports, ...(resolve ? [resolve] : [])];
|
|
103
195
|
return defineWorkflow({
|
|
104
196
|
// The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
|
|
105
197
|
name: "drift" satisfies typeof DRIFT_WORKFLOW,
|
|
@@ -112,38 +204,56 @@ export function driftWorkflow(
|
|
|
112
204
|
const { resources, problems } = await declared();
|
|
113
205
|
// Every import first, so a reference compares against what its resource holds now.
|
|
114
206
|
const found = new Map<string, Found>();
|
|
115
|
-
for (const
|
|
116
|
-
const
|
|
117
|
-
const why = types.has(key) ? unreadable.get(key) : `the config's connectors declare no resource type ${key}`;
|
|
207
|
+
for (const resource of resources) {
|
|
208
|
+
const by = observeBy(resource, types, imports);
|
|
118
209
|
// An if/else, not a `continue`: the outline draws the import as the way past the error.
|
|
119
|
-
if (
|
|
210
|
+
if (!("id" in by)) found.set(resource.id, by);
|
|
120
211
|
else {
|
|
121
212
|
try {
|
|
122
|
-
|
|
123
|
-
found.set(id, out.gone || !out.state ? { gone: true } : { state: out.state });
|
|
213
|
+
found.set(resource.id, observed(await ctx[resource.vendor]![resource.type]!.import({ id: by.id })));
|
|
124
214
|
} catch (err) {
|
|
125
215
|
if (isRunControlError(err)) throw err;
|
|
126
|
-
found.set(id, { error: errorMessage(err) });
|
|
216
|
+
found.set(resource.id, { error: errorMessage(err) });
|
|
127
217
|
}
|
|
128
218
|
}
|
|
129
219
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
220
|
+
// A host's revision against the commit its ref names now: a merge is drift on the host.
|
|
221
|
+
const revisions = new Map<string, Revision>();
|
|
222
|
+
for (const { id, vendor, type, desired, refs } of resources) {
|
|
223
|
+
const resource = types.get(`${vendor}.${type}`);
|
|
224
|
+
const host = resource?.host;
|
|
225
|
+
const source = host && sourceUrl(desired, refs[host.inputs.source], resource, found);
|
|
226
|
+
const ref = host && valueAt(desired, host.inputs.ref);
|
|
227
|
+
if (host && "state" in found.get(id)! && source !== undefined && typeof ref === "string" && ref) {
|
|
228
|
+
try {
|
|
229
|
+
revisions.set(id, await ctx.sanoma!.source!.resolve({ url: source, ref }));
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (isRunControlError(err)) throw err;
|
|
232
|
+
revisions.set(id, { error: errorMessage(err) });
|
|
233
|
+
}
|
|
142
234
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
});
|
|
235
|
+
}
|
|
236
|
+
const results = compare(types, resources, found, revisions);
|
|
146
237
|
return { startedAt, finishedAt: await ctx.now(), problems, resources: results } satisfies DriftReport;
|
|
147
238
|
},
|
|
148
239
|
});
|
|
149
240
|
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A host's source: the clone URL its data file gives, or, when it names a declared repository
|
|
244
|
+
* (`target`), the first of the reference's `by` fields that holds one in that repository's state,
|
|
245
|
+
* read in this run.
|
|
246
|
+
*/
|
|
247
|
+
export function sourceUrl(
|
|
248
|
+
desired: State,
|
|
249
|
+
target: string | undefined,
|
|
250
|
+
{ host, fields }: Resource,
|
|
251
|
+
found: ReadonlyMap<string, Found>,
|
|
252
|
+
): string | undefined {
|
|
253
|
+
const path = host!.inputs.source;
|
|
254
|
+
const named = target === undefined ? undefined : found.get(target);
|
|
255
|
+
const by = fields.references?.[path]?.by ?? [];
|
|
256
|
+
const values =
|
|
257
|
+
target === undefined ? [valueAt(desired, path)] : named && "state" in named ? by.map((f) => named.state[f]) : [];
|
|
258
|
+
return values.find((v): v is string => typeof v === "string" && CLONE_URL.test(v));
|
|
259
|
+
}
|
package/src/expectations.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
2
|
import type { Approver } from "./define.ts";
|
|
3
|
+
import type { ApplyReport } from "./apply.ts";
|
|
3
4
|
import type { DriftReport, DriftResult } from "./drift.ts";
|
|
4
5
|
import type { LedgerRecord } from "./ledger.ts";
|
|
5
6
|
import type { RecordedDecision } from "./policy.ts";
|
|
@@ -99,7 +100,7 @@ type Unstepped<T> = T extends unknown ? Omit<T, "step"> : never;
|
|
|
99
100
|
* output, or undefined when the report found the resource as expected.
|
|
100
101
|
*/
|
|
101
102
|
function driftDetail(
|
|
102
|
-
e: Extract<CoreExpectation, {
|
|
103
|
+
e: Extract<CoreExpectation, { status: string }>,
|
|
103
104
|
end: Extract<LedgerRecord, { type: "run.finished" }>,
|
|
104
105
|
): string | undefined {
|
|
105
106
|
const report = end.output as Partial<DriftReport> | null | undefined;
|
|
@@ -115,14 +116,38 @@ function driftDetail(
|
|
|
115
116
|
return `${e.declared} is ${found(resource)}, not ${e.status === "drifted" ? `drifted in ${e.field}` : e.status}`;
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Why an apply expectation fails against the report a finished apply run returned as its output:
|
|
121
|
+
* the resource `created` (a step that created it, done), or `bound` to an object in the lockfile it ended with.
|
|
122
|
+
*/
|
|
123
|
+
function applyDetail(
|
|
124
|
+
e: Extract<CoreExpectation, { created: true } | { bound: string }>,
|
|
125
|
+
end: Extract<LedgerRecord, { type: "run.finished" }>,
|
|
126
|
+
): string | undefined {
|
|
127
|
+
const report = end.output as Partial<ApplyReport> | null | undefined;
|
|
128
|
+
if (!Array.isArray(report?.results) || !report.lockfile) return "the run's output is not an apply report";
|
|
129
|
+
if ("bound" in e) {
|
|
130
|
+
const binding = report.lockfile.bindings[e.declared];
|
|
131
|
+
if (binding?.id === e.bound) return undefined;
|
|
132
|
+
return binding ? `${e.declared} is bound to ${binding.id}, not ${e.bound}` : `${e.declared} is not bound`;
|
|
133
|
+
}
|
|
134
|
+
const result = report.results.find((r) => r.id === e.declared);
|
|
135
|
+
// A gone object recreated is bound again; one forgotten is not.
|
|
136
|
+
const created =
|
|
137
|
+
result?.status === "done" && (result.action === "create" || (result.action === "gone" && !!result.binding));
|
|
138
|
+
if (created) return undefined;
|
|
139
|
+
if (!result) return `apply did nothing with ${e.declared}`;
|
|
140
|
+
return `apply's ${result.action} of ${e.declared} ${result.status === "failed" ? `failed: ${result.error}` : `was ${result.status}`}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
118
143
|
/**
|
|
119
144
|
* Why an expectation of the generic steps does not hold against a run's ledger records, or
|
|
120
145
|
* undefined when it does. A call is an `op.called` record without an `error`; one with an error
|
|
121
146
|
* (a policy denial, a rejected approval, a vendor's final failure) was attempted, not made. An
|
|
122
147
|
* operation `was denied` when the policy's decision on some call to it is `deny`, and `was held
|
|
123
148
|
* for` an approver when an approval for that approver holds it: one the policy asked for on its
|
|
124
|
-
* call, or one that `covers` it. A drift expectation reads the report a finished
|
|
125
|
-
* returned. A step named in the workflow's outline is read by the `name` its records carry
|
|
149
|
+
* call, or one that `covers` it. A drift or apply expectation reads the report a finished run
|
|
150
|
+
* of it returned. A step named in the workflow's outline is read by the `name` its records carry
|
|
126
151
|
* (its calls made, and `waitsOf`); a record written before steps had names has none.
|
|
127
152
|
*/
|
|
128
153
|
export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]): string | undefined {
|
|
@@ -161,5 +186,6 @@ export function detailOf(e: CoreExpectation, records: readonly LedgerRecord[]):
|
|
|
161
186
|
}
|
|
162
187
|
// A finished run: what `the run succeeds` expects, and what a drift expectation reads its report from.
|
|
163
188
|
if (end.type === "run.failed") return `the run failed: ${end.error.message}`;
|
|
164
|
-
|
|
189
|
+
if (!("declared" in e)) return undefined;
|
|
190
|
+
return "status" in e ? driftDetail(e, end) : applyDetail(e, end);
|
|
165
191
|
}
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
|
|
3
|
+
// The `Host` contract: what a resource type that runs the company (a container, a Render service,
|
|
4
|
+
// a Fly app) declares about itself, so hosts are swappable and one conformance kit decides
|
|
5
|
+
// whether one works. A host is a resource type like any other; this is what sets it apart.
|
|
6
|
+
|
|
7
|
+
/** How a host keeps the runtime up: `long-lived`, or (later) woken on a schedule. */
|
|
8
|
+
export type HostLifetime = "long-lived" | "wake-on-schedule";
|
|
9
|
+
|
|
10
|
+
/** What a host can do, for choosing one. */
|
|
11
|
+
export interface HostCapabilities {
|
|
12
|
+
lifetime: HostLifetime;
|
|
13
|
+
/** Its `url` is reachable from the internet, not only from this machine. */
|
|
14
|
+
publicUrl: boolean;
|
|
15
|
+
/** It builds the repository from git itself, at the revision apply gives it. */
|
|
16
|
+
buildsFromGit: boolean;
|
|
17
|
+
/** Its outbound connections come from fixed addresses, for a vendor's allow-list. */
|
|
18
|
+
staticEgress: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What a host's state says of it: where the app answers, which commit it runs, and whether it is up. */
|
|
22
|
+
export interface HostOutputs {
|
|
23
|
+
url: string;
|
|
24
|
+
/** The commit it runs, when its state says. */
|
|
25
|
+
revision?: string;
|
|
26
|
+
healthy: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A resource type's `host`: the fields it takes the runtime's inputs in, by dotted path into its
|
|
31
|
+
* declared fields, and how its state reads as `HostOutputs`. A host is drifted when
|
|
32
|
+
* `outputs(state).revision` differs from what `sanoma.source.resolve` says `ref` is now.
|
|
33
|
+
*/
|
|
34
|
+
export interface HostSpec {
|
|
35
|
+
capabilities: HostCapabilities;
|
|
36
|
+
inputs: {
|
|
37
|
+
/** The repository's clone URL, or a reference to a declared repository whose state holds one (a `by` field). */
|
|
38
|
+
source: string;
|
|
39
|
+
/** The branch (or tag) it runs: `main`, never a commit. */
|
|
40
|
+
ref: string;
|
|
41
|
+
/** The secrets it passes the runtime (`SANOMA_DATABASE_URL`, `SANOMA_SECRETS_KEY`): a write-only field. */
|
|
42
|
+
secrets: string;
|
|
43
|
+
/** The port the app listens on. */
|
|
44
|
+
port: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* The field apply fills with the commit `ref` resolves to, before it plans: hidden, as a data
|
|
48
|
+
* file may not declare it, so a merge to `ref` is a change to the host.
|
|
49
|
+
*/
|
|
50
|
+
revision: string;
|
|
51
|
+
outputs: (state: Record<string, unknown>) => HostOutputs;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const LIFETIMES: readonly HostLifetime[] = ["long-lived", "wake-on-schedule"];
|
|
55
|
+
|
|
56
|
+
/** True when `schema` has a field at the dotted `path`, looking through optional, nullable and default wrappers. */
|
|
57
|
+
function hasPath(schema: z.ZodType, path: string): boolean {
|
|
58
|
+
let at: z.ZodType | undefined = schema;
|
|
59
|
+
for (const key of path.split(".")) {
|
|
60
|
+
at = unwrap(at);
|
|
61
|
+
const shape = (at as Partial<z.ZodObject>).shape;
|
|
62
|
+
at = shape?.[key];
|
|
63
|
+
if (!at) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function unwrap(schema: z.ZodType): z.ZodType {
|
|
69
|
+
let at = schema;
|
|
70
|
+
while ("unwrap" in at && typeof at.unwrap === "function") at = at.unwrap() as z.ZodType;
|
|
71
|
+
return at;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Throws what is wrong with a resource type's `host`: an input or `revision` that names no field,
|
|
76
|
+
* `secrets` that is not write-only, a `revision` that is, an unknown lifetime, a type apply cannot
|
|
77
|
+
* change (not `writable`).
|
|
78
|
+
*/
|
|
79
|
+
export function checkHost(
|
|
80
|
+
what: string,
|
|
81
|
+
host: HostSpec,
|
|
82
|
+
type: { schema: z.ZodObject; fields: { writeOnly: readonly string[] }; writable?: boolean },
|
|
83
|
+
): void {
|
|
84
|
+
const problems: string[] = [];
|
|
85
|
+
if (!type.writable) problems.push("it must be writable, so apply can create and update it");
|
|
86
|
+
if (!LIFETIMES.includes(host.capabilities?.lifetime)) {
|
|
87
|
+
problems.push(`capabilities.lifetime is one of ${LIFETIMES.join(", ")}`);
|
|
88
|
+
}
|
|
89
|
+
for (const [input, path] of [...Object.entries(host.inputs ?? {}), ["revision", host.revision] as const]) {
|
|
90
|
+
if (typeof path !== "string" || !hasPath(type.schema, path)) problems.push(`${input} names no field (${path})`);
|
|
91
|
+
}
|
|
92
|
+
if (!type.fields.writeOnly.includes(host.inputs?.secrets)) problems.push("inputs.secrets must be a write-only field");
|
|
93
|
+
if (type.fields.writeOnly.includes(host.revision)) problems.push("revision must not be write-only: drift reads it");
|
|
94
|
+
if (typeof host.outputs !== "function") problems.push("outputs must be a function of the state");
|
|
95
|
+
if (problems.length) throw new Error(`${what}: its host: ${problems.join("; ")}`);
|
|
96
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -38,14 +38,25 @@ export {
|
|
|
38
38
|
defineResource,
|
|
39
39
|
diffDeclared,
|
|
40
40
|
type FieldReference,
|
|
41
|
+
type PlanAction,
|
|
41
42
|
type References,
|
|
42
43
|
type Resource,
|
|
43
44
|
type ResourceFields,
|
|
44
45
|
type ResourceOps,
|
|
45
46
|
type ResourceSpec,
|
|
47
|
+
withoutWriteOnly,
|
|
46
48
|
} from "./resource.ts";
|
|
47
49
|
export { defineConfig, resolveConfig, resolveDatabaseUrl, type ResolvedConfig, type SanomaConfig } from "./config.ts";
|
|
48
50
|
export { type CredentialStatus, credentialsOf } from "./env.ts";
|
|
51
|
+
export {
|
|
52
|
+
APPLY_WORKFLOW,
|
|
53
|
+
ApplyInput,
|
|
54
|
+
type ApplyPlan,
|
|
55
|
+
type ApplyReport,
|
|
56
|
+
type ApplyResult,
|
|
57
|
+
type PlanAnswer,
|
|
58
|
+
type PlanStep,
|
|
59
|
+
} from "./apply.ts";
|
|
49
60
|
export {
|
|
50
61
|
DRIFT_WORKFLOW,
|
|
51
62
|
type DriftDeclared,
|
|
@@ -93,7 +104,19 @@ export { ApprovalMessage, APPROVALS_EVENT, decisionEventOf } from "./approvals.t
|
|
|
93
104
|
export { RUNTIME_VERSION } from "./version.ts";
|
|
94
105
|
export type { RunArgs } from "./run.ts";
|
|
95
106
|
export { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
|
|
96
|
-
|
|
107
|
+
// `secret` itself is at `@sanoma-ai/workflows/resources`, for data files; this is the drivers' side.
|
|
108
|
+
export {
|
|
109
|
+
generateSecret,
|
|
110
|
+
isSecret,
|
|
111
|
+
memorySecrets,
|
|
112
|
+
type Resolved,
|
|
113
|
+
resolveSecrets,
|
|
114
|
+
type Secret,
|
|
115
|
+
type Secrets,
|
|
116
|
+
} from "./secret.ts";
|
|
117
|
+
export { resolveRevision, SOURCE_RESOLVE, type Source } from "./source.ts";
|
|
118
|
+
export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
|
|
119
|
+
export { SanomaClient, type RunsFilter, type RunSummary, type StartOptions, type StoredSecret } from "./client.ts";
|
|
97
120
|
// A raw DBOS client on the runtime's database, for tests and tools that read DBOS directly.
|
|
98
121
|
export { dbosClient } from "./db.ts";
|
|
99
122
|
// Also at `@sanoma-ai/workflows/shared`, which a browser bundle can import.
|