codify-plugin-lib 1.0.114 → 1.0.116

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,8 @@
1
+ import { Plan } from '../plan/plan.js';
2
+ export declare class ApplyValidationError extends Error {
3
+ resourceType: string;
4
+ resourceName?: string;
5
+ plan: Plan<any>;
6
+ constructor(plan: Plan<any>);
7
+ private static prettyPrintPlan;
8
+ }
@@ -0,0 +1,24 @@
1
+ export class ApplyValidationError extends Error {
2
+ resourceType;
3
+ resourceName;
4
+ plan;
5
+ constructor(plan) {
6
+ super(`Failed to apply changes to resource: "${plan.resourceId}". Additional changes are needed to complete apply.\nChanges remaining:\n${ApplyValidationError.prettyPrintPlan(plan)}`);
7
+ this.resourceType = plan.coreParameters.type;
8
+ this.resourceName = plan.coreParameters.name;
9
+ this.plan = plan;
10
+ }
11
+ static prettyPrintPlan(plan) {
12
+ const { operation, parameters } = plan.toResponse();
13
+ const prettyParameters = parameters.map(({ name, operation, previousValue, newValue }) => ({
14
+ name,
15
+ operation,
16
+ currentValue: previousValue,
17
+ desiredValue: newValue,
18
+ }));
19
+ return JSON.stringify({
20
+ operation,
21
+ parameters: prettyParameters,
22
+ }, null, 2);
23
+ }
24
+ }
package/dist/index.js CHANGED
@@ -13,4 +13,7 @@ export * from './utils/utils.js';
13
13
  export async function runPlugin(plugin) {
14
14
  const messageHandler = new MessageHandler(plugin);
15
15
  process.on('message', (message) => messageHandler.onMessage(message));
16
+ process.on('beforeExit', () => {
17
+ plugin.kill();
18
+ });
16
19
  }
@@ -8,9 +8,16 @@ import { ChangeSet } from './change-set.js';
8
8
  */
9
9
  export declare class Plan<T extends StringIndexedObject> {
10
10
  id: string;
11
+ /**
12
+ * List of changes to make
13
+ */
11
14
  changeSet: ChangeSet<T>;
15
+ /**
16
+ * Ex: name, type, dependsOn etc. Metadata parameters
17
+ */
12
18
  coreParameters: ResourceConfig;
13
- constructor(id: string, changeSet: ChangeSet<T>, resourceMetadata: ResourceConfig);
19
+ statefulMode: boolean;
20
+ constructor(id: string, changeSet: ChangeSet<T>, resourceMetadata: ResourceConfig, statefulMode: boolean);
14
21
  /**
15
22
  * The desired config that a plan will achieve after executing all the actions.
16
23
  */
@@ -19,6 +26,15 @@ export declare class Plan<T extends StringIndexedObject> {
19
26
  * The current config that the plan is changing.
20
27
  */
21
28
  get currentConfig(): T | null;
29
+ get resourceId(): string;
30
+ static calculate<T extends StringIndexedObject>(params: {
31
+ desiredParameters: Partial<T> | null;
32
+ currentParametersArray: Partial<T>[] | null;
33
+ stateParameters: Partial<T> | null;
34
+ coreParameters: ResourceConfig;
35
+ settings: ParsedResourceSettings<T>;
36
+ statefulMode: boolean;
37
+ }): Plan<T>;
22
38
  /**
23
39
  * When multiples of the same resource are allowed, this matching function will match a given config with one of the
24
40
  * existing configs on the system. For example if there are multiple versions of Android Studios installed, we can use
@@ -34,14 +50,7 @@ export declare class Plan<T extends StringIndexedObject> {
34
50
  * @return string
35
51
  */
36
52
  getResourceType(): string;
37
- static calculate<T extends StringIndexedObject>(params: {
38
- desiredParameters: Partial<T> | null;
39
- currentParametersArray: Partial<T>[] | null;
40
- stateParameters: Partial<T> | null;
41
- coreParameters: ResourceConfig;
42
- settings: ParsedResourceSettings<T>;
43
- statefulMode: boolean;
44
- }): Plan<T>;
53
+ static fromResponse<T extends ResourceConfig>(data: ApplyRequestData['plan'], defaultValues?: Partial<Record<keyof T, unknown>>): Plan<T>;
45
54
  /**
46
55
  * Only keep relevant params for the plan. We don't want to change settings that were not already
47
56
  * defined.
@@ -51,7 +60,7 @@ export declare class Plan<T extends StringIndexedObject> {
51
60
  * or wants to set. If a parameter is not specified then it's not managed by Codify.
52
61
  */
53
62
  private static filterCurrentParams;
54
- static fromResponse<T extends ResourceConfig>(data: ApplyRequestData['plan'], defaultValues?: Partial<Record<keyof T, unknown>>): Plan<T>;
63
+ requiresChanges(): boolean;
55
64
  /**
56
65
  * Convert the plan to a JSON response object
57
66
  */
package/dist/plan/plan.js CHANGED
@@ -8,12 +8,20 @@ import { ChangeSet } from './change-set.js';
8
8
  */
9
9
  export class Plan {
10
10
  id;
11
+ /**
12
+ * List of changes to make
13
+ */
11
14
  changeSet;
15
+ /**
16
+ * Ex: name, type, dependsOn etc. Metadata parameters
17
+ */
12
18
  coreParameters;
13
- constructor(id, changeSet, resourceMetadata) {
19
+ statefulMode;
20
+ constructor(id, changeSet, resourceMetadata, statefulMode) {
14
21
  this.id = id;
15
22
  this.changeSet = changeSet;
16
23
  this.coreParameters = resourceMetadata;
24
+ this.statefulMode = statefulMode;
17
25
  }
18
26
  /**
19
27
  * The desired config that a plan will achieve after executing all the actions.
@@ -39,6 +47,43 @@ export class Plan {
39
47
  ...this.changeSet.currentParameters,
40
48
  };
41
49
  }
50
+ get resourceId() {
51
+ return this.coreParameters.name
52
+ ? `${this.coreParameters.type}.${this.coreParameters.name}`
53
+ : this.coreParameters.type;
54
+ }
55
+ static calculate(params) {
56
+ const { desiredParameters, currentParametersArray, stateParameters, coreParameters, settings, statefulMode } = params;
57
+ const currentParameters = Plan.matchCurrentParameters({
58
+ desiredParameters,
59
+ currentParametersArray,
60
+ stateParameters,
61
+ settings,
62
+ statefulMode
63
+ });
64
+ const filteredCurrentParameters = Plan.filterCurrentParams({
65
+ desiredParameters,
66
+ currentParameters,
67
+ stateParameters,
68
+ settings,
69
+ statefulMode
70
+ });
71
+ // Empty
72
+ if (!filteredCurrentParameters && !desiredParameters) {
73
+ return new Plan(uuidV4(), ChangeSet.empty(), coreParameters, statefulMode);
74
+ }
75
+ // CREATE
76
+ if (!filteredCurrentParameters && desiredParameters) {
77
+ return new Plan(uuidV4(), ChangeSet.create(desiredParameters), coreParameters, statefulMode);
78
+ }
79
+ // DESTROY
80
+ if (filteredCurrentParameters && !desiredParameters) {
81
+ return new Plan(uuidV4(), ChangeSet.destroy(filteredCurrentParameters), coreParameters, statefulMode);
82
+ }
83
+ // NO-OP, MODIFY or RE-CREATE
84
+ const changeSet = ChangeSet.calculateModification(desiredParameters, filteredCurrentParameters, settings.parameterSettings);
85
+ return new Plan(uuidV4(), changeSet, coreParameters, statefulMode);
86
+ }
42
87
  /**
43
88
  * When multiples of the same resource are allowed, this matching function will match a given config with one of the
44
89
  * existing configs on the system. For example if there are multiple versions of Android Studios installed, we can use
@@ -70,37 +115,59 @@ export class Plan {
70
115
  getResourceType() {
71
116
  return this.coreParameters.type;
72
117
  }
73
- static calculate(params) {
74
- const { desiredParameters, currentParametersArray, stateParameters, coreParameters, settings, statefulMode } = params;
75
- const currentParameters = Plan.matchCurrentParameters({
76
- desiredParameters,
77
- currentParametersArray,
78
- stateParameters,
79
- settings,
80
- statefulMode
81
- });
82
- const filteredCurrentParameters = Plan.filterCurrentParams({
83
- desiredParameters,
84
- currentParameters,
85
- stateParameters,
86
- settings,
87
- statefulMode
88
- });
89
- // Empty
90
- if (!filteredCurrentParameters && !desiredParameters) {
91
- return new Plan(uuidV4(), ChangeSet.empty(), coreParameters);
92
- }
93
- // CREATE
94
- if (!filteredCurrentParameters && desiredParameters) {
95
- return new Plan(uuidV4(), ChangeSet.create(desiredParameters), coreParameters);
118
+ // 2. Even if there was (maybe for testing reasons), the plan values should not be adjusted
119
+ static fromResponse(data, defaultValues) {
120
+ if (!data) {
121
+ throw new Error('Data is empty');
96
122
  }
97
- // DESTROY
98
- if (filteredCurrentParameters && !desiredParameters) {
99
- return new Plan(uuidV4(), ChangeSet.destroy(filteredCurrentParameters), coreParameters);
123
+ addDefaultValues();
124
+ return new Plan(uuidV4(), new ChangeSet(data.operation, data.parameters), {
125
+ type: data.resourceType,
126
+ name: data.resourceName,
127
+ }, data.statefulMode);
128
+ function addDefaultValues() {
129
+ Object.entries(defaultValues ?? {})
130
+ .forEach(([key, defaultValue]) => {
131
+ const configValueExists = data
132
+ .parameters
133
+ .some((p) => p.name === key);
134
+ // Only set default values if the value does not exist in the config
135
+ if (configValueExists) {
136
+ return;
137
+ }
138
+ switch (data.operation) {
139
+ case ResourceOperation.CREATE: {
140
+ data.parameters.push({
141
+ name: key,
142
+ operation: ParameterOperation.ADD,
143
+ previousValue: null,
144
+ newValue: defaultValue,
145
+ });
146
+ break;
147
+ }
148
+ case ResourceOperation.DESTROY: {
149
+ data.parameters.push({
150
+ name: key,
151
+ operation: ParameterOperation.REMOVE,
152
+ previousValue: defaultValue,
153
+ newValue: null,
154
+ });
155
+ break;
156
+ }
157
+ case ResourceOperation.MODIFY:
158
+ case ResourceOperation.RECREATE:
159
+ case ResourceOperation.NOOP: {
160
+ data.parameters.push({
161
+ name: key,
162
+ operation: ParameterOperation.NOOP,
163
+ previousValue: defaultValue,
164
+ newValue: defaultValue,
165
+ });
166
+ break;
167
+ }
168
+ }
169
+ });
100
170
  }
101
- // NO-OP, MODIFY or RE-CREATE
102
- const changeSet = ChangeSet.calculateModification(desiredParameters, filteredCurrentParameters, settings.parameterSettings);
103
- return new Plan(uuidV4(), changeSet, coreParameters);
104
171
  }
105
172
  /**
106
173
  * Only keep relevant params for the plan. We don't want to change settings that were not already
@@ -199,59 +266,8 @@ export class Plan {
199
266
  }
200
267
  // TODO: This needs to be revisited. I don't think this is valid anymore.
201
268
  // 1. For all scenarios, there shouldn't be an apply without a plan beforehand
202
- // 2. Even if there was (maybe for testing reasons), the plan values should not be adjusted
203
- static fromResponse(data, defaultValues) {
204
- if (!data) {
205
- throw new Error('Data is empty');
206
- }
207
- addDefaultValues();
208
- return new Plan(uuidV4(), new ChangeSet(data.operation, data.parameters), {
209
- type: data.resourceType,
210
- name: data.resourceName,
211
- });
212
- function addDefaultValues() {
213
- Object.entries(defaultValues ?? {})
214
- .forEach(([key, defaultValue]) => {
215
- const configValueExists = data
216
- .parameters
217
- .some((p) => p.name === key);
218
- // Only set default values if the value does not exist in the config
219
- if (configValueExists) {
220
- return;
221
- }
222
- switch (data.operation) {
223
- case ResourceOperation.CREATE: {
224
- data.parameters.push({
225
- name: key,
226
- operation: ParameterOperation.ADD,
227
- previousValue: null,
228
- newValue: defaultValue,
229
- });
230
- break;
231
- }
232
- case ResourceOperation.DESTROY: {
233
- data.parameters.push({
234
- name: key,
235
- operation: ParameterOperation.REMOVE,
236
- previousValue: defaultValue,
237
- newValue: null,
238
- });
239
- break;
240
- }
241
- case ResourceOperation.MODIFY:
242
- case ResourceOperation.RECREATE:
243
- case ResourceOperation.NOOP: {
244
- data.parameters.push({
245
- name: key,
246
- operation: ParameterOperation.NOOP,
247
- previousValue: defaultValue,
248
- newValue: defaultValue,
249
- });
250
- break;
251
- }
252
- }
253
- });
254
- }
269
+ requiresChanges() {
270
+ return this.changeSet.operation !== ResourceOperation.NOOP;
255
271
  }
256
272
  /**
257
273
  * Convert the plan to a JSON response object
@@ -1,8 +1,8 @@
1
1
  import { ApplyRequestData, GetResourceInfoRequestData, GetResourceInfoResponseData, ImportRequestData, ImportResponseData, InitializeResponseData, PlanRequestData, PlanResponseData, ResourceConfig, ValidateRequestData, ValidateResponseData } from 'codify-schemas';
2
2
  import { Plan } from '../plan/plan.js';
3
+ import { BackgroundPty } from '../pty/background-pty.js';
3
4
  import { Resource } from '../resource/resource.js';
4
5
  import { ResourceController } from '../resource/resource-controller.js';
5
- import { BackgroundPty } from '../pty/background-pty.js';
6
6
  export declare class Plugin {
7
7
  name: string;
8
8
  resourceControllers: Map<string, ResourceController<ResourceConfig>>;
@@ -16,6 +16,7 @@ export declare class Plugin {
16
16
  validate(data: ValidateRequestData): Promise<ValidateResponseData>;
17
17
  plan(data: PlanRequestData): Promise<PlanResponseData>;
18
18
  apply(data: ApplyRequestData): Promise<void>;
19
+ kill(): Promise<void>;
19
20
  private resolvePlan;
20
21
  protected crossValidateResources(configs: ResourceConfig[]): Promise<void>;
21
22
  }
@@ -1,7 +1,9 @@
1
+ import { ApplyValidationError } from '../common/errors.js';
1
2
  import { Plan } from '../plan/plan.js';
3
+ import { BackgroundPty } from '../pty/background-pty.js';
4
+ import { getPty } from '../pty/index.js';
2
5
  import { ResourceController } from '../resource/resource-controller.js';
3
6
  import { ptyLocalStorage } from '../utils/pty-local-storage.js';
4
- import { BackgroundPty } from '../pty/background-pty.js';
5
7
  export class Plugin {
6
8
  name;
7
9
  resourceControllers;
@@ -82,9 +84,7 @@ export class Plugin {
82
84
  if (!type || !this.resourceControllers.has(type)) {
83
85
  throw new Error(`Resource type not found: ${type}`);
84
86
  }
85
- const plan = await ptyLocalStorage.run(this.planPty, async () => {
86
- return this.resourceControllers.get(type).plan(data.desired ?? null, data.state ?? null, data.isStateful);
87
- });
87
+ const plan = await ptyLocalStorage.run(this.planPty, async () => this.resourceControllers.get(type).plan(data.desired ?? null, data.state ?? null, data.isStateful));
88
88
  this.planStorage.set(plan.id, plan);
89
89
  return plan.toResponse();
90
90
  }
@@ -98,6 +98,17 @@ export class Plugin {
98
98
  throw new Error('Malformed plan with resource that cannot be found');
99
99
  }
100
100
  await resource.apply(plan);
101
+ const validationPlan = await ptyLocalStorage.run(new BackgroundPty(), async () => {
102
+ const result = await resource.plan(plan.desiredConfig, plan.currentConfig, plan.statefulMode);
103
+ await getPty().kill();
104
+ return result;
105
+ });
106
+ if (validationPlan.requiresChanges()) {
107
+ throw new ApplyValidationError(plan);
108
+ }
109
+ }
110
+ async kill() {
111
+ await this.planPty.kill();
101
112
  }
102
113
  resolvePlan(data) {
103
114
  const { plan: planRequest, planId } = data;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codify-plugin-lib",
3
- "version": "1.0.114",
3
+ "version": "1.0.116",
4
4
  "description": "Library plugin library",
5
5
  "main": "dist/index.js",
6
6
  "typings": "dist/index.d.ts",
@@ -14,7 +14,7 @@
14
14
  "dependencies": {
15
15
  "ajv": "^8.12.0",
16
16
  "ajv-formats": "^2.1.1",
17
- "codify-schemas": "1.0.53",
17
+ "codify-schemas": "1.0.54",
18
18
  "@npmcli/promise-spawn": "^7.0.1",
19
19
  "uuid": "^10.0.0",
20
20
  "lodash.isequal": "^4.5.0",
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ApplyValidationError } from './errors.js';
3
+ import { testPlan } from '../utils/test-utils.test.js';
4
+
5
+ describe('Test file for errors file', () => {
6
+ it('Can properly format ApplyValidationError', () => {
7
+ const plan = testPlan({
8
+ desired: null,
9
+ current: [{ propZ: ['a', 'b', 'c'] }],
10
+ state: { propZ: ['a', 'b', 'c'] },
11
+ core: {
12
+ type: 'homebrew',
13
+ name: 'first'
14
+ },
15
+ statefulMode: true,
16
+ })
17
+
18
+ try {
19
+ throw new ApplyValidationError(plan);
20
+ } catch (e) {
21
+ console.error(e);
22
+ expect(e.message).toMatch(
23
+ `Failed to apply changes to resource: "homebrew.first". Additional changes are needed to complete apply.
24
+ Changes remaining:
25
+ {
26
+ "operation": "destroy",
27
+ "parameters": [
28
+ {
29
+ "name": "propZ",
30
+ "operation": "remove",
31
+ "currentValue": [
32
+ "a",
33
+ "b",
34
+ "c"
35
+ ],
36
+ "desiredValue": null
37
+ }
38
+ ]
39
+ }`
40
+ )
41
+ }
42
+ })
43
+ })
@@ -0,0 +1,31 @@
1
+ import { Plan } from '../plan/plan.js';
2
+
3
+ export class ApplyValidationError extends Error {
4
+ resourceType: string;
5
+ resourceName?: string;
6
+ plan: Plan<any>;
7
+
8
+ constructor(plan: Plan<any>) {
9
+ super(`Failed to apply changes to resource: "${plan.resourceId}". Additional changes are needed to complete apply.\nChanges remaining:\n${ApplyValidationError.prettyPrintPlan(plan)}`);
10
+
11
+ this.resourceType = plan.coreParameters.type;
12
+ this.resourceName = plan.coreParameters.name;
13
+ this.plan = plan;
14
+ }
15
+
16
+ private static prettyPrintPlan(plan: Plan<any>): string {
17
+ const { operation, parameters } = plan.toResponse();
18
+
19
+ const prettyParameters = parameters.map(({ name, operation, previousValue, newValue }) => ({
20
+ name,
21
+ operation,
22
+ currentValue: previousValue,
23
+ desiredValue: newValue,
24
+ }));
25
+
26
+ return JSON.stringify({
27
+ operation,
28
+ parameters: prettyParameters,
29
+ }, null, 2);
30
+ }
31
+ }
package/src/index.ts CHANGED
@@ -16,4 +16,8 @@ export * from './utils/utils.js'
16
16
  export async function runPlugin(plugin: Plugin) {
17
17
  const messageHandler = new MessageHandler(plugin);
18
18
  process.on('message', (message) => messageHandler.onMessage(message))
19
+
20
+ process.on('beforeExit', () => {
21
+ plugin.kill();
22
+ })
19
23
  }
@@ -19,7 +19,8 @@ describe('Plan entity tests', () => {
19
19
  operation: ParameterOperation.ADD,
20
20
  previousValue: null,
21
21
  newValue: 'propBValue'
22
- }]
22
+ }],
23
+ statefulMode: false,
23
24
  }, controller.parsedSettings.defaultValues);
24
25
 
25
26
  expect(plan.currentConfig).to.be.null;
@@ -47,7 +48,8 @@ describe('Plan entity tests', () => {
47
48
  operation: ParameterOperation.REMOVE,
48
49
  previousValue: 'propBValue',
49
50
  newValue: null,
50
- }]
51
+ }],
52
+ statefulMode: false,
51
53
  }, controller.parsedSettings.defaultValues);
52
54
 
53
55
  expect(plan.currentConfig).toMatchObject({
@@ -75,7 +77,8 @@ describe('Plan entity tests', () => {
75
77
  operation: ParameterOperation.NOOP,
76
78
  previousValue: 'propBValue',
77
79
  newValue: 'propBValue',
78
- }]
80
+ }],
81
+ statefulMode: false,
79
82
  }, controller.parsedSettings.defaultValues);
80
83
 
81
84
  expect(plan.currentConfig).toMatchObject({
@@ -112,7 +115,8 @@ describe('Plan entity tests', () => {
112
115
  operation: ParameterOperation.ADD,
113
116
  previousValue: null,
114
117
  newValue: 'propAValue',
115
- }]
118
+ }],
119
+ statefulMode: false,
116
120
  }, controller.parsedSettings.defaultValues);
117
121
 
118
122
  expect(plan.currentConfig).to.be.null
package/src/plan/plan.ts CHANGED
@@ -23,13 +23,24 @@ import { ChangeSet } from './change-set.js';
23
23
  */
24
24
  export class Plan<T extends StringIndexedObject> {
25
25
  id: string;
26
+
27
+ /**
28
+ * List of changes to make
29
+ */
26
30
  changeSet: ChangeSet<T>;
27
- coreParameters: ResourceConfig
28
31
 
29
- constructor(id: string, changeSet: ChangeSet<T>, resourceMetadata: ResourceConfig) {
32
+ /**
33
+ * Ex: name, type, dependsOn etc. Metadata parameters
34
+ */
35
+ coreParameters: ResourceConfig;
36
+
37
+ statefulMode: boolean;
38
+
39
+ constructor(id: string, changeSet: ChangeSet<T>, resourceMetadata: ResourceConfig, statefulMode: boolean) {
30
40
  this.id = id;
31
41
  this.changeSet = changeSet;
32
42
  this.coreParameters = resourceMetadata;
43
+ this.statefulMode = statefulMode;
33
44
  }
34
45
 
35
46
  /**
@@ -60,53 +71,10 @@ export class Plan<T extends StringIndexedObject> {
60
71
  }
61
72
  }
62
73
 
63
- /**
64
- * When multiples of the same resource are allowed, this matching function will match a given config with one of the
65
- * existing configs on the system. For example if there are multiple versions of Android Studios installed, we can use
66
- * the application name and location to match it to our desired configs name and location.
67
- *
68
- * @param params
69
- * @private
70
- */
71
- private static matchCurrentParameters<T extends StringIndexedObject>(params: {
72
- desiredParameters: Partial<T> | null,
73
- currentParametersArray: Partial<T>[] | null,
74
- stateParameters: Partial<T> | null,
75
- settings: ResourceSettings<T>,
76
- statefulMode: boolean,
77
- }): Partial<T> | null {
78
- const {
79
- desiredParameters,
80
- currentParametersArray,
81
- stateParameters,
82
- settings,
83
- statefulMode
84
- } = params;
85
-
86
- if (!settings.allowMultiple) {
87
- return currentParametersArray?.[0] ?? null;
88
- }
89
-
90
- if (!currentParametersArray) {
91
- return null;
92
- }
93
-
94
- if (statefulMode) {
95
- return stateParameters
96
- ? settings.allowMultiple.matcher(stateParameters, currentParametersArray)
97
- : null
98
- }
99
-
100
- return settings.allowMultiple.matcher(desiredParameters!, currentParametersArray);
101
- }
102
-
103
- /**
104
- * The type (id) of the resource
105
- *
106
- * @return string
107
- */
108
- getResourceType(): string {
109
- return this.coreParameters.type
74
+ get resourceId(): string {
75
+ return this.coreParameters.name
76
+ ? `${this.coreParameters.type}.${this.coreParameters.name}`
77
+ : this.coreParameters.type;
110
78
  }
111
79
 
112
80
  static calculate<T extends StringIndexedObject>(params: {
@@ -148,6 +116,7 @@ export class Plan<T extends StringIndexedObject> {
148
116
  uuidV4(),
149
117
  ChangeSet.empty<T>(),
150
118
  coreParameters,
119
+ statefulMode,
151
120
  )
152
121
  }
153
122
 
@@ -156,7 +125,8 @@ export class Plan<T extends StringIndexedObject> {
156
125
  return new Plan(
157
126
  uuidV4(),
158
127
  ChangeSet.create(desiredParameters),
159
- coreParameters
128
+ coreParameters,
129
+ statefulMode,
160
130
  )
161
131
  }
162
132
 
@@ -165,7 +135,8 @@ export class Plan<T extends StringIndexedObject> {
165
135
  return new Plan(
166
136
  uuidV4(),
167
137
  ChangeSet.destroy(filteredCurrentParameters),
168
- coreParameters
138
+ coreParameters,
139
+ statefulMode,
169
140
  )
170
141
  }
171
142
 
@@ -180,7 +151,128 @@ export class Plan<T extends StringIndexedObject> {
180
151
  uuidV4(),
181
152
  changeSet,
182
153
  coreParameters,
154
+ statefulMode,
155
+ );
156
+ }
157
+
158
+ /**
159
+ * When multiples of the same resource are allowed, this matching function will match a given config with one of the
160
+ * existing configs on the system. For example if there are multiple versions of Android Studios installed, we can use
161
+ * the application name and location to match it to our desired configs name and location.
162
+ *
163
+ * @param params
164
+ * @private
165
+ */
166
+ private static matchCurrentParameters<T extends StringIndexedObject>(params: {
167
+ desiredParameters: Partial<T> | null,
168
+ currentParametersArray: Partial<T>[] | null,
169
+ stateParameters: Partial<T> | null,
170
+ settings: ResourceSettings<T>,
171
+ statefulMode: boolean,
172
+ }): Partial<T> | null {
173
+ const {
174
+ desiredParameters,
175
+ currentParametersArray,
176
+ stateParameters,
177
+ settings,
178
+ statefulMode
179
+ } = params;
180
+
181
+ if (!settings.allowMultiple) {
182
+ return currentParametersArray?.[0] ?? null;
183
+ }
184
+
185
+ if (!currentParametersArray) {
186
+ return null;
187
+ }
188
+
189
+ if (statefulMode) {
190
+ return stateParameters
191
+ ? settings.allowMultiple.matcher(stateParameters, currentParametersArray)
192
+ : null
193
+ }
194
+
195
+ return settings.allowMultiple.matcher(desiredParameters!, currentParametersArray);
196
+ }
197
+
198
+ /**
199
+ * The type (id) of the resource
200
+ *
201
+ * @return string
202
+ */
203
+ getResourceType(): string {
204
+ return this.coreParameters.type
205
+ }
206
+
207
+ // 2. Even if there was (maybe for testing reasons), the plan values should not be adjusted
208
+ static fromResponse<T extends ResourceConfig>(data: ApplyRequestData['plan'], defaultValues?: Partial<Record<keyof T, unknown>>): Plan<T> {
209
+ if (!data) {
210
+ throw new Error('Data is empty');
211
+ }
212
+
213
+ addDefaultValues();
214
+
215
+ return new Plan(
216
+ uuidV4(),
217
+ new ChangeSet<T>(
218
+ data.operation,
219
+ data.parameters
220
+ ),
221
+ {
222
+ type: data.resourceType,
223
+ name: data.resourceName,
224
+ },
225
+ data.statefulMode
183
226
  );
227
+
228
+ function addDefaultValues(): void {
229
+ Object.entries(defaultValues ?? {})
230
+ .forEach(([key, defaultValue]) => {
231
+ const configValueExists = data!
232
+ .parameters
233
+ .some((p) => p.name === key);
234
+
235
+ // Only set default values if the value does not exist in the config
236
+ if (configValueExists) {
237
+ return;
238
+ }
239
+
240
+ switch (data!.operation) {
241
+ case ResourceOperation.CREATE: {
242
+ data!.parameters.push({
243
+ name: key,
244
+ operation: ParameterOperation.ADD,
245
+ previousValue: null,
246
+ newValue: defaultValue,
247
+ });
248
+ break;
249
+ }
250
+
251
+ case ResourceOperation.DESTROY: {
252
+ data!.parameters.push({
253
+ name: key,
254
+ operation: ParameterOperation.REMOVE,
255
+ previousValue: defaultValue,
256
+ newValue: null,
257
+ });
258
+ break;
259
+ }
260
+
261
+ case ResourceOperation.MODIFY:
262
+ case ResourceOperation.RECREATE:
263
+ case ResourceOperation.NOOP: {
264
+ data!.parameters.push({
265
+ name: key,
266
+ operation: ParameterOperation.NOOP,
267
+ previousValue: defaultValue,
268
+ newValue: defaultValue,
269
+ });
270
+ break;
271
+ }
272
+ }
273
+ });
274
+ }
275
+
184
276
  }
185
277
 
186
278
  /**
@@ -322,74 +414,9 @@ export class Plan<T extends StringIndexedObject> {
322
414
 
323
415
  // TODO: This needs to be revisited. I don't think this is valid anymore.
324
416
  // 1. For all scenarios, there shouldn't be an apply without a plan beforehand
325
- // 2. Even if there was (maybe for testing reasons), the plan values should not be adjusted
326
- static fromResponse<T extends ResourceConfig>(data: ApplyRequestData['plan'], defaultValues?: Partial<Record<keyof T, unknown>>): Plan<T> {
327
- if (!data) {
328
- throw new Error('Data is empty');
329
- }
330
-
331
- addDefaultValues();
332
-
333
- return new Plan(
334
- uuidV4(),
335
- new ChangeSet<T>(
336
- data.operation,
337
- data.parameters
338
- ),
339
- {
340
- type: data.resourceType,
341
- name: data.resourceName,
342
- },
343
- );
344
-
345
- function addDefaultValues(): void {
346
- Object.entries(defaultValues ?? {})
347
- .forEach(([key, defaultValue]) => {
348
- const configValueExists = data!
349
- .parameters
350
- .some((p) => p.name === key);
351
-
352
- // Only set default values if the value does not exist in the config
353
- if (configValueExists) {
354
- return;
355
- }
356
-
357
- switch (data!.operation) {
358
- case ResourceOperation.CREATE: {
359
- data!.parameters.push({
360
- name: key,
361
- operation: ParameterOperation.ADD,
362
- previousValue: null,
363
- newValue: defaultValue,
364
- });
365
- break;
366
- }
367
-
368
- case ResourceOperation.DESTROY: {
369
- data!.parameters.push({
370
- name: key,
371
- operation: ParameterOperation.REMOVE,
372
- previousValue: defaultValue,
373
- newValue: null,
374
- });
375
- break;
376
- }
377
-
378
- case ResourceOperation.MODIFY:
379
- case ResourceOperation.RECREATE:
380
- case ResourceOperation.NOOP: {
381
- data!.parameters.push({
382
- name: key,
383
- operation: ParameterOperation.NOOP,
384
- previousValue: defaultValue,
385
- newValue: defaultValue,
386
- });
387
- break;
388
- }
389
- }
390
- });
391
- }
392
417
 
418
+ requiresChanges(): boolean {
419
+ return this.changeSet.operation !== ResourceOperation.NOOP;
393
420
  }
394
421
 
395
422
  /**
@@ -1,10 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { Plugin } from './plugin.js';
3
- import { ParameterOperation, ResourceOperation, StringIndexedObject } from 'codify-schemas';
3
+ import { ApplyRequestData, ParameterOperation, ResourceOperation, StringIndexedObject } from 'codify-schemas';
4
4
  import { Resource } from '../resource/resource.js';
5
5
  import { Plan } from '../plan/plan.js';
6
6
  import { spy } from 'sinon';
7
7
  import { ResourceSettings } from '../resource/resource-settings.js';
8
+ import { TestConfig } from '../utils/test-utils.test.js';
9
+ import { ApplyValidationError } from '../common/errors.js';
10
+ import { getPty } from '../pty/index.js';
8
11
 
9
12
  interface TestConfig extends StringIndexedObject {
10
13
  propA: string;
@@ -38,15 +41,22 @@ class TestResource extends Resource<TestConfig> {
38
41
 
39
42
  describe('Plugin tests', () => {
40
43
  it('Can apply resource', async () => {
41
- const resource= spy(new TestResource())
44
+ const resource = spy(new class extends TestResource {
45
+ async refresh(): Promise<Partial<TestConfig> | null> {
46
+ return {
47
+ propA: 'abc',
48
+ }
49
+ }
50
+ })
42
51
  const plugin = Plugin.create('testPlugin', [resource as any])
43
52
 
44
- const plan = {
53
+ const plan: ApplyRequestData['plan'] = {
45
54
  operation: ResourceOperation.CREATE,
46
55
  resourceType: 'testResource',
47
56
  parameters: [
48
57
  { name: 'propA', operation: ParameterOperation.ADD, newValue: 'abc', previousValue: null },
49
- ]
58
+ ],
59
+ statefulMode: false,
50
60
  };
51
61
 
52
62
  await plugin.apply({ plan });
@@ -54,15 +64,20 @@ describe('Plugin tests', () => {
54
64
  });
55
65
 
56
66
  it('Can destroy resource', async () => {
57
- const resource = spy(new TestResource());
67
+ const resource = spy(new class extends TestResource {
68
+ async refresh(): Promise<Partial<TestConfig> | null> {
69
+ return null;
70
+ }
71
+ });
58
72
  const testPlugin = Plugin.create('testPlugin', [resource as any])
59
73
 
60
- const plan = {
74
+ const plan: ApplyRequestData['plan'] = {
61
75
  operation: ResourceOperation.DESTROY,
62
76
  resourceType: 'testResource',
63
77
  parameters: [
64
78
  { name: 'propA', operation: ParameterOperation.REMOVE, newValue: null, previousValue: 'abc' },
65
- ]
79
+ ],
80
+ statefulMode: true,
66
81
  };
67
82
 
68
83
  await testPlugin.apply({ plan })
@@ -70,15 +85,22 @@ describe('Plugin tests', () => {
70
85
  });
71
86
 
72
87
  it('Can re-create resource', async () => {
73
- const resource = spy(new TestResource())
88
+ const resource = spy(new class extends TestResource {
89
+ async refresh(): Promise<Partial<TestConfig> | null> {
90
+ return {
91
+ propA: 'def',
92
+ }
93
+ }
94
+ })
74
95
  const testPlugin = Plugin.create('testPlugin', [resource as any])
75
96
 
76
- const plan = {
97
+ const plan: ApplyRequestData['plan'] = {
77
98
  operation: ResourceOperation.RECREATE,
78
99
  resourceType: 'testResource',
79
100
  parameters: [
80
101
  { name: 'propA', operation: ParameterOperation.MODIFY, newValue: 'def', previousValue: 'abc' },
81
- ]
102
+ ],
103
+ statefulMode: false,
82
104
  };
83
105
 
84
106
  await testPlugin.apply({ plan })
@@ -87,15 +109,22 @@ describe('Plugin tests', () => {
87
109
  });
88
110
 
89
111
  it('Can modify resource', async () => {
90
- const resource = spy(new TestResource())
112
+ const resource = spy(new class extends TestResource {
113
+ async refresh(): Promise<Partial<TestConfig> | null> {
114
+ return {
115
+ propA: 'def',
116
+ }
117
+ }
118
+ })
91
119
  const testPlugin = Plugin.create('testPlugin', [resource as any])
92
120
 
93
- const plan = {
121
+ const plan: ApplyRequestData['plan'] = {
94
122
  operation: ResourceOperation.MODIFY,
95
123
  resourceType: 'testResource',
96
124
  parameters: [
97
125
  { name: 'propA', operation: ParameterOperation.MODIFY, newValue: 'def', previousValue: 'abc' },
98
- ]
126
+ ],
127
+ statefulMode: false,
99
128
  };
100
129
 
101
130
  await testPlugin.apply({ plan })
@@ -178,4 +207,78 @@ describe('Plugin tests', () => {
178
207
  requiredParameters: []
179
208
  })
180
209
  })
210
+
211
+ it('Fails an apply if the validation fails', async () => {
212
+ const resource = spy(new class extends TestResource {
213
+ async refresh(): Promise<Partial<TestConfig> | null> {
214
+ return {
215
+ propA: 'abc',
216
+ }
217
+ }
218
+ })
219
+ const testPlugin = Plugin.create('testPlugin', [resource as any])
220
+
221
+ const plan: ApplyRequestData['plan'] = {
222
+ operation: ResourceOperation.MODIFY,
223
+ resourceType: 'testResource',
224
+ parameters: [
225
+ { name: 'propA', operation: ParameterOperation.MODIFY, newValue: 'def', previousValue: 'abc' },
226
+ ],
227
+ statefulMode: false,
228
+ };
229
+
230
+ await expect(() => testPlugin.apply({ plan }))
231
+ .rejects
232
+ .toThrowError(new ApplyValidationError(Plan.fromResponse(plan)));
233
+ expect(resource.modify.calledOnce).to.be.true;
234
+ })
235
+
236
+ it('Allows the usage of pty in refresh (plan)', async () => {
237
+ const resource = spy(new class extends TestResource {
238
+ async refresh(): Promise<Partial<TestConfig> | null> {
239
+ expect(getPty()).to.not.be.undefined;
240
+ expect(getPty()).to.not.be.null;
241
+
242
+ return null;
243
+ }
244
+ })
245
+
246
+ const testPlugin = Plugin.create('testPlugin', [resource as any]);
247
+ await testPlugin.plan({
248
+ desired: {
249
+ type: 'testResource'
250
+ },
251
+ state: undefined,
252
+ isStateful: false,
253
+ })
254
+
255
+ expect(resource.refresh.calledOnce).to.be.true;
256
+ });
257
+
258
+ it('Allows the usage of pty in validation refresh (apply)', async () => {
259
+ const resource = spy(new class extends TestResource {
260
+ async refresh(): Promise<Partial<TestConfig> | null> {
261
+ expect(getPty()).to.not.be.undefined;
262
+ expect(getPty()).to.not.be.null;
263
+
264
+ return {
265
+ propA: 'abc'
266
+ };
267
+ }
268
+ })
269
+
270
+ const testPlugin = Plugin.create('testPlugin', [resource as any]);
271
+
272
+ const plan: ApplyRequestData['plan'] = {
273
+ operation: ResourceOperation.CREATE,
274
+ resourceType: 'testResource',
275
+ parameters: [
276
+ { name: 'propA', operation: ParameterOperation.ADD, newValue: 'abc', previousValue: null },
277
+ ],
278
+ statefulMode: false,
279
+ };
280
+
281
+ await testPlugin.apply({ plan })
282
+ expect(resource.refresh.calledOnce).to.be.true;
283
+ })
181
284
  });
@@ -13,11 +13,13 @@ import {
13
13
  ValidateResponseData
14
14
  } from 'codify-schemas';
15
15
 
16
+ import { ApplyValidationError } from '../common/errors.js';
16
17
  import { Plan } from '../plan/plan.js';
18
+ import { BackgroundPty } from '../pty/background-pty.js';
19
+ import { getPty } from '../pty/index.js';
17
20
  import { Resource } from '../resource/resource.js';
18
21
  import { ResourceController } from '../resource/resource-controller.js';
19
22
  import { ptyLocalStorage } from '../utils/pty-local-storage.js';
20
- import { BackgroundPty } from '../pty/background-pty.js';
21
23
 
22
24
  export class Plugin {
23
25
  planStorage: Map<string, Plan<any>>;
@@ -122,13 +124,11 @@ export class Plugin {
122
124
  throw new Error(`Resource type not found: ${type}`);
123
125
  }
124
126
 
125
- const plan = await ptyLocalStorage.run(this.planPty, async () => {
126
- return this.resourceControllers.get(type)!.plan(
127
+ const plan = await ptyLocalStorage.run(this.planPty, async () => this.resourceControllers.get(type)!.plan(
127
128
  data.desired ?? null,
128
129
  data.state ?? null,
129
130
  data.isStateful
130
- );
131
- })
131
+ ))
132
132
 
133
133
  this.planStorage.set(plan.id, plan);
134
134
 
@@ -148,6 +148,25 @@ export class Plugin {
148
148
  }
149
149
 
150
150
  await resource.apply(plan);
151
+
152
+ const validationPlan = await ptyLocalStorage.run(new BackgroundPty(), async () => {
153
+ const result = await resource.plan(
154
+ plan.desiredConfig,
155
+ plan.currentConfig,
156
+ plan.statefulMode
157
+ );
158
+
159
+ await getPty().kill();
160
+ return result;
161
+ })
162
+
163
+ if (validationPlan.requiresChanges()) {
164
+ throw new ApplyValidationError(plan);
165
+ }
166
+ }
167
+
168
+ async kill() {
169
+ await this.planPty.kill();
151
170
  }
152
171
 
153
172
  private resolvePlan(data: ApplyRequestData): Plan<ResourceConfig> {
@@ -170,5 +189,4 @@ export class Plugin {
170
189
  }
171
190
 
172
191
  protected async crossValidateResources(configs: ResourceConfig[]): Promise<void> {}
173
-
174
192
  }
@@ -499,7 +499,8 @@ describe('Resource parameter tests', () => {
499
499
  { name: 'propA', operation: ParameterOperation.ADD, previousValue: null, newValue: null },
500
500
  { name: 'propB', operation: ParameterOperation.ADD, previousValue: null, newValue: null },
501
501
  { name: 'propC', operation: ParameterOperation.ADD, previousValue: null, newValue: null },
502
- ]
502
+ ],
503
+ statefulMode: false,
503
504
  }, {}) as any
504
505
  );
505
506
 
@@ -521,7 +522,8 @@ describe('Resource parameter tests', () => {
521
522
  { name: 'propA', operation: ParameterOperation.MODIFY, previousValue: null, newValue: null },
522
523
  { name: 'propB', operation: ParameterOperation.MODIFY, previousValue: null, newValue: null },
523
524
  { name: 'propC', operation: ParameterOperation.MODIFY, previousValue: null, newValue: null },
524
- ]
525
+ ],
526
+ statefulMode: false,
525
527
  }, {}) as any
526
528
  );
527
529
 
@@ -539,7 +541,8 @@ describe('Resource parameter tests', () => {
539
541
  { name: 'propA', operation: ParameterOperation.REMOVE, previousValue: null, newValue: null },
540
542
  { name: 'propB', operation: ParameterOperation.REMOVE, previousValue: null, newValue: null },
541
543
  { name: 'propC', operation: ParameterOperation.REMOVE, previousValue: null, newValue: null },
542
- ]
544
+ ],
545
+ statefulMode: false,
543
546
  }, {}) as any
544
547
  );
545
548
 
package/vitest.config.ts CHANGED
@@ -2,6 +2,7 @@ import { defaultExclude, defineConfig } from 'vitest/config';
2
2
 
3
3
  export default defineConfig({
4
4
  test: {
5
+ pool: 'forks',
5
6
  exclude: [
6
7
  ...defaultExclude,
7
8
  './src/utils/test-utils.test.ts',