Skip to content

Commit dd7832e

Browse files
Support channels in v2 sync (#1566)
* project: read and write channels via channels.yaml * project: fix sandbox merge dropping channels and keep remote channel ids on merge * cli: deploy channel changes and deletions, and clear stale channels.yaml on checkout * project: track removed channels on merge and send them as deletes Move channel delete detection out of the CLI deploy handler and into the project's merge step, so any caller of toAppState sends removed channels to Lightning as deletes. * project: store channels under a channels key in resources.yaml instead of channels.yaml * project: key resources.yaml channels by id with a required name * project: match channels by their resources.yaml id so renames keep the channel * Resource Diffs (#1572) * restructure project-diff * diff channels in project * log resource diff in CLI * changeset * one more changeset for the road * format * simplify --------- Co-authored-by: Joe Clark <joe@openfn.org>
1 parent 8f459f1 commit dd7832e

21 files changed

Lines changed: 728 additions & 40 deletions

‎.changeset/green-olives-cross.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openfn/cli': patch
3+
---
4+
5+
Show resource diffs on deploy
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openfn/project': patch
3+
---
4+
5+
Project diff now returns { workflows, resources } and includes channel changes
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@openfn/project': minor
3+
'@openfn/cli': minor
4+
'@openfn/lexicon': patch
5+
---
6+
7+
Manage project channels locally through a new resources.yaml file

‎packages/cli/src/projects/checkout.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ export const handler = async (options: CheckoutOptions, logger?: Logger) => {
131131
logger?.warn('WARNING! No content for file', f);
132132
}
133133
}
134+
// Remove any resources.yaml left over from the previous project, so its
135+
// channels don't get deployed to this one
136+
if (!files['resources.yaml']) {
137+
await rimraf(path.join(workspacePath, 'resources.yaml'));
138+
}
134139
if (options.createCredentials) {
135140
createProjectCredentials(workspacePath, switchProject, logger);
136141
}

‎packages/cli/src/projects/deploy.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import yargs from 'yargs';
22
import Project, {
33
MergeProjectOptions,
4+
toResourceChannels,
45
versionsEqual,
56
Workspace,
67
} from '@openfn/project';
8+
import { isEqual } from 'lodash-es';
79
import { writeFile } from 'node:fs/promises';
810
import path from 'node:path';
911

@@ -171,6 +173,15 @@ export const deletedCollections = (
171173
.map((c) => ({ id: c.uuid as string, name: c.name, delete: true }));
172174
};
173175

176+
// A missing resources.yaml (local.channels undefined) means channels aren't
177+
// managed locally, so there's nothing to change
178+
export const channelsChanged = (local: Project, remote: Project) =>
179+
!!local.channels &&
180+
!isEqual(
181+
toResourceChannels(local.channels, local.credentials),
182+
toResourceChannels(remote.channels, remote.credentials)
183+
);
184+
174185
export type SyncResult = {
175186
merged: Project;
176187
remoteProject: Project;
@@ -244,10 +255,11 @@ const syncProjects = async (
244255

245256
// TODO: what if remote diff and the version checked disagree for some reason?
246257
const workflowDiffs = mergeCandidates.length
247-
? remoteProject.diff(localProject, mergeCandidates)
258+
? remoteProject.diff(localProject, mergeCandidates).workflows
248259
: [];
249260
const didCollectionsChange = collectionsChanged(localProject, remoteProject);
250-
if (!workflowDiffs.length && !didCollectionsChange) {
261+
const didChannelsChange = channelsChanged(localProject, remoteProject);
262+
if (!workflowDiffs.length && !didCollectionsChange && !didChannelsChange) {
251263
logger.success('Nothing to deploy');
252264
return null;
253265
}

‎packages/cli/src/projects/diff.ts‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import c from 'chalk';
22
import Project, { generateStepDiff, generateEdgeDiff } from '@openfn/project';
3+
import type { ChannelDiff } from '@openfn/project';
34
import type { StepChange, EdgeChange } from '@openfn/project';
45
import type { Logger } from '../util/logger';
56

@@ -60,6 +61,26 @@ const printStepDiff = (steps: StepChange[], logger: Logger) => {
6061
}
6162
};
6263

64+
const printChannelDiff = (channels: ChannelDiff[], logger: Logger) => {
65+
logger.always('Channels:');
66+
for (const { name, type, changes } of channels) {
67+
if (type === 'added') {
68+
logger.always(c.green(` ${name}: added`));
69+
} else if (type === 'removed') {
70+
logger.always(c.red(` ${name}: removed`));
71+
} else {
72+
logger.always(c.yellow(` ${name}: changed`));
73+
for (const key in changes) {
74+
const { from, to } = changes[key];
75+
logger.always(
76+
c.yellow(` - ${key}: "${from ?? ''}" -> "${to ?? ''}"`)
77+
);
78+
}
79+
}
80+
}
81+
logger.break();
82+
};
83+
6384
// TODO need to include collection diffs
6485
// https://github.com/OpenFn/kit/issues/1524
6586
export const printRichDiff = (
@@ -68,8 +89,11 @@ export const printRichDiff = (
6889
locallyChangedWorkflows: string[],
6990
logger: Logger
7091
) => {
71-
const diffs = remote.diff(local, locallyChangedWorkflows);
72-
if (diffs.length === 0) {
92+
const { workflows: diffs, resources } = remote.diff(
93+
local,
94+
locallyChangedWorkflows
95+
);
96+
if (diffs.length === 0 && resources.channels.length === 0) {
7397
logger.info('No workflow changes detected');
7498
return diffs;
7599
}
@@ -112,5 +136,9 @@ export const printRichDiff = (
112136
logger.break();
113137
}
114138

139+
if (resources.channels?.length > 0) {
140+
printChannelDiff(resources.channels, logger);
141+
}
142+
115143
return diffs;
116144
};

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
hasRemoteDiverged,
1818
collectionsChanged,
1919
deletedCollections,
20+
channelsChanged,
2021
} from '../../src/projects/deploy';
2122
import { printRichDiff } from '../../src/projects/diff';
2223
import {
@@ -1117,3 +1118,96 @@ test('deletedCollections: nothing to delete when every remote name survives', (t
11171118

11181119
t.deepEqual(deletedCollections(merged, remote), []);
11191120
});
1121+
1122+
const channel = {
1123+
name: 'my-channel',
1124+
destination_url: 'https://example.com',
1125+
enabled: true,
1126+
};
1127+
1128+
test('channelsChanged: false when there is no local resources.yaml', (t) => {
1129+
const local = { channels: undefined } as unknown as Project;
1130+
const remote = {
1131+
channels: [{ ...channel, id: 'chan-1' }],
1132+
} as unknown as Project;
1133+
1134+
t.false(channelsChanged(local, remote));
1135+
});
1136+
1137+
test('channelsChanged: true when a channel is edited locally', (t) => {
1138+
const local = {
1139+
channels: [{ ...channel, enabled: false }],
1140+
} as unknown as Project;
1141+
const remote = {
1142+
channels: [{ ...channel, id: 'chan-1' }],
1143+
} as unknown as Project;
1144+
1145+
t.true(channelsChanged(local, remote));
1146+
});
1147+
1148+
test('channelsChanged: false when a credential name matches the remote uuid', (t) => {
1149+
const local = {
1150+
channels: [{ ...channel, destination_credential_id: 'me@openfn.org|cred' }],
1151+
} as unknown as Project;
1152+
const remote = {
1153+
credentials: [{ uuid: 'cred-uuid', name: 'cred', owner: 'me@openfn.org' }],
1154+
channels: [
1155+
{ ...channel, id: 'chan-1', destination_credential_id: 'cred-uuid' },
1156+
],
1157+
} as unknown as Project;
1158+
1159+
t.false(channelsChanged(local, remote));
1160+
});
1161+
1162+
const diffChannel = (name: string, extra = {}) => ({
1163+
name,
1164+
destination_url: `https://example.com/${name}`,
1165+
enabled: true,
1166+
...extra,
1167+
});
1168+
1169+
test('printRichDiff: should report a channel-only change', (t) => {
1170+
const local = new Project({
1171+
name: 'local',
1172+
workflows: [],
1173+
channels: [diffChannel('one')],
1174+
});
1175+
const remote = new Project({ name: 'remote', workflows: [], channels: [] });
1176+
1177+
printRichDiff(local, remote, [], logger);
1178+
1179+
t.truthy(logger._find('always', /following changes to the remote project/));
1180+
t.truthy(logger._find('always', /Channels:/));
1181+
t.truthy(logger._find('always', /one: added/));
1182+
});
1183+
1184+
test('printRichDiff: should report removed channels', (t) => {
1185+
const local = new Project({ name: 'local', workflows: [], channels: [] });
1186+
const remote = new Project({
1187+
name: 'remote',
1188+
workflows: [],
1189+
channels: [diffChannel('one')],
1190+
});
1191+
1192+
printRichDiff(local, remote, [], logger);
1193+
1194+
t.truthy(logger._find('always', /one: removed/));
1195+
});
1196+
1197+
test('printRichDiff: should list changed channel fields', (t) => {
1198+
const local = new Project({
1199+
name: 'local',
1200+
workflows: [],
1201+
channels: [diffChannel('one', { enabled: false })],
1202+
});
1203+
const remote = new Project({
1204+
name: 'remote',
1205+
workflows: [],
1206+
channels: [diffChannel('one')],
1207+
});
1208+
1209+
printRichDiff(local, remote, [], logger);
1210+
1211+
t.truthy(logger._find('always', /one: changed/));
1212+
t.truthy(logger._find('always', /enabled: "true" -> "false"/));
1213+
});

‎packages/lexicon/core.d.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { RawSourceMap } from 'source-map';
44
import {
55
Credential,
66
Collection,
7+
Channel,
78
Job,
89
Trigger,
910
ProjectSpec,
@@ -47,6 +48,10 @@ export interface ProjectState extends WithState<ProjectSpec, ProjectMeta> {
4748
// tracks each collection's uuid once it's been created on the server
4849
collections?: Array<CollectionState>;
4950

51+
// override channels - locally authored channels (channels.yaml) have no
52+
// id until they're deployed
53+
channels?: Array<ChannelState>;
54+
5055
/** Stuff only used by the CLI for this project */
5156
cli?: LocalMeta;
5257
}
@@ -131,6 +136,12 @@ export interface CollectionState extends Collection {
131136
uuid?: UUID;
132137
}
133138

139+
// Like step.configuration, destination_credential_id may hold a credential
140+
// name (owner|name) rather than a uuid until it's serialized for Lightning
141+
// `key` is the local id from resources.yaml, used to match channels on merge.
142+
// It's never sent to Lightning
143+
export type ChannelState = Omit<Channel, 'id'> & { id?: string; key?: string };
144+
134145
type FileFormats = 'yaml' | 'json';
135146

136147
// This is the old workspace config file, up to 0.6

‎packages/project/src/Project.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ export class Project {
7777

7878
collections?: Array<CollectionState>;
7979

80-
channels?: l.Channel[];
80+
channels?: l.ChannelState[];
81+
82+
// channels removed locally - to-app-state sends these as delete: true
83+
removedChannels: l.ChannelState[] = [];
8184

8285
credentials: Credential[];
8386

@@ -279,7 +282,7 @@ export class Project {
279282
return Object.values(creds);
280283
}
281284

282-
// Compare this project with another and return a list of workflow changes
285+
// Compare this project with another and return the changes
283286
diff(project: Project, workflows: string[] = []) {
284287
return projectDiff(this, project, workflows);
285288
}

‎packages/project/src/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ export { Workspace, yamlToJson, jsonToYaml };
99
export { generateWorkflow, generateProject } from './gen/generator';
1010

1111
export { diff } from './util/project-diff';
12-
export type { WorkflowDiff, DiffType } from './util/project-diff';
12+
export type {
13+
WorkflowDiff,
14+
ChannelDiff,
15+
ProjectDiff,
16+
DiffType,
17+
} from './util/project-diff';
1318

1419
export { generateStepDiff, generateEdgeDiff } from './util/workflow-diff';
1520
export type {
@@ -29,3 +34,5 @@ export { default as detectVersion } from './util/detect-version';
2934
export type { MergeProjectOptions } from './merge/merge-project';
3035

3136
export { mergeCollections } from './merge/merge-project';
37+
38+
export { toResourceChannels } from './util/resources';

0 commit comments

Comments
 (0)