codify-plugin-lib 1.0.64 → 1.0.66

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.
@@ -74,38 +74,39 @@ export abstract class Resource<T extends StringIndexedObject> {
74
74
  return this.validate(parameters);
75
75
  }
76
76
 
77
- // TODO: Add state in later.
78
- // Currently only calculating how to add things to reach desired state. Can't delete resources.
79
- // Add previousConfig as a parameter for plan(desired, previous);
80
- async plan(desiredConfig: Partial<T> & ResourceConfig): Promise<Plan<T>> {
77
+ // TODO: Currently stateful mode expects that the currentConfig does not need any additional transformations (default and transform parameters)
78
+ // This may change in the future?
79
+ async plan(
80
+ desiredConfig: Partial<T> & ResourceConfig | null,
81
+ currentConfig: Partial<T> & ResourceConfig | null = null,
82
+ statefulMode = false,
83
+ ): Promise<Plan<T>> {
84
+ this.validatePlanInputs(desiredConfig, currentConfig, statefulMode);
85
+
81
86
  const planOptions: PlanOptions<T> = {
82
- statefulMode: false,
87
+ statefulMode,
83
88
  parameterOptions: this.parameterOptions,
84
89
  }
85
90
 
86
91
  this.addDefaultValues(desiredConfig);
92
+ await this.applyTransformParameters(desiredConfig);
87
93
 
88
94
  // Parse data from the user supplied config
89
- const parsedConfig = new ConfigParser(desiredConfig, this.statefulParameters, this.transformParameters)
95
+ const parsedConfig = new ConfigParser(desiredConfig, currentConfig, this.statefulParameters, this.transformParameters)
90
96
  const {
91
- parameters: desiredParameters,
97
+ desiredParameters,
92
98
  resourceMetadata,
93
- resourceParameters,
99
+ nonStatefulParameters,
94
100
  statefulParameters,
95
- transformParameters,
96
101
  } = parsedConfig;
97
102
 
98
- // Apply transform parameters. Transform parameters turn into other parameters.
99
- // Ex: csvFile: './location' => { password: 'pass', 'username': 'user' }
100
- await this.applyTransformParameters(transformParameters, resourceParameters);
101
-
102
103
  // Refresh resource parameters. This refreshes the parameters that configure the resource itself
103
- const currentParameters = await this.refreshResourceParameters(resourceParameters);
104
+ const currentParameters = await this.refreshNonStatefulParameters(nonStatefulParameters);
104
105
 
105
106
  // Short circuit here. If the resource is non-existent, there's no point checking stateful parameters
106
107
  if (currentParameters == null) {
107
108
  return Plan.create(
108
- { ...resourceParameters, ...statefulParameters },
109
+ desiredParameters,
109
110
  null,
110
111
  resourceMetadata,
111
112
  planOptions,
@@ -116,7 +117,7 @@ export abstract class Resource<T extends StringIndexedObject> {
116
117
  const statefulCurrentParameters = await this.refreshStatefulParameters(statefulParameters, planOptions.statefulMode);
117
118
 
118
119
  return Plan.create(
119
- { ...resourceParameters, ...statefulParameters },
120
+ desiredParameters,
120
121
  { ...currentParameters, ...statefulCurrentParameters } as Partial<T>,
121
122
  resourceMetadata,
122
123
  planOptions,
@@ -232,17 +233,29 @@ Additional: ${[...refreshKeys].filter(k => !desiredKeys.has(k))};`
232
233
  }
233
234
  }
234
235
 
235
- private async applyTransformParameters(transformParameters: Partial<T>, desired: Partial<T>): Promise<void> {
236
- const orderedEntries = [...Object.entries(transformParameters)]
236
+ private async applyTransformParameters(desired: Partial<T> | null): Promise<void> {
237
+ if (!desired) {
238
+ return;
239
+ }
240
+
241
+ const transformParameters = [...this.transformParameters.entries()]
237
242
  .sort(([keyA], [keyB]) => this.transformParameterOrder.get(keyA)! - this.transformParameterOrder.get(keyB)!)
238
243
 
239
- for (const [key, value] of orderedEntries) {
240
- const transformedValue = await this.transformParameters.get(key)!.transform(value);
244
+ for (const [key, transformParameter] of transformParameters) {
245
+ if (desired[key] === undefined) {
246
+ continue;
247
+ }
248
+
249
+ const transformedValue = await transformParameter.transform(desired[key]);
241
250
 
242
251
  if (Object.keys(transformedValue).some((k) => desired[k] !== undefined)) {
243
252
  throw new Error(`Transform parameter ${key as string} is attempting to override existing values ${JSON.stringify(transformedValue, null, 2)}`);
244
253
  }
245
254
 
255
+ // Remove original transform parameter from the config
256
+ delete desired[key];
257
+
258
+ // Add the new transformed values
246
259
  Object.entries(transformedValue).forEach(([tvKey, tvValue]) => {
247
260
  // @ts-ignore
248
261
  desired[tvKey] = tvValue;
@@ -250,7 +263,11 @@ Additional: ${[...refreshKeys].filter(k => !desiredKeys.has(k))};`
250
263
  }
251
264
  }
252
265
 
253
- private addDefaultValues(desired: Partial<T>): void {
266
+ private addDefaultValues(desired: Partial<T> | null): void {
267
+ if (!desired) {
268
+ return;
269
+ }
270
+
254
271
  Object.entries(this.defaultValues)
255
272
  .forEach(([key, defaultValue]) => {
256
273
  if (defaultValue !== undefined && desired[key as any] === undefined) {
@@ -260,10 +277,11 @@ Additional: ${[...refreshKeys].filter(k => !desiredKeys.has(k))};`
260
277
  });
261
278
  }
262
279
 
263
- private async refreshResourceParameters(resourceParameters: Partial<T>): Promise<Partial<T> | null> {
264
- const entriesToRefresh = new Map(Object.entries(resourceParameters));
280
+ private async refreshNonStatefulParameters(resourceParameters: Partial<T>): Promise<Partial<T> | null> {
281
+ const entriesToRefresh = new Map<keyof T, T[keyof T]>(
282
+ Object.entries(resourceParameters)
283
+ )
265
284
  const currentParameters = await this.refresh(entriesToRefresh);
266
-
267
285
  this.validateRefreshResults(currentParameters, entriesToRefresh);
268
286
  return currentParameters;
269
287
  }
@@ -308,6 +326,20 @@ Additional: ${[...refreshKeys].filter(k => !desiredKeys.has(k))};`
308
326
  return currentParameters;
309
327
  }
310
328
 
329
+ private validatePlanInputs(
330
+ desired: Partial<T> & ResourceConfig | null,
331
+ current: Partial<T> & ResourceConfig | null,
332
+ statefulMode: boolean,
333
+ ) {
334
+ if (!desired && !current) {
335
+ throw new Error('Desired config and current config cannot both be missing')
336
+ }
337
+
338
+ if (!statefulMode && !desired) {
339
+ throw new Error('Desired config must be provided in non-stateful mode')
340
+ }
341
+ }
342
+
311
343
  async validate(parameters: unknown): Promise<ValidationResult> {
312
344
  return {
313
345
  isValid: true,
@@ -324,51 +356,76 @@ Additional: ${[...refreshKeys].filter(k => !desiredKeys.has(k))};`
324
356
  }
325
357
 
326
358
  class ConfigParser<T extends StringIndexedObject> {
327
- private config: Partial<T> & ResourceConfig;
359
+ private desiredConfig: Partial<T> & ResourceConfig | null;
360
+ private currentConfig: Partial<T> & ResourceConfig | null;
328
361
  private statefulParametersMap: Map<keyof T, StatefulParameter<T, T[keyof T]>>;
329
362
  private transformParametersMap: Map<keyof T, TransformParameter<T>>;
330
363
 
331
364
  constructor(
332
- config: Partial<T> & ResourceConfig,
365
+ desiredConfig: Partial<T> & ResourceConfig | null,
366
+ currentConfig: Partial<T> & ResourceConfig | null,
333
367
  statefulParameters: Map<keyof T, StatefulParameter<T, T[keyof T]>>,
334
- transformParameters: Map<keyof T, TransformParameter<T>>,
368
+ transformParameters: Map<keyof T, TransformParameter<T>>,
335
369
  ) {
336
- this.config = config;
370
+ this.desiredConfig = desiredConfig;
371
+ this.currentConfig = currentConfig
337
372
  this.statefulParametersMap = statefulParameters;
338
373
  this.transformParametersMap = transformParameters;
339
374
  }
340
375
 
341
376
  get resourceMetadata(): ResourceConfig {
342
- const { resourceMetadata } = splitUserConfig(this.config);
343
- return resourceMetadata;
377
+ const desiredMetadata = this.desiredConfig ? splitUserConfig(this.desiredConfig).resourceMetadata : undefined;
378
+ const currentMetadata = this.currentConfig ? splitUserConfig(this.currentConfig).resourceMetadata : undefined;
379
+
380
+ if (!desiredMetadata && !currentMetadata) {
381
+ throw new Error(`Unable to parse resource metadata from ${this.desiredConfig}, ${this.currentConfig}`)
382
+ }
383
+
384
+ if (currentMetadata && desiredMetadata && (
385
+ Object.keys(desiredMetadata).length !== Object.keys(currentMetadata).length
386
+ || Object.entries(desiredMetadata).some(([key, value]) => currentMetadata[key] !== value)
387
+ )) {
388
+ throw new Error(`The metadata for the current config does not match the desired config.
389
+ Desired metadata:
390
+ ${JSON.stringify(desiredMetadata, null, 2)}
391
+
392
+ Current metadata:
393
+ ${JSON.stringify(currentMetadata, null, 2)}`);
394
+ }
395
+
396
+ return desiredMetadata ?? currentMetadata!;
344
397
  }
345
398
 
346
- get parameters(): Partial<T> {
347
- const { parameters } = splitUserConfig(this.config);
399
+ get desiredParameters(): Partial<T> | null {
400
+ if (!this.desiredConfig) {
401
+ return null;
402
+ }
403
+
404
+ const { parameters } = splitUserConfig(this.desiredConfig);
348
405
  return parameters;
349
406
  }
350
407
 
351
- get resourceParameters(): Partial<T> {
352
- const parameters = this.parameters;
353
408
 
354
- return Object.fromEntries([
355
- ...Object.entries(parameters).filter(([key]) => !(this.statefulParametersMap.has(key) || this.transformParametersMap.has(key))),
356
- ]) as Partial<T>;
409
+ get parameters(): Partial<T> {
410
+ const desiredParameters = this.desiredConfig ? splitUserConfig(this.desiredConfig).parameters : undefined;
411
+ const currentParameters = this.currentConfig ? splitUserConfig(this.currentConfig).parameters : undefined;
412
+
413
+ return { ...(desiredParameters ?? {}), ...(currentParameters ?? {}) } as Partial<T>;
357
414
  }
358
415
 
359
- get statefulParameters(): Partial<T> {
416
+ get nonStatefulParameters(): Partial<T> {
360
417
  const parameters = this.parameters;
361
418
 
362
419
  return Object.fromEntries([
363
- ...Object.entries(parameters).filter(([key]) => this.statefulParametersMap.has(key)),
420
+ ...Object.entries(parameters).filter(([key]) => !(this.statefulParametersMap.has(key) || this.transformParametersMap.has(key))),
364
421
  ]) as Partial<T>;
365
422
  }
366
423
 
367
- get transformParameters(): Partial<T> {
424
+ get statefulParameters(): Partial<T> {
368
425
  const parameters = this.parameters;
369
426
 
370
427
  return Object.fromEntries([
371
- ...Object.entries(parameters).filter(([key]) => this.transformParametersMap.has(key)),
428
+ ...Object.entries(parameters).filter(([key]) => this.statefulParametersMap.has(key)),
372
429
  ]) as Partial<T>;
373
430
  }
374
431
  }
@@ -10,10 +10,8 @@ interface TestConfig {
10
10
  }
11
11
 
12
12
  class TestArrayParameter extends ArrayStatefulParameter<TestConfig, string> {
13
- constructor(options?: ArrayStatefulParameterOptions<TestConfig>) {
14
- super(options ?? {
15
- name: 'propA'
16
- })
13
+ constructor(options?: ArrayStatefulParameterOptions<string>) {
14
+ super(options)
17
15
  }
18
16
 
19
17
  async applyAddItem(item: string, plan: Plan<TestConfig>): Promise<void> {}
@@ -104,7 +102,6 @@ describe('Stateful parameter tests', () => {
104
102
  const testParameter = spy(new class extends TestArrayParameter {
105
103
  constructor() {
106
104
  super({
107
- name: 'propA',
108
105
  isElementEqual: (desired, current) => current.includes(desired),
109
106
  });
110
107
  }
package/src/index.ts CHANGED
@@ -11,7 +11,6 @@ export * from './entities/plan-types.js'
11
11
  export * from './entities/stateful-parameter.js'
12
12
  export * from './entities/errors.js'
13
13
 
14
- export * from './utils/test-utils.js'
15
14
  export * from './utils/utils.js'
16
15
 
17
16
  export async function runPlugin(plugin: Plugin) {
@@ -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?.({
@@ -1,52 +0,0 @@
1
- import { EventEmitter } from 'node:events';
2
- import { ChildProcess } from 'node:child_process';
3
- import { Readable } from 'stream';
4
- import { mock } from 'node:test';
5
- import { AssertionError } from 'chai';
6
- import { CodifyTestUtils } from './test-utils.js';
7
- import { describe, expect, it } from 'vitest';
8
-
9
- describe('Test Utils tests', async () => {
10
-
11
- const mockChildProcess = () => {
12
- const process = new ChildProcess();
13
- process.stdout = new EventEmitter() as Readable;
14
- process.stderr = new EventEmitter() as Readable
15
- process.send = () => true;
16
-
17
- return process;
18
- }
19
-
20
- it('send a message', async () => {
21
- const process = mockChildProcess();
22
- const sendMock = mock.method(process, 'send');
23
-
24
- CodifyTestUtils.sendMessageToProcessAwaitResponse(process, { cmd: 'message', data: 'data' })
25
-
26
- expect(sendMock.mock.calls.length).to.eq(1);
27
- expect(sendMock.mock.calls[0].arguments[0]).to.deep.eq({ cmd: 'message', data: 'data' });
28
- })
29
-
30
- it('send a message and receives the response', async () => {
31
- const process = mockChildProcess();
32
-
33
- try {
34
- const result = await Promise.all([
35
- (async () => {
36
- await sleep(30);
37
- process.emit('message', { cmd: 'messageResult', data: 'data' })
38
- })(),
39
- CodifyTestUtils.sendMessageToProcessAwaitResponse(process, { cmd: 'message', data: 'data' }),
40
- ]);
41
-
42
- expect(result[1]).to.deep.eq({ cmd: 'messageResult', data: 'data' })
43
- } catch (e) {
44
- console.log(e);
45
- throw new AssertionError('Failed to receive message');
46
- }
47
- });
48
- });
49
-
50
- async function sleep(ms: number) {
51
- return new Promise((resolve, reject) => setTimeout(resolve, ms))
52
- }
@@ -1,20 +0,0 @@
1
- import { ChildProcess } from 'child_process';
2
-
3
- export class CodifyTestUtils {
4
- static sendMessageToProcessAwaitResponse(process: ChildProcess, message: any): Promise<any> {
5
- return new Promise((resolve, reject) => {
6
- process.on('message', (response) => {
7
- resolve(response)
8
- });
9
- process.on('error', (err) => reject(err))
10
- process.on('exit', (code) => {
11
- if (code != 0) {
12
- reject('Exit code is not 0');
13
- }
14
- resolve(code);
15
- })
16
- process.send(message);
17
- });
18
- }
19
-
20
- }