Skip to content

Commit 9c7c765

Browse files
committed
Added validation plan after apply in plugins. This plan will check that noop is returned for the same plan. Added tests. Changed constructor for plugin to be Plugin.create(). Added handling for apply validation errors. Changed canModify to modifyOnChange.
1 parent ff1c18a commit 9c7c765

9 files changed

Lines changed: 159 additions & 26 deletions

File tree

src/entities/errors.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { Plan } from './plan.js';
2+
import { StringIndexedObject } from 'codify-schemas';
3+
14
export class SudoError extends Error {
25
command: string;
36

@@ -6,3 +9,17 @@ export class SudoError extends Error {
69
this.command = command;
710
}
811
}
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+
}

src/entities/plan-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export interface ParameterOptions {
55
/**
66
* Chose if the resource should be re-created or modified if this parameter is changed. Defaults to false (re-creates resource on change).
77
*/
8-
canModify?: boolean;
8+
modifyOnChange?: boolean;
99
/**
1010
* Customize the equality comparison for a parameter.
1111
* @param a

src/entities/plan.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ export class Plan<T extends StringIndexedObject> {
5555
let newOperation: ResourceOperation;
5656
if (statefulParameterNames.has(curr.name)) {
5757
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;
6060
} else {
6161
newOperation = ResourceOperation.RECREATE; // Default to Re-create. Should handle the majority of use cases
6262
}
@@ -75,7 +75,7 @@ export class Plan<T extends StringIndexedObject> {
7575
return this.resourceMetadata.type
7676
}
7777

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> {
7979
if (!data) {
8080
throw new Error('Data is empty');
8181
}
@@ -95,7 +95,7 @@ export class Plan<T extends StringIndexedObject> {
9595
);
9696

9797
function addDefaultValues(): void {
98-
Object.entries(defaultValues)
98+
Object.entries(defaultValues ?? {})
9999
.forEach(([key, defaultValue]) => {
100100
const configValueExists = data!
101101
.parameters

src/entities/plugin.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

src/entities/plugin.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,24 @@ import {
55
PlanRequestData,
66
PlanResponseData,
77
ResourceConfig,
8+
ResourceOperation,
89
ValidateRequestData,
910
ValidateResponseData
1011
} from 'codify-schemas';
1112
import { Plan } from './plan.js';
1213
import { splitUserConfig } from '../utils/utils.js';
14+
import { ApplyValidationError } from './errors.js';
1315

1416
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+
}
1626

1727
constructor(
1828
public name: string,
@@ -82,6 +92,13 @@ export class Plugin {
8292
}
8393

8494
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+
}
85102
}
86103

87104
private resolvePlan(data: ApplyRequestData): Plan<ResourceConfig> {

src/entities/resource-parameters.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ describe('Resource parameter tests', () => {
119119
type: 'resource',
120120
parameterOptions: {
121121
propA: { statefulParameter: statefulParameterSpy },
122-
propB: { canModify: true },
122+
propB: { modifyOnChange: true },
123123
}
124124
});
125125
}

src/entities/resource-types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export interface ResourceParameterOptions {
77
/**
88
* Chose if the resource should be re-created or modified if this parameter is changed. Defaults to false (re-create).
99
*/
10-
canModify?: boolean;
10+
modifyOnChange?: boolean;
1111
/**
1212
* Customize the equality comparison for a parameter.
1313
* @param desired

src/entities/resource.test.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,8 @@ describe('Resource tests', () => {
195195
super({
196196
type: 'resource',
197197
parameterOptions: {
198-
propA: { canModify: true },
199-
propB: { canModify: true },
198+
propA: { modifyOnChange: true },
199+
propB: { modifyOnChange: true },
200200
}
201201
});
202202
}
@@ -218,12 +218,6 @@ describe('Resource tests', () => {
218218

219219
it('Validates the resource options correct (pass)', () => {
220220
const statefulParameter = new class extends StatefulParameter<TestConfig, string> {
221-
constructor() {
222-
super({
223-
name: 'propC',
224-
});
225-
}
226-
227221
async refresh(): Promise<string | null> {
228222
return null;
229223
}
@@ -244,7 +238,7 @@ describe('Resource tests', () => {
244238
type: 'type',
245239
dependencies: ['homebrew', 'python'],
246240
parameterOptions: {
247-
propA: { canModify: true },
241+
propA: { modifyOnChange: true },
248242
propB: { statefulParameter },
249243
propC: { isEqual: (a, b) => true },
250244
}
@@ -255,12 +249,6 @@ describe('Resource tests', () => {
255249

256250
it('Validates the resource options correct (fail)', () => {
257251
const statefulParameter = new class extends StatefulParameter<TestConfig, string> {
258-
constructor() {
259-
super({
260-
name: 'propC',
261-
});
262-
}
263-
264252
async refresh(): Promise<string | null> {
265253
return null;
266254
}
@@ -281,7 +269,7 @@ describe('Resource tests', () => {
281269
type: 'type',
282270
dependencies: ['homebrew', 'python'],
283271
parameterOptions: {
284-
propA: { canModify: true },
272+
propA: { modifyOnChange: true },
285273
propB: { statefulParameter },
286274
propC: { isEqual: (a, b) => true },
287275
}

src/messages/handlers.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
ValidateResponseDataSchema
1616
} from 'codify-schemas';
1717
import Ajv2020, { SchemaObject, ValidateFunction } from 'ajv/dist/2020.js';
18-
import { SudoError } from '../entities/errors.js';
18+
import { ApplyValidationError, SudoError } from '../entities/errors.js';
1919

2020
const SupportedRequests: Record<string, { requestValidator: SchemaObject; responseValidator: SchemaObject; handler: (plugin: Plugin, data: any) => Promise<unknown> }> = {
2121
'initialize': {
@@ -117,13 +117,28 @@ export class MessageHandler {
117117
const cmd = message.cmd + '_Response';
118118

119119
if (e instanceof SudoError) {
120-
process.send?.({
120+
return process.send?.({
121121
cmd,
122122
status: MessageStatus.ERROR,
123123
data: `Plugin: '${this.plugin.name}'. Forbidden usage of sudo for command '${e.command}'. Please contact the plugin developer to fix this.`,
124124
})
125125
}
126126

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+
127142
const isDebug = process.env.DEBUG?.includes('*') ?? false;
128143

129144
process.send?.({

0 commit comments

Comments
 (0)