@sanoma-ai/workflows 0.0.0-stage → 0.1.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 +105 -0
- package/README.md +493 -2
- package/dist/approvals.d.ts +46 -0
- package/dist/approvals.js +111 -0
- package/dist/approvals.js.map +1 -0
- package/dist/ast.d.ts +57 -0
- package/dist/ast.js +81 -0
- package/dist/ast.js.map +1 -0
- package/dist/call.d.ts +33 -0
- package/dist/call.js +402 -0
- package/dist/call.js.map +1 -0
- package/dist/client.d.ts +167 -0
- package/dist/client.js +400 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +126 -0
- package/dist/config.js +247 -0
- package/dist/config.js.map +1 -0
- package/dist/credentials.d.ts +30 -0
- package/dist/credentials.js +193 -0
- package/dist/credentials.js.map +1 -0
- package/dist/cucumber.d.ts +64 -0
- package/dist/cucumber.js +158 -0
- package/dist/cucumber.js.map +1 -0
- package/dist/datafile.d.ts +62 -0
- package/dist/datafile.js +326 -0
- package/dist/datafile.js.map +1 -0
- package/dist/db.d.ts +58 -0
- package/dist/db.js +179 -0
- package/dist/db.js.map +1 -0
- package/dist/define.d.ts +205 -0
- package/dist/define.js +103 -0
- package/dist/define.js.map +1 -0
- package/dist/describe.d.ts +135 -0
- package/dist/describe.js +127 -0
- package/dist/describe.js.map +1 -0
- package/dist/drift.d.ts +75 -0
- package/dist/drift.js +90 -0
- package/dist/drift.js.map +1 -0
- package/dist/env.d.ts +49 -0
- package/dist/env.js +83 -0
- package/dist/env.js.map +1 -0
- package/dist/errors.d.ts +88 -0
- package/dist/errors.js +148 -0
- package/dist/errors.js.map +1 -0
- package/dist/expectations.d.ts +26 -0
- package/dist/expectations.js +137 -0
- package/dist/expectations.js.map +1 -0
- package/dist/fake.d.ts +55 -0
- package/dist/fake.js +87 -0
- package/dist/fake.js.map +1 -0
- package/dist/fill.d.ts +9 -0
- package/dist/fill.js +17 -0
- package/dist/fill.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +217 -0
- package/dist/ledger.js +251 -0
- package/dist/ledger.js.map +1 -0
- package/dist/lint.d.ts +32 -0
- package/dist/lint.js +168 -0
- package/dist/lint.js.map +1 -0
- package/dist/log.d.ts +24 -0
- package/dist/log.js +83 -0
- package/dist/log.js.map +1 -0
- package/dist/op.d.ts +257 -0
- package/dist/op.js +188 -0
- package/dist/op.js.map +1 -0
- package/dist/outline.d.ts +179 -0
- package/dist/outline.js +506 -0
- package/dist/outline.js.map +1 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.js +30 -0
- package/dist/paths.js.map +1 -0
- package/dist/plugin.d.ts +28 -0
- package/dist/plugin.js +25 -0
- package/dist/plugin.js.map +1 -0
- package/dist/policy.d.ts +114 -0
- package/dist/policy.js +60 -0
- package/dist/policy.js.map +1 -0
- package/dist/resource.d.ts +212 -0
- package/dist/resource.js +285 -0
- package/dist/resource.js.map +1 -0
- package/dist/resources.d.ts +62 -0
- package/dist/resources.js +197 -0
- package/dist/resources.js.map +1 -0
- package/dist/run.d.ts +87 -0
- package/dist/run.js +2 -0
- package/dist/run.js.map +1 -0
- package/dist/sandbox.d.ts +33 -0
- package/dist/sandbox.js +123 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/scenario.d.ts +212 -0
- package/dist/scenario.js +1079 -0
- package/dist/scenario.js.map +1 -0
- package/dist/scenarios.d.ts +55 -0
- package/dist/scenarios.js +71 -0
- package/dist/scenarios.js.map +1 -0
- package/dist/shared.d.ts +61 -0
- package/dist/shared.js +77 -0
- package/dist/shared.js.map +1 -0
- package/dist/steps.d.ts +76 -0
- package/dist/steps.js +23 -0
- package/dist/steps.js.map +1 -0
- package/dist/version.d.ts +29 -0
- package/dist/version.js +76 -0
- package/dist/version.js.map +1 -0
- package/dist/worker.d.ts +34 -0
- package/dist/worker.js +219 -0
- package/dist/worker.js.map +1 -0
- package/oxlint.json +128 -0
- package/package.json +91 -3
- package/src/approvals.ts +157 -0
- package/src/ast.ts +115 -0
- package/src/call.ts +468 -0
- package/src/client.ts +534 -0
- package/src/config.ts +381 -0
- package/src/credentials.ts +211 -0
- package/src/cucumber.ts +230 -0
- package/src/datafile.ts +409 -0
- package/src/db.ts +197 -0
- package/src/define.ts +285 -0
- package/src/describe.ts +262 -0
- package/src/drift.ts +149 -0
- package/src/env.ts +122 -0
- package/src/errors.ts +202 -0
- package/src/expectations.ts +165 -0
- package/src/fake.ts +156 -0
- package/src/fill.ts +24 -0
- package/src/index.ts +120 -0
- package/src/ledger.ts +419 -0
- package/src/lint.ts +208 -0
- package/src/log.ts +82 -0
- package/src/op.ts +399 -0
- package/src/outline.ts +620 -0
- package/src/paths.ts +32 -0
- package/src/plugin.ts +37 -0
- package/src/policy.ts +139 -0
- package/src/resource.ts +447 -0
- package/src/resources.ts +256 -0
- package/src/run.ts +91 -0
- package/src/sandbox.ts +157 -0
- package/src/scenario.ts +1328 -0
- package/src/scenarios.ts +111 -0
- package/src/shared.ts +116 -0
- package/src/steps.ts +90 -0
- package/src/version.ts +86 -0
- package/src/worker.ts +253 -0
package/src/describe.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import { type LintProblem, readSource, type SourceFile } from "./ast.ts";
|
|
3
|
+
import { resolveConfig, type SanomaConfig } from "./config.ts";
|
|
4
|
+
import { type Builtin, jsonSchemaOf, opIdsOf, type Use } from "./define.ts";
|
|
5
|
+
import { DRIFT_WORKFLOW } from "./drift.ts";
|
|
6
|
+
import { lintWorkflow } from "./lint.ts";
|
|
7
|
+
import { type Effect, VENDOR, type VendorInfo } from "./op.ts";
|
|
8
|
+
import { type Outline, outlineWithSource } from "./outline.ts";
|
|
9
|
+
import { allowAll, policyOpOf } from "./policy.ts";
|
|
10
|
+
import { type ResourceFields, resourceTypesOf } from "./resource.ts";
|
|
11
|
+
import { type DeclaredResource, readDataFiles, type ResourceProblem } from "./resources.ts";
|
|
12
|
+
|
|
13
|
+
// `@sanoma-ai/workflows/describe`: what a UI renders from, apart from the main entry.
|
|
14
|
+
export { outlineWorkflow, type Outline, type OutlineNode, type Span } from "./outline.ts";
|
|
15
|
+
// The reader parses data files with oxc-parser too.
|
|
16
|
+
export type { LintProblem } from "./ast.ts";
|
|
17
|
+
export {
|
|
18
|
+
readDataFiles,
|
|
19
|
+
readResources,
|
|
20
|
+
type DataFiles,
|
|
21
|
+
type DeclaredResource,
|
|
22
|
+
type ResourceProblem,
|
|
23
|
+
} from "./resources.ts";
|
|
24
|
+
|
|
25
|
+
/** What a workflow is, read from its definition: enough to draw a start form and show what it may call. */
|
|
26
|
+
export interface WorkflowEntry {
|
|
27
|
+
name: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
trigger: "manual";
|
|
30
|
+
/**
|
|
31
|
+
* Set for a workflow the runtime adds itself, `drift`, rather than the config: a label. It
|
|
32
|
+
* starts like any other.
|
|
33
|
+
*/
|
|
34
|
+
builtin?: true;
|
|
35
|
+
/** The input, as JSON Schema (draft 2020-12), from the zod schema. */
|
|
36
|
+
input: Record<string, unknown>;
|
|
37
|
+
/** Operation ids the workflow may call, in the order declared. */
|
|
38
|
+
ops: string[];
|
|
39
|
+
/** Built-ins the workflow may call. */
|
|
40
|
+
builtins: Builtin[];
|
|
41
|
+
/** The file that defined it, absolute. */
|
|
42
|
+
file: string;
|
|
43
|
+
/** What its `run` calls, in order, read from its file (`outlineWorkflow`), which the worker holds its runs to. */
|
|
44
|
+
outline: Outline;
|
|
45
|
+
/**
|
|
46
|
+
* What is wrong in its file, by position: what `lintWorkflow` finds (syntax, an import a
|
|
47
|
+
* workflow may not make, `instanceof` against the runtime's error classes, what the outline
|
|
48
|
+
* cannot draw in each `run`), read the one way the outline is, so a check sees what the
|
|
49
|
+
* worker refuses; and, at 1:1, an outline error that is the file's (it cannot be read, or holds
|
|
50
|
+
* no workflow of this name). A file defining several workflows gives each its problems. Empty
|
|
51
|
+
* for the built-in `drift`, which the package's tests hold to the lint.
|
|
52
|
+
*/
|
|
53
|
+
problems: LintProblem[];
|
|
54
|
+
/**
|
|
55
|
+
* The text the outline's spans index into: the workflow's file, with `\n` line endings.
|
|
56
|
+
* Absent only when the outline is `{ error }`. Whole files: a server may keep it from clients.
|
|
57
|
+
*/
|
|
58
|
+
source?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What an operation is, read from its connector: enough to badge it and show its contract. */
|
|
62
|
+
export interface OpEntry {
|
|
63
|
+
id: string;
|
|
64
|
+
vendor: string;
|
|
65
|
+
resource: string;
|
|
66
|
+
name: string;
|
|
67
|
+
effect: Effect;
|
|
68
|
+
idempotent: boolean;
|
|
69
|
+
description?: string;
|
|
70
|
+
/** How a scenario's steps name it: Cucumber expressions over its input's fields. */
|
|
71
|
+
phrases?: { given?: string; expect?: string };
|
|
72
|
+
/** What a call sends, as JSON Schema (`io: "input"`: fields with defaults are optional). */
|
|
73
|
+
input: Record<string, unknown>;
|
|
74
|
+
/** What a call returns once its schema has parsed the vendor's reply (`io: "output"`). */
|
|
75
|
+
output: Record<string, unknown>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Who a vendor is, from its connector's `VendorInfo`: enough to name it, show its logo and link to its connector. */
|
|
79
|
+
export interface VendorEntry {
|
|
80
|
+
/** The connector's `title`, else the vendor's id. */
|
|
81
|
+
title: string;
|
|
82
|
+
/** The logo as `data:image/svg+xml` URLs, for an `<img>`: `dark` is for dark backgrounds. */
|
|
83
|
+
logo?: { src: string; dark?: string };
|
|
84
|
+
/** The connector's npm package name. */
|
|
85
|
+
package?: string;
|
|
86
|
+
/** The connector package's `homepage` from its package.json: where its code and README are, as an https URL. */
|
|
87
|
+
homepage?: string;
|
|
88
|
+
/** Whether the vendor's connector attaches a fake (`withSetup`), for sandbox runs. */
|
|
89
|
+
fake: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A resource type a connector declares with `defineResource`. */
|
|
93
|
+
export interface ResourceTypeEntry {
|
|
94
|
+
/** `<vendor>.<type>`, such as `github.repository`. */
|
|
95
|
+
id: string;
|
|
96
|
+
vendor: string;
|
|
97
|
+
type: string;
|
|
98
|
+
title: string;
|
|
99
|
+
/** How a declared resource's import id is made, such as `name` or `repository_id:pattern`. */
|
|
100
|
+
identity: string;
|
|
101
|
+
/** Its flagged fields, by dotted path. */
|
|
102
|
+
fields: ResourceFields;
|
|
103
|
+
/**
|
|
104
|
+
* Its state, as a read returns it, as JSON Schema (`io: "output"`), with `$id`
|
|
105
|
+
* `sanoma:resource-type/<id>`: the operations' contracts `$ref` it instead of repeating it.
|
|
106
|
+
*/
|
|
107
|
+
schema: Record<string, unknown>;
|
|
108
|
+
/** Its operations' ids: `<id>.import` and `<id>.read`. */
|
|
109
|
+
ops: string[];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A plain-JSON description of what a config can do: its workflows, the operations they call,
|
|
114
|
+
* and whether a policy gates them. The app server builds it from the config and sends it to the
|
|
115
|
+
* browser, so the UI shows exactly what the worker enforces. Never written by hand.
|
|
116
|
+
*/
|
|
117
|
+
export interface ConfigDescription {
|
|
118
|
+
appName: string;
|
|
119
|
+
/** The application version runs started now are stamped with. */
|
|
120
|
+
version: string;
|
|
121
|
+
workflows: WorkflowEntry[];
|
|
122
|
+
ops: OpEntry[];
|
|
123
|
+
/** Every vendor the operations are from, by its id (an `OpEntry`'s `vendor`). */
|
|
124
|
+
vendors: Record<string, VendorEntry>;
|
|
125
|
+
/** The resource types the connectors declare, by `id`. */
|
|
126
|
+
resourceTypes: ResourceTypeEntry[];
|
|
127
|
+
/**
|
|
128
|
+
* The resources the data files under `resources/` declare, by file and then in file order,
|
|
129
|
+
* less any with a problem and any that names one.
|
|
130
|
+
*/
|
|
131
|
+
resources: DeclaredResource[];
|
|
132
|
+
/** 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
|
+
problems: ResourceProblem[];
|
|
134
|
+
/** `defined` is false for `allowAll`. */
|
|
135
|
+
policy: { defined: boolean; version?: string };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Describes a config. Throws what `startWorker` would refuse (see `resolveConfig`), except missing
|
|
140
|
+
* credentials (`SanomaClient.credentials` gives their statuses) and what is wrong in a workflow's
|
|
141
|
+
* file, which the worker refuses when it reads the outline: that is data, on each workflow's
|
|
142
|
+
* entry, as the data files' problems are, `problems`, beside the resources read without any.
|
|
143
|
+
* Each workflow's file is read and parsed once, however many workflows it defines.
|
|
144
|
+
*/
|
|
145
|
+
export function describeConfig(config: SanomaConfig): ConfigDescription {
|
|
146
|
+
const resolved = resolveConfig(config);
|
|
147
|
+
const vendors: Record<string, VendorEntry> = {};
|
|
148
|
+
// A vendor whose operations are split over several connectors is named by the first; its
|
|
149
|
+
// resource types are every connector's.
|
|
150
|
+
for (const connector of config.connectors) {
|
|
151
|
+
const { id, info } = connector[VENDOR];
|
|
152
|
+
vendors[id] ??= vendorEntry(id, info, resolved.fakes.has(id));
|
|
153
|
+
}
|
|
154
|
+
const resources = resourceTypesOf(config.connectors);
|
|
155
|
+
// Each resource type's state is described once, on its entry, and referenced from its operations.
|
|
156
|
+
const refs = new Map<z.ZodType, string>([...resources].map(([id, r]) => [r.schema, resourceTypeUri(id)]));
|
|
157
|
+
const resourceTypes: ResourceTypeEntry[] = [...resources]
|
|
158
|
+
.map(([id, { vendor, type, title, identity, fields, schema }]) => ({
|
|
159
|
+
id,
|
|
160
|
+
vendor,
|
|
161
|
+
type,
|
|
162
|
+
title,
|
|
163
|
+
identity,
|
|
164
|
+
fields,
|
|
165
|
+
schema: { $id: resourceTypeUri(id), ...toJsonSchema(schema, `${id} state`, "output") },
|
|
166
|
+
ops: [`${id}.import`, `${id}.read`],
|
|
167
|
+
}))
|
|
168
|
+
.toSorted((a, b) => a.id.localeCompare(b.id));
|
|
169
|
+
|
|
170
|
+
const ops: OpEntry[] = [...resolved.ops.values()].map((op) => ({
|
|
171
|
+
...policyOpOf(op),
|
|
172
|
+
idempotent: op.idempotent,
|
|
173
|
+
description: op.description,
|
|
174
|
+
...(op.phrases && { phrases: op.phrases }),
|
|
175
|
+
input: toJsonSchema(op.input, `${op.id} input`, "input", refs),
|
|
176
|
+
output: toJsonSchema(op.output, `${op.id} output`, "output", refs),
|
|
177
|
+
}));
|
|
178
|
+
ops.sort((a, b) => a.id.localeCompare(b.id));
|
|
179
|
+
|
|
180
|
+
const read = memoised(readSource);
|
|
181
|
+
const lint = memoised((file: SourceFile) => lintWorkflow(file));
|
|
182
|
+
const workflows: WorkflowEntry[] = [...resolved.workflows.values()].map((wf) => {
|
|
183
|
+
const file = read(wf.file);
|
|
184
|
+
const { outline, source, found } = outlineWithSource(wf, file);
|
|
185
|
+
// What the lint does not report: the file unread, or no workflow of this name in it.
|
|
186
|
+
const own = "error" in outline && !found && (typeof file === "string" || !file.problems.length);
|
|
187
|
+
const problems =
|
|
188
|
+
wf.name === DRIFT_WORKFLOW
|
|
189
|
+
? []
|
|
190
|
+
: [
|
|
191
|
+
...(typeof file === "string" ? [] : lint(file)),
|
|
192
|
+
...(own ? [{ line: 1, column: 1, message: outline.error }] : []),
|
|
193
|
+
];
|
|
194
|
+
return {
|
|
195
|
+
name: wf.name,
|
|
196
|
+
title: wf.title,
|
|
197
|
+
trigger: wf.trigger,
|
|
198
|
+
...(wf.name === DRIFT_WORKFLOW && { builtin: true as const }),
|
|
199
|
+
input: toJsonSchema(wf.input, `${wf.name} input`, "input"),
|
|
200
|
+
ops: opIdsOf(wf),
|
|
201
|
+
builtins: (wf.uses as Use[]).filter((u): u is Builtin => typeof u === "string"),
|
|
202
|
+
file: wf.file,
|
|
203
|
+
outline,
|
|
204
|
+
problems,
|
|
205
|
+
...(source === undefined ? {} : { source }),
|
|
206
|
+
};
|
|
207
|
+
});
|
|
208
|
+
workflows.sort((a, b) => a.name.localeCompare(b.name));
|
|
209
|
+
const dataFiles = readDataFiles(resolved.root, config.connectors, resources);
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
appName: resolved.appName,
|
|
213
|
+
version: resolved.version,
|
|
214
|
+
workflows,
|
|
215
|
+
ops,
|
|
216
|
+
vendors,
|
|
217
|
+
resourceTypes,
|
|
218
|
+
resources: dataFiles.resources,
|
|
219
|
+
problems: dataFiles.problems,
|
|
220
|
+
policy: {
|
|
221
|
+
defined: resolved.policy !== allowAll,
|
|
222
|
+
...(resolved.policy.version === undefined ? {} : { version: resolved.policy.version }),
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** `fn`, called once per argument for as long as the returned function lives. */
|
|
228
|
+
function memoised<A, R>(fn: (arg: A) => R): (arg: A) => R {
|
|
229
|
+
const seen = new Map<A, R>();
|
|
230
|
+
return (arg) => {
|
|
231
|
+
if (!seen.has(arg)) seen.set(arg, fn(arg));
|
|
232
|
+
return seen.get(arg)!;
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const svgDataUrl = (svg: string) => `data:image/svg+xml,${encodeURIComponent(svg.trim())}`;
|
|
237
|
+
|
|
238
|
+
const vendorEntry = (id: string, info: VendorInfo | undefined, fake: boolean): VendorEntry => ({
|
|
239
|
+
title: info?.title ?? id,
|
|
240
|
+
...(info?.logo && {
|
|
241
|
+
logo: { src: svgDataUrl(info.logo.svg), ...(info.logo.dark && { dark: svgDataUrl(info.logo.dark) }) },
|
|
242
|
+
}),
|
|
243
|
+
...(info?.package && { package: info.package }),
|
|
244
|
+
...(info?.homepage && { homepage: info.homepage }),
|
|
245
|
+
fake,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
/** Where a resource type's state schema is, for a `$ref`. */
|
|
249
|
+
const resourceTypeUri = (id: string) => `sanoma:resource-type/${id}`;
|
|
250
|
+
|
|
251
|
+
function toJsonSchema(
|
|
252
|
+
schema: z.ZodType,
|
|
253
|
+
what: string,
|
|
254
|
+
io: "input" | "output",
|
|
255
|
+
refs?: ReadonlyMap<unknown, string>,
|
|
256
|
+
): Record<string, unknown> {
|
|
257
|
+
try {
|
|
258
|
+
return jsonSchemaOf(schema, io, refs);
|
|
259
|
+
} catch (e) {
|
|
260
|
+
throw new Error(`Cannot describe ${what} as JSON Schema: ${(e as Error).message}`, { cause: e });
|
|
261
|
+
}
|
|
262
|
+
}
|
package/src/drift.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
|
|
3
|
+
import { isRunControlError } from "./errors.ts";
|
|
4
|
+
import type { Op } from "./op.ts";
|
|
5
|
+
import { type DriftField, diffDeclared, type Resource } from "./resource.ts";
|
|
6
|
+
import type { DeclaredResource, ResourceProblem } from "./resources.ts";
|
|
7
|
+
import { type DRIFT_WORKFLOW, errorMessage } from "./shared.ts";
|
|
8
|
+
|
|
9
|
+
export type { DriftField } from "./resource.ts";
|
|
10
|
+
export { DRIFT_WORKFLOW } from "./shared.ts";
|
|
11
|
+
|
|
12
|
+
// The built-in `drift` workflow: for each resource the data files declare, what the vendor holds
|
|
13
|
+
// now against what is declared. It is a workflow like any other, held to the same lint, so every
|
|
14
|
+
// vendor call goes through `ctx`: the policy sees each (effect `read`) and the ledger records it.
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How a declared resource compares: `clean` when every declared field holds its declared value,
|
|
18
|
+
* `drifted` when one does not, `gone` when the vendor has no such object, and `error` when it
|
|
19
|
+
* could not be read (the vendor's error, a policy denial, or no driver for its type).
|
|
20
|
+
*/
|
|
21
|
+
export type DriftStatus = "clean" | "drifted" | "gone" | "error";
|
|
22
|
+
|
|
23
|
+
/** One declared resource in a drift report. */
|
|
24
|
+
export interface DriftResult {
|
|
25
|
+
/** The resource's id, `<file>#<export>`: `resources/identity/github.ts#website`. */
|
|
26
|
+
id: string;
|
|
27
|
+
/** Its type within the vendor, such as `repository`. */
|
|
28
|
+
type: string;
|
|
29
|
+
vendor: string;
|
|
30
|
+
/** Its import id. */
|
|
31
|
+
name: string;
|
|
32
|
+
status: DriftStatus;
|
|
33
|
+
/** The declared fields that differ; empty unless `drifted`. */
|
|
34
|
+
fields: DriftField[];
|
|
35
|
+
/** Why it could not be read, for `error`. */
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The drift workflow's output. */
|
|
40
|
+
export interface DriftReport {
|
|
41
|
+
/** Epoch milliseconds, by `ctx.now()`. */
|
|
42
|
+
startedAt: number;
|
|
43
|
+
/** Epoch milliseconds, by `ctx.now()`. */
|
|
44
|
+
finishedAt: number;
|
|
45
|
+
/** What was wrong in the data files when the run read them: their resources are not in `resources`. */
|
|
46
|
+
problems: ResourceProblem[];
|
|
47
|
+
/** Each resource the data files declared when the run read them, in their order. */
|
|
48
|
+
resources: DriftResult[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The drift workflow's input: nothing. The run reads the data files itself, so a run says what
|
|
53
|
+
* they declared when it ran, and no caller can hand it other resources to check.
|
|
54
|
+
*/
|
|
55
|
+
export const DriftInput = z.object({}).strict();
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What a drift run records of the data files, as its first step: each resource without its
|
|
59
|
+
* write-only values, and the problems.
|
|
60
|
+
*/
|
|
61
|
+
export interface DriftDeclared {
|
|
62
|
+
resources: Pick<DeclaredResource, "id" | "vendor" | "type" | "name" | "desired" | "refs">[];
|
|
63
|
+
problems: ResourceProblem[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type State = Record<string, unknown>;
|
|
67
|
+
|
|
68
|
+
// A resource type's import, as `ctx` gives it: by vendor and type, both only known per run.
|
|
69
|
+
type ImportOp = Op<string, string, "import", { id: string }, { id: string; gone: boolean; state?: State }>;
|
|
70
|
+
|
|
71
|
+
/** 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 };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The drift workflow for a config: it `uses` the `import` of every resource type the connectors
|
|
76
|
+
* declare (`types`) that has a driver, so its version changes with them, not with the data
|
|
77
|
+
* files. `resolveConfig` adds it to a config whose connectors declare resource types, with
|
|
78
|
+
* `declared`, the step that reads the data files.
|
|
79
|
+
*
|
|
80
|
+
* A run reads the data files (`declared`, recorded as a step, so a replay checks the same list),
|
|
81
|
+
* then imports each resource by its import id (its `name`), in the files' order: one vendor call
|
|
82
|
+
* each. An import that says gone is `gone`. Else the state and the declared fields are compared
|
|
83
|
+
* by `diffDeclared`, a reference against the state of the resource it names, read in the same run
|
|
84
|
+
* (so GitHub's node id for a repository counts as its name). A failed call is that resource's
|
|
85
|
+
* `error`, and the run goes on to the next; a failure that ends the run (`isRunControlError`)
|
|
86
|
+
* ends it.
|
|
87
|
+
*/
|
|
88
|
+
export function driftWorkflow(
|
|
89
|
+
types: ReadonlyMap<string, Resource>,
|
|
90
|
+
ops: ReadonlyMap<string, Op>,
|
|
91
|
+
drivers: ReadonlyMap<string, unknown>,
|
|
92
|
+
declared: () => Promise<DriftDeclared>,
|
|
93
|
+
): WorkflowDefinition<readonly ImportOp[], typeof DriftInput> {
|
|
94
|
+
const uses = [...types.keys()].toSorted().flatMap((type) => {
|
|
95
|
+
const op = ops.get(`${type}.import`);
|
|
96
|
+
return op && drivers.has(op.id) ? [op as ImportOp] : [];
|
|
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
|
+
}
|
|
103
|
+
return defineWorkflow({
|
|
104
|
+
// The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
|
|
105
|
+
name: "drift" satisfies typeof DRIFT_WORKFLOW,
|
|
106
|
+
title: "Check resources for drift",
|
|
107
|
+
trigger: "manual",
|
|
108
|
+
input: DriftInput,
|
|
109
|
+
uses,
|
|
110
|
+
run: async (ctx) => {
|
|
111
|
+
const startedAt = await ctx.now();
|
|
112
|
+
const { resources, problems } = await declared();
|
|
113
|
+
// Every import first, so a reference compares against what its resource holds now.
|
|
114
|
+
const found = new Map<string, Found>();
|
|
115
|
+
for (const { id, vendor, type, name } of resources) {
|
|
116
|
+
const key = `${vendor}.${type}`;
|
|
117
|
+
const why = types.has(key) ? unreadable.get(key) : `the config's connectors declare no resource type ${key}`;
|
|
118
|
+
// An if/else, not a `continue`: the outline draws the import as the way past the error.
|
|
119
|
+
if (why !== undefined) found.set(id, { error: why });
|
|
120
|
+
else {
|
|
121
|
+
try {
|
|
122
|
+
const out = await ctx[vendor]![type]!.import({ id: name });
|
|
123
|
+
found.set(id, out.gone || !out.state ? { gone: true } : { state: out.state });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
if (isRunControlError(err)) throw err;
|
|
126
|
+
found.set(id, { error: errorMessage(err) });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const results = resources.map(({ id, vendor, type, name, desired, refs }): DriftResult => {
|
|
131
|
+
const row = { id, type, vendor, name };
|
|
132
|
+
const now = found.get(id)!;
|
|
133
|
+
if ("error" in now) return { ...row, status: "error", fields: [], error: now.error };
|
|
134
|
+
if ("gone" in now) return { ...row, status: "gone", fields: [] };
|
|
135
|
+
const resource = types.get(`${vendor}.${type}`)!;
|
|
136
|
+
// Each reference, by where it is: the values the vendor may hold there for its resource.
|
|
137
|
+
const accept: Record<string, unknown[]> = {};
|
|
138
|
+
for (const [at, target] of Object.entries(refs)) {
|
|
139
|
+
const by = resource.fields.references?.[at.replaceAll(/\.\d+(?=\.|$)/g, "")]?.by ?? [];
|
|
140
|
+
const named = found.get(target);
|
|
141
|
+
if (named && "state" in named) accept[at] = by.map((field) => named.state[field]).filter((v) => v != null);
|
|
142
|
+
}
|
|
143
|
+
const fields = diffDeclared(resource, now.state, desired, accept);
|
|
144
|
+
return { ...row, status: fields.length ? "drifted" : "clean", fields };
|
|
145
|
+
});
|
|
146
|
+
return { startedAt, finishedAt: await ctx.now(), problems, resources: results } satisfies DriftReport;
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { Driver, DriverEnv } from "./op.ts";
|
|
3
|
+
import { credentialReady } from "./shared.ts";
|
|
4
|
+
|
|
5
|
+
// A driver's `env` against an environment: what each declared variable's status is, never its
|
|
6
|
+
// value. The worker, the client, the app and each live call read the declarations through here.
|
|
7
|
+
|
|
8
|
+
/** One environment variable a driver declares in `env`, and whether its value lets the driver run: never the value. */
|
|
9
|
+
export interface CredentialStatus {
|
|
10
|
+
/** The variable's name, such as `GHOST_ADMIN_API_KEY`. */
|
|
11
|
+
name: string;
|
|
12
|
+
/** Its one-line manual, from its schema's `.describe()`. */
|
|
13
|
+
description?: string;
|
|
14
|
+
/** It may be unset. */
|
|
15
|
+
optional: boolean;
|
|
16
|
+
/** `missing` when unset or empty; `invalid` when its schema refuses the value. */
|
|
17
|
+
status: "set" | "missing" | "invalid";
|
|
18
|
+
/** Why it is `invalid`: the schema's first issue, never the value. */
|
|
19
|
+
problem?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Where the value is from, when there is one: the process environment, which wins, or the
|
|
22
|
+
* credentials stored with `SanomaClient.setCredential`. Only `SanomaClient.credentials` says.
|
|
23
|
+
*/
|
|
24
|
+
source?: "environment" | "stored";
|
|
25
|
+
/** Who set the stored value (`setCredential`'s `by`). */
|
|
26
|
+
setBy?: string;
|
|
27
|
+
/** When the stored value was set, as an ISO 8601 time. */
|
|
28
|
+
setAt?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Each vendor's driver's `env` (a resolved config's `driversByVendor`), by vendor in the map's
|
|
33
|
+
* order (a vendor whose driver declares none has no entry), each variable in declared order and
|
|
34
|
+
* checked against the value `lookup` gives for its name: only the declared names are looked up,
|
|
35
|
+
* an empty value counts as unset, and a value is never kept. The one walk of the declarations:
|
|
36
|
+
* the worker, the client and its checks all read them through it.
|
|
37
|
+
*/
|
|
38
|
+
export function credentialsOf(
|
|
39
|
+
drivers: ReadonlyMap<string, Driver>,
|
|
40
|
+
lookup: (name: string) => string | undefined,
|
|
41
|
+
): Map<string, CredentialStatus[]> {
|
|
42
|
+
const map = new Map<string, CredentialStatus[]>();
|
|
43
|
+
for (const [vendor, { env }] of drivers) if (env) map.set(vendor, statusesOf(env, lookup));
|
|
44
|
+
return map;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One driver's `env`, each variable checked against `lookup`'s value, as `credentialsOf` checks it. */
|
|
48
|
+
export function statusesOf(env: DriverEnv, lookup: (name: string) => string | undefined): CredentialStatus[] {
|
|
49
|
+
return Object.entries(env.shape).map(([name, schema]) => credentialStatus(name, schema, lookup(name) || undefined));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Why the vendor's drivers cannot run on `statuses`, as a call to one fails (`startWorker` names
|
|
54
|
+
* the vendor alone, `unconfiguredSummary`): `<vendor> is not configured: <problems>; <hint>`, a
|
|
55
|
+
* missing variable (not optional) as `NAME (description)`, an invalid one as `NAME is invalid:
|
|
56
|
+
* <why>`, never a value. The hint says
|
|
57
|
+
* where to set them: in the environment when an invalid value is there (the environment wins
|
|
58
|
+
* over a stored value, so the connector page cannot replace it), else on the connector page.
|
|
59
|
+
* Undefined when the drivers can run.
|
|
60
|
+
*/
|
|
61
|
+
export function unconfigured(vendor: string, statuses: readonly CredentialStatus[]): string | undefined {
|
|
62
|
+
const unready = statuses.filter((c) => !credentialReady(c));
|
|
63
|
+
if (!unready.length) return undefined;
|
|
64
|
+
const problems = unready.map((c) =>
|
|
65
|
+
c.status === "invalid"
|
|
66
|
+
? `${c.name} is invalid: ${c.problem}`
|
|
67
|
+
: `${c.name}${c.description ? ` (${c.description})` : ""}`,
|
|
68
|
+
);
|
|
69
|
+
const inEnvironment = unready.some((c) => c.status === "invalid" && process.env[c.name]);
|
|
70
|
+
const hint = inEnvironment ? "set in the environment" : "set it on the connector page";
|
|
71
|
+
return `${vendor} is not configured: ${problems.join(", ")}; ${hint}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The one line `startWorker` prints for the vendors whose drivers cannot run yet (`vendors`, those
|
|
76
|
+
* `unconfigured` has a message for) among the config's `total`: their names alone, since a call
|
|
77
|
+
* to one fails with its own message, and its connector page says the same. Undefined for none.
|
|
78
|
+
*/
|
|
79
|
+
export function unconfiguredSummary(vendors: readonly string[], total: number): string | undefined {
|
|
80
|
+
if (!vendors.length) return undefined;
|
|
81
|
+
return (
|
|
82
|
+
`${vendors.length} of ${total} connectors not configured yet: ${vendors.join(", ")}; ` +
|
|
83
|
+
"set their variables on the connector pages or in the environment"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function credentialStatus(
|
|
88
|
+
name: string,
|
|
89
|
+
schema: DriverEnv["shape"][string],
|
|
90
|
+
value: string | undefined,
|
|
91
|
+
): CredentialStatus {
|
|
92
|
+
const description = descriptionOf(schema);
|
|
93
|
+
const parsed = value === undefined ? undefined : check(schema, value);
|
|
94
|
+
const problem = parsed?.problem;
|
|
95
|
+
return {
|
|
96
|
+
name,
|
|
97
|
+
...(description !== undefined && { description }),
|
|
98
|
+
optional: check(schema, undefined).success,
|
|
99
|
+
status: !parsed ? "missing" : parsed.success ? "set" : "invalid",
|
|
100
|
+
...(problem !== undefined && { problem }),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `schema.safeParse(value)`, with its first issue's message as the problem. A check that throws
|
|
106
|
+
* (zod lets a `.refine()` or `.transform()` throw) fails without its error, which may carry the value.
|
|
107
|
+
*/
|
|
108
|
+
function check(schema: z.ZodType, value: string | undefined): { success: boolean; problem?: string } {
|
|
109
|
+
try {
|
|
110
|
+
const { success, error } = schema.safeParse(value);
|
|
111
|
+
return { success, ...(error && { problem: error.issues[0]!.message }) };
|
|
112
|
+
} catch {
|
|
113
|
+
return { success: false, problem: "its check threw" };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** A schema's `.describe()`, also through `.optional()`, `.default()` and `.prefault()` in any order. */
|
|
118
|
+
function descriptionOf(schema: z.ZodType): string | undefined {
|
|
119
|
+
let s: z.ZodType | undefined = schema;
|
|
120
|
+
while (s && !s.description) s = "unwrap" in s ? (s as { unwrap(): z.ZodType }).unwrap() : undefined;
|
|
121
|
+
return s?.description;
|
|
122
|
+
}
|