@stackwright-services/compiler 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +423 -0
- package/dist/index.d.ts +423 -0
- package/dist/index.js +1995 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1945 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +46 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1945 @@
|
|
|
1
|
+
// src/validate.ts
|
|
2
|
+
import {
|
|
3
|
+
scanDataSources as defaultScanDataSources
|
|
4
|
+
} from "@stackwright-services/runtime";
|
|
5
|
+
import {
|
|
6
|
+
FlowDefinition,
|
|
7
|
+
WorkflowDefinition
|
|
8
|
+
} from "@stackwright-services/types";
|
|
9
|
+
import { parse as parseYaml } from "yaml";
|
|
10
|
+
function detectDefinitionType(obj) {
|
|
11
|
+
if (typeof obj !== "object" || obj === null) return "unknown";
|
|
12
|
+
const record = obj;
|
|
13
|
+
if ("steps" in record && "trigger" in record) return "flow";
|
|
14
|
+
if ("states" in record && "initial" in record) return "workflow";
|
|
15
|
+
return "unknown";
|
|
16
|
+
}
|
|
17
|
+
function validate(yamlContent, registry, options) {
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = parseYaml(yamlContent);
|
|
21
|
+
} catch (err) {
|
|
22
|
+
return {
|
|
23
|
+
success: false,
|
|
24
|
+
errors: [{ code: "YAML_PARSE_ERROR", message: `Invalid YAML: ${err.message}` }],
|
|
25
|
+
warnings: []
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (parsed === null || parsed === void 0) {
|
|
29
|
+
return {
|
|
30
|
+
success: false,
|
|
31
|
+
errors: [{ code: "EMPTY_DOCUMENT", message: "YAML document is empty" }],
|
|
32
|
+
warnings: []
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const defType = detectDefinitionType(parsed);
|
|
36
|
+
if (defType === "flow") {
|
|
37
|
+
return validateFlow(parsed, registry, options);
|
|
38
|
+
} else if (defType === "workflow") {
|
|
39
|
+
return validateWorkflow(parsed, registry);
|
|
40
|
+
} else {
|
|
41
|
+
return {
|
|
42
|
+
success: false,
|
|
43
|
+
errors: [
|
|
44
|
+
{
|
|
45
|
+
code: "UNKNOWN_DEFINITION_TYPE",
|
|
46
|
+
message: "Could not detect definition type. Expected a flow (with trigger + steps) or workflow (with states + initial)."
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
warnings: []
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function validateFlow(parsed, registry, options) {
|
|
54
|
+
const result = FlowDefinition.safeParse(parsed);
|
|
55
|
+
if (!result.success) {
|
|
56
|
+
const errors2 = result.error.issues.map((issue) => ({
|
|
57
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
58
|
+
message: issue.message,
|
|
59
|
+
path: issue.path.join(".")
|
|
60
|
+
}));
|
|
61
|
+
return { success: false, errors: errors2, warnings: [] };
|
|
62
|
+
}
|
|
63
|
+
const flow = result.data;
|
|
64
|
+
const errors = [];
|
|
65
|
+
const warnings = [];
|
|
66
|
+
if (registry) {
|
|
67
|
+
for (const step of flow.steps) {
|
|
68
|
+
if (!registry.has(step.use)) {
|
|
69
|
+
errors.push({
|
|
70
|
+
code: "UNKNOWN_CAPABILITY",
|
|
71
|
+
message: `Step "${step.name}" references unknown capability "${step.use}"`,
|
|
72
|
+
path: `steps.${step.name}.use`
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const stepNames = flow.steps.map((s) => s.name);
|
|
78
|
+
const seen = /* @__PURE__ */ new Set();
|
|
79
|
+
for (const name of stepNames) {
|
|
80
|
+
if (seen.has(name)) {
|
|
81
|
+
warnings.push({
|
|
82
|
+
code: "DUPLICATE_STEP_NAME",
|
|
83
|
+
message: `Duplicate step name "${name}" -- step names should be unique for observability`,
|
|
84
|
+
path: `steps.${name}`
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
seen.add(name);
|
|
88
|
+
}
|
|
89
|
+
const sourceRefs = [];
|
|
90
|
+
for (const step of flow.steps) {
|
|
91
|
+
const sourceVal = step.with?.["source"];
|
|
92
|
+
if (typeof sourceVal === "string" && (sourceVal.startsWith("openapi:") || sourceVal.startsWith("pulse:"))) {
|
|
93
|
+
sourceRefs.push({ ref: sourceVal, stepName: step.name });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (sourceRefs.length > 0) {
|
|
97
|
+
const doScan = options?.scanDataSources ?? defaultScanDataSources;
|
|
98
|
+
const scanResult = doScan();
|
|
99
|
+
for (const { ref, stepName } of sourceRefs) {
|
|
100
|
+
const sourceId = ref.slice(ref.indexOf(":") + 1);
|
|
101
|
+
if (scanResult.detectedProviders.length === 0) {
|
|
102
|
+
warnings.push({
|
|
103
|
+
code: "SOURCE_UNVERIFIABLE",
|
|
104
|
+
message: `source '${ref}' cannot be verified: no DataSourceProvider is installed`,
|
|
105
|
+
path: `steps.${stepName}.with.source`
|
|
106
|
+
});
|
|
107
|
+
} else if (!scanResult.sources.some((s) => s.id === sourceId)) {
|
|
108
|
+
warnings.push({
|
|
109
|
+
code: "SOURCE_NOT_REGISTERED",
|
|
110
|
+
message: `source '${ref}' is not registered by any installed provider`,
|
|
111
|
+
path: `steps.${stepName}.with.source`
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
success: errors.length === 0,
|
|
118
|
+
data: flow,
|
|
119
|
+
errors,
|
|
120
|
+
warnings
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function validateWorkflow(parsed, registry) {
|
|
124
|
+
const result = WorkflowDefinition.safeParse(parsed);
|
|
125
|
+
if (!result.success) {
|
|
126
|
+
const errors2 = result.error.issues.map((issue) => ({
|
|
127
|
+
code: "SCHEMA_VALIDATION_ERROR",
|
|
128
|
+
message: issue.message,
|
|
129
|
+
path: issue.path.join(".")
|
|
130
|
+
}));
|
|
131
|
+
return { success: false, errors: errors2, warnings: [] };
|
|
132
|
+
}
|
|
133
|
+
const workflow = result.data;
|
|
134
|
+
const errors = [];
|
|
135
|
+
const warnings = [];
|
|
136
|
+
const stateNames = Object.keys(workflow.states);
|
|
137
|
+
if (!stateNames.includes(workflow.initial)) {
|
|
138
|
+
errors.push({
|
|
139
|
+
code: "INITIAL_STATE_NOT_FOUND",
|
|
140
|
+
message: `Initial state "${workflow.initial}" does not exist in states map`,
|
|
141
|
+
path: "initial"
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
for (const [stateName, state] of Object.entries(workflow.states)) {
|
|
145
|
+
if (state.transitions) {
|
|
146
|
+
for (const transition of state.transitions) {
|
|
147
|
+
if (!stateNames.includes(transition.to)) {
|
|
148
|
+
errors.push({
|
|
149
|
+
code: "TRANSITION_TARGET_NOT_FOUND",
|
|
150
|
+
message: `State "${stateName}" has transition to unknown state "${transition.to}"`,
|
|
151
|
+
path: `states.${stateName}.transitions`
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (registry && state.on_enter) {
|
|
157
|
+
if (!registry.has(state.on_enter.use)) {
|
|
158
|
+
errors.push({
|
|
159
|
+
code: "UNKNOWN_CAPABILITY",
|
|
160
|
+
message: `State "${stateName}" on_enter references unknown capability "${state.on_enter.use}"`,
|
|
161
|
+
path: `states.${stateName}.on_enter.use`
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const terminalStates = stateNames.filter((name) => workflow.states[name]?.type === "terminal");
|
|
167
|
+
if (terminalStates.length === 0 && stateNames.length > 0) {
|
|
168
|
+
errors.push({
|
|
169
|
+
code: "NO_TERMINAL_STATE",
|
|
170
|
+
message: "Workflow has no terminal state -- workflows must have at least one terminal state"
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
for (const [stateName, state] of Object.entries(workflow.states)) {
|
|
174
|
+
if (state.type !== "terminal" && (!state.transitions || state.transitions.length === 0)) {
|
|
175
|
+
errors.push({
|
|
176
|
+
code: "DEADLOCK_STATE",
|
|
177
|
+
message: `State "${stateName}" is not terminal but has no transitions \u2014 workflow will deadlock here`,
|
|
178
|
+
path: `states.${stateName}`
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (stateNames.includes(workflow.initial)) {
|
|
183
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
184
|
+
const queue = [workflow.initial];
|
|
185
|
+
while (queue.length > 0) {
|
|
186
|
+
const current = queue.shift();
|
|
187
|
+
if (reachable.has(current)) continue;
|
|
188
|
+
reachable.add(current);
|
|
189
|
+
const state = workflow.states[current];
|
|
190
|
+
if (state?.transitions) {
|
|
191
|
+
for (const transition of state.transitions) {
|
|
192
|
+
if (!reachable.has(transition.to)) {
|
|
193
|
+
queue.push(transition.to);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (const stateName of stateNames) {
|
|
199
|
+
if (!reachable.has(stateName)) {
|
|
200
|
+
warnings.push({
|
|
201
|
+
code: "UNREACHABLE_STATE",
|
|
202
|
+
message: `State "${stateName}" is not reachable from initial state "${workflow.initial}"`,
|
|
203
|
+
path: `states.${stateName}`
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (terminalStates.length > 0) {
|
|
209
|
+
const canReachTerminal = new Set(terminalStates);
|
|
210
|
+
const reverseQueue = [...terminalStates];
|
|
211
|
+
const reverseAdj = /* @__PURE__ */ new Map();
|
|
212
|
+
for (const name of stateNames) {
|
|
213
|
+
reverseAdj.set(name, []);
|
|
214
|
+
}
|
|
215
|
+
for (const [stateName, state] of Object.entries(workflow.states)) {
|
|
216
|
+
if (state.transitions) {
|
|
217
|
+
for (const transition of state.transitions) {
|
|
218
|
+
reverseAdj.get(transition.to)?.push(stateName);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
while (reverseQueue.length > 0) {
|
|
223
|
+
const current = reverseQueue.shift();
|
|
224
|
+
for (const predecessor of reverseAdj.get(current) ?? []) {
|
|
225
|
+
if (!canReachTerminal.has(predecessor)) {
|
|
226
|
+
canReachTerminal.add(predecessor);
|
|
227
|
+
reverseQueue.push(predecessor);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (const stateName of stateNames) {
|
|
232
|
+
if (workflow.states[stateName]?.type !== "terminal" && !canReachTerminal.has(stateName)) {
|
|
233
|
+
warnings.push({
|
|
234
|
+
code: "NO_PATH_TO_TERMINAL",
|
|
235
|
+
message: `State "${stateName}" has no path to any terminal state`,
|
|
236
|
+
path: `states.${stateName}`
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (workflow.signals) {
|
|
242
|
+
const declaredSignals = new Set(workflow.signals.map((s) => s.name));
|
|
243
|
+
const usedSignals = /* @__PURE__ */ new Set();
|
|
244
|
+
for (const [stateName, state] of Object.entries(workflow.states)) {
|
|
245
|
+
if (state.transitions) {
|
|
246
|
+
for (const transition of state.transitions) {
|
|
247
|
+
usedSignals.add(transition.on);
|
|
248
|
+
if (!declaredSignals.has(transition.on)) {
|
|
249
|
+
warnings.push({
|
|
250
|
+
code: "UNKNOWN_SIGNAL",
|
|
251
|
+
message: `Transition in state "${stateName}" uses signal "${transition.on}" which is not declared in workflow signals`,
|
|
252
|
+
path: `states.${stateName}.transitions`
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const signal of workflow.signals) {
|
|
259
|
+
if (!usedSignals.has(signal.name)) {
|
|
260
|
+
warnings.push({
|
|
261
|
+
code: "UNUSED_SIGNAL",
|
|
262
|
+
message: `Signal "${signal.name}" is declared but never used by any transition`,
|
|
263
|
+
path: "signals"
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const [stateName, state] of Object.entries(workflow.states)) {
|
|
269
|
+
if (state.transitions) {
|
|
270
|
+
const unguardedSignals = /* @__PURE__ */ new Set();
|
|
271
|
+
for (const transition of state.transitions) {
|
|
272
|
+
if (!transition.when) {
|
|
273
|
+
if (unguardedSignals.has(transition.on)) {
|
|
274
|
+
warnings.push({
|
|
275
|
+
code: "AMBIGUOUS_TRANSITION",
|
|
276
|
+
message: `State "${stateName}" has multiple unguarded transitions for signal "${transition.on}" \u2014 only the first will match`,
|
|
277
|
+
path: `states.${stateName}.transitions`
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
unguardedSignals.add(transition.on);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (registry && workflow.timeouts?.on_timeout) {
|
|
286
|
+
if (!registry.has(workflow.timeouts.on_timeout.use)) {
|
|
287
|
+
errors.push({
|
|
288
|
+
code: "UNKNOWN_CAPABILITY",
|
|
289
|
+
message: `Timeout on_timeout references unknown capability "${workflow.timeouts.on_timeout.use}"`,
|
|
290
|
+
path: "timeouts.on_timeout.use"
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
success: errors.length === 0,
|
|
296
|
+
data: workflow,
|
|
297
|
+
errors,
|
|
298
|
+
warnings
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/permissions.ts
|
|
303
|
+
function resolvePermissionVariables(permission, stepParams) {
|
|
304
|
+
if (!stepParams) return permission;
|
|
305
|
+
let { resource } = permission;
|
|
306
|
+
const variablePattern = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
|
307
|
+
resource = resource.replace(variablePattern, (_match, varName) => {
|
|
308
|
+
const value = stepParams[varName];
|
|
309
|
+
if (typeof value === "string") return value;
|
|
310
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
311
|
+
return _match;
|
|
312
|
+
});
|
|
313
|
+
return { ...permission, resource };
|
|
314
|
+
}
|
|
315
|
+
function deriveFlowPermissions(flow, registry) {
|
|
316
|
+
const sources = [];
|
|
317
|
+
for (const step of flow.steps) {
|
|
318
|
+
const capability = registry.lookup(step.use);
|
|
319
|
+
if (!capability) continue;
|
|
320
|
+
if (capability.definition.kind === "effect") {
|
|
321
|
+
for (const perm of capability.definition.requiredPermissions) {
|
|
322
|
+
const resolved = resolvePermissionVariables(perm, step.with);
|
|
323
|
+
sources.push({
|
|
324
|
+
permission: resolved,
|
|
325
|
+
capabilityName: step.use,
|
|
326
|
+
location: `steps.${step.name}`
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
permissions: deduplicatePermissions(sources.map((s) => s.permission)),
|
|
333
|
+
sources
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function deriveWorkflowPermissions(workflow, registry) {
|
|
337
|
+
const sources = [];
|
|
338
|
+
for (const [stateName, state] of Object.entries(workflow.states)) {
|
|
339
|
+
if (state.on_enter) {
|
|
340
|
+
const capability = registry.lookup(state.on_enter.use);
|
|
341
|
+
if (!capability) continue;
|
|
342
|
+
if (capability.definition.kind === "effect") {
|
|
343
|
+
for (const perm of capability.definition.requiredPermissions) {
|
|
344
|
+
sources.push({
|
|
345
|
+
permission: perm,
|
|
346
|
+
capabilityName: state.on_enter.use,
|
|
347
|
+
location: `states.${stateName}.on_enter`
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (workflow.timeouts?.on_timeout) {
|
|
354
|
+
const capability = registry.lookup(workflow.timeouts.on_timeout.use);
|
|
355
|
+
if (capability && capability.definition.kind === "effect") {
|
|
356
|
+
for (const perm of capability.definition.requiredPermissions) {
|
|
357
|
+
sources.push({
|
|
358
|
+
permission: perm,
|
|
359
|
+
capabilityName: workflow.timeouts.on_timeout.use,
|
|
360
|
+
location: "timeouts.on_timeout"
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return {
|
|
366
|
+
permissions: deduplicatePermissions(sources.map((s) => s.permission)),
|
|
367
|
+
sources
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function deduplicatePermissions(permissions) {
|
|
371
|
+
const seen = /* @__PURE__ */ new Set();
|
|
372
|
+
const unique = [];
|
|
373
|
+
for (const perm of permissions) {
|
|
374
|
+
const key = `${perm.resource}:${perm.action}:${perm.scope ?? ""}`;
|
|
375
|
+
if (!seen.has(key)) {
|
|
376
|
+
seen.add(key);
|
|
377
|
+
unique.push(perm);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return unique;
|
|
381
|
+
}
|
|
382
|
+
function toIamPolicy(derived) {
|
|
383
|
+
if (derived.permissions.length === 0) {
|
|
384
|
+
return {
|
|
385
|
+
Version: "2012-10-17",
|
|
386
|
+
Statement: []
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
Version: "2012-10-17",
|
|
391
|
+
Statement: derived.permissions.map((perm) => ({
|
|
392
|
+
Effect: "Allow",
|
|
393
|
+
Action: `stackwright:${perm.action}`,
|
|
394
|
+
Resource: perm.resource,
|
|
395
|
+
...perm.scope ? { Condition: { StringEquals: { "stackwright:scope": perm.scope } } } : {}
|
|
396
|
+
}))
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
function toStepFunctionsIamPolicy(workflow, derived) {
|
|
400
|
+
const statements = [];
|
|
401
|
+
const statesWithActions = Object.entries(workflow.states).filter(([, state]) => state.on_enter !== void 0).map(([name]) => name);
|
|
402
|
+
if (statesWithActions.length > 0) {
|
|
403
|
+
statements.push({
|
|
404
|
+
Sid: "InvokeStateLambdas",
|
|
405
|
+
Effect: "Allow",
|
|
406
|
+
Action: ["lambda:InvokeFunction"],
|
|
407
|
+
Resource: statesWithActions.map(
|
|
408
|
+
(name) => `arn:aws:lambda:*:*:function:${workflow.id}-${name}`
|
|
409
|
+
)
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
if (derived.permissions.length > 0) {
|
|
413
|
+
statements.push(
|
|
414
|
+
...derived.permissions.map((perm) => ({
|
|
415
|
+
Effect: "Allow",
|
|
416
|
+
Action: `stackwright:${perm.action}`,
|
|
417
|
+
Resource: perm.resource,
|
|
418
|
+
...perm.scope ? { Condition: { StringEquals: { "stackwright:scope": perm.scope } } } : {}
|
|
419
|
+
}))
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
if (workflow.timeouts?.pending_after || workflow.timeouts?.absolute) {
|
|
423
|
+
statements.push({
|
|
424
|
+
Sid: "TimeoutScheduling",
|
|
425
|
+
Effect: "Allow",
|
|
426
|
+
Action: ["events:PutRule", "events:PutTargets", "events:DeleteRule", "events:RemoveTargets"],
|
|
427
|
+
Resource: `arn:aws:events:*:*:rule/${workflow.id}-timeout-*`
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
statements.push({
|
|
431
|
+
Sid: "ExecutionLogging",
|
|
432
|
+
Effect: "Allow",
|
|
433
|
+
Action: ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
|
|
434
|
+
Resource: `arn:aws:logs:*:*:log-group:/aws/stepfunctions/${workflow.id}:*`
|
|
435
|
+
});
|
|
436
|
+
return {
|
|
437
|
+
Version: "2012-10-17",
|
|
438
|
+
Statement: statements
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
function toRbacRules(derived) {
|
|
442
|
+
if (derived.permissions.length === 0) return [];
|
|
443
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
444
|
+
for (const perm of derived.permissions) {
|
|
445
|
+
const verbs = grouped.get(perm.resource) ?? [];
|
|
446
|
+
if (!verbs.includes(perm.action)) {
|
|
447
|
+
verbs.push(perm.action);
|
|
448
|
+
}
|
|
449
|
+
grouped.set(perm.resource, verbs);
|
|
450
|
+
}
|
|
451
|
+
return Array.from(grouped.entries()).map(([resource, verbs]) => ({
|
|
452
|
+
apiGroups: ["stackwright.io"],
|
|
453
|
+
resources: [resource],
|
|
454
|
+
verbs
|
|
455
|
+
}));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/observability.ts
|
|
459
|
+
function generateFlowInstrumentation(flow) {
|
|
460
|
+
return flow.steps.map((step) => ({
|
|
461
|
+
stepName: step.name,
|
|
462
|
+
capabilityName: step.use,
|
|
463
|
+
flowId: flow.id,
|
|
464
|
+
metrics: {
|
|
465
|
+
latency: "flow.step.latency",
|
|
466
|
+
invocations: "flow.step.invocations",
|
|
467
|
+
errors: "flow.step.errors"
|
|
468
|
+
}
|
|
469
|
+
}));
|
|
470
|
+
}
|
|
471
|
+
function sanitizeIdentifier(name) {
|
|
472
|
+
return name.replace(/[^a-zA-Z0-9]/g, "_");
|
|
473
|
+
}
|
|
474
|
+
function generateInstrumentedStepCode(instrumentation) {
|
|
475
|
+
const { stepName, capabilityName, flowId, metrics } = instrumentation;
|
|
476
|
+
const fnName = sanitizeIdentifier(stepName);
|
|
477
|
+
return [
|
|
478
|
+
`async function executeStep_${fnName}(`,
|
|
479
|
+
" input: unknown,",
|
|
480
|
+
" context: CapabilityContext,",
|
|
481
|
+
" provider: ObservabilityProvider,",
|
|
482
|
+
" auditProvider: AuditProvider,",
|
|
483
|
+
" handler: CapabilityHandler,",
|
|
484
|
+
"): Promise<unknown> {",
|
|
485
|
+
` const span = provider.startSpan('step.${stepName}', {`,
|
|
486
|
+
` 'capability.name': '${capabilityName}',`,
|
|
487
|
+
` 'flow.id': '${flowId}',`,
|
|
488
|
+
` 'step.name': '${stepName}',`,
|
|
489
|
+
" });",
|
|
490
|
+
" const startTime = Date.now();",
|
|
491
|
+
"",
|
|
492
|
+
" try {",
|
|
493
|
+
" const result = await handler(input, context);",
|
|
494
|
+
` provider.recordMetric('${metrics.invocations}', 1, { step: '${stepName}', capability: '${capabilityName}', status: 'success' });`,
|
|
495
|
+
" auditProvider.record({",
|
|
496
|
+
" type: 'capability_invocation',",
|
|
497
|
+
" traceId: context.traceId,",
|
|
498
|
+
" timestamp: new Date().toISOString(),",
|
|
499
|
+
` flowId: '${flowId}',`,
|
|
500
|
+
` stepName: '${stepName}',`,
|
|
501
|
+
` capabilityName: '${capabilityName}',`,
|
|
502
|
+
" inputHash: auditHash(input),",
|
|
503
|
+
" outputHash: auditHash(result),",
|
|
504
|
+
" durationMs: Date.now() - startTime,",
|
|
505
|
+
" status: 'success',",
|
|
506
|
+
" });",
|
|
507
|
+
" return result;",
|
|
508
|
+
" } catch (error) {",
|
|
509
|
+
" span.setError(error instanceof Error ? error : new Error(String(error)));",
|
|
510
|
+
` provider.recordMetric('${metrics.errors}', 1, { step: '${stepName}', capability: '${capabilityName}' });`,
|
|
511
|
+
" auditProvider.record({",
|
|
512
|
+
" type: 'capability_invocation',",
|
|
513
|
+
" traceId: context.traceId,",
|
|
514
|
+
" timestamp: new Date().toISOString(),",
|
|
515
|
+
` flowId: '${flowId}',`,
|
|
516
|
+
` stepName: '${stepName}',`,
|
|
517
|
+
` capabilityName: '${capabilityName}',`,
|
|
518
|
+
" inputHash: auditHash(input),",
|
|
519
|
+
" outputHash: '',",
|
|
520
|
+
" durationMs: Date.now() - startTime,",
|
|
521
|
+
" status: 'error',",
|
|
522
|
+
" error: error instanceof Error ? error.message : String(error),",
|
|
523
|
+
" });",
|
|
524
|
+
" throw error;",
|
|
525
|
+
" } finally {",
|
|
526
|
+
" const duration = Date.now() - startTime;",
|
|
527
|
+
` provider.recordMetric('${metrics.latency}', duration, { step: '${stepName}', capability: '${capabilityName}' });`,
|
|
528
|
+
" span.end();",
|
|
529
|
+
" }",
|
|
530
|
+
"}"
|
|
531
|
+
].join("\n");
|
|
532
|
+
}
|
|
533
|
+
function generateInstrumentedFlowCode(flow) {
|
|
534
|
+
const instrumentations = generateFlowInstrumentation(flow);
|
|
535
|
+
const stepFunctions = instrumentations.map(generateInstrumentedStepCode);
|
|
536
|
+
const stepCalls = instrumentations.map(
|
|
537
|
+
(inst) => ` stepResult = await executeStep_${sanitizeIdentifier(inst.stepName)}(stepResult, context, provider, auditProvider, registry.get('${inst.capabilityName}').handler);`
|
|
538
|
+
).join("\n");
|
|
539
|
+
const header = [
|
|
540
|
+
"// Auto-generated by @stackwright-services/compiler",
|
|
541
|
+
`// Flow: ${flow.id}`,
|
|
542
|
+
`// Description: ${flow.description}`,
|
|
543
|
+
"//",
|
|
544
|
+
"// DO NOT EDIT -- regenerate from the source YAML.",
|
|
545
|
+
"",
|
|
546
|
+
"import type { CapabilityContext, CapabilityHandler } from '@stackwright-services/capabilities';",
|
|
547
|
+
"import type { ObservabilityProvider } from '@stackwright-services/compiler';",
|
|
548
|
+
"import type { AuditProvider, AuditEvent } from '@stackwright-services/runtime';",
|
|
549
|
+
"import { auditHash } from '@stackwright-services/runtime';",
|
|
550
|
+
""
|
|
551
|
+
].join("\n");
|
|
552
|
+
const executeFlow = [
|
|
553
|
+
"export async function executeFlow(",
|
|
554
|
+
" input: unknown,",
|
|
555
|
+
" context: CapabilityContext,",
|
|
556
|
+
" provider: ObservabilityProvider,",
|
|
557
|
+
" auditProvider: AuditProvider,",
|
|
558
|
+
" registry: { get(name: string): { handler: CapabilityHandler } },",
|
|
559
|
+
"): Promise<unknown> {",
|
|
560
|
+
" auditProvider.record({",
|
|
561
|
+
" type: 'flow_start',",
|
|
562
|
+
" traceId: context.traceId,",
|
|
563
|
+
" timestamp: new Date().toISOString(),",
|
|
564
|
+
` flowId: '${flow.id}',`,
|
|
565
|
+
" stepName: '',",
|
|
566
|
+
" capabilityName: '',",
|
|
567
|
+
" inputHash: auditHash(input),",
|
|
568
|
+
" outputHash: '',",
|
|
569
|
+
" durationMs: 0,",
|
|
570
|
+
" status: 'success',",
|
|
571
|
+
" });",
|
|
572
|
+
` const flowSpan = provider.startSpan('flow.${flow.id}', {`,
|
|
573
|
+
` 'flow.id': '${flow.id}',`,
|
|
574
|
+
` 'flow.steps': ${flow.steps.length},`,
|
|
575
|
+
" });",
|
|
576
|
+
" const flowStart = Date.now();",
|
|
577
|
+
"",
|
|
578
|
+
" try {",
|
|
579
|
+
" let stepResult: unknown = input;",
|
|
580
|
+
stepCalls,
|
|
581
|
+
" auditProvider.record({",
|
|
582
|
+
" type: 'flow_end',",
|
|
583
|
+
" traceId: context.traceId,",
|
|
584
|
+
" timestamp: new Date().toISOString(),",
|
|
585
|
+
` flowId: '${flow.id}',`,
|
|
586
|
+
" stepName: '',",
|
|
587
|
+
" capabilityName: '',",
|
|
588
|
+
" inputHash: auditHash(input),",
|
|
589
|
+
" outputHash: auditHash(stepResult),",
|
|
590
|
+
" durationMs: Date.now() - flowStart,",
|
|
591
|
+
" status: 'success',",
|
|
592
|
+
" });",
|
|
593
|
+
` provider.recordMetric('flow.invocations', 1, { flow: '${flow.id}', status: 'success' });`,
|
|
594
|
+
" return stepResult;",
|
|
595
|
+
" } catch (error) {",
|
|
596
|
+
" flowSpan.setError(error instanceof Error ? error : new Error(String(error)));",
|
|
597
|
+
` provider.recordMetric('flow.errors', 1, { flow: '${flow.id}' });`,
|
|
598
|
+
" auditProvider.record({",
|
|
599
|
+
" type: 'flow_end',",
|
|
600
|
+
" traceId: context.traceId,",
|
|
601
|
+
" timestamp: new Date().toISOString(),",
|
|
602
|
+
` flowId: '${flow.id}',`,
|
|
603
|
+
" stepName: '',",
|
|
604
|
+
" capabilityName: '',",
|
|
605
|
+
" inputHash: auditHash(input),",
|
|
606
|
+
" outputHash: '',",
|
|
607
|
+
" durationMs: Date.now() - flowStart,",
|
|
608
|
+
" status: 'error',",
|
|
609
|
+
" error: error instanceof Error ? error.message : String(error),",
|
|
610
|
+
" });",
|
|
611
|
+
" throw error;",
|
|
612
|
+
" } finally {",
|
|
613
|
+
` provider.recordMetric('flow.latency', Date.now() - flowStart, { flow: '${flow.id}' });`,
|
|
614
|
+
" flowSpan.end();",
|
|
615
|
+
" }",
|
|
616
|
+
"}",
|
|
617
|
+
""
|
|
618
|
+
].join("\n");
|
|
619
|
+
return header + stepFunctions.join("\n\n") + "\n\n" + executeFlow;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// src/workflow-observability.ts
|
|
623
|
+
function generateWorkflowInstrumentation(workflow) {
|
|
624
|
+
return Object.entries(workflow.states).filter(([, state]) => !!state.on_enter).map(([stateName, state]) => ({
|
|
625
|
+
stateName,
|
|
626
|
+
// filter above guarantees on_enter exists; fallback silences linter.
|
|
627
|
+
capabilityName: state.on_enter?.use ?? "unknown",
|
|
628
|
+
workflowId: workflow.id,
|
|
629
|
+
isTerminal: state.type === "terminal",
|
|
630
|
+
metrics: {
|
|
631
|
+
invocations: "workflow.state.invocations",
|
|
632
|
+
errors: "workflow.state.errors",
|
|
633
|
+
latency: "workflow.state.latency",
|
|
634
|
+
transitions: "workflow.state.transitions"
|
|
635
|
+
}
|
|
636
|
+
}));
|
|
637
|
+
}
|
|
638
|
+
function generateInstrumentedStateHandlerCode(instrumentation, capabilityParams) {
|
|
639
|
+
const { stateName, capabilityName, workflowId, metrics } = instrumentation;
|
|
640
|
+
const paramsJson = JSON.stringify(capabilityParams);
|
|
641
|
+
const header = [
|
|
642
|
+
"// Generated by @stackwright-services/compiler \u2014 DO NOT EDIT",
|
|
643
|
+
`// Workflow: ${workflowId}, State: ${stateName}`,
|
|
644
|
+
`// Capability: ${capabilityName}`,
|
|
645
|
+
"//",
|
|
646
|
+
"// DO NOT EDIT \u2014 regenerate from the source YAML.",
|
|
647
|
+
"",
|
|
648
|
+
"import type { Context } from 'aws-lambda';",
|
|
649
|
+
"import type { ObservabilityProvider } from '@stackwright-services/compiler';",
|
|
650
|
+
"import type { AuditProvider } from '@stackwright-services/runtime';",
|
|
651
|
+
"import { auditHash, noopAuditProvider } from '@stackwright-services/runtime';",
|
|
652
|
+
"",
|
|
653
|
+
"// Noop observability \u2014 replace with real implementation in production bootstrap.",
|
|
654
|
+
"const noopProvider: ObservabilityProvider = {",
|
|
655
|
+
" startSpan: () => ({ setAttribute: () => {}, setError: () => {}, end: () => {} }),",
|
|
656
|
+
" recordMetric: () => {},",
|
|
657
|
+
"};",
|
|
658
|
+
""
|
|
659
|
+
].join("\n");
|
|
660
|
+
const handlerLines = [
|
|
661
|
+
"export async function handler(event: unknown, _context: Context): Promise<unknown> {",
|
|
662
|
+
" // TODO: Replace noop providers with real implementations via DI bootstrap.",
|
|
663
|
+
" const provider: ObservabilityProvider = noopProvider;",
|
|
664
|
+
" const auditProvider: AuditProvider = noopAuditProvider;",
|
|
665
|
+
` // Capability params: ${paramsJson}`,
|
|
666
|
+
"",
|
|
667
|
+
` const span = provider.startSpan('workflow.state.${stateName}', {`,
|
|
668
|
+
` 'workflow.id': '${workflowId}',`,
|
|
669
|
+
` 'state.name': '${stateName}',`,
|
|
670
|
+
` 'capability.name': '${capabilityName}',`,
|
|
671
|
+
" });",
|
|
672
|
+
" const startTime = Date.now();",
|
|
673
|
+
"",
|
|
674
|
+
" // Transition count \u2014 which signal drove entry to this state.",
|
|
675
|
+
" const incomingSignal = (event as Record<string, unknown>)?.['signal'] as string | undefined;",
|
|
676
|
+
` provider.recordMetric('${metrics.transitions}', 1, {`,
|
|
677
|
+
` workflow: '${workflowId}',`,
|
|
678
|
+
` state: '${stateName}',`,
|
|
679
|
+
" signal: incomingSignal ?? 'initial',",
|
|
680
|
+
" });",
|
|
681
|
+
"",
|
|
682
|
+
" try {",
|
|
683
|
+
` // TODO: Wire capability invocation for '${capabilityName}'.`,
|
|
684
|
+
" const result = {",
|
|
685
|
+
" ...(event as Record<string, unknown>),",
|
|
686
|
+
` __state: '${stateName}',`,
|
|
687
|
+
` __capability: '${capabilityName}',`,
|
|
688
|
+
" __timestamp: new Date().toISOString(),",
|
|
689
|
+
" };",
|
|
690
|
+
"",
|
|
691
|
+
` provider.recordMetric('${metrics.invocations}', 1, {`,
|
|
692
|
+
` workflow: '${workflowId}',`,
|
|
693
|
+
` state: '${stateName}',`,
|
|
694
|
+
` capability: '${capabilityName}',`,
|
|
695
|
+
" status: 'success',",
|
|
696
|
+
" });",
|
|
697
|
+
" auditProvider.record({",
|
|
698
|
+
" type: 'capability_invocation',",
|
|
699
|
+
" traceId: _context.awsRequestId,",
|
|
700
|
+
" timestamp: new Date().toISOString(),",
|
|
701
|
+
` flowId: '${workflowId}',`,
|
|
702
|
+
` stepName: '${stateName}',`,
|
|
703
|
+
` capabilityName: '${capabilityName}',`,
|
|
704
|
+
" inputHash: auditHash(event),",
|
|
705
|
+
" outputHash: auditHash(result),",
|
|
706
|
+
" durationMs: Date.now() - startTime,",
|
|
707
|
+
" status: 'success',",
|
|
708
|
+
" });",
|
|
709
|
+
" return result;",
|
|
710
|
+
" } catch (error) {",
|
|
711
|
+
" span.setError(error instanceof Error ? error : new Error(String(error)));",
|
|
712
|
+
` provider.recordMetric('${metrics.errors}', 1, {`,
|
|
713
|
+
` workflow: '${workflowId}',`,
|
|
714
|
+
` state: '${stateName}',`,
|
|
715
|
+
" });",
|
|
716
|
+
" auditProvider.record({",
|
|
717
|
+
" type: 'capability_invocation',",
|
|
718
|
+
" traceId: _context.awsRequestId,",
|
|
719
|
+
" timestamp: new Date().toISOString(),",
|
|
720
|
+
` flowId: '${workflowId}',`,
|
|
721
|
+
` stepName: '${stateName}',`,
|
|
722
|
+
` capabilityName: '${capabilityName}',`,
|
|
723
|
+
" inputHash: auditHash(event),",
|
|
724
|
+
" outputHash: '',",
|
|
725
|
+
" durationMs: Date.now() - startTime,",
|
|
726
|
+
" status: 'error',",
|
|
727
|
+
" error: error instanceof Error ? error.message : String(error),",
|
|
728
|
+
" });",
|
|
729
|
+
" throw error;",
|
|
730
|
+
" } finally {",
|
|
731
|
+
" const duration = Date.now() - startTime;",
|
|
732
|
+
` provider.recordMetric('${metrics.latency}', duration, {`,
|
|
733
|
+
` workflow: '${workflowId}',`,
|
|
734
|
+
` state: '${stateName}',`,
|
|
735
|
+
" });",
|
|
736
|
+
" span.end();",
|
|
737
|
+
" }",
|
|
738
|
+
"}",
|
|
739
|
+
""
|
|
740
|
+
].join("\n");
|
|
741
|
+
return header + handlerLines;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// src/targets/lambda.ts
|
|
745
|
+
function compileLambdaTarget(flow, permissions, instrumentedCode) {
|
|
746
|
+
const files = {};
|
|
747
|
+
files["handler.ts"] = generateHandlerTs(flow);
|
|
748
|
+
files["flow.ts"] = instrumentedCode;
|
|
749
|
+
files["Dockerfile"] = generateDockerfile(flow);
|
|
750
|
+
files["iam-policy.json"] = generateIamPolicy(flow.trigger, permissions);
|
|
751
|
+
files["package.json"] = generatePackageJson(flow);
|
|
752
|
+
const triggerFiles = generateTriggerFiles(flow);
|
|
753
|
+
for (const [path, content] of Object.entries(triggerFiles)) {
|
|
754
|
+
files[path] = content;
|
|
755
|
+
}
|
|
756
|
+
return { files };
|
|
757
|
+
}
|
|
758
|
+
function generateHandlerTs(flow) {
|
|
759
|
+
const trigger = flow.trigger;
|
|
760
|
+
let routeSetup;
|
|
761
|
+
if (trigger.type === "http") {
|
|
762
|
+
const method = trigger.method.toLowerCase();
|
|
763
|
+
const inputExpr = trigger.method === "GET" ? "c.req.query()" : "await c.req.json()";
|
|
764
|
+
routeSetup = `
|
|
765
|
+
app.${method}('${trigger.path}', async (c) => {
|
|
766
|
+
const input = ${inputExpr};
|
|
767
|
+
const context = createContext(c);
|
|
768
|
+
const provider = getObservabilityProvider();
|
|
769
|
+
|
|
770
|
+
try {
|
|
771
|
+
const result = await executeFlow(input, context, provider, registry);
|
|
772
|
+
return c.json(result);
|
|
773
|
+
} catch (error) {
|
|
774
|
+
context.logger.error('Flow execution failed', { error: String(error) });
|
|
775
|
+
return c.json({ error: 'Internal server error' }, 500);
|
|
776
|
+
}
|
|
777
|
+
});`;
|
|
778
|
+
} else {
|
|
779
|
+
routeSetup = `
|
|
780
|
+
// Non-HTTP trigger: ${trigger.type}
|
|
781
|
+
// This handler expects to be invoked by the trigger adapter
|
|
782
|
+
app.post('/_invoke', async (c) => {
|
|
783
|
+
const input = await c.req.json();
|
|
784
|
+
const context = createContext(c);
|
|
785
|
+
const provider = getObservabilityProvider();
|
|
786
|
+
|
|
787
|
+
try {
|
|
788
|
+
const result = await executeFlow(input, context, provider, registry);
|
|
789
|
+
return c.json(result);
|
|
790
|
+
} catch (error) {
|
|
791
|
+
context.logger.error('Flow execution failed', { error: String(error) });
|
|
792
|
+
return c.json({ error: 'Internal server error' }, 500);
|
|
793
|
+
}
|
|
794
|
+
});`;
|
|
795
|
+
}
|
|
796
|
+
return `// Auto-generated Lambda handler for flow: ${flow.id}
|
|
797
|
+
// DO NOT EDIT -- regenerate from source YAML.
|
|
798
|
+
|
|
799
|
+
import { Hono } from 'hono';
|
|
800
|
+
import { executeFlow } from './flow';
|
|
801
|
+
import { CapabilityRegistry, registerAllCapabilities } from '@stackwright-services/capabilities';
|
|
802
|
+
import type { CapabilityContext } from '@stackwright-services/capabilities';
|
|
803
|
+
|
|
804
|
+
// Initialize registry with all built-in capabilities
|
|
805
|
+
const registry = new CapabilityRegistry();
|
|
806
|
+
registerAllCapabilities(registry);
|
|
807
|
+
|
|
808
|
+
const app = new Hono();
|
|
809
|
+
|
|
810
|
+
// Health check
|
|
811
|
+
app.get('/health', (c) => c.json({ status: 'ok', flow: '${flow.id}' }));
|
|
812
|
+
${routeSetup}
|
|
813
|
+
|
|
814
|
+
function createContext(c: any): CapabilityContext {
|
|
815
|
+
return {
|
|
816
|
+
traceId: c.req.header('x-trace-id') ?? crypto.randomUUID(),
|
|
817
|
+
logger: {
|
|
818
|
+
debug: (msg: string, data?: Record<string, unknown>) => console.debug(JSON.stringify({ level: 'debug', msg, ...data })),
|
|
819
|
+
info: (msg: string, data?: Record<string, unknown>) => console.info(JSON.stringify({ level: 'info', msg, ...data })),
|
|
820
|
+
warn: (msg: string, data?: Record<string, unknown>) => console.warn(JSON.stringify({ level: 'warn', msg, ...data })),
|
|
821
|
+
error: (msg: string, data?: Record<string, unknown>) => console.error(JSON.stringify({ level: 'error', msg, ...data })),
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function getObservabilityProvider() {
|
|
827
|
+
// No-op provider by default -- swap with OTel provider when configured
|
|
828
|
+
return {
|
|
829
|
+
startSpan: () => ({
|
|
830
|
+
setAttribute: () => {},
|
|
831
|
+
setError: () => {},
|
|
832
|
+
end: () => {},
|
|
833
|
+
}),
|
|
834
|
+
recordMetric: () => {},
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
export default app;
|
|
839
|
+
`;
|
|
840
|
+
}
|
|
841
|
+
function generateDockerfile(flow) {
|
|
842
|
+
return `# Auto-generated Dockerfile for flow: ${flow.id}
|
|
843
|
+
# Uses AWS Lambda Web Adapter for Hono compatibility
|
|
844
|
+
# DO NOT EDIT -- regenerate from source YAML.
|
|
845
|
+
|
|
846
|
+
FROM public.ecr.aws/docker/library/node:22-slim AS builder
|
|
847
|
+
|
|
848
|
+
WORKDIR /app
|
|
849
|
+
COPY package.json ./
|
|
850
|
+
RUN npm install --production=false
|
|
851
|
+
COPY . .
|
|
852
|
+
RUN npm run build
|
|
853
|
+
|
|
854
|
+
FROM public.ecr.aws/docker/library/node:22-slim AS runner
|
|
855
|
+
|
|
856
|
+
# Install AWS Lambda Web Adapter
|
|
857
|
+
COPY --from=public.ecr.aws/awsguru/aws-lambda-web-adapter:0.8.4 /lambda-adapter /opt/extensions/lambda-adapter
|
|
858
|
+
|
|
859
|
+
WORKDIR /app
|
|
860
|
+
COPY --from=builder /app/dist ./dist
|
|
861
|
+
COPY --from=builder /app/node_modules ./node_modules
|
|
862
|
+
COPY --from=builder /app/package.json ./
|
|
863
|
+
|
|
864
|
+
ENV PORT=8080
|
|
865
|
+
ENV AWS_LWA_PORT=8080
|
|
866
|
+
ENV NODE_ENV=production
|
|
867
|
+
|
|
868
|
+
CMD ["node", "dist/handler.js"]
|
|
869
|
+
`;
|
|
870
|
+
}
|
|
871
|
+
function generatePackageJson(flow) {
|
|
872
|
+
return JSON.stringify(
|
|
873
|
+
{
|
|
874
|
+
name: `${flow.id}-lambda`,
|
|
875
|
+
version: "0.0.1",
|
|
876
|
+
private: true,
|
|
877
|
+
scripts: {
|
|
878
|
+
build: "tsc",
|
|
879
|
+
start: "node dist/handler.js"
|
|
880
|
+
},
|
|
881
|
+
dependencies: {
|
|
882
|
+
hono: "^4",
|
|
883
|
+
"@stackwright-services/capabilities": "workspace:*"
|
|
884
|
+
},
|
|
885
|
+
devDependencies: {
|
|
886
|
+
typescript: "^6"
|
|
887
|
+
}
|
|
888
|
+
},
|
|
889
|
+
null,
|
|
890
|
+
2
|
|
891
|
+
) + "\n";
|
|
892
|
+
}
|
|
893
|
+
function getTriggerPermissions(trigger) {
|
|
894
|
+
switch (trigger.type) {
|
|
895
|
+
case "event":
|
|
896
|
+
return [
|
|
897
|
+
{
|
|
898
|
+
Effect: "Allow",
|
|
899
|
+
Action: ["sns:Subscribe", "sns:Unsubscribe"],
|
|
900
|
+
Resource: "${TOPIC_ARN}"
|
|
901
|
+
}
|
|
902
|
+
];
|
|
903
|
+
case "schedule":
|
|
904
|
+
return [
|
|
905
|
+
{
|
|
906
|
+
Effect: "Allow",
|
|
907
|
+
Action: ["events:PutRule", "events:PutTargets", "events:DeleteRule"],
|
|
908
|
+
Resource: "*"
|
|
909
|
+
}
|
|
910
|
+
];
|
|
911
|
+
case "queue":
|
|
912
|
+
return [
|
|
913
|
+
{
|
|
914
|
+
Effect: "Allow",
|
|
915
|
+
Action: ["sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:GetQueueAttributes"],
|
|
916
|
+
Resource: "${QUEUE_ARN}"
|
|
917
|
+
}
|
|
918
|
+
];
|
|
919
|
+
case "http":
|
|
920
|
+
default:
|
|
921
|
+
return [];
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
function generateIamPolicy(trigger, permissions) {
|
|
925
|
+
const base = toIamPolicy(permissions);
|
|
926
|
+
const triggerStatements = getTriggerPermissions(trigger);
|
|
927
|
+
base.Statement = [...base.Statement, ...triggerStatements];
|
|
928
|
+
return JSON.stringify(base, null, 2) + "\n";
|
|
929
|
+
}
|
|
930
|
+
function generateTriggerFiles(flow) {
|
|
931
|
+
const trigger = flow.trigger;
|
|
932
|
+
switch (trigger.type) {
|
|
933
|
+
case "event": {
|
|
934
|
+
const source = trigger.source.replace(/^bus:/, "");
|
|
935
|
+
return {
|
|
936
|
+
"sns-subscription.json": JSON.stringify(
|
|
937
|
+
{
|
|
938
|
+
_comment: `Auto-generated SNS subscription for flow: ${flow.id}. Bind TOPIC_ARN and FUNCTION_ARN at deploy time.`,
|
|
939
|
+
TopicArn: "${TOPIC_ARN}",
|
|
940
|
+
Protocol: "lambda",
|
|
941
|
+
Endpoint: "${FUNCTION_ARN}",
|
|
942
|
+
Attributes: {
|
|
943
|
+
RawMessageDelivery: "true"
|
|
944
|
+
},
|
|
945
|
+
source
|
|
946
|
+
},
|
|
947
|
+
null,
|
|
948
|
+
2
|
|
949
|
+
) + "\n"
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
case "schedule":
|
|
953
|
+
return {
|
|
954
|
+
"eventbridge-rule.json": JSON.stringify(
|
|
955
|
+
{
|
|
956
|
+
_comment: `Auto-generated EventBridge rule for flow: ${flow.id}. Bind FUNCTION_ARN at deploy time.`,
|
|
957
|
+
Name: `${flow.id}-schedule`,
|
|
958
|
+
ScheduleExpression: `cron(${trigger.cron})`,
|
|
959
|
+
State: "ENABLED",
|
|
960
|
+
Targets: [
|
|
961
|
+
{
|
|
962
|
+
Id: `${flow.id}-target`,
|
|
963
|
+
Arn: "${FUNCTION_ARN}"
|
|
964
|
+
}
|
|
965
|
+
]
|
|
966
|
+
},
|
|
967
|
+
null,
|
|
968
|
+
2
|
|
969
|
+
) + "\n"
|
|
970
|
+
};
|
|
971
|
+
case "queue":
|
|
972
|
+
return {
|
|
973
|
+
"sqs-event-source-mapping.json": JSON.stringify(
|
|
974
|
+
{
|
|
975
|
+
_comment: `Auto-generated SQS event source mapping for flow: ${flow.id}. Bind QUEUE_ARN and FUNCTION_ARN at deploy time.`,
|
|
976
|
+
EventSourceArn: "${QUEUE_ARN}",
|
|
977
|
+
FunctionName: "${FUNCTION_ARN}",
|
|
978
|
+
BatchSize: 10,
|
|
979
|
+
Enabled: true,
|
|
980
|
+
queueName: trigger.name
|
|
981
|
+
},
|
|
982
|
+
null,
|
|
983
|
+
2
|
|
984
|
+
) + "\n"
|
|
985
|
+
};
|
|
986
|
+
case "http":
|
|
987
|
+
default:
|
|
988
|
+
return {};
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// src/targets/helm.ts
|
|
993
|
+
function compileHelmTarget(flow, permissions) {
|
|
994
|
+
const files = {};
|
|
995
|
+
const chartName = sanitizeChartName(flow.id);
|
|
996
|
+
const trigger = flow.trigger;
|
|
997
|
+
files["Chart.yaml"] = generateChartYaml(chartName, flow);
|
|
998
|
+
files["values.yaml"] = generateValuesYaml(chartName, flow);
|
|
999
|
+
files["templates/service.yaml"] = generateServiceYaml(chartName);
|
|
1000
|
+
files["templates/serviceaccount.yaml"] = generateServiceAccountYaml(chartName);
|
|
1001
|
+
files["templates/_helpers.tpl"] = generateHelpersTpl(chartName);
|
|
1002
|
+
if (trigger.type === "schedule") {
|
|
1003
|
+
files["templates/cronjob.yaml"] = generateCronJobYaml(chartName, trigger.cron);
|
|
1004
|
+
} else {
|
|
1005
|
+
files["templates/deployment.yaml"] = generateDeploymentYaml(chartName, trigger);
|
|
1006
|
+
if (trigger.type === "http") {
|
|
1007
|
+
files["templates/ingress.yaml"] = generateIngressYaml(chartName);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
if (trigger.type === "event") {
|
|
1011
|
+
files["templates/bus-subscription.yaml"] = generateBusConfigYaml(chartName, trigger.source);
|
|
1012
|
+
}
|
|
1013
|
+
if (trigger.type === "queue") {
|
|
1014
|
+
files["templates/queue-config.yaml"] = generateQueueConfigYaml(chartName, trigger.name);
|
|
1015
|
+
}
|
|
1016
|
+
if (permissions.permissions.length > 0) {
|
|
1017
|
+
files["templates/rbac.yaml"] = generateRbacYaml(chartName, permissions);
|
|
1018
|
+
}
|
|
1019
|
+
return { files };
|
|
1020
|
+
}
|
|
1021
|
+
function sanitizeChartName(name) {
|
|
1022
|
+
return name.replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1023
|
+
}
|
|
1024
|
+
function generateChartYaml(chartName, flow) {
|
|
1025
|
+
return `# Auto-generated Helm chart for flow: ${flow.id}
|
|
1026
|
+
# Defense Unicorns compatible
|
|
1027
|
+
# DO NOT EDIT -- regenerate from source YAML.
|
|
1028
|
+
apiVersion: v2
|
|
1029
|
+
name: ${chartName}
|
|
1030
|
+
description: ${flow.description}
|
|
1031
|
+
type: application
|
|
1032
|
+
version: 0.1.0
|
|
1033
|
+
appVersion: "0.0.1"
|
|
1034
|
+
`;
|
|
1035
|
+
}
|
|
1036
|
+
function generateValuesYaml(chartName, flow) {
|
|
1037
|
+
const trigger = flow.trigger;
|
|
1038
|
+
let triggerValues = "";
|
|
1039
|
+
if (trigger.type === "event") {
|
|
1040
|
+
const topic = trigger.source.replace(/^bus:/, "").toUpperCase().replace(/-/g, "_");
|
|
1041
|
+
triggerValues = `
|
|
1042
|
+
# Event trigger configuration
|
|
1043
|
+
trigger:
|
|
1044
|
+
type: "event"
|
|
1045
|
+
busTopicArn: "\${BUS_TOPIC_${topic}_ARN}"
|
|
1046
|
+
`;
|
|
1047
|
+
} else if (trigger.type === "schedule") {
|
|
1048
|
+
triggerValues = `
|
|
1049
|
+
# Schedule trigger configuration
|
|
1050
|
+
trigger:
|
|
1051
|
+
type: "schedule"
|
|
1052
|
+
schedule: "${trigger.cron}"
|
|
1053
|
+
`;
|
|
1054
|
+
} else if (trigger.type === "queue") {
|
|
1055
|
+
const queueKey = trigger.name.toUpperCase().replace(/-/g, "_");
|
|
1056
|
+
triggerValues = `
|
|
1057
|
+
# Queue trigger configuration
|
|
1058
|
+
trigger:
|
|
1059
|
+
type: "queue"
|
|
1060
|
+
queueUrl: "\${BUS_QUEUE_${queueKey}_URL}"
|
|
1061
|
+
`;
|
|
1062
|
+
}
|
|
1063
|
+
const ingressSection = trigger.type === "http" ? `
|
|
1064
|
+
ingress:
|
|
1065
|
+
enabled: false
|
|
1066
|
+
className: ""
|
|
1067
|
+
annotations: {}
|
|
1068
|
+
hosts:
|
|
1069
|
+
- host: ${chartName}.local
|
|
1070
|
+
paths:
|
|
1071
|
+
- path: /
|
|
1072
|
+
pathType: ImplementationSpecific
|
|
1073
|
+
tls: []
|
|
1074
|
+
` : "";
|
|
1075
|
+
return `# Default values for ${chartName}
|
|
1076
|
+
# DO NOT EDIT -- regenerate from source YAML.
|
|
1077
|
+
|
|
1078
|
+
replicaCount: 1
|
|
1079
|
+
|
|
1080
|
+
image:
|
|
1081
|
+
repository: ""
|
|
1082
|
+
tag: "latest"
|
|
1083
|
+
pullPolicy: IfNotPresent
|
|
1084
|
+
|
|
1085
|
+
imagePullSecrets: []
|
|
1086
|
+
|
|
1087
|
+
serviceAccount:
|
|
1088
|
+
create: true
|
|
1089
|
+
name: ""
|
|
1090
|
+
annotations: {}
|
|
1091
|
+
|
|
1092
|
+
service:
|
|
1093
|
+
type: ClusterIP
|
|
1094
|
+
port: 8080
|
|
1095
|
+
${ingressSection}
|
|
1096
|
+
resources:
|
|
1097
|
+
limits:
|
|
1098
|
+
cpu: 500m
|
|
1099
|
+
memory: 256Mi
|
|
1100
|
+
requests:
|
|
1101
|
+
cpu: 100m
|
|
1102
|
+
memory: 128Mi
|
|
1103
|
+
|
|
1104
|
+
env: []
|
|
1105
|
+
|
|
1106
|
+
nodeSelector: {}
|
|
1107
|
+
tolerations: []
|
|
1108
|
+
affinity: {}
|
|
1109
|
+
|
|
1110
|
+
# Flow metadata
|
|
1111
|
+
flow:
|
|
1112
|
+
id: "${flow.id}"
|
|
1113
|
+
description: "${flow.description}"
|
|
1114
|
+
${triggerValues}`;
|
|
1115
|
+
}
|
|
1116
|
+
function generateDeploymentYaml(chartName, trigger) {
|
|
1117
|
+
let annotations = "";
|
|
1118
|
+
if (trigger.type === "event") {
|
|
1119
|
+
annotations = ` annotations:
|
|
1120
|
+
stackwright.io/trigger-type: event
|
|
1121
|
+
stackwright.io/bus-topic: "${trigger.source.replace(/^bus:/, "")}"
|
|
1122
|
+
`;
|
|
1123
|
+
} else if (trigger.type === "queue") {
|
|
1124
|
+
annotations = ` annotations:
|
|
1125
|
+
stackwright.io/trigger-type: queue
|
|
1126
|
+
stackwright.io/queue-name: "${trigger.name}"
|
|
1127
|
+
`;
|
|
1128
|
+
}
|
|
1129
|
+
return `# Auto-generated Deployment for ${chartName}
|
|
1130
|
+
apiVersion: apps/v1
|
|
1131
|
+
kind: Deployment
|
|
1132
|
+
metadata:
|
|
1133
|
+
name: {{ include "${chartName}.fullname" . }}
|
|
1134
|
+
labels:
|
|
1135
|
+
{{- include "${chartName}.labels" . | nindent 4 }}
|
|
1136
|
+
${annotations}spec:
|
|
1137
|
+
replicas: {{ .Values.replicaCount }}
|
|
1138
|
+
selector:
|
|
1139
|
+
matchLabels:
|
|
1140
|
+
{{- include "${chartName}.selectorLabels" . | nindent 6 }}
|
|
1141
|
+
template:
|
|
1142
|
+
metadata:
|
|
1143
|
+
labels:
|
|
1144
|
+
{{- include "${chartName}.selectorLabels" . | nindent 8 }}
|
|
1145
|
+
spec:
|
|
1146
|
+
{{- with .Values.imagePullSecrets }}
|
|
1147
|
+
imagePullSecrets:
|
|
1148
|
+
{{- toYaml . | nindent 8 }}
|
|
1149
|
+
{{- end }}
|
|
1150
|
+
serviceAccountName: {{ include "${chartName}.serviceAccountName" . }}
|
|
1151
|
+
containers:
|
|
1152
|
+
- name: {{ .Chart.Name }}
|
|
1153
|
+
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
|
1154
|
+
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
|
1155
|
+
ports:
|
|
1156
|
+
- name: http
|
|
1157
|
+
containerPort: 8080
|
|
1158
|
+
protocol: TCP
|
|
1159
|
+
livenessProbe:
|
|
1160
|
+
httpGet:
|
|
1161
|
+
path: /health
|
|
1162
|
+
port: http
|
|
1163
|
+
initialDelaySeconds: 5
|
|
1164
|
+
periodSeconds: 10
|
|
1165
|
+
readinessProbe:
|
|
1166
|
+
httpGet:
|
|
1167
|
+
path: /health
|
|
1168
|
+
port: http
|
|
1169
|
+
initialDelaySeconds: 5
|
|
1170
|
+
periodSeconds: 10
|
|
1171
|
+
resources:
|
|
1172
|
+
{{- toYaml .Values.resources | nindent 12 }}
|
|
1173
|
+
env:
|
|
1174
|
+
- name: PORT
|
|
1175
|
+
value: "8080"
|
|
1176
|
+
- name: NODE_ENV
|
|
1177
|
+
value: "production"
|
|
1178
|
+
- name: FLOW_ID
|
|
1179
|
+
value: {{ .Values.flow.id | quote }}
|
|
1180
|
+
{{- with .Values.env }}
|
|
1181
|
+
{{- toYaml . | nindent 12 }}
|
|
1182
|
+
{{- end }}
|
|
1183
|
+
{{- with .Values.nodeSelector }}
|
|
1184
|
+
nodeSelector:
|
|
1185
|
+
{{- toYaml . | nindent 8 }}
|
|
1186
|
+
{{- end }}
|
|
1187
|
+
{{- with .Values.tolerations }}
|
|
1188
|
+
tolerations:
|
|
1189
|
+
{{- toYaml . | nindent 8 }}
|
|
1190
|
+
{{- end }}
|
|
1191
|
+
{{- with .Values.affinity }}
|
|
1192
|
+
affinity:
|
|
1193
|
+
{{- toYaml . | nindent 8 }}
|
|
1194
|
+
{{- end }}
|
|
1195
|
+
`;
|
|
1196
|
+
}
|
|
1197
|
+
function generateServiceYaml(chartName) {
|
|
1198
|
+
return `# Auto-generated Service for ${chartName}
|
|
1199
|
+
apiVersion: v1
|
|
1200
|
+
kind: Service
|
|
1201
|
+
metadata:
|
|
1202
|
+
name: {{ include "${chartName}.fullname" . }}
|
|
1203
|
+
labels:
|
|
1204
|
+
{{- include "${chartName}.labels" . | nindent 4 }}
|
|
1205
|
+
spec:
|
|
1206
|
+
type: {{ .Values.service.type }}
|
|
1207
|
+
ports:
|
|
1208
|
+
- port: {{ .Values.service.port }}
|
|
1209
|
+
targetPort: http
|
|
1210
|
+
protocol: TCP
|
|
1211
|
+
name: http
|
|
1212
|
+
selector:
|
|
1213
|
+
{{- include "${chartName}.selectorLabels" . | nindent 4 }}
|
|
1214
|
+
`;
|
|
1215
|
+
}
|
|
1216
|
+
function generateIngressYaml(chartName) {
|
|
1217
|
+
return `# Auto-generated Ingress for ${chartName}
|
|
1218
|
+
{{- if .Values.ingress.enabled }}
|
|
1219
|
+
apiVersion: networking.k8s.io/v1
|
|
1220
|
+
kind: Ingress
|
|
1221
|
+
metadata:
|
|
1222
|
+
name: {{ include "${chartName}.fullname" . }}
|
|
1223
|
+
labels:
|
|
1224
|
+
{{- include "${chartName}.labels" . | nindent 4 }}
|
|
1225
|
+
{{- with .Values.ingress.annotations }}
|
|
1226
|
+
annotations:
|
|
1227
|
+
{{- toYaml . | nindent 4 }}
|
|
1228
|
+
{{- end }}
|
|
1229
|
+
spec:
|
|
1230
|
+
{{- if .Values.ingress.className }}
|
|
1231
|
+
ingressClassName: {{ .Values.ingress.className }}
|
|
1232
|
+
{{- end }}
|
|
1233
|
+
{{- if .Values.ingress.tls }}
|
|
1234
|
+
tls:
|
|
1235
|
+
{{- range .Values.ingress.tls }}
|
|
1236
|
+
- hosts:
|
|
1237
|
+
{{- range .hosts }}
|
|
1238
|
+
- {{ . | quote }}
|
|
1239
|
+
{{- end }}
|
|
1240
|
+
secretName: {{ .secretName }}
|
|
1241
|
+
{{- end }}
|
|
1242
|
+
{{- end }}
|
|
1243
|
+
rules:
|
|
1244
|
+
{{- range .Values.ingress.hosts }}
|
|
1245
|
+
- host: {{ .host | quote }}
|
|
1246
|
+
http:
|
|
1247
|
+
paths:
|
|
1248
|
+
{{- range .paths }}
|
|
1249
|
+
- path: {{ .path }}
|
|
1250
|
+
pathType: {{ .pathType }}
|
|
1251
|
+
backend:
|
|
1252
|
+
service:
|
|
1253
|
+
name: {{ include "${chartName}.fullname" $ }}
|
|
1254
|
+
port:
|
|
1255
|
+
name: http
|
|
1256
|
+
{{- end }}
|
|
1257
|
+
{{- end }}
|
|
1258
|
+
{{- end }}
|
|
1259
|
+
`;
|
|
1260
|
+
}
|
|
1261
|
+
function generateServiceAccountYaml(chartName) {
|
|
1262
|
+
return `# Auto-generated ServiceAccount for ${chartName}
|
|
1263
|
+
{{- if .Values.serviceAccount.create }}
|
|
1264
|
+
apiVersion: v1
|
|
1265
|
+
kind: ServiceAccount
|
|
1266
|
+
metadata:
|
|
1267
|
+
name: {{ include "${chartName}.serviceAccountName" . }}
|
|
1268
|
+
labels:
|
|
1269
|
+
{{- include "${chartName}.labels" . | nindent 4 }}
|
|
1270
|
+
{{- with .Values.serviceAccount.annotations }}
|
|
1271
|
+
annotations:
|
|
1272
|
+
{{- toYaml . | nindent 4 }}
|
|
1273
|
+
{{- end }}
|
|
1274
|
+
{{- end }}
|
|
1275
|
+
`;
|
|
1276
|
+
}
|
|
1277
|
+
function generateHelpersTpl(chartName) {
|
|
1278
|
+
return `{{/*
|
|
1279
|
+
Auto-generated helpers for ${chartName}
|
|
1280
|
+
*/}}
|
|
1281
|
+
|
|
1282
|
+
{{- define "${chartName}.name" -}}
|
|
1283
|
+
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
|
1284
|
+
{{- end }}
|
|
1285
|
+
|
|
1286
|
+
{{- define "${chartName}.fullname" -}}
|
|
1287
|
+
{{- if .Values.fullnameOverride }}
|
|
1288
|
+
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
|
|
1289
|
+
{{- else }}
|
|
1290
|
+
{{- $name := default .Chart.Name .Values.nameOverride }}
|
|
1291
|
+
{{- if contains $name .Release.Name }}
|
|
1292
|
+
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
|
|
1293
|
+
{{- else }}
|
|
1294
|
+
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
|
|
1295
|
+
{{- end }}
|
|
1296
|
+
{{- end }}
|
|
1297
|
+
{{- end }}
|
|
1298
|
+
|
|
1299
|
+
{{- define "${chartName}.labels" -}}
|
|
1300
|
+
helm.sh/chart: {{ include "${chartName}.name" . }}-{{ .Chart.Version | replace "+" "_" }}
|
|
1301
|
+
{{ include "${chartName}.selectorLabels" . }}
|
|
1302
|
+
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
|
1303
|
+
app.kubernetes.io/part-of: stackwright-services
|
|
1304
|
+
{{- end }}
|
|
1305
|
+
|
|
1306
|
+
{{- define "${chartName}.selectorLabels" -}}
|
|
1307
|
+
app.kubernetes.io/name: {{ include "${chartName}.name" . }}
|
|
1308
|
+
app.kubernetes.io/instance: {{ .Release.Name }}
|
|
1309
|
+
{{- end }}
|
|
1310
|
+
|
|
1311
|
+
{{- define "${chartName}.serviceAccountName" -}}
|
|
1312
|
+
{{- if .Values.serviceAccount.create }}
|
|
1313
|
+
{{- default (include "${chartName}.fullname" .) .Values.serviceAccount.name }}
|
|
1314
|
+
{{- else }}
|
|
1315
|
+
{{- default "default" .Values.serviceAccount.name }}
|
|
1316
|
+
{{- end }}
|
|
1317
|
+
{{- end }}
|
|
1318
|
+
`;
|
|
1319
|
+
}
|
|
1320
|
+
function generateCronJobYaml(chartName, cron) {
|
|
1321
|
+
return `# Auto-generated CronJob for ${chartName}
|
|
1322
|
+
# DO NOT EDIT -- regenerate from source YAML.
|
|
1323
|
+
apiVersion: batch/v1
|
|
1324
|
+
kind: CronJob
|
|
1325
|
+
metadata:
|
|
1326
|
+
name: {{ include "${chartName}.fullname" . }}
|
|
1327
|
+
labels:
|
|
1328
|
+
{{- include "${chartName}.labels" . | nindent 4 }}
|
|
1329
|
+
spec:
|
|
1330
|
+
schedule: "${cron}"
|
|
1331
|
+
concurrencyPolicy: Forbid
|
|
1332
|
+
successfulJobsHistoryLimit: 3
|
|
1333
|
+
failedJobsHistoryLimit: 3
|
|
1334
|
+
jobTemplate:
|
|
1335
|
+
spec:
|
|
1336
|
+
template:
|
|
1337
|
+
metadata:
|
|
1338
|
+
labels:
|
|
1339
|
+
{{- include "${chartName}.selectorLabels" . | nindent 12 }}
|
|
1340
|
+
spec:
|
|
1341
|
+
serviceAccountName: {{ include "${chartName}.serviceAccountName" . }}
|
|
1342
|
+
containers:
|
|
1343
|
+
- name: {{ .Chart.Name }}
|
|
1344
|
+
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
|
1345
|
+
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
|
1346
|
+
env:
|
|
1347
|
+
- name: PORT
|
|
1348
|
+
value: "8080"
|
|
1349
|
+
- name: NODE_ENV
|
|
1350
|
+
value: "production"
|
|
1351
|
+
- name: TRIGGER_MODE
|
|
1352
|
+
value: "schedule"
|
|
1353
|
+
- name: FLOW_ID
|
|
1354
|
+
value: {{ .Values.flow.id | quote }}
|
|
1355
|
+
{{- with .Values.env }}
|
|
1356
|
+
{{- toYaml . | nindent 16 }}
|
|
1357
|
+
{{- end }}
|
|
1358
|
+
resources:
|
|
1359
|
+
{{- toYaml .Values.resources | nindent 16 }}
|
|
1360
|
+
restartPolicy: OnFailure
|
|
1361
|
+
`;
|
|
1362
|
+
}
|
|
1363
|
+
function generateBusConfigYaml(chartName, source) {
|
|
1364
|
+
const topic = source.replace(/^bus:/, "");
|
|
1365
|
+
return `# Auto-generated bus subscription config for ${chartName}
|
|
1366
|
+
# The concrete subscription mechanism depends on the cluster's message bus operator.
|
|
1367
|
+
# DO NOT EDIT -- regenerate from source YAML.
|
|
1368
|
+
apiVersion: v1
|
|
1369
|
+
kind: ConfigMap
|
|
1370
|
+
metadata:
|
|
1371
|
+
name: {{ include "${chartName}.fullname" . }}-bus-config
|
|
1372
|
+
labels:
|
|
1373
|
+
{{- include "${chartName}.labels" . | nindent 4 }}
|
|
1374
|
+
data:
|
|
1375
|
+
trigger-type: "event"
|
|
1376
|
+
bus-topic: "${topic}"
|
|
1377
|
+
source: "${source}"
|
|
1378
|
+
`;
|
|
1379
|
+
}
|
|
1380
|
+
function generateQueueConfigYaml(chartName, queueName) {
|
|
1381
|
+
return `# Auto-generated queue config for ${chartName}
|
|
1382
|
+
# The concrete queue binding depends on the cluster's message bus operator.
|
|
1383
|
+
# DO NOT EDIT -- regenerate from source YAML.
|
|
1384
|
+
apiVersion: v1
|
|
1385
|
+
kind: ConfigMap
|
|
1386
|
+
metadata:
|
|
1387
|
+
name: {{ include "${chartName}.fullname" . }}-queue-config
|
|
1388
|
+
labels:
|
|
1389
|
+
{{- include "${chartName}.labels" . | nindent 4 }}
|
|
1390
|
+
data:
|
|
1391
|
+
trigger-type: "queue"
|
|
1392
|
+
queue-name: "${queueName}"
|
|
1393
|
+
`;
|
|
1394
|
+
}
|
|
1395
|
+
function generateRbacYaml(chartName, permissions) {
|
|
1396
|
+
const rules = toRbacRules(permissions);
|
|
1397
|
+
const rulesYaml = rules.map(
|
|
1398
|
+
(r) => ` - apiGroups: [${r.apiGroups.map((g) => `"${g}"`).join(", ")}]
|
|
1399
|
+
resources: [${r.resources.map((res) => `"${res}"`).join(", ")}]
|
|
1400
|
+
verbs: [${r.verbs.map((v) => `"${v}"`).join(", ")}]`
|
|
1401
|
+
).join("\n");
|
|
1402
|
+
return `# Auto-generated RBAC for ${chartName}
|
|
1403
|
+
# Derived from least-privilege permission analysis
|
|
1404
|
+
apiVersion: rbac.authorization.k8s.io/v1
|
|
1405
|
+
kind: Role
|
|
1406
|
+
metadata:
|
|
1407
|
+
name: {{ include "${chartName}.fullname" . }}
|
|
1408
|
+
labels:
|
|
1409
|
+
{{- include "${chartName}.labels" . | nindent 4 }}
|
|
1410
|
+
rules:
|
|
1411
|
+
${rulesYaml}
|
|
1412
|
+
---
|
|
1413
|
+
apiVersion: rbac.authorization.k8s.io/v1
|
|
1414
|
+
kind: RoleBinding
|
|
1415
|
+
metadata:
|
|
1416
|
+
name: {{ include "${chartName}.fullname" . }}
|
|
1417
|
+
labels:
|
|
1418
|
+
{{- include "${chartName}.labels" . | nindent 4 }}
|
|
1419
|
+
roleRef:
|
|
1420
|
+
apiGroup: rbac.authorization.k8s.io
|
|
1421
|
+
kind: Role
|
|
1422
|
+
name: {{ include "${chartName}.fullname" . }}
|
|
1423
|
+
subjects:
|
|
1424
|
+
- kind: ServiceAccount
|
|
1425
|
+
name: {{ include "${chartName}.serviceAccountName" . }}
|
|
1426
|
+
namespace: {{ .Release.Namespace }}
|
|
1427
|
+
`;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// src/targets/stepfunctions.ts
|
|
1431
|
+
function compileStepFunctionsTarget(workflow, permissions) {
|
|
1432
|
+
const files = {};
|
|
1433
|
+
const asl = generateAslDefinition(workflow);
|
|
1434
|
+
files["asl-definition.json"] = JSON.stringify(asl, null, 2);
|
|
1435
|
+
files["iam-execution-role.json"] = JSON.stringify(
|
|
1436
|
+
toStepFunctionsIamPolicy(workflow, permissions),
|
|
1437
|
+
null,
|
|
1438
|
+
2
|
|
1439
|
+
);
|
|
1440
|
+
for (const inst of generateWorkflowInstrumentation(workflow)) {
|
|
1441
|
+
const params = workflow.states[inst.stateName]?.on_enter?.with ?? {};
|
|
1442
|
+
files[`handlers/${inst.stateName}.ts`] = generateInstrumentedStateHandlerCode(inst, params);
|
|
1443
|
+
}
|
|
1444
|
+
files["package.json"] = generatePackageJson2(workflow);
|
|
1445
|
+
return { files };
|
|
1446
|
+
}
|
|
1447
|
+
function generateAslDefinition(workflow) {
|
|
1448
|
+
const states = {};
|
|
1449
|
+
for (const [name, state] of Object.entries(workflow.states)) {
|
|
1450
|
+
Object.assign(states, generateAslStates(name, state, workflow));
|
|
1451
|
+
}
|
|
1452
|
+
const definition = {
|
|
1453
|
+
Comment: `Stackwright workflow: ${workflow.description}`,
|
|
1454
|
+
StartAt: workflow.initial,
|
|
1455
|
+
States: states
|
|
1456
|
+
};
|
|
1457
|
+
if (workflow.timeouts?.absolute) {
|
|
1458
|
+
definition["TimeoutSeconds"] = iso8601ToSeconds(workflow.timeouts.absolute);
|
|
1459
|
+
}
|
|
1460
|
+
return definition;
|
|
1461
|
+
}
|
|
1462
|
+
function generateAslStates(name, state, workflow) {
|
|
1463
|
+
const isTerminal = state.type === "terminal";
|
|
1464
|
+
const hasOnEnter = !!state.on_enter;
|
|
1465
|
+
const transitions = state.transitions ?? [];
|
|
1466
|
+
if (isTerminal && hasOnEnter) {
|
|
1467
|
+
const succeedName = `${name}__succeed`;
|
|
1468
|
+
return {
|
|
1469
|
+
[name]: buildTaskState(workflow.id, name, succeedName),
|
|
1470
|
+
[succeedName]: { Type: "Succeed" }
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
if (isTerminal) {
|
|
1474
|
+
return { [name]: { Type: "Succeed" } };
|
|
1475
|
+
}
|
|
1476
|
+
if (transitions.length === 0) {
|
|
1477
|
+
return hasOnEnter ? { [name]: buildTaskState(workflow.id, name) } : { [name]: { Type: "Pass", End: true } };
|
|
1478
|
+
}
|
|
1479
|
+
const first = transitions[0];
|
|
1480
|
+
if (transitions.length === 1 && first && !first.when) {
|
|
1481
|
+
return hasOnEnter ? { [name]: buildTaskState(workflow.id, name, first.to) } : { [name]: { Type: "Pass", Next: first.to } };
|
|
1482
|
+
}
|
|
1483
|
+
if (hasOnEnter) {
|
|
1484
|
+
const choiceName = `${name}__choice`;
|
|
1485
|
+
return {
|
|
1486
|
+
[name]: buildTaskState(workflow.id, name, choiceName),
|
|
1487
|
+
[choiceName]: buildChoiceState(transitions)
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
return { [name]: buildChoiceState(transitions) };
|
|
1491
|
+
}
|
|
1492
|
+
function buildTaskState(workflowId, stateName, next) {
|
|
1493
|
+
const task = {
|
|
1494
|
+
Type: "Task",
|
|
1495
|
+
Resource: "arn:aws:states:::lambda:invoke",
|
|
1496
|
+
Parameters: {
|
|
1497
|
+
FunctionName: `${workflowId}-${stateName}`,
|
|
1498
|
+
"Payload.$": "$"
|
|
1499
|
+
}
|
|
1500
|
+
};
|
|
1501
|
+
if (next) {
|
|
1502
|
+
task["Next"] = next;
|
|
1503
|
+
} else {
|
|
1504
|
+
task["End"] = true;
|
|
1505
|
+
}
|
|
1506
|
+
return task;
|
|
1507
|
+
}
|
|
1508
|
+
function buildChoiceState(transitions) {
|
|
1509
|
+
const choices = [];
|
|
1510
|
+
let defaultTarget;
|
|
1511
|
+
const lastNoWhenIdx = findLastIndex(transitions, (t) => !t.when);
|
|
1512
|
+
for (const [i, t] of transitions.entries()) {
|
|
1513
|
+
if (i === lastNoWhenIdx) {
|
|
1514
|
+
defaultTarget = t.to;
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
choices.push(transitionToChoiceRule(t));
|
|
1518
|
+
}
|
|
1519
|
+
const choiceState = {
|
|
1520
|
+
Type: "Choice",
|
|
1521
|
+
Choices: choices
|
|
1522
|
+
};
|
|
1523
|
+
if (defaultTarget) {
|
|
1524
|
+
choiceState["Default"] = defaultTarget;
|
|
1525
|
+
}
|
|
1526
|
+
return choiceState;
|
|
1527
|
+
}
|
|
1528
|
+
function transitionToChoiceRule(transition) {
|
|
1529
|
+
const signalCheck = { Variable: "$.signal", StringEquals: transition.on };
|
|
1530
|
+
if (!transition.when || transition.when.length === 0) {
|
|
1531
|
+
return { ...signalCheck, Next: transition.to };
|
|
1532
|
+
}
|
|
1533
|
+
return {
|
|
1534
|
+
And: [signalCheck, ...transition.when.map(predicateToChoiceRule)],
|
|
1535
|
+
Next: transition.to
|
|
1536
|
+
};
|
|
1537
|
+
}
|
|
1538
|
+
function predicateToChoiceRule(predicate) {
|
|
1539
|
+
const variable = `$.${predicate.field}`;
|
|
1540
|
+
switch (predicate.op) {
|
|
1541
|
+
case "equals":
|
|
1542
|
+
return buildEqualsRule(variable, predicate.value);
|
|
1543
|
+
case "not_equals":
|
|
1544
|
+
return { Not: buildEqualsRule(variable, predicate.value) };
|
|
1545
|
+
case "less_than":
|
|
1546
|
+
return { Variable: variable, NumericLessThan: predicate.value };
|
|
1547
|
+
case "greater_than":
|
|
1548
|
+
return { Variable: variable, NumericGreaterThan: predicate.value };
|
|
1549
|
+
case "less_than_or_equal":
|
|
1550
|
+
return { Variable: variable, NumericLessThanEquals: predicate.value };
|
|
1551
|
+
case "greater_than_or_equal":
|
|
1552
|
+
return { Variable: variable, NumericGreaterThanEquals: predicate.value };
|
|
1553
|
+
case "matches_prefix":
|
|
1554
|
+
return { Variable: variable, StringMatches: `${String(predicate.value)}*` };
|
|
1555
|
+
case "contains":
|
|
1556
|
+
return { Variable: variable, StringMatches: `*${String(predicate.value)}*` };
|
|
1557
|
+
case "in":
|
|
1558
|
+
return buildInRule(variable, predicate.value);
|
|
1559
|
+
case "not_in":
|
|
1560
|
+
return { Not: buildInRule(variable, predicate.value) };
|
|
1561
|
+
case "less_than_field":
|
|
1562
|
+
case "greater_than_field":
|
|
1563
|
+
case "equals_field":
|
|
1564
|
+
return { Variable: variable, StringEquals: `__UNSUPPORTED_FIELD_OP_${predicate.op}__` };
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
function buildEqualsRule(variable, value) {
|
|
1568
|
+
if (typeof value === "string") return { Variable: variable, StringEquals: value };
|
|
1569
|
+
if (typeof value === "number") return { Variable: variable, NumericEquals: value };
|
|
1570
|
+
if (typeof value === "boolean") return { Variable: variable, BooleanEquals: value };
|
|
1571
|
+
return { Variable: variable, StringEquals: String(value) };
|
|
1572
|
+
}
|
|
1573
|
+
function buildInRule(variable, value) {
|
|
1574
|
+
if (!Array.isArray(value)) return buildEqualsRule(variable, value);
|
|
1575
|
+
return { Or: value.map((v) => buildEqualsRule(variable, v)) };
|
|
1576
|
+
}
|
|
1577
|
+
function iso8601ToSeconds(duration) {
|
|
1578
|
+
const match = duration.match(
|
|
1579
|
+
/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/
|
|
1580
|
+
);
|
|
1581
|
+
if (!match) {
|
|
1582
|
+
throw new Error(`Invalid ISO 8601 duration: ${duration}`);
|
|
1583
|
+
}
|
|
1584
|
+
const [, years, months, weeks, days, hours, minutes, seconds] = match;
|
|
1585
|
+
let total = 0;
|
|
1586
|
+
if (years) total += parseInt(years, 10) * 365 * 86400;
|
|
1587
|
+
if (months) total += parseInt(months, 10) * 30 * 86400;
|
|
1588
|
+
if (weeks) total += parseInt(weeks, 10) * 7 * 86400;
|
|
1589
|
+
if (days) total += parseInt(days, 10) * 86400;
|
|
1590
|
+
if (hours) total += parseInt(hours, 10) * 3600;
|
|
1591
|
+
if (minutes) total += parseInt(minutes, 10) * 60;
|
|
1592
|
+
if (seconds) total += parseInt(seconds, 10);
|
|
1593
|
+
return total;
|
|
1594
|
+
}
|
|
1595
|
+
function generatePackageJson2(workflow) {
|
|
1596
|
+
return JSON.stringify(
|
|
1597
|
+
{
|
|
1598
|
+
name: `${workflow.id}-stepfunctions`,
|
|
1599
|
+
version: "0.0.1",
|
|
1600
|
+
private: true,
|
|
1601
|
+
scripts: {
|
|
1602
|
+
build: "tsc"
|
|
1603
|
+
},
|
|
1604
|
+
dependencies: {
|
|
1605
|
+
"@stackwright-services/capabilities": "workspace:*"
|
|
1606
|
+
},
|
|
1607
|
+
devDependencies: {
|
|
1608
|
+
"@types/aws-lambda": "^8",
|
|
1609
|
+
typescript: "^6"
|
|
1610
|
+
}
|
|
1611
|
+
},
|
|
1612
|
+
null,
|
|
1613
|
+
2
|
|
1614
|
+
) + "\n";
|
|
1615
|
+
}
|
|
1616
|
+
function findLastIndex(arr, predicate) {
|
|
1617
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
1618
|
+
if (predicate(arr[i])) return i;
|
|
1619
|
+
}
|
|
1620
|
+
return -1;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// src/targets/openapi.ts
|
|
1624
|
+
function compileOpenApiTarget(flow, options = {}) {
|
|
1625
|
+
if (flow.trigger.type !== "http") {
|
|
1626
|
+
return { files: {} };
|
|
1627
|
+
}
|
|
1628
|
+
const trigger = flow.trigger;
|
|
1629
|
+
const version = options.version ?? "0.0.1";
|
|
1630
|
+
const spec = buildOpenApiSpec(flow, trigger, version, options);
|
|
1631
|
+
return {
|
|
1632
|
+
files: {
|
|
1633
|
+
"openapi.yaml": serializeYaml(spec)
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
}
|
|
1637
|
+
function buildOpenApiSpec(flow, trigger, version, options) {
|
|
1638
|
+
const operationId = generateOperationId(flow.id, trigger.method);
|
|
1639
|
+
const spec = {
|
|
1640
|
+
openapi: "3.1.0",
|
|
1641
|
+
info: {
|
|
1642
|
+
title: flow.id,
|
|
1643
|
+
description: flow.description,
|
|
1644
|
+
version
|
|
1645
|
+
},
|
|
1646
|
+
paths: {
|
|
1647
|
+
[trigger.path]: {
|
|
1648
|
+
[trigger.method.toLowerCase()]: {
|
|
1649
|
+
operationId,
|
|
1650
|
+
summary: flow.description,
|
|
1651
|
+
...trigger.method === "GET" ? { parameters: buildQueryParameters(trigger) } : { requestBody: buildRequestBody(trigger) },
|
|
1652
|
+
responses: buildResponses(trigger)
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
};
|
|
1657
|
+
if (options.serverUrl) {
|
|
1658
|
+
spec.servers = [{ url: options.serverUrl }];
|
|
1659
|
+
}
|
|
1660
|
+
if (options.buildKey) {
|
|
1661
|
+
spec.components = {
|
|
1662
|
+
...spec.components,
|
|
1663
|
+
securitySchemes: {
|
|
1664
|
+
buildKey: {
|
|
1665
|
+
type: "apiKey",
|
|
1666
|
+
in: "header",
|
|
1667
|
+
name: "x-stackwright-build-key",
|
|
1668
|
+
description: "Build-coherence key. Auto-generated at compile time. Ensures frontend/backend version match."
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
};
|
|
1672
|
+
spec.security = [{ buildKey: [] }];
|
|
1673
|
+
spec["x-stackwright-build-key"] = options.buildKey;
|
|
1674
|
+
}
|
|
1675
|
+
spec.components = {
|
|
1676
|
+
...spec.components,
|
|
1677
|
+
schemas: {
|
|
1678
|
+
[trigger.input_schema]: {
|
|
1679
|
+
type: "object",
|
|
1680
|
+
description: `Input schema: ${trigger.input_schema} (resolve from Zod schema at build time)`
|
|
1681
|
+
},
|
|
1682
|
+
[trigger.output_schema]: {
|
|
1683
|
+
type: "object",
|
|
1684
|
+
description: `Output schema: ${trigger.output_schema} (resolve from Zod schema at build time)`
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
};
|
|
1688
|
+
return spec;
|
|
1689
|
+
}
|
|
1690
|
+
function generateOperationId(flowId, method) {
|
|
1691
|
+
const camelCase = flowId.replace(/[^a-zA-Z0-9]+(.)/g, (_, ch) => ch.toUpperCase()).replace(/^[A-Z]/, (ch) => ch.toLowerCase());
|
|
1692
|
+
const prefix = method.toLowerCase();
|
|
1693
|
+
return `${prefix}${camelCase.charAt(0).toUpperCase()}${camelCase.slice(1)}`;
|
|
1694
|
+
}
|
|
1695
|
+
function buildQueryParameters(trigger) {
|
|
1696
|
+
return [
|
|
1697
|
+
{
|
|
1698
|
+
name: trigger.input_schema,
|
|
1699
|
+
in: "query",
|
|
1700
|
+
description: `Query parameters per ${trigger.input_schema} schema`,
|
|
1701
|
+
required: false,
|
|
1702
|
+
schema: { $ref: `#/components/schemas/${trigger.input_schema}` }
|
|
1703
|
+
}
|
|
1704
|
+
];
|
|
1705
|
+
}
|
|
1706
|
+
function buildRequestBody(trigger) {
|
|
1707
|
+
return {
|
|
1708
|
+
required: true,
|
|
1709
|
+
content: {
|
|
1710
|
+
"application/json": {
|
|
1711
|
+
schema: { $ref: `#/components/schemas/${trigger.input_schema}` }
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
function buildResponses(trigger) {
|
|
1717
|
+
return {
|
|
1718
|
+
"200": {
|
|
1719
|
+
description: "Successful response",
|
|
1720
|
+
content: {
|
|
1721
|
+
"application/json": {
|
|
1722
|
+
schema: { $ref: `#/components/schemas/${trigger.output_schema}` }
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
},
|
|
1726
|
+
"400": {
|
|
1727
|
+
description: "Validation error",
|
|
1728
|
+
content: {
|
|
1729
|
+
"application/json": {
|
|
1730
|
+
schema: {
|
|
1731
|
+
type: "object",
|
|
1732
|
+
properties: {
|
|
1733
|
+
error: { type: "string" },
|
|
1734
|
+
code: { type: "string" },
|
|
1735
|
+
details: { type: "object" }
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
},
|
|
1741
|
+
"500": {
|
|
1742
|
+
description: "Internal server error",
|
|
1743
|
+
content: {
|
|
1744
|
+
"application/json": {
|
|
1745
|
+
schema: {
|
|
1746
|
+
type: "object",
|
|
1747
|
+
properties: {
|
|
1748
|
+
error: { type: "string" },
|
|
1749
|
+
code: { type: "string" }
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
function serializeYaml(obj, indent = 0) {
|
|
1758
|
+
const pad = " ".repeat(indent);
|
|
1759
|
+
if (obj === null || obj === void 0) return "null";
|
|
1760
|
+
if (typeof obj === "string") {
|
|
1761
|
+
if (needsQuoting(obj)) {
|
|
1762
|
+
return `'${obj.replace(/'/g, "''")}'`;
|
|
1763
|
+
}
|
|
1764
|
+
return obj;
|
|
1765
|
+
}
|
|
1766
|
+
if (typeof obj === "number" || typeof obj === "boolean") return String(obj);
|
|
1767
|
+
if (Array.isArray(obj)) {
|
|
1768
|
+
if (obj.length === 0) return "[]";
|
|
1769
|
+
return obj.map((item) => {
|
|
1770
|
+
if (typeof item === "object" && item !== null) {
|
|
1771
|
+
const inner = serializeYaml(item, indent + 1);
|
|
1772
|
+
const lines = inner.split("\n");
|
|
1773
|
+
const firstLine = lines[0] ?? "";
|
|
1774
|
+
return `${pad}- ${firstLine.trimStart()}
|
|
1775
|
+
${lines.slice(1).join("\n")}`.trimEnd();
|
|
1776
|
+
}
|
|
1777
|
+
return `${pad}- ${serializeYaml(item)}`;
|
|
1778
|
+
}).join("\n");
|
|
1779
|
+
}
|
|
1780
|
+
if (typeof obj === "object") {
|
|
1781
|
+
const entries = Object.entries(obj);
|
|
1782
|
+
if (entries.length === 0) return "{}";
|
|
1783
|
+
return entries.map(([key, val]) => {
|
|
1784
|
+
const safeKey = keyNeedsQuoting(key) ? `'${key}'` : key;
|
|
1785
|
+
if (typeof val === "object" && val !== null && !Array.isArray(val) && Object.keys(val).length > 0) {
|
|
1786
|
+
return `${pad}${safeKey}:
|
|
1787
|
+
${serializeYaml(val, indent + 1)}`;
|
|
1788
|
+
}
|
|
1789
|
+
if (Array.isArray(val)) {
|
|
1790
|
+
if (val.length === 0) return `${pad}${safeKey}: []`;
|
|
1791
|
+
return `${pad}${safeKey}:
|
|
1792
|
+
${serializeYaml(val, indent + 1)}`;
|
|
1793
|
+
}
|
|
1794
|
+
return `${pad}${safeKey}: ${serializeYaml(val)}`;
|
|
1795
|
+
}).join("\n");
|
|
1796
|
+
}
|
|
1797
|
+
return String(obj);
|
|
1798
|
+
}
|
|
1799
|
+
function needsQuoting(str) {
|
|
1800
|
+
return /[:{},&*#?|<>=!%@`\n[\]]/.test(str) || str === "" || str === "true" || str === "false";
|
|
1801
|
+
}
|
|
1802
|
+
function keyNeedsQuoting(key) {
|
|
1803
|
+
return /^\d+$/.test(key) || key === "true" || key === "false" || key === "null" || key === "";
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// src/auth-permissions.ts
|
|
1807
|
+
function deriveFlowAuthRequirements(flow) {
|
|
1808
|
+
void flow;
|
|
1809
|
+
return {
|
|
1810
|
+
requiresAuth: false,
|
|
1811
|
+
requiredRoles: [],
|
|
1812
|
+
sources: []
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
function deriveWorkflowAuthRequirements(workflow) {
|
|
1816
|
+
const sources = [];
|
|
1817
|
+
for (const [stateName, state] of Object.entries(workflow.states)) {
|
|
1818
|
+
if (state.transitions) {
|
|
1819
|
+
for (const transition of state.transitions) {
|
|
1820
|
+
if (transition.guard_role) {
|
|
1821
|
+
sources.push({
|
|
1822
|
+
role: transition.guard_role,
|
|
1823
|
+
location: `states.${stateName}.transitions[on=${transition.on}].guard_role`
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
const uniqueRoles = [...new Set(sources.map((s) => s.role))];
|
|
1830
|
+
return {
|
|
1831
|
+
requiresAuth: sources.length > 0,
|
|
1832
|
+
requiredRoles: uniqueRoles,
|
|
1833
|
+
sources
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
function toLambdaAuthorizerConfig(auth) {
|
|
1837
|
+
if (!auth.requiresAuth) return null;
|
|
1838
|
+
return {
|
|
1839
|
+
type: "REQUEST",
|
|
1840
|
+
identitySource: "method.request.header.Authorization",
|
|
1841
|
+
authorizerResultTtlInSeconds: 300,
|
|
1842
|
+
// The actual authorizer Lambda is provisioned separately —
|
|
1843
|
+
// this config tells API Gateway to require it
|
|
1844
|
+
requiredRoles: auth.requiredRoles
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
function toHelmAuthAnnotations(auth) {
|
|
1848
|
+
if (!auth.requiresAuth) return {};
|
|
1849
|
+
return {
|
|
1850
|
+
"stackwright.io/auth-required": "true",
|
|
1851
|
+
"stackwright.io/auth-roles": auth.requiredRoles.join(","),
|
|
1852
|
+
"stackwright.io/auth-provider": "oidc"
|
|
1853
|
+
// default, overridable via values.yaml
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// src/build-coherence.ts
|
|
1858
|
+
import { createHash } from "crypto";
|
|
1859
|
+
function deriveBuildCoherence(flow, options = {}) {
|
|
1860
|
+
const enabled = options.enabled ?? true;
|
|
1861
|
+
const buildTimestamp = options.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1862
|
+
const contentHash = createHash("sha256").update(JSON.stringify(flow)).digest("hex").slice(0, 16);
|
|
1863
|
+
const buildKey = createHash("sha256").update(`${contentHash}:${buildTimestamp}`).digest("hex").slice(0, 32);
|
|
1864
|
+
return {
|
|
1865
|
+
enabled,
|
|
1866
|
+
buildKey,
|
|
1867
|
+
contentHash,
|
|
1868
|
+
buildTimestamp
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
function toBuildKeyMiddleware(config) {
|
|
1872
|
+
if (!config.enabled) return "";
|
|
1873
|
+
return `
|
|
1874
|
+
// \u2500\u2500\u2500 Build Coherence \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1875
|
+
// Auto-generated by the Stackwright Services compiler.
|
|
1876
|
+
// Ensures this backend matches the frontend that was compiled with it.
|
|
1877
|
+
// NOT authentication \u2014 that's AuthProvider's job.
|
|
1878
|
+
const STACKWRIGHT_BUILD_KEY = process.env.STACKWRIGHT_BUILD_KEY ?? '${config.buildKey}';
|
|
1879
|
+
const STACKWRIGHT_CONTENT_HASH = '${config.contentHash}';
|
|
1880
|
+
|
|
1881
|
+
app.use('*', async (c, next) => {
|
|
1882
|
+
// Skip health check
|
|
1883
|
+
if (c.req.path === '/health') return next();
|
|
1884
|
+
|
|
1885
|
+
const clientKey = c.req.header('x-stackwright-build-key');
|
|
1886
|
+
if (!clientKey) {
|
|
1887
|
+
return c.json({
|
|
1888
|
+
error: 'Missing build-coherence key',
|
|
1889
|
+
code: 'BUILD_KEY_MISSING',
|
|
1890
|
+
hint: 'Include x-stackwright-build-key header. This is auto-set by the generated client.',
|
|
1891
|
+
}, 403);
|
|
1892
|
+
}
|
|
1893
|
+
if (clientKey !== STACKWRIGHT_BUILD_KEY) {
|
|
1894
|
+
return c.json({
|
|
1895
|
+
error: 'Build mismatch \u2014 frontend and backend were compiled from different builds',
|
|
1896
|
+
code: 'BUILD_COHERENCE_FAILURE',
|
|
1897
|
+
expected: STACKWRIGHT_BUILD_KEY.slice(0, 8) + '\u2026',
|
|
1898
|
+
received: clientKey.slice(0, 8) + '\u2026',
|
|
1899
|
+
contentHash: STACKWRIGHT_CONTENT_HASH,
|
|
1900
|
+
}, 403);
|
|
1901
|
+
}
|
|
1902
|
+
await next();
|
|
1903
|
+
});
|
|
1904
|
+
`;
|
|
1905
|
+
}
|
|
1906
|
+
function toBuildKeyEnv(config) {
|
|
1907
|
+
if (!config.enabled) return {};
|
|
1908
|
+
return {
|
|
1909
|
+
STACKWRIGHT_BUILD_KEY: config.buildKey,
|
|
1910
|
+
STACKWRIGHT_CONTENT_HASH: config.contentHash,
|
|
1911
|
+
STACKWRIGHT_BUILD_TIMESTAMP: config.buildTimestamp
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
export {
|
|
1915
|
+
compileHelmTarget,
|
|
1916
|
+
compileLambdaTarget,
|
|
1917
|
+
compileOpenApiTarget,
|
|
1918
|
+
compileStepFunctionsTarget,
|
|
1919
|
+
deriveBuildCoherence,
|
|
1920
|
+
deriveFlowAuthRequirements,
|
|
1921
|
+
deriveFlowPermissions,
|
|
1922
|
+
deriveWorkflowAuthRequirements,
|
|
1923
|
+
deriveWorkflowPermissions,
|
|
1924
|
+
detectDefinitionType,
|
|
1925
|
+
generateFlowInstrumentation,
|
|
1926
|
+
generateInstrumentedFlowCode,
|
|
1927
|
+
generateInstrumentedStateHandlerCode,
|
|
1928
|
+
generateInstrumentedStepCode,
|
|
1929
|
+
generateWorkflowInstrumentation,
|
|
1930
|
+
getTriggerPermissions,
|
|
1931
|
+
iso8601ToSeconds,
|
|
1932
|
+
sanitizeChartName,
|
|
1933
|
+
sanitizeIdentifier,
|
|
1934
|
+
toBuildKeyEnv,
|
|
1935
|
+
toBuildKeyMiddleware,
|
|
1936
|
+
toHelmAuthAnnotations,
|
|
1937
|
+
toIamPolicy,
|
|
1938
|
+
toLambdaAuthorizerConfig,
|
|
1939
|
+
toRbacRules,
|
|
1940
|
+
toStepFunctionsIamPolicy,
|
|
1941
|
+
validate,
|
|
1942
|
+
validateFlow,
|
|
1943
|
+
validateWorkflow
|
|
1944
|
+
};
|
|
1945
|
+
//# sourceMappingURL=index.mjs.map
|