codify-plugin-lib 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintignore +1 -0
- package/.eslintrc.json +23 -0
- package/.mocharc.json +11 -0
- package/.prettierrc.json +1 -0
- package/dist/entities/change-set.d.ts +15 -0
- package/dist/entities/change-set.js +88 -0
- package/dist/entities/plan.d.ts +11 -0
- package/dist/entities/plan.js +30 -0
- package/dist/entities/plugin.d.ts +12 -0
- package/dist/entities/plugin.js +35 -0
- package/dist/entities/resource.d.ts +18 -0
- package/dist/entities/resource.js +41 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +30 -0
- package/dist/messages/handlers.d.ts +11 -0
- package/dist/messages/handlers.js +81 -0
- package/dist/utils/test-utils.d.ts +5 -0
- package/dist/utils/test-utils.js +21 -0
- package/dist/utils/utils.d.ts +16 -0
- package/dist/utils/utils.js +16 -0
- package/package.json +49 -0
- package/src/entities/change-set.test.ts +100 -0
- package/src/entities/change-set.ts +117 -0
- package/src/entities/plan.ts +40 -0
- package/src/entities/plugin.ts +53 -0
- package/src/entities/resource.test.ts +146 -0
- package/src/entities/resource.ts +72 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +17 -0
- package/src/messages/handlers.test.ts +124 -0
- package/src/messages/handlers.ts +100 -0
- package/src/utils/test-utils.test.ts +52 -0
- package/src/utils/test-utils.ts +20 -0
- package/src/utils/utils.ts +33 -0
- package/tsconfig.json +25 -0
- package/tsconfig.test.json +9 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ParameterOperation, ResourceConfig, ResourceOperation } from 'codify-schemas';
|
|
2
|
+
|
|
3
|
+
export interface ParameterChange {
|
|
4
|
+
name: string;
|
|
5
|
+
operation: ParameterOperation;
|
|
6
|
+
previousValue: string | null;
|
|
7
|
+
newValue: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ChangeSet {
|
|
11
|
+
operation: ResourceOperation
|
|
12
|
+
parameterChanges: Array<ParameterChange>
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
operation: ResourceOperation,
|
|
16
|
+
parameterChanges: Array<ParameterChange>
|
|
17
|
+
) {
|
|
18
|
+
this.operation = operation;
|
|
19
|
+
this.parameterChanges = parameterChanges;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static createForNullCurrentConfig(desiredConfig: ResourceConfig) {
|
|
23
|
+
const parameterChangeSet = Object.entries(desiredConfig)
|
|
24
|
+
.filter(([k,]) => k !== 'type' && k !== 'name')
|
|
25
|
+
.map(([k, v]) => {
|
|
26
|
+
return {
|
|
27
|
+
name: k,
|
|
28
|
+
operation: ParameterOperation.ADD,
|
|
29
|
+
previousValue: null,
|
|
30
|
+
newValue: v,
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return new ChangeSet(ResourceOperation.CREATE, parameterChangeSet);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static calculateParameterChangeSet(prev: ResourceConfig, next: ResourceConfig): ParameterChange[] {
|
|
38
|
+
const parameterChangeSet = new Array<ParameterChange>();
|
|
39
|
+
|
|
40
|
+
const filteredPrev = Object.fromEntries(
|
|
41
|
+
Object.entries(prev)
|
|
42
|
+
.filter(([k,]) => k !== 'type' && k !== 'name')
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const filteredNext = Object.fromEntries(
|
|
46
|
+
Object.entries(next)
|
|
47
|
+
.filter(([k,]) => k !== 'type' && k !== 'name')
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
for (const [k, v] of Object.entries(filteredPrev)) {
|
|
51
|
+
if (!filteredNext[k]) {
|
|
52
|
+
parameterChangeSet.push({
|
|
53
|
+
name: k,
|
|
54
|
+
previousValue: v,
|
|
55
|
+
newValue: null,
|
|
56
|
+
operation: ParameterOperation.REMOVE,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
delete filteredPrev[k];
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (filteredPrev[k] !== filteredNext[k]) {
|
|
64
|
+
parameterChangeSet.push({
|
|
65
|
+
name: k,
|
|
66
|
+
previousValue: v,
|
|
67
|
+
newValue: filteredNext[k],
|
|
68
|
+
operation: ParameterOperation.MODIFY,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
delete filteredPrev[k];
|
|
72
|
+
delete filteredNext[k];
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
parameterChangeSet.push({
|
|
77
|
+
name: k,
|
|
78
|
+
previousValue: v,
|
|
79
|
+
newValue: filteredNext[k],
|
|
80
|
+
operation: ParameterOperation.NOOP,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
delete filteredPrev[k];
|
|
84
|
+
delete filteredNext[k];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (Object.keys(filteredPrev).length !== 0) {
|
|
88
|
+
throw Error('Diff algorithm error');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const [k, v] of Object.entries(filteredNext)) {
|
|
92
|
+
parameterChangeSet.push({
|
|
93
|
+
name: k,
|
|
94
|
+
previousValue: null,
|
|
95
|
+
newValue: v,
|
|
96
|
+
operation: ParameterOperation.ADD,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return parameterChangeSet;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
static combineResourceOperations(prev: ResourceOperation, next: ResourceOperation) {
|
|
104
|
+
const orderOfOperations = [
|
|
105
|
+
ResourceOperation.NOOP,
|
|
106
|
+
ResourceOperation.MODIFY,
|
|
107
|
+
ResourceOperation.RECREATE,
|
|
108
|
+
ResourceOperation.CREATE,
|
|
109
|
+
ResourceOperation.DESTROY,
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
const indexPrev = orderOfOperations.indexOf(prev);
|
|
113
|
+
const indexNext = orderOfOperations.indexOf(next);
|
|
114
|
+
|
|
115
|
+
return orderOfOperations[Math.max(indexPrev, indexNext)];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ChangeSet } from './change-set';
|
|
2
|
+
import {
|
|
3
|
+
PlanResponseData,
|
|
4
|
+
ResourceConfig,
|
|
5
|
+
} from 'codify-schemas';
|
|
6
|
+
import { randomUUID } from 'crypto';
|
|
7
|
+
|
|
8
|
+
export class Plan {
|
|
9
|
+
id: string;
|
|
10
|
+
changeSet: ChangeSet;
|
|
11
|
+
resourceConfig: ResourceConfig
|
|
12
|
+
|
|
13
|
+
constructor(id: string, changeSet: ChangeSet, resourceConfig: ResourceConfig) {
|
|
14
|
+
this.id = id;
|
|
15
|
+
this.changeSet = changeSet;
|
|
16
|
+
this.resourceConfig = resourceConfig;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
static create(changeSet: ChangeSet, resourceConfig: ResourceConfig) {
|
|
20
|
+
return new Plan(
|
|
21
|
+
randomUUID(),
|
|
22
|
+
changeSet,
|
|
23
|
+
resourceConfig,
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
getResourceType(): string {
|
|
28
|
+
return this.resourceConfig.type;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
toResponse(): PlanResponseData {
|
|
32
|
+
return {
|
|
33
|
+
planId: this.id,
|
|
34
|
+
operation: this.changeSet.operation,
|
|
35
|
+
resourceName: this.resourceConfig.name,
|
|
36
|
+
resourceType: this.resourceConfig.type,
|
|
37
|
+
parameters: this.changeSet.parameterChanges,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Resource } from './resource';
|
|
2
|
+
import {
|
|
3
|
+
ApplyRequestData,
|
|
4
|
+
PlanRequestData,
|
|
5
|
+
PlanResponseData,
|
|
6
|
+
ResourceConfig,
|
|
7
|
+
ValidateRequestData,
|
|
8
|
+
ValidateResponseData
|
|
9
|
+
} from 'codify-schemas';
|
|
10
|
+
|
|
11
|
+
export class Plugin {
|
|
12
|
+
planStorage: Map<string, any>;
|
|
13
|
+
|
|
14
|
+
constructor(
|
|
15
|
+
public resources: Map<string, Resource<ResourceConfig>>
|
|
16
|
+
) {
|
|
17
|
+
this.planStorage = new Map();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async onInitialize(): Promise<void> {
|
|
21
|
+
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async validate(data: ValidateRequestData): Promise<ValidateResponseData> {
|
|
25
|
+
for (const config of data.configs) {
|
|
26
|
+
if (!this.resources.has(config.type)) {
|
|
27
|
+
throw new Error(`Resource type not found: ${config.type}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await this.resources.get(config.type)!.validate(config);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
await this.crossValidateResources(data.configs);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async plan(data: PlanRequestData): Promise<PlanResponseData> {
|
|
38
|
+
if (!this.resources.has(data.type)) {
|
|
39
|
+
throw new Error(`Resource type not found: ${data.type}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const plan = await this.resources.get(data.type)!.plan(data);
|
|
43
|
+
this.planStorage.set(plan.id, plan);
|
|
44
|
+
|
|
45
|
+
return plan.toResponse();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async apply(data: ApplyRequestData): Promise<void> {
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
protected async crossValidateResources(configs: ResourceConfig[]): Promise<void> {}
|
|
52
|
+
|
|
53
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe } from 'mocha';
|
|
2
|
+
import { Resource } from './resource';
|
|
3
|
+
import { ResourceConfig, ResourceOperation } from 'codify-schemas';
|
|
4
|
+
import { ChangeSet, ParameterChange } from './change-set';
|
|
5
|
+
import { spy } from 'sinon';
|
|
6
|
+
import { expect } from 'chai';
|
|
7
|
+
|
|
8
|
+
class TestResource extends Resource<TestConfig> {
|
|
9
|
+
applyCreate(changeSet: ChangeSet): Promise<void> {
|
|
10
|
+
return Promise.resolve(undefined);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
applyDestroy(changeSet: ChangeSet): Promise<void> {
|
|
14
|
+
return Promise.resolve(undefined);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
applyModify(changeSet: ChangeSet): Promise<void> {
|
|
18
|
+
return Promise.resolve(undefined);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
applyRecreate(changeSet: ChangeSet): Promise<void> {
|
|
22
|
+
return Promise.resolve(undefined);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
calculateOperation(change: ParameterChange): ResourceOperation.RECREATE | ResourceOperation.MODIFY {
|
|
26
|
+
return ResourceOperation.MODIFY;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async getCurrentConfig(): Promise<TestConfig> {
|
|
30
|
+
return Promise.resolve(undefined);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
validate(config: ResourceConfig): Promise<boolean> {
|
|
34
|
+
return Promise.resolve(false);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
getTypeId(): string {
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface TestConfig extends ResourceConfig {
|
|
43
|
+
propA: string;
|
|
44
|
+
propB: number;
|
|
45
|
+
propC?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('Resource tests', () => {
|
|
49
|
+
it('plans correctly', async () => {
|
|
50
|
+
const resource = new class extends TestResource {
|
|
51
|
+
calculateOperation(change: ParameterChange): ResourceOperation.RECREATE | ResourceOperation.MODIFY {
|
|
52
|
+
return ResourceOperation.MODIFY;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async getCurrentConfig(): Promise<TestConfig> {
|
|
56
|
+
return {
|
|
57
|
+
type: 'type',
|
|
58
|
+
name: 'name',
|
|
59
|
+
propA: "propABefore",
|
|
60
|
+
propB: 10,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const resourceSpy = spy(resource);
|
|
66
|
+
const result = await resourceSpy.plan({
|
|
67
|
+
type: 'type',
|
|
68
|
+
name: 'name',
|
|
69
|
+
propA: 'propA',
|
|
70
|
+
propB: 10,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(result.resourceConfig).to.deep.eq({
|
|
74
|
+
type: 'type',
|
|
75
|
+
name: 'name',
|
|
76
|
+
propA: 'propA',
|
|
77
|
+
propB: 10,
|
|
78
|
+
});
|
|
79
|
+
expect(result.changeSet.operation).to.eq(ResourceOperation.MODIFY);
|
|
80
|
+
expect(result.changeSet.parameterChanges[0]).to.deep.eq({
|
|
81
|
+
name: 'propA',
|
|
82
|
+
previousValue: 'propABefore',
|
|
83
|
+
newValue: 'propA',
|
|
84
|
+
operation: 'modify'
|
|
85
|
+
})
|
|
86
|
+
expect(result.changeSet.parameterChanges[1]).to.deep.eq( {
|
|
87
|
+
name: 'propB',
|
|
88
|
+
previousValue: 10,
|
|
89
|
+
newValue: 10,
|
|
90
|
+
operation: 'noop'
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('calls calculateOperation for only modifications and recreates', async () => {
|
|
95
|
+
const resource = new class extends TestResource {
|
|
96
|
+
calculateOperation(change: ParameterChange): ResourceOperation.RECREATE | ResourceOperation.MODIFY {
|
|
97
|
+
return ResourceOperation.MODIFY;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async getCurrentConfig(): Promise<TestConfig> {
|
|
101
|
+
return {
|
|
102
|
+
type: 'type',
|
|
103
|
+
name: 'name',
|
|
104
|
+
propA: "propABefore",
|
|
105
|
+
propB: 10,
|
|
106
|
+
propC: 'somethingBefore'
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const resourceSpy = spy(resource);
|
|
112
|
+
await resourceSpy.plan({
|
|
113
|
+
type: 'type',
|
|
114
|
+
name: 'name',
|
|
115
|
+
propA: 'propA',
|
|
116
|
+
propB: 10,
|
|
117
|
+
propC: 'somethingAfter'
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(resourceSpy.calculateOperation.calledTwice).to.be.true;
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('creates the resource if it doesnt exist', async () => {
|
|
124
|
+
const resource = new class extends TestResource {
|
|
125
|
+
calculateOperation(change: ParameterChange): ResourceOperation.RECREATE | ResourceOperation.MODIFY {
|
|
126
|
+
return ResourceOperation.MODIFY;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async getCurrentConfig(): Promise<TestConfig> {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const resourceSpy = spy(resource);
|
|
135
|
+
const result = await resourceSpy.plan({
|
|
136
|
+
type: 'type',
|
|
137
|
+
name: 'name',
|
|
138
|
+
propA: 'propA',
|
|
139
|
+
propB: 10,
|
|
140
|
+
propC: 'somethingAfter'
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
expect(result.changeSet.operation).to.eq(ResourceOperation.CREATE);
|
|
144
|
+
expect(result.changeSet.parameterChanges.length).to.eq(3);
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { ParameterOperation, ResourceConfig, ResourceOperation } from 'codify-schemas';
|
|
2
|
+
import { ChangeSet, ParameterChange } from './change-set';
|
|
3
|
+
import { Plan } from './plan';
|
|
4
|
+
|
|
5
|
+
export abstract class Resource<T extends ResourceConfig> {
|
|
6
|
+
|
|
7
|
+
constructor(
|
|
8
|
+
private dependencies: Resource<any>[] = [],
|
|
9
|
+
) {}
|
|
10
|
+
|
|
11
|
+
abstract getTypeId(): string;
|
|
12
|
+
|
|
13
|
+
async onInitialize(): Promise<void> {}
|
|
14
|
+
|
|
15
|
+
// TODO: Add state in later.
|
|
16
|
+
// Calculate change set from current config -> state -> desired in the future
|
|
17
|
+
async plan(desiredConfig: T): Promise<Plan> {
|
|
18
|
+
await this.validate(desiredConfig);
|
|
19
|
+
|
|
20
|
+
const currentConfig = await this.getCurrentConfig();
|
|
21
|
+
if (!currentConfig) {
|
|
22
|
+
return Plan.create(ChangeSet.createForNullCurrentConfig(desiredConfig), desiredConfig);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// TODO: After adding in state files, need to calculate deletes here
|
|
26
|
+
// Where current config exists and state config exists but desired config doesn't
|
|
27
|
+
|
|
28
|
+
// Explanation: This calculates the change set of the parameters between the
|
|
29
|
+
// two configs and then passes it to the subclass to calculate the overall
|
|
30
|
+
// operation for the resource
|
|
31
|
+
const parameterChangeSet = ChangeSet.calculateParameterChangeSet(currentConfig, desiredConfig);
|
|
32
|
+
const resourceOperation = parameterChangeSet
|
|
33
|
+
.filter((change) => change.operation !== ParameterOperation.NOOP)
|
|
34
|
+
.reduce((operation: ResourceOperation, curr: ParameterChange) => {
|
|
35
|
+
const newOperation = this.calculateOperation(curr);
|
|
36
|
+
return ChangeSet.combineResourceOperations(operation, newOperation);
|
|
37
|
+
}, ResourceOperation.NOOP);
|
|
38
|
+
|
|
39
|
+
return Plan.create(
|
|
40
|
+
new ChangeSet(resourceOperation, parameterChangeSet),
|
|
41
|
+
desiredConfig
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async apply(plan: Plan): Promise<any> {
|
|
46
|
+
if (plan.getResourceType()) {
|
|
47
|
+
throw new Error('Internal error: Plan set to wrong resource during apply');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const changeSet = plan.changeSet;
|
|
51
|
+
switch (plan.changeSet.operation) {
|
|
52
|
+
case ResourceOperation.CREATE: return this.applyCreate(changeSet);
|
|
53
|
+
case ResourceOperation.MODIFY: return this.applyModify(changeSet);
|
|
54
|
+
case ResourceOperation.RECREATE: return this.applyRecreate(changeSet);
|
|
55
|
+
case ResourceOperation.DESTROY: return this.applyDestroy(changeSet);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
abstract validate(config: ResourceConfig): Promise<boolean>;
|
|
60
|
+
|
|
61
|
+
abstract getCurrentConfig(): Promise<T | null>;
|
|
62
|
+
|
|
63
|
+
abstract calculateOperation(change: ParameterChange): ResourceOperation.MODIFY | ResourceOperation.RECREATE;
|
|
64
|
+
|
|
65
|
+
abstract applyCreate(changeSet: ChangeSet): Promise<void>;
|
|
66
|
+
|
|
67
|
+
abstract applyModify(changeSet: ChangeSet): Promise<void>;
|
|
68
|
+
|
|
69
|
+
abstract applyRecreate(changeSet: ChangeSet): Promise<void>;
|
|
70
|
+
|
|
71
|
+
abstract applyDestroy(changeSet: ChangeSet): Promise<void>;
|
|
72
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Plugin } from './entities/plugin';
|
|
2
|
+
import { MessageHandler } from './messages/handlers';
|
|
3
|
+
|
|
4
|
+
export * from './entities/resource'
|
|
5
|
+
export * from './entities/plugin'
|
|
6
|
+
export * from './entities/change-set'
|
|
7
|
+
export * from './entities/plan'
|
|
8
|
+
export * from './utils/test-utils'
|
|
9
|
+
export * from './utils/utils'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
export async function runPlugin(plugin: Plugin) {
|
|
13
|
+
await plugin.onInitialize();
|
|
14
|
+
|
|
15
|
+
const messageHandler = new MessageHandler(plugin);
|
|
16
|
+
process.on('message', (message) => messageHandler.onMessage(message))
|
|
17
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { MessageHandler } from './handlers';
|
|
2
|
+
import { createStubInstance } from 'sinon';
|
|
3
|
+
import { Plugin } from '../entities/plugin';
|
|
4
|
+
import { expect } from 'chai';
|
|
5
|
+
|
|
6
|
+
describe('Message handler tests', () => {
|
|
7
|
+
it('handles plan requests', async () => {
|
|
8
|
+
const plugin = createStubInstance(Plugin);
|
|
9
|
+
const handler = new MessageHandler(plugin);
|
|
10
|
+
|
|
11
|
+
// Message handler also validates the response. That part does not need to be tested
|
|
12
|
+
try {
|
|
13
|
+
await handler.onMessage({
|
|
14
|
+
cmd: 'plan',
|
|
15
|
+
data: {
|
|
16
|
+
type: 'resourceType',
|
|
17
|
+
name: 'name',
|
|
18
|
+
prop1: 'A',
|
|
19
|
+
prop2: 'B',
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
} catch (e) {}
|
|
23
|
+
|
|
24
|
+
expect(plugin.plan.calledOnce).to.be.true;
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('rejects bad plan requests', async () => {
|
|
28
|
+
const plugin = createStubInstance(Plugin);
|
|
29
|
+
const handler = new MessageHandler(plugin);
|
|
30
|
+
|
|
31
|
+
// Message handler also validates the response. That part does not need to be tested
|
|
32
|
+
try {
|
|
33
|
+
await handler.onMessage({
|
|
34
|
+
cmd: 'plan',
|
|
35
|
+
data: {
|
|
36
|
+
type: 'resourceType',
|
|
37
|
+
name: '1name',
|
|
38
|
+
prop1: 'A',
|
|
39
|
+
prop2: 'B',
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
} catch (e) {}
|
|
43
|
+
|
|
44
|
+
expect(plugin.plan.called).to.be.false;
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('handles apply requests', async () => {
|
|
48
|
+
const plugin = createStubInstance(Plugin);
|
|
49
|
+
const handler = new MessageHandler(plugin);
|
|
50
|
+
|
|
51
|
+
// Message handler also validates the response. That part does not need to be tested
|
|
52
|
+
try {
|
|
53
|
+
await handler.onMessage({
|
|
54
|
+
cmd: 'apply',
|
|
55
|
+
data: {
|
|
56
|
+
planId: '1803fff7-a378-4006-95bb-7c97cba02c82'
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
} catch (e) {}
|
|
60
|
+
|
|
61
|
+
expect(plugin.apply.calledOnce).to.be.true;
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('rejects bad plan requests', async () => {
|
|
65
|
+
const plugin = createStubInstance(Plugin);
|
|
66
|
+
const handler = new MessageHandler(plugin);
|
|
67
|
+
|
|
68
|
+
// Message handler also validates the response. That part does not need to be tested
|
|
69
|
+
try {
|
|
70
|
+
await handler.onMessage({
|
|
71
|
+
cmd: 'apply',
|
|
72
|
+
data: {}
|
|
73
|
+
})
|
|
74
|
+
} catch (e) {}
|
|
75
|
+
|
|
76
|
+
expect(plugin.apply.called).to.be.false;
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('handles validate requests', async () => {
|
|
80
|
+
const plugin = createStubInstance(Plugin);
|
|
81
|
+
const handler = new MessageHandler(plugin);
|
|
82
|
+
|
|
83
|
+
// Message handler also validates the response. That part does not need to be tested
|
|
84
|
+
try {
|
|
85
|
+
await handler.onMessage({
|
|
86
|
+
cmd: 'validate',
|
|
87
|
+
data: {
|
|
88
|
+
configs: [
|
|
89
|
+
{
|
|
90
|
+
type: 'type1',
|
|
91
|
+
name: 'name1'
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
type: 'type2',
|
|
95
|
+
name: 'name2'
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: 'type2',
|
|
99
|
+
name: 'name3'
|
|
100
|
+
}
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
} catch (e) {}
|
|
105
|
+
|
|
106
|
+
expect(plugin.validate.calledOnce).to.be.true;
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('rejects bad validate requests', async () => {
|
|
110
|
+
const plugin = createStubInstance(Plugin);
|
|
111
|
+
const handler = new MessageHandler(plugin);
|
|
112
|
+
|
|
113
|
+
// Message handler also validates the response. That part does not need to be tested
|
|
114
|
+
try {
|
|
115
|
+
await handler.onMessage({
|
|
116
|
+
cmd: 'validate',
|
|
117
|
+
data: {}
|
|
118
|
+
})
|
|
119
|
+
} catch (e) {}
|
|
120
|
+
|
|
121
|
+
expect(plugin.apply.called).to.be.false;
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import Ajv2020, { SchemaObject, ValidateFunction } from 'ajv/dist/2020';
|
|
2
|
+
import { Plugin } from '../entities/plugin';
|
|
3
|
+
import addFormats from 'ajv-formats';
|
|
4
|
+
import {
|
|
5
|
+
IpcMessage,
|
|
6
|
+
IpcMessageSchema,
|
|
7
|
+
ResourceSchema,
|
|
8
|
+
ValidateRequestDataSchema,
|
|
9
|
+
ValidateResponseDataSchema,
|
|
10
|
+
MessageStatus,
|
|
11
|
+
PlanRequestDataSchema,
|
|
12
|
+
PlanResponseDataSchema,
|
|
13
|
+
ApplyRequestDataSchema
|
|
14
|
+
} from 'codify-schemas';
|
|
15
|
+
|
|
16
|
+
const SupportedRequests: Record<string, { requestValidator: SchemaObject; responseValidator: SchemaObject; handler: (plugin: Plugin, data: any) => Promise<unknown> }> = {
|
|
17
|
+
'validate': {
|
|
18
|
+
requestValidator: ValidateRequestDataSchema,
|
|
19
|
+
responseValidator: ValidateResponseDataSchema,
|
|
20
|
+
handler: async (plugin: Plugin, data: any) => plugin.validate(data)
|
|
21
|
+
},
|
|
22
|
+
'plan': {
|
|
23
|
+
requestValidator: PlanRequestDataSchema,
|
|
24
|
+
responseValidator: PlanResponseDataSchema,
|
|
25
|
+
handler: async (plugin: Plugin, data: any) => plugin.plan(data)
|
|
26
|
+
},
|
|
27
|
+
'apply': {
|
|
28
|
+
requestValidator: ApplyRequestDataSchema,
|
|
29
|
+
responseValidator: ApplyRequestDataSchema, // Replace with response validator
|
|
30
|
+
handler: async (plugin: Plugin, data: any) => plugin.apply(data)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class MessageHandler {
|
|
35
|
+
private ajv: Ajv2020;
|
|
36
|
+
private readonly plugin: Plugin;
|
|
37
|
+
private messageSchemaValidator: ValidateFunction;
|
|
38
|
+
private requestValidators: Map<string, ValidateFunction>;
|
|
39
|
+
private responseValidators: Map<string, ValidateFunction>;
|
|
40
|
+
|
|
41
|
+
constructor(plugin: Plugin) {
|
|
42
|
+
this.ajv = new Ajv2020({ strict: true });
|
|
43
|
+
addFormats(this.ajv);
|
|
44
|
+
this.ajv.addSchema(ResourceSchema);
|
|
45
|
+
this.plugin = plugin;
|
|
46
|
+
|
|
47
|
+
this.messageSchemaValidator = this.ajv.compile(IpcMessageSchema);
|
|
48
|
+
this.requestValidators = new Map(
|
|
49
|
+
Object.entries(SupportedRequests)
|
|
50
|
+
.map(([k, v]) => [k, this.ajv.compile(v.requestValidator)])
|
|
51
|
+
)
|
|
52
|
+
this.responseValidators = new Map(
|
|
53
|
+
Object.entries(SupportedRequests)
|
|
54
|
+
.map(([k, v]) => [k, this.ajv.compile(v.responseValidator)])
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async onMessage(message: unknown): Promise<void> {
|
|
59
|
+
if (!this.validateMessage(message)) {
|
|
60
|
+
throw new Error(`Message is malformed: ${JSON.stringify(this.messageSchemaValidator.errors, null, 2)}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!this.requestValidators.has(message.cmd)) {
|
|
64
|
+
throw new Error(`Unsupported message: ${message.cmd}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const requestValidator = this.requestValidators.get(message.cmd)!;
|
|
68
|
+
if (!requestValidator(message.data)) {
|
|
69
|
+
throw new Error(`Malformed message data: ${JSON.stringify(requestValidator.errors, null, 2)}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let result: unknown;
|
|
73
|
+
try {
|
|
74
|
+
result = await SupportedRequests[message.cmd].handler(this.plugin, message.data);
|
|
75
|
+
} catch(e: any) {
|
|
76
|
+
process.send!({
|
|
77
|
+
cmd: message.cmd + '_Response',
|
|
78
|
+
status: MessageStatus.ERROR,
|
|
79
|
+
data: e.message,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const responseValidator = this.responseValidators.get(message.cmd);
|
|
86
|
+
if (responseValidator && !responseValidator(result)) {
|
|
87
|
+
throw new Error(`Malformed response data: ${JSON.stringify(responseValidator.errors, null, 2)}`)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
process.send!({
|
|
91
|
+
cmd: message.cmd + '_Response',
|
|
92
|
+
status: MessageStatus.SUCCESS,
|
|
93
|
+
data: result,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private validateMessage(message: unknown): message is IpcMessage {
|
|
98
|
+
return this.messageSchemaValidator(message);
|
|
99
|
+
}
|
|
100
|
+
}
|