Skip to content

Commit 56da9b3

Browse files
authored
CLI: Deploy through v1 API with v2 spec (#1465)
* convert v2 spec down to v1 * refactor to make a version-sniffing util * integrate the new feature * test * 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 5851a7d commit 56da9b3

18 files changed

Lines changed: 884 additions & 74 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

.claude/yaml-formats.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# OpenFn Project YAML Formats
2+
3+
Two YAML formats are used across the monorepo. The key distinction: **v1** uses objects keyed by ID; **v2** uses arrays.
4+
5+
## v1 (Lightning app state)
6+
7+
Used by `packages/deploy` and sent to/from the Lightning API (`Provisioner.Project` type from `@openfn/lexicon/lightning`).
8+
9+
- `workflows` is a keyed object (`{ [slug]: Workflow }`)
10+
- Each workflow has `jobs`, `triggers`, and `edges` as keyed objects
11+
- Steps are called `jobs`; code is stored in `body`
12+
- Credentials referenced by UUID (`project_credential_id`)
13+
- No version marker — absence of `schema_version`/`cli.version` means v1
14+
15+
```yaml
16+
id: abc-123
17+
name: My Project
18+
project_credentials:
19+
- id: cred-uuid
20+
name: My Credential
21+
owner: admin@openfn.org
22+
workflows:
23+
my-workflow:
24+
id: wf-uuid
25+
name: My Workflow
26+
jobs:
27+
transform-data:
28+
id: job-uuid
29+
name: Transform data
30+
body: 'fn(s => s)'
31+
adaptor: '@openfn/language-common@latest'
32+
project_credential_id: cred-uuid
33+
keychain_credential_id: null
34+
triggers:
35+
webhook:
36+
id: trig-uuid
37+
type: webhook
38+
enabled: true
39+
edges:
40+
trigger->transform-data:
41+
id: edge-uuid
42+
enabled: true
43+
source_trigger_id: trig-uuid
44+
target_job_id: job-uuid
45+
```
46+
47+
## v2 (local project state)
48+
49+
Used by `packages/project` and the CLI project subcommands (`ProjectState` type from `@openfn/lexicon`).
50+
51+
- Identified by `schema_version` field (current: `'4.0'`) or legacy `cli.version: 2`
52+
- `workflows` is an array
53+
- Each workflow has a `steps` array; triggers are steps with a `type` field
54+
- Code stored in `expression`; edges expressed inline via `next` map on each step
55+
- Credentials referenced by name string (`configuration`)
56+
57+
```yaml
58+
id: my-project
59+
name: My Project
60+
schema_version: '4.0'
61+
credentials:
62+
- uuid: cred-uuid
63+
name: My Credential
64+
owner: admin@openfn.org
65+
workflows:
66+
- id: my-workflow
67+
name: My Workflow
68+
start: webhook
69+
steps:
70+
- id: webhook
71+
type: webhook
72+
enabled: true
73+
next:
74+
transform-data:
75+
condition: always
76+
- id: transform-data
77+
name: Transform data
78+
expression: 'fn(s => s)'
79+
adaptor: '@openfn/language-common@latest'
80+
configuration: 'admin@openfn.org|My Credential'
81+
```
82+
83+
## Detection logic
84+
85+
Use `detectVersion(data)` from `@openfn/project` — returns `1` or `2`. Accepts YAML/JSON string or pre-parsed object.
86+
87+
```typescript
88+
import { detectVersion } from '@openfn/project';
89+
if (detectVersion(json) === 2) { /* v2 */ }
90+
```
91+
92+
## Conversion
93+
94+
- **v2 → v1**: `Project.from('project', json).then(p => p.serialize('state', { format: 'yaml' }))` — see `maybeConvertV2spec` in `packages/cli/src/deploy/handler.ts`
95+
- **v1 → v2**: `Project.from('state', json)` — see `packages/project/src/parse/from-app-state.ts`
96+
- Full conversion logic: `packages/project/src/serialize/to-app-state.ts` (v2→v1) and `packages/project/src/parse/from-app-state.ts` (v1→v2)

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ cd packages/cli && pnpm test:watch # Watch mode
131131
The [.claude](.claude) folder contains detailed guides:
132132

133133
- **[event-processor.md](.claude/event-processor.md)** - Worker event processing deep-dive (ordering, batching) — companion to `packages/ws-worker/CLAUDE.md`
134+
- **[yaml-formats.md](.claude/yaml-formats.md)** - v1 vs v2 project YAML formats: structure, detection logic, and conversion paths
134135

135136
Key packages also carry their own `CLAUDE.md` (runtime, engine-multi, ws-worker), auto-loaded when you work in them.
136137

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

Lines changed: 150 additions & 3 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);
@@ -269,6 +312,51 @@ test.serial('redirect to v2 protocol if openfn.yaml is present', async (t) => {
269312
);
270313
});
271314

315+
test.serial('deploy a v2 spec file', async (t) => {
316+
const testProjectV2 = `
317+
name: test-project
318+
schema_version: '4.0'
319+
workflows:
320+
- id: my-workflow
321+
name: My Workflow
322+
start: webhook
323+
steps:
324+
- id: webhook
325+
type: webhook
326+
enabled: true
327+
next:
328+
my-job: {}
329+
- id: my-job
330+
name: My Job
331+
expression: 'fn(s => s)'
332+
adaptor: '@openfn/language-common@latest'
333+
`.trim();
334+
335+
await fs.writeFile(path.join(tmpDir, 'project.yaml'), testProjectV2);
336+
337+
t.is(Object.keys(server.state.projects).length, 0);
338+
339+
const { stdout, stderr } = await run(
340+
`openfn deploy \
341+
--project-path ${tmpDir}/project.yaml \
342+
--state-path ${tmpDir}/.state.json \
343+
--no-confirm \
344+
--log-json \
345+
-l debug`
346+
);
347+
t.falsy(stderr);
348+
349+
const logs = extractLogs(stdout);
350+
assertLog(t, logs, /v2 spec/i);
351+
assertLog(t, logs, /Deployed/);
352+
353+
t.is(Object.keys(server.state.projects).length, 1);
354+
const [project] = Object.values(server.state.projects) as any[];
355+
t.is(project.name, 'test-project');
356+
const [workflow] = Object.values(project.workflows) as any[];
357+
t.is(workflow.name, 'My Workflow');
358+
});
359+
272360
test.serial('deploy then pull, changes one workflow, deploy', async (t) => {
273361
t.is(Object.keys(server.state.projects).length, 0);
274362

@@ -318,7 +406,6 @@ test.serial('deploy then pull, changes one workflow, deploy', async (t) => {
318406

319407
// And deploy those changes
320408
const { stdout, stderr } = await run(deployCmd);
321-
322409
t.falsy(stderr);
323410

324411
const logs = extractLogs(stdout);
@@ -332,3 +419,63 @@ test.serial('deploy then pull, changes one workflow, deploy', async (t) => {
332419
t.is(Object.keys(server.state.projects).length, 1);
333420
t.truthy(server.state.projects[projectId]);
334421
});
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: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { DeployOptions } from './command';
1010
import * as beta from '../projects/deploy';
1111
import path from 'node:path';
1212
import { fileExists } from '../util/file-exists';
13-
import { yamlToJson } from '@openfn/project';
13+
import Project, { detectVersion, yamlToJson } from '@openfn/project';
1414
import fs from 'node:fs/promises';
1515

1616
export type DeployFn = typeof deploy;
@@ -62,6 +62,15 @@ async function deployHandler(
6262
config.endpoint = process.env['OPENFN_ENDPOINT'];
6363
}
6464

65+
const rawSpec = await fs.readFile(config.specPath, 'utf-8');
66+
const convertedSpec = await maybeConvertV2spec(rawSpec);
67+
if (convertedSpec !== rawSpec) {
68+
logger.info(
69+
'Detected v2 spec file - converting to legacy format; validation will be skipped.'
70+
);
71+
config.spec = convertedSpec;
72+
}
73+
6574
logger.debug('Deploying with config', config);
6675
logger.info(`Deploying`);
6776

@@ -137,4 +146,16 @@ const redirectTov2 = async (
137146
);
138147
};
139148

149+
export const maybeConvertV2spec = async (yaml: string): Promise<string> => {
150+
const json = yamlToJson(yaml) as any;
151+
if (detectVersion(json) > 1) {
152+
const project = await Project.from('project', json);
153+
return project.serialize('state', {
154+
format: 'yaml',
155+
asSpec: true,
156+
}) as string;
157+
}
158+
return yaml;
159+
};
160+
140161
export default deployHandler;

0 commit comments

Comments
 (0)