Skip to content

Commit da63087

Browse files
authored
Add special case for to-app-state to convert a project to a v1 spec stucture
* Add validation to mock provisioner * experiment with using Project to generate a spec file from state * format * restore tests * little style tweak * remove state.json * handle credentials properly in spec * update tests * mock: handle deleted edges * fix tests * correct project credential name * one more test for luck * one more test fix and log removal
1 parent 4f2ecea commit da63087

10 files changed

Lines changed: 534 additions & 69 deletions

File tree

.changeset/forty-areas-remain.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openfn/lightning-mock': patch
3+
---
4+
5+
Add validation to the provisioner endpoint

integration-tests/cli/test/deploy.test.ts

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import test from 'ava';
22
import path from 'node:path';
3-
import fs from 'node:fs/promises';
3+
import fs, { rm } from 'node:fs/promises';
44
import run from '../src/run';
55
import createLightningServer from '@openfn/lightning-mock';
66
import { extractLogs, assertLog } from '../src/util';
@@ -12,6 +12,50 @@ const port = 8967;
1212
const endpoint = `http://localhost:${port}`;
1313
let tmpDir = path.resolve('tmp/deploy');
1414

15+
const testProjectV2 = `
16+
id: my-project
17+
name: My Project
18+
schema_version: '4.0'
19+
workflows:
20+
- id: my-workflow
21+
name: My Workflow
22+
start: webhook
23+
steps:
24+
- id: webhook
25+
type: webhook
26+
enabled: true
27+
next:
28+
transform-data: {}
29+
- id: transform-data
30+
name: Transform data
31+
expression: 'fn(s => s)'
32+
adaptor: '@openfn/language-common@latest'
33+
`.trim();
34+
35+
const testProjectV2WithCredential = `
36+
id: my-project
37+
name: My Project
38+
schema_version: '4.0'
39+
credentials:
40+
- name: http1
41+
owner: super@openfn.org
42+
workflows:
43+
- id: my-workflow
44+
name: My Workflow
45+
start: webhook
46+
steps:
47+
- id: webhook
48+
type: webhook
49+
enabled: true
50+
next:
51+
transform-data: {}
52+
- id: transform-data
53+
name: Transform data
54+
expression: 'fn(s => s)'
55+
adaptor: '@openfn/language-common@latest'
56+
configuration: super@openfn.org|http1
57+
`.trim();
58+
1559
const testProject = `
1660
name: test-project
1761
workflows:
@@ -97,7 +141,6 @@ test.serial('deploy a local project', async (t) => {
97141
--log-json \
98142
-l debug`
99143
);
100-
101144
t.falsy(stderr);
102145

103146
const logs = extractLogs(stdout);
@@ -301,7 +344,6 @@ workflows:
301344
--log-json \
302345
-l debug`
303346
);
304-
305347
t.falsy(stderr);
306348

307349
const logs = extractLogs(stdout);
@@ -364,7 +406,6 @@ test.serial('deploy then pull, changes one workflow, deploy', async (t) => {
364406

365407
// And deploy those changes
366408
const { stdout, stderr } = await run(deployCmd);
367-
368409
t.falsy(stderr);
369410

370411
const logs = extractLogs(stdout);
@@ -378,3 +419,63 @@ test.serial('deploy then pull, changes one workflow, deploy', async (t) => {
378419
t.is(Object.keys(server.state.projects).length, 1);
379420
t.truthy(server.state.projects[projectId]);
380421
});
422+
423+
test.serial('deploy a v2 project.yaml', async (t) => {
424+
await fs.writeFile(path.join(tmpDir, 'project.yaml'), testProjectV2);
425+
426+
const { stdout, stderr } = await run(
427+
`openfn deploy \
428+
--project-path ${tmpDir}/project.yaml \
429+
--state-path ${tmpDir}/.state.json \
430+
--no-confirm \
431+
--log-json \
432+
-l debug`
433+
);
434+
435+
t.falsy(stderr);
436+
437+
const logs = extractLogs(stdout);
438+
assertLog(t, logs, /Deployed/);
439+
440+
t.is(Object.keys(server.state.projects).length, 1);
441+
const [project] = Object.values(server.state.projects) as any[];
442+
t.is(project.name, 'My Project');
443+
});
444+
445+
test.serial('deploy a new v2 project.yaml with credentials', async (t) => {
446+
await fs.writeFile(
447+
path.join(tmpDir, 'project.yaml'),
448+
testProjectV2WithCredential
449+
);
450+
451+
try {
452+
await rm(`${tmpDir}/.state.json`);
453+
} catch (e) {
454+
// ignore
455+
}
456+
457+
const { stdout, stderr } = await run(
458+
`openfn deploy \
459+
--project-path ${tmpDir}/project.yaml \
460+
--no-confirm \
461+
--log-json \
462+
-l debug`
463+
);
464+
465+
t.falsy(stderr);
466+
467+
const logs = extractLogs(stdout);
468+
assertLog(t, logs, /Deployed/);
469+
470+
t.is(Object.keys(server.state.projects).length, 1);
471+
const [project] = Object.values(server.state.projects) as any[];
472+
t.is(project.name, 'My Project');
473+
474+
t.is(project.project_credentials[0].name, 'http1');
475+
t.is(project.project_credentials[0].owner, 'super@openfn.org');
476+
477+
const uuid = project.project_credentials[0].id;
478+
479+
const workflow: any = Object.values(project.workflows).pop();
480+
t.is(workflow.jobs[0].project_credential_id, uuid);
481+
});

packages/cli/src/deploy/handler.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,10 @@ export const maybeConvertV2spec = async (yaml: string): Promise<string> => {
150150
const json = yamlToJson(yaml) as any;
151151
if (detectVersion(json) > 1) {
152152
const project = await Project.from('project', json);
153-
return project.serialize('state', { format: 'yaml' }) as string;
153+
return project.serialize('state', {
154+
format: 'yaml',
155+
asSpec: true,
156+
}) as string;
154157
}
155158
return yaml;
156159
};

packages/cli/test/deploy/deploy.test.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,6 @@ test.serial('catches DeployErrors', async (t) => {
188188
process.exitCode = origExitCode;
189189
});
190190

191-
// maybeConvertV2spec
192-
193191
const v1Yaml = `id: '1234'
194192
name: My Project
195193
workflows:
@@ -221,6 +219,9 @@ project_credentials: []
221219
const v2Yaml = `id: my-project
222220
name: My Project
223221
schema_version: '4.0'
222+
credentials:
223+
- name: http1
224+
owner: super@openfn.org
224225
workflows:
225226
- id: my-workflow
226227
name: My Workflow
@@ -235,14 +236,15 @@ workflows:
235236
name: Transform data
236237
expression: 'fn(s => s)'
237238
adaptor: '@openfn/language-common@latest'
239+
configuration: super@openfn.org|http1
238240
`;
239241

240242
test('maybeConvertV2spec: returns v1 yaml unchanged', async (t) => {
241243
const result = await maybeConvertV2spec(v1Yaml);
242244
t.is(result, v1Yaml);
243245
});
244246

245-
test('maybeConvertV2spec: converts v2 (schema_version) to v1', async (t) => {
247+
test('maybeConvertV2spec: converts v2 to v1', async (t) => {
246248
const result = await maybeConvertV2spec(v2Yaml);
247249
const json = yamlToJson(result) as any;
248250

@@ -256,10 +258,48 @@ test('maybeConvertV2spec: converts v2 (schema_version) to v1', async (t) => {
256258
t.falsy(workflow.steps);
257259
t.truthy(workflow.triggers);
258260

261+
// no uuids
262+
const edge = workflow.edges['webhook->transform-data'];
263+
t.is(edge.target_job, 'transform-data');
264+
t.is(edge.source_trigger, 'webhook');
265+
t.falsy(workflow.jobs['transform-data'].id);
266+
259267
// no v2 marker
260268
t.falsy(json.schema_version);
261269
});
262270

271+
test('maybeConvertV2spec: converts with credentials', async (t) => {
272+
const result = await maybeConvertV2spec(v2Yaml);
273+
const json = yamlToJson(result) as any;
274+
275+
t.deepEqual(json.credentials, {
276+
'super@openfn.org|http1': { name: 'http1', owner: 'super@openfn.org' },
277+
});
278+
279+
t.is(
280+
json.workflows['my-workflow'].jobs['transform-data'].credential,
281+
'super@openfn.org|http1'
282+
);
283+
});
284+
285+
test('maybeConvertV2spec: converted edges use key references, not UUIDs', async (t) => {
286+
const result = await maybeConvertV2spec(v2Yaml);
287+
const json = yamlToJson(result) as any;
288+
289+
const workflow = Object.values(json.workflows)[0] as any;
290+
const edge = Object.values(workflow.edges)[0] as any;
291+
292+
// edge must use spec format (key references) so mergeSpecIntoState can resolve them
293+
t.truthy(edge.source_trigger);
294+
t.truthy(edge.target_job);
295+
t.falsy(edge.source_trigger_id);
296+
t.falsy(edge.target_job_id);
297+
298+
// source_trigger must match a trigger key; target_job must match a job key
299+
t.truthy(workflow.triggers[edge.source_trigger]);
300+
t.truthy(workflow.jobs[edge.target_job]);
301+
});
302+
263303
test('maybeConvertV2spec: converts legacy v2 (cli.version: 2) to v1', async (t) => {
264304
const legacyV2Yaml = `id: my-project
265305
name: My Project

packages/cli/test/projects/deploy.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
TWO_WORKFLOWS_UUID,
2020
} from './fixtures';
2121
import { checkout } from '../../src/projects';
22-
import { readFileSync } from 'node:fs';
2322

2423
let server: any;
2524
const logger = createMockLogger(undefined, { level: 'debug' });

packages/deploy/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ export async function deploy(config: DeployConfig, logger: Logger) {
118118
throw new DeployError(`${config.specPath} has errors`, 'VALIDATION_ERROR');
119119
}
120120
const nextState = mergeSpecIntoState(state, spec.doc);
121-
122121
validateProjectState(nextState);
123122

124123
// Convert the state to a payload for the API.

packages/lightning-mock/src/api-rest.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,45 @@ workflows:
8383
enabled: true
8484
`;
8585

86+
// Validates a provisioner payload, returning an error body if invalid or null if valid.
87+
// Mirrors Lightning's error format so deploy code sees realistic rejection responses.
88+
export function validateProvisionPayload(
89+
incoming: any
90+
): Record<string, any> | null {
91+
const workflowErrors: Record<string, any> = {};
92+
93+
const wfList: any[] = Array.isArray(incoming.workflows)
94+
? incoming.workflows
95+
: Object.values(incoming.workflows ?? {});
96+
97+
for (const wf of wfList) {
98+
const edgeErrors: Record<string, any> = {};
99+
const edgeList: any[] = Array.isArray(wf.edges)
100+
? wf.edges
101+
: Object.values(wf.edges ?? {});
102+
103+
for (const edge of edgeList) {
104+
if (!edge.delete && !edge.source_trigger_id && !edge.source_job_id) {
105+
const key = edge.id ?? '->';
106+
edgeErrors[key] = {
107+
source_job_id: ['source_job_id or source_trigger_id must be present'],
108+
};
109+
}
110+
}
111+
112+
if (Object.keys(edgeErrors).length > 0) {
113+
const wfKey = wf.name ?? wf.id ?? 'unknown';
114+
workflowErrors[wfKey] = { edges: edgeErrors };
115+
}
116+
}
117+
118+
if (Object.keys(workflowErrors).length > 0) {
119+
return { errors: { workflows: workflowErrors } };
120+
}
121+
122+
return null;
123+
}
124+
86125
export default (
87126
app: DevServer,
88127
state: ServerState,
@@ -121,6 +160,14 @@ export default (
121160

122161
router.post('/api/provision', (ctx) => {
123162
const incoming: any = ctx.request.body;
163+
164+
const validationErrors = validateProvisionPayload(incoming);
165+
if (validationErrors) {
166+
ctx.response.status = 422;
167+
ctx.response.body = validationErrors;
168+
return;
169+
}
170+
124171
const now = new Date().toISOString();
125172

126173
if (!state.projects[incoming.id]) {

0 commit comments

Comments
 (0)