Skip to content

Commit 3e9ea80

Browse files
feat: add find instances ipc call and supersededBy flag (#24)
1 parent c9290a9 commit 3e9ea80

8 files changed

Lines changed: 214 additions & 22 deletions

File tree

‎.npmrc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
min-release-age=7
2+
min-release-age-exclude[]=@codifycli/*

‎package-lock.json‎

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@codifycli/plugin-core",
3-
"version": "1.2.11",
3+
"version": "1.3.0",
44
"description": "TypeScript library for building Codify plugins to manage system resources (applications, CLI tools, settings) through infrastructure-as-code",
55
"main": "dist/index.js",
66
"typings": "dist/index.d.ts",
@@ -35,7 +35,7 @@
3535
},
3636
"license": "ISC",
3737
"dependencies": {
38-
"@codifycli/schemas": "^1.2.0",
38+
"@codifycli/schemas": "^1.4.0",
3939
"ajv": "^8.18.0",
4040
"ajv-formats": "^2.1.1",
4141
"clean-deep": "^3.4.0",

‎src/messages/handlers.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
ApplyRequestDataSchema,
33
EmptyResponseDataSchema,
4+
FindInstancesRequestDataSchema,
5+
FindInstancesResponseDataSchema,
46
GetResourceInfoRequestDataSchema,
57
GetResourceInfoResponseDataSchema,
68
ImportRequestDataSchema,
@@ -63,6 +65,11 @@ const SupportedRequests: Record<string, { handler: (plugin: Plugin, data: any) =
6365
requestValidator: ImportRequestDataSchema,
6466
responseValidator: ImportResponseDataSchema
6567
},
68+
'findInstances': {
69+
handler: async (plugin: Plugin, data: any) => plugin.findInstances(data),
70+
requestValidator: FindInstancesRequestDataSchema,
71+
responseValidator: FindInstancesResponseDataSchema
72+
},
6673
'plan': {
6774
handler: async (plugin: Plugin, data: any) => plugin.plan(data),
6875
requestValidator: PlanRequestDataSchema,

‎src/plugin/plugin.test.ts‎

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -530,4 +530,120 @@ describe('Plugin tests', () => {
530530
})
531531
).rejects.toThrowError();
532532
})
533+
534+
describe('findInstances', () => {
535+
class MultipleResource extends TestResource {
536+
findAllCalls = 0;
537+
538+
getSettings(): ResourceSettings<TestConfig> {
539+
return {
540+
id: 'testResource',
541+
operatingSystems: [OS.Darwin],
542+
importAndDestroy: {
543+
preventImport: true,
544+
},
545+
allowMultiple: {
546+
identifyingParameters: ['propA'],
547+
findAllParameters: async () => {
548+
this.findAllCalls++;
549+
return [{ propA: 'a' }, { propA: 'b' }, { propA: 'missing' }];
550+
},
551+
},
552+
}
553+
}
554+
555+
async refresh(parameters: Partial<TestConfig>): Promise<Partial<TestConfig> | null> {
556+
if (parameters.propA === 'missing') {
557+
return null;
558+
}
559+
560+
return { propA: parameters.propA, propB: 10 };
561+
}
562+
}
563+
564+
it('Finds every existing instance even if the resource prevents imports', async () => {
565+
const resource = new MultipleResource();
566+
const testPlugin = Plugin.create('testPlugin', [resource]);
567+
568+
const result = await testPlugin.findInstances({ core: { type: 'testResource' } });
569+
570+
expect(result.request).toMatchObject({ core: { type: 'testResource' } });
571+
expect(result.result).toEqual([
572+
{ core: { type: 'testResource' }, parameters: { propA: 'a', propB: 10 } },
573+
{ core: { type: 'testResource' }, parameters: { propA: 'b', propB: 10 } },
574+
]);
575+
expect(resource.findAllCalls).to.eq(1);
576+
})
577+
578+
it('Still prevents imports when preventImport is set', async () => {
579+
const testPlugin = Plugin.create('testPlugin', [new MultipleResource()]);
580+
581+
await expect(testPlugin.import({ core: { type: 'testResource' }, parameters: {}, autoSearchAll: true }))
582+
.rejects.toThrowError();
583+
})
584+
585+
it('Import all uses the same discovery and only calls findAllParameters once', async () => {
586+
const resource = new class extends MultipleResource {
587+
getSettings(): ResourceSettings<TestConfig> {
588+
return { ...super.getSettings(), importAndDestroy: undefined };
589+
}
590+
}
591+
const testPlugin = Plugin.create('testPlugin', [resource]);
592+
593+
const result = await testPlugin.import({ core: { type: 'testResource' }, parameters: {}, autoSearchAll: true });
594+
595+
expect(result.result.map((r) => r.parameters.propA)).toEqual(['a', 'b']);
596+
expect(resource.findAllCalls).to.eq(1);
597+
})
598+
599+
it('Returns nothing if the resource can not discover instances', async () => {
600+
const singleton = Plugin.create('testPlugin', [new TestResource()]);
601+
expect((await singleton.findInstances({ core: { type: 'testResource' } })).result).toEqual([]);
602+
603+
const noFindAll = Plugin.create('testPlugin', [new class extends TestResource {
604+
getSettings(): ResourceSettings<TestConfig> {
605+
return { ...super.getSettings(), allowMultiple: { identifyingParameters: ['propA'] } };
606+
}
607+
}]);
608+
expect((await noFindAll.findInstances({ core: { type: 'testResource' } })).result).toEqual([]);
609+
})
610+
611+
it('Errors for unknown resources', async () => {
612+
const testPlugin = Plugin.create('testPlugin', [new TestResource()]);
613+
await expect(testPlugin.findInstances({ core: { type: 'unknown' } })).rejects.toThrowError();
614+
})
615+
})
616+
617+
describe('supersededBy', () => {
618+
const singular = new class extends TestResource {
619+
getSettings(): ResourceSettings<TestConfig> {
620+
return { id: 'singular', operatingSystems: [OS.Darwin], importAndDestroy: { supersededBy: 'plural' } };
621+
}
622+
}
623+
624+
const plural = new class extends TestResource {
625+
getSettings(): ResourceSettings<TestConfig> {
626+
return { id: 'plural', operatingSystems: [OS.Darwin] };
627+
}
628+
}
629+
630+
it('Returns supersededBy in the resource definitions', async () => {
631+
const testPlugin = Plugin.create('testPlugin', [singular, plural]);
632+
const { resourceDefinitions } = await testPlugin.initialize({});
633+
634+
expect(resourceDefinitions.find((d) => d.type === 'singular')?.supersededBy).to.eq('plural');
635+
expect(resourceDefinitions.find((d) => d.type === 'plural')?.supersededBy).to.be.undefined;
636+
})
637+
638+
it('Errors if supersededBy is not another resource in the plugin', () => {
639+
expect(() => Plugin.create('testPlugin', [singular])).toThrowError(/supersededBy/);
640+
641+
const self = new class extends TestResource {
642+
getSettings(): ResourceSettings<TestConfig> {
643+
return { id: 'self', operatingSystems: [OS.Darwin], importAndDestroy: { supersededBy: 'self' } };
644+
}
645+
}
646+
expect(() => Plugin.create('testPlugin', [self])).toThrowError(/supersededBy/);
647+
})
648+
})
533649
});

‎src/plugin/plugin.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {
22
ApplyRequestData,
3+
FindInstancesRequestData,
4+
FindInstancesResponseData,
35
GetResourceInfoRequestData,
46
GetResourceInfoResponseData,
57
ImportRequestData,
@@ -54,6 +56,13 @@ export class Plugin {
5456
controllers.map((r) => [r.typeId, r] as const)
5557
);
5658

59+
for (const controller of controllers) {
60+
const supersededBy = controller.settings.importAndDestroy?.supersededBy;
61+
if (supersededBy !== undefined && (supersededBy === controller.typeId || !controllersMap.has(supersededBy))) {
62+
throw new Error(`Resource: ${controller.typeId}. importAndDestroy.supersededBy must be the type of another resource in this plugin. Received: ${supersededBy}`);
63+
}
64+
}
65+
5766
return new Plugin(name, controllersMap, options);
5867
}
5968

@@ -87,6 +96,7 @@ export class Plugin {
8796
sensitiveParameters,
8897
operatingSystems: r.settings.operatingSystems,
8998
linuxDistros: r.settings.linuxDistros,
99+
supersededBy: r.settings.importAndDestroy?.supersededBy,
90100
}
91101
})
92102
}
@@ -173,6 +183,25 @@ export class Plugin {
173183
}
174184
}
175185

186+
async findInstances(data: FindInstancesRequestData): Promise<FindInstancesResponseData> {
187+
const { core } = data;
188+
189+
if (!this.resourceControllers.has(core.type)) {
190+
throw new Error(`Cannot find instances for resource ${core.type}, resource doesn't exist`);
191+
}
192+
193+
const result = await ptyLocalStorage.run(this.planPty, () =>
194+
this.resourceControllers
195+
.get(core.type)!
196+
.findInstances(core)
197+
)
198+
199+
return {
200+
request: data,
201+
result,
202+
}
203+
}
204+
176205
async validate(data: ValidateRequestData): Promise<ValidateResponseData> {
177206
const validationResults: ValidateResponseData['resourceValidations'] = [];
178207
for (const config of data.configs) {

‎src/resource/resource-controller.ts‎

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -276,26 +276,51 @@ export class ResourceController<T extends StringIndexedObject> {
276276
throw new Error(`Type: ${this.typeId} cannot be imported`);
277277
}
278278

279-
const context: RefreshContext<T> = {
280-
commandType: 'import',
281-
isStateful: true,
282-
originalDesiredConfig: structuredClone(parameters),
283-
};
284-
285279
// Auto search means that no required parameters will be provided. We will try to generate it ourselves or return an
286280
// empty array if they can't be.
287281
if (autoSearchAll && this.settings.allowMultiple) {
288-
if (this.settings.allowMultiple === true || !this.settings.allowMultiple.findAllParameters?.()) {
289-
return [];
290-
}
282+
return this.findInstances(core);
283+
}
291284

292-
const parametersToImport = await this.settings.allowMultiple.findAllParameters?.();
293-
const results = await Promise.all(parametersToImport.map((p) =>
294-
this.import(core, p).catch(() => null))
295-
);
296-
return results.filter(Boolean).flat() as ResourceJson[];
285+
return this.refreshForImport(core, parameters);
286+
}
287+
288+
/**
289+
* Find every instance of this resource that currently exists on the system using
290+
* {@link ResourceSettings.allowMultiple.findAllParameters}. Each instance is refreshed so only instances that
291+
* actually exist are returned.
292+
*
293+
* Unlike {@link import}, this is discovery only and is not blocked by `importAndDestroy.preventImport` (ex: destroy
294+
* uses it to let the user pick which instance to remove). Returns an empty array if the resource doesn't support
295+
* multiple instances or can't discover them.
296+
*
297+
* @param core The core config (type, name) to attach to each instance found
298+
* @returns Every instance found on the system
299+
*/
300+
async findInstances(core: ResourceConfig): Promise<Array<ResourceJson>> {
301+
const { allowMultiple } = this.settings;
302+
if (!allowMultiple || allowMultiple === true || !allowMultiple.findAllParameters) {
303+
return [];
297304
}
298305

306+
const parametersToFind = await allowMultiple.findAllParameters();
307+
const results = await Promise.all(parametersToFind.map((p) =>
308+
this.refreshForImport(core, p).catch(() => null))
309+
);
310+
311+
return results.filter(Boolean).flat() as ResourceJson[];
312+
}
313+
314+
private async refreshForImport(
315+
core: ResourceConfig,
316+
parameters: Partial<T>,
317+
): Promise<Array<ResourceJson>> {
318+
const context: RefreshContext<T> = {
319+
commandType: 'import',
320+
isStateful: true,
321+
originalDesiredConfig: structuredClone(parameters),
322+
};
323+
299324
this.addDefaultValues(parameters);
300325
await this.applyTransformations(parameters);
301326

‎src/resource/resource-settings.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,23 @@ export interface ResourceSettings<T extends StringIndexedObject> {
156156
* resource valid for imports. Defaults to false.
157157
*
158158
* Resources that can't be imported in the core library for example are: action resources
159+
*
160+
* Only use this for resources that truly can't be imported. If the resource conflicts with another resource during
161+
* import all (ex: `alias` and `aliases` manage the same state) use {@link supersededBy} instead.
159162
*/
160163
preventImport?: boolean;
161164

165+
/**
166+
* The type id of another resource that manages the same system state as this one, typically the plural version of
167+
* this resource (ex: `alias` is superseded by `aliases`, `git-repository` by `git-repositories`).
168+
*
169+
* When both resources are imported automatically (`codify init` / `codify import` with no arguments), this resource
170+
* is skipped so the same state isn't imported twice. It can still be imported directly (`codify import alias`),
171+
* planned, applied and destroyed, and its instances can still be discovered with
172+
* {@link allowMultiple.findAllParameters}.
173+
*/
174+
supersededBy?: string;
175+
162176
/**
163177
* Can this resources be destroyed? If set to false then the codifyCLI will skip over/not consider this
164178
* resource valid for destroys. Defaults to false.

0 commit comments

Comments
 (0)