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.
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ import { describe } from 'mocha';
2
+
3
+ describe('Library integration tests', () => {
4
+ it('does something', async () => {
5
+ })
6
+ });
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
+ }