codify-plugin-lib 1.0.63 → 1.0.65

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.
@@ -1,4 +1,11 @@
1
+ import { Plan } from './plan.js';
2
+ import { StringIndexedObject } from 'codify-schemas';
1
3
  export declare class SudoError extends Error {
2
4
  command: string;
3
5
  constructor(command: string);
4
6
  }
7
+ export declare class ApplyValidationError<T extends StringIndexedObject> extends Error {
8
+ desiredPlan: Plan<T>;
9
+ validatedPlan: Plan<T>;
10
+ constructor(desiredPlan: Plan<T>, validatedPlan: Plan<T>);
11
+ }
@@ -5,3 +5,12 @@ export class SudoError extends Error {
5
5
  this.command = command;
6
6
  }
7
7
  }
8
+ export class ApplyValidationError extends Error {
9
+ desiredPlan;
10
+ validatedPlan;
11
+ constructor(desiredPlan, validatedPlan) {
12
+ super();
13
+ this.desiredPlan = desiredPlan;
14
+ this.validatedPlan = validatedPlan;
15
+ }
16
+ }
@@ -1,5 +1,5 @@
1
1
  export interface ParameterOptions {
2
- canModify?: boolean;
2
+ modifyOnChange?: boolean;
3
3
  isEqual?: (desired: any, current: any) => boolean;
4
4
  isElementEqual?: (desired: any, current: any) => boolean;
5
5
  default?: unknown;
@@ -8,7 +8,7 @@ export declare class Plan<T extends StringIndexedObject> {
8
8
  constructor(id: string, changeSet: ChangeSet<T>, resourceMetadata: ResourceConfig);
9
9
  static create<T extends StringIndexedObject>(desiredParameters: Partial<T> | null, currentParameters: Partial<T> | null, resourceMetadata: ResourceConfig, options: PlanOptions<T>): Plan<T>;
10
10
  getResourceType(): string;
11
- static fromResponse<T extends ResourceConfig>(data: ApplyRequestData['plan'], defaultValues: Partial<Record<keyof T, unknown>>): Plan<T>;
11
+ static fromResponse<T extends ResourceConfig>(data: ApplyRequestData['plan'], defaultValues?: Partial<Record<keyof T, unknown>>): Plan<T>;
12
12
  get desiredConfig(): T;
13
13
  get currentConfig(): T;
14
14
  toResponse(): PlanResponseData;
@@ -31,8 +31,8 @@ export class Plan {
31
31
  if (statefulParameterNames.has(curr.name)) {
32
32
  newOperation = ResourceOperation.MODIFY;
33
33
  }
34
- else if (parameterOptions[curr.name]?.canModify) {
35
- newOperation = parameterOptions[curr.name].canModify ? ResourceOperation.MODIFY : ResourceOperation.RECREATE;
34
+ else if (parameterOptions[curr.name]?.modifyOnChange) {
35
+ newOperation = parameterOptions[curr.name].modifyOnChange ? ResourceOperation.MODIFY : ResourceOperation.RECREATE;
36
36
  }
37
37
  else {
38
38
  newOperation = ResourceOperation.RECREATE;
@@ -55,7 +55,7 @@ export class Plan {
55
55
  name: data.resourceName,
56
56
  });
57
57
  function addDefaultValues() {
58
- Object.entries(defaultValues)
58
+ Object.entries(defaultValues ?? {})
59
59
  .forEach(([key, defaultValue]) => {
60
60
  const configValueExists = data
61
61
  .parameters
@@ -4,7 +4,8 @@ import { Plan } from './plan.js';
4
4
  export declare class Plugin {
5
5
  name: string;
6
6
  resources: Map<string, Resource<ResourceConfig>>;
7
- planStorage: Map<string, Plan<ResourceConfig>>;
7
+ planStorage: Map<string, Plan<any>>;
8
+ static create(name: string, resources: Resource<any>[]): Plugin;
8
9
  constructor(name: string, resources: Map<string, Resource<ResourceConfig>>);
9
10
  initialize(): Promise<InitializeResponseData>;
10
11
  validate(data: ValidateRequestData): Promise<ValidateResponseData>;
@@ -1,9 +1,15 @@
1
+ import { ResourceOperation } from 'codify-schemas';
1
2
  import { Plan } from './plan.js';
2
3
  import { splitUserConfig } from '../utils/utils.js';
4
+ import { ApplyValidationError } from './errors.js';
3
5
  export class Plugin {
4
6
  name;
5
7
  resources;
6
8
  planStorage;
9
+ static create(name, resources) {
10
+ const resourceMap = new Map(resources.map((r) => [r.typeId, r]));
11
+ return new Plugin(name, resourceMap);
12
+ }
7
13
  constructor(name, resources) {
8
14
  this.name = name;
9
15
  this.resources = resources;
@@ -58,6 +64,10 @@ export class Plugin {
58
64
  throw new Error('Malformed plan with resource that cannot be found');
59
65
  }
60
66
  await resource.apply(plan);
67
+ const validationPlan = await resource.plan({ ...plan.resourceMetadata, ...plan.desiredConfig });
68
+ if (validationPlan.changeSet.operation !== ResourceOperation.NOOP) {
69
+ throw new ApplyValidationError(plan, validationPlan);
70
+ }
61
71
  }
62
72
  resolvePlan(data) {
63
73
  const { planId, plan: planRequest } = data;
@@ -1,6 +1,6 @@
1
1
  export type ErrorMessage = string;
2
2
  export interface ResourceParameterOptions {
3
- canModify?: boolean;
3
+ modifyOnChange?: boolean;
4
4
  isEqual?: (desired: any, current: any) => boolean;
5
5
  default?: unknown;
6
6
  }
@@ -19,7 +19,7 @@ export declare abstract class StatefulParameter<T extends StringIndexedObject, V
19
19
  }
20
20
  export declare abstract class ArrayStatefulParameter<T extends StringIndexedObject, V> extends StatefulParameter<T, any> {
21
21
  options: ArrayStatefulParameterOptions<V>;
22
- constructor(options: ArrayStatefulParameterOptions<V>);
22
+ constructor(options?: ArrayStatefulParameterOptions<V>);
23
23
  applyAdd(valuesToAdd: V[], plan: Plan<T>): Promise<void>;
24
24
  applyModify(newValues: V[], previousValues: V[], allowDeletes: boolean, plan: Plan<T>): Promise<void>;
25
25
  applyRemove(valuesToRemove: V[], plan: Plan<T>): Promise<void>;
@@ -6,7 +6,7 @@ export class StatefulParameter {
6
6
  }
7
7
  export class ArrayStatefulParameter extends StatefulParameter {
8
8
  options;
9
- constructor(options) {
9
+ constructor(options = {}) {
10
10
  super(options);
11
11
  this.options = options;
12
12
  }
@@ -1,7 +1,7 @@
1
1
  import addFormats from 'ajv-formats';
2
2
  import { ApplyRequestDataSchema, ApplyResponseDataSchema, InitializeRequestDataSchema, InitializeResponseDataSchema, IpcMessageSchema, MessageStatus, PlanRequestDataSchema, PlanResponseDataSchema, ResourceSchema, ValidateRequestDataSchema, ValidateResponseDataSchema } from 'codify-schemas';
3
3
  import Ajv2020 from 'ajv/dist/2020.js';
4
- import { SudoError } from '../entities/errors.js';
4
+ import { ApplyValidationError, SudoError } from '../entities/errors.js';
5
5
  const SupportedRequests = {
6
6
  'initialize': {
7
7
  requestValidator: InitializeRequestDataSchema,
@@ -83,12 +83,26 @@ export class MessageHandler {
83
83
  }
84
84
  const cmd = message.cmd + '_Response';
85
85
  if (e instanceof SudoError) {
86
- process.send?.({
86
+ return process.send?.({
87
87
  cmd,
88
88
  status: MessageStatus.ERROR,
89
89
  data: `Plugin: '${this.plugin.name}'. Forbidden usage of sudo for command '${e.command}'. Please contact the plugin developer to fix this.`,
90
90
  });
91
91
  }
92
+ if (e instanceof ApplyValidationError) {
93
+ return process.send?.({
94
+ cmd,
95
+ status: MessageStatus.ERROR,
96
+ data: `Plugin: '${this.plugin.name}'. Apply validation was not successful (additional changes are needed to match the desired plan).
97
+
98
+ Validation plan:
99
+ ${JSON.stringify(e.validatedPlan, null, 2)},
100
+
101
+ User desired plan:
102
+ ${JSON.stringify(e.desiredPlan, null, 2)}
103
+ `
104
+ });
105
+ }
92
106
  const isDebug = process.env.DEBUG?.includes('*') ?? false;
93
107
  process.send?.({
94
108
  cmd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codify-plugin-lib",
3
- "version": "1.0.63",
3
+ "version": "1.0.65",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -1,3 +1,6 @@
1
+ import { Plan } from './plan.js';
2
+ import { StringIndexedObject } from 'codify-schemas';
3
+
1
4
  export class SudoError extends Error {
2
5
  command: string;
3
6
 
@@ -6,3 +9,17 @@ export class SudoError extends Error {
6
9
  this.command = command;
7
10
  }
8
11
  }
12
+
13
+ export class ApplyValidationError<T extends StringIndexedObject> extends Error {
14
+ desiredPlan: Plan<T>;
15
+ validatedPlan: Plan<T>;
16
+
17
+ constructor(
18
+ desiredPlan: Plan<T>,
19
+ validatedPlan: Plan<T>
20
+ ) {
21
+ super();
22
+ this.desiredPlan = desiredPlan;
23
+ this.validatedPlan = validatedPlan;
24
+ }
25
+ }
@@ -5,7 +5,7 @@ export interface ParameterOptions {
5
5
  /**
6
6
  * Chose if the resource should be re-created or modified if this parameter is changed. Defaults to false (re-creates resource on change).
7
7
  */
8
- canModify?: boolean;
8
+ modifyOnChange?: boolean;
9
9
  /**
10
10
  * Customize the equality comparison for a parameter.
11
11
  * @param a
@@ -55,8 +55,8 @@ export class Plan<T extends StringIndexedObject> {
55
55
  let newOperation: ResourceOperation;
56
56
  if (statefulParameterNames.has(curr.name)) {
57
57
  newOperation = ResourceOperation.MODIFY // All stateful parameters are modify only
58
- } else if (parameterOptions[curr.name]?.canModify) {
59
- newOperation = parameterOptions[curr.name].canModify ? ResourceOperation.MODIFY : ResourceOperation.RECREATE;
58
+ } else if (parameterOptions[curr.name]?.modifyOnChange) {
59
+ newOperation = parameterOptions[curr.name].modifyOnChange ? ResourceOperation.MODIFY : ResourceOperation.RECREATE;
60
60
  } else {
61
61
  newOperation = ResourceOperation.RECREATE; // Default to Re-create. Should handle the majority of use cases
62
62
  }
@@ -75,7 +75,7 @@ export class Plan<T extends StringIndexedObject> {
75
75
  return this.resourceMetadata.type
76
76
  }
77
77
 
78
- static fromResponse<T extends ResourceConfig>(data: ApplyRequestData['plan'], defaultValues: Partial<Record<keyof T, unknown>>): Plan<T> {
78
+ static fromResponse<T extends ResourceConfig>(data: ApplyRequestData['plan'], defaultValues?: Partial<Record<keyof T, unknown>>): Plan<T> {
79
79
  if (!data) {
80
80
  throw new Error('Data is empty');
81
81
  }
@@ -95,7 +95,7 @@ export class Plan<T extends StringIndexedObject> {
95
95
  );
96
96
 
97
97
  function addDefaultValues(): void {
98
- Object.entries(defaultValues)
98
+ Object.entries(defaultValues ?? {})
99
99
  .forEach(([key, defaultValue]) => {
100
100
  const configValueExists = data!
101
101
  .parameters
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Plugin } from './plugin.js';
3
+ import { ParameterOperation, ResourceOperation, StringIndexedObject } from 'codify-schemas';
4
+ import { Resource } from './resource.js';
5
+ import { Plan } from './plan.js';
6
+ import { ValidationResult } from './resource-types.js';
7
+ import { ApplyValidationError } from './errors.js';
8
+
9
+ interface TestConfig extends StringIndexedObject {
10
+ propA: string;
11
+ propB: number;
12
+ propC?: string;
13
+ }
14
+
15
+ class TestResource extends Resource<TestConfig> {
16
+ constructor() {
17
+ super({
18
+ type: 'testResource'
19
+ });
20
+ }
21
+
22
+ applyCreate(plan: Plan<TestConfig>): Promise<void> {
23
+ return Promise.resolve(undefined);
24
+ }
25
+
26
+ applyDestroy(plan: Plan<TestConfig>): Promise<void> {
27
+ return Promise.resolve(undefined);
28
+ }
29
+
30
+ async refresh(keys: Map<string, unknown>): Promise<Partial<TestConfig> | null> {
31
+ return {
32
+ propA: 'a',
33
+ propB: 10,
34
+ propC: 'c',
35
+ };
36
+ }
37
+
38
+ async validateResource(config: unknown): Promise<ValidationResult> {
39
+ return {
40
+ isValid: true
41
+ }
42
+ }
43
+ }
44
+
45
+ describe('Plugin tests', () => {
46
+ it('Validates that applies were successfully applied', async () => {
47
+ const resource = new class extends TestResource {
48
+ async applyCreate(plan: Plan<TestConfig>): Promise<void> {
49
+ }
50
+
51
+ // Refresh has to line up with desired for the apply to go through
52
+ async refresh(keys: Map<string, unknown>): Promise<Partial<TestConfig> | null> {
53
+ return {
54
+ propA: 'abc'
55
+ }
56
+ }
57
+ }
58
+
59
+ const testPlugin = Plugin.create('testPlugin', [resource])
60
+
61
+ const desiredPlan = {
62
+ operation: ResourceOperation.CREATE,
63
+ resourceType: 'testResource',
64
+ parameters: [
65
+ { name: 'propA', operation: ParameterOperation.ADD, newValue: 'abc', previousValue: null },
66
+ ]
67
+ };
68
+
69
+ // If this doesn't throw then it passes the test
70
+ await testPlugin.apply({ plan: desiredPlan });
71
+ });
72
+
73
+ it('Validates that applies were successfully applied (error)', async () => {
74
+ const resource = new class extends TestResource {
75
+ async applyCreate(plan: Plan<TestConfig>): Promise<void> {
76
+ }
77
+
78
+ // Return null to indicate that the resource was not created
79
+ async refresh(keys: Map<string, unknown>): Promise<Partial<TestConfig> | null> {
80
+ return null;
81
+ }
82
+ }
83
+
84
+ const testPlugin = Plugin.create('testPlugin', [resource])
85
+
86
+ const desiredPlan = {
87
+ operation: ResourceOperation.CREATE,
88
+ resourceType: 'testResource',
89
+ parameters: [
90
+ { name: 'propA', operation: ParameterOperation.ADD, newValue: 'abc', previousValue: null },
91
+ ]
92
+ };
93
+
94
+ await expect(async () => testPlugin.apply({ plan: desiredPlan })).rejects.toThrowError(expect.any(ApplyValidationError));
95
+ });
96
+ });
@@ -5,14 +5,24 @@ import {
5
5
  PlanRequestData,
6
6
  PlanResponseData,
7
7
  ResourceConfig,
8
+ ResourceOperation,
8
9
  ValidateRequestData,
9
10
  ValidateResponseData
10
11
  } from 'codify-schemas';
11
12
  import { Plan } from './plan.js';
12
13
  import { splitUserConfig } from '../utils/utils.js';
14
+ import { ApplyValidationError } from './errors.js';
13
15
 
14
16
  export class Plugin {
15
- planStorage: Map<string, Plan<ResourceConfig>>;
17
+ planStorage: Map<string, Plan<any>>;
18
+
19
+ static create(name: string, resources: Resource<any>[]) {
20
+ const resourceMap = new Map<string, Resource<any>>(
21
+ resources.map((r) => [r.typeId, r] as const)
22
+ );
23
+
24
+ return new Plugin(name, resourceMap);
25
+ }
16
26
 
17
27
  constructor(
18
28
  public name: string,
@@ -82,6 +92,13 @@ export class Plugin {
82
92
  }
83
93
 
84
94
  await resource.apply(plan);
95
+
96
+ // Perform a validation check after to ensure that the plan was properly applied.
97
+ // Sometimes no errors are returned (exit code 0) but the apply was not successful
98
+ const validationPlan = await resource.plan({ ...plan.resourceMetadata, ...plan.desiredConfig });
99
+ if (validationPlan.changeSet.operation !== ResourceOperation.NOOP) {
100
+ throw new ApplyValidationError(plan, validationPlan);
101
+ }
85
102
  }
86
103
 
87
104
  private resolvePlan(data: ApplyRequestData): Plan<ResourceConfig> {
@@ -119,7 +119,7 @@ describe('Resource parameter tests', () => {
119
119
  type: 'resource',
120
120
  parameterOptions: {
121
121
  propA: { statefulParameter: statefulParameterSpy },
122
- propB: { canModify: true },
122
+ propB: { modifyOnChange: true },
123
123
  }
124
124
  });
125
125
  }
@@ -7,7 +7,7 @@ export interface ResourceParameterOptions {
7
7
  /**
8
8
  * Chose if the resource should be re-created or modified if this parameter is changed. Defaults to false (re-create).
9
9
  */
10
- canModify?: boolean;
10
+ modifyOnChange?: boolean;
11
11
  /**
12
12
  * Customize the equality comparison for a parameter.
13
13
  * @param desired
@@ -195,8 +195,8 @@ describe('Resource tests', () => {
195
195
  super({
196
196
  type: 'resource',
197
197
  parameterOptions: {
198
- propA: { canModify: true },
199
- propB: { canModify: true },
198
+ propA: { modifyOnChange: true },
199
+ propB: { modifyOnChange: true },
200
200
  }
201
201
  });
202
202
  }
@@ -218,12 +218,6 @@ describe('Resource tests', () => {
218
218
 
219
219
  it('Validates the resource options correct (pass)', () => {
220
220
  const statefulParameter = new class extends StatefulParameter<TestConfig, string> {
221
- constructor() {
222
- super({
223
- name: 'propC',
224
- });
225
- }
226
-
227
221
  async refresh(): Promise<string | null> {
228
222
  return null;
229
223
  }
@@ -244,7 +238,7 @@ describe('Resource tests', () => {
244
238
  type: 'type',
245
239
  dependencies: ['homebrew', 'python'],
246
240
  parameterOptions: {
247
- propA: { canModify: true },
241
+ propA: { modifyOnChange: true },
248
242
  propB: { statefulParameter },
249
243
  propC: { isEqual: (a, b) => true },
250
244
  }
@@ -255,12 +249,6 @@ describe('Resource tests', () => {
255
249
 
256
250
  it('Validates the resource options correct (fail)', () => {
257
251
  const statefulParameter = new class extends StatefulParameter<TestConfig, string> {
258
- constructor() {
259
- super({
260
- name: 'propC',
261
- });
262
- }
263
-
264
252
  async refresh(): Promise<string | null> {
265
253
  return null;
266
254
  }
@@ -281,7 +269,7 @@ describe('Resource tests', () => {
281
269
  type: 'type',
282
270
  dependencies: ['homebrew', 'python'],
283
271
  parameterOptions: {
284
- propA: { canModify: true },
272
+ propA: { modifyOnChange: true },
285
273
  propB: { statefulParameter },
286
274
  propC: { isEqual: (a, b) => true },
287
275
  }
@@ -41,7 +41,7 @@ export abstract class StatefulParameter<T extends StringIndexedObject, V extends
41
41
  export abstract class ArrayStatefulParameter<T extends StringIndexedObject, V> extends StatefulParameter<T, any>{
42
42
  options: ArrayStatefulParameterOptions<V>;
43
43
 
44
- constructor(options: ArrayStatefulParameterOptions<V>) {
44
+ constructor(options: ArrayStatefulParameterOptions<V> = {}) {
45
45
  super(options);
46
46
  this.options = options;
47
47
  }
@@ -15,7 +15,7 @@ import {
15
15
  ValidateResponseDataSchema
16
16
  } from 'codify-schemas';
17
17
  import Ajv2020, { SchemaObject, ValidateFunction } from 'ajv/dist/2020.js';
18
- import { SudoError } from '../entities/errors.js';
18
+ import { ApplyValidationError, SudoError } from '../entities/errors.js';
19
19
 
20
20
  const SupportedRequests: Record<string, { requestValidator: SchemaObject; responseValidator: SchemaObject; handler: (plugin: Plugin, data: any) => Promise<unknown> }> = {
21
21
  'initialize': {
@@ -117,13 +117,28 @@ export class MessageHandler {
117
117
  const cmd = message.cmd + '_Response';
118
118
 
119
119
  if (e instanceof SudoError) {
120
- process.send?.({
120
+ return process.send?.({
121
121
  cmd,
122
122
  status: MessageStatus.ERROR,
123
123
  data: `Plugin: '${this.plugin.name}'. Forbidden usage of sudo for command '${e.command}'. Please contact the plugin developer to fix this.`,
124
124
  })
125
125
  }
126
126
 
127
+ if (e instanceof ApplyValidationError) {
128
+ return process.send?.({
129
+ cmd,
130
+ status: MessageStatus.ERROR,
131
+ data: `Plugin: '${this.plugin.name}'. Apply validation was not successful (additional changes are needed to match the desired plan).
132
+
133
+ Validation plan:
134
+ ${JSON.stringify(e.validatedPlan, null, 2)},
135
+
136
+ User desired plan:
137
+ ${JSON.stringify(e.desiredPlan, null, 2)}
138
+ `
139
+ })
140
+ }
141
+
127
142
  const isDebug = process.env.DEBUG?.includes('*') ?? false;
128
143
 
129
144
  process.send?.({