Skip to content

Commit

Permalink
feat: handle extensionPack and extensionDependencies when installing …
Browse files Browse the repository at this point in the history
…extensions

fixes containers#2829
fixes containers#2828

Signed-off-by: Florent Benoit <fbenoit@redhat.com>
  • Loading branch information
benoitf committed Jul 7, 2023
1 parent 7ad6455 commit 235d2d6
Show file tree
Hide file tree
Showing 3 changed files with 164 additions and 13 deletions.
7 changes: 6 additions & 1 deletion packages/main/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1679,7 +1679,12 @@ export class PluginSystem {
const dockerExtensionAdapter = new DockerPluginAdapter(contributionManager);
dockerExtensionAdapter.init();

const extensionInstaller = new ExtensionInstaller(apiSender, this.extensionLoader, imageRegistry);
const extensionInstaller = new ExtensionInstaller(
apiSender,
this.extensionLoader,
imageRegistry,
extensionsCatalog,
);
await extensionInstaller.init();

// launch the updater
Expand Down
66 changes: 64 additions & 2 deletions packages/main/src/plugin/install/extension-installer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ import { beforeAll, beforeEach, expect, test, vi } from 'vitest';

import { ExtensionInstaller } from './extension-installer.js';
import type { ApiSenderType } from '../api.js';
import type { ExtensionLoader } from '../extension-loader.js';
import type { AnalyzedExtension, ExtensionLoader } from '../extension-loader.js';
import type { ImageRegistry } from '../image-registry.js';
import * as path from 'node:path';
import type { IpcMain, IpcMainEvent } from 'electron';
import { ipcMain } from 'electron';
import type { ExtensionsCatalog } from '../extensions-catalog/extensions-catalog.js';
import type { CatalogFetchableExtension } from '../extensions-catalog/extensions-catalog-api.js';

let extensionInstaller: ExtensionInstaller;

Expand All @@ -39,10 +41,12 @@ getPluginsDirectoryMock.mockReturnValue('/fake/plugins/directory');
const listExtensionsMock = vi.fn();
const loadExtensionMock = vi.fn();
const analyzeExtensionMock = vi.fn();
const loadExtensionsMock = vi.fn();
const extensionLoader: ExtensionLoader = {
getPluginsDirectory: getPluginsDirectoryMock,
listExtensions: listExtensionsMock,
loadExtension: loadExtensionMock,
loadExtensions: loadExtensionsMock,
analyzeExtension: analyzeExtensionMock,
} as unknown as ExtensionLoader;

Expand All @@ -53,6 +57,11 @@ const imageRegistry: ImageRegistry = {
downloadAndExtractImage: downloadAndExtractImageMock,
} as unknown as ImageRegistry;

const getFetchableExtensionsMock = vi.fn();
const extensionsCatalog = {
getFetchableExtensions: getFetchableExtensionsMock,
} as unknown as ExtensionsCatalog;

vi.mock('electron', () => {
const mockIpcMain = {
on: vi.fn().mockReturnThis(),
Expand All @@ -61,7 +70,7 @@ vi.mock('electron', () => {
});

beforeAll(async () => {
extensionInstaller = new ExtensionInstaller(apiSender, extensionLoader, imageRegistry);
extensionInstaller = new ExtensionInstaller(apiSender, extensionLoader, imageRegistry, extensionsCatalog);
});

beforeEach(() => {
Expand All @@ -87,6 +96,10 @@ test('should install an image if labels are correct', async () => {
const spyExtractExtensionFiles = vi.spyOn(extensionInstaller, 'extractExtensionFiles');
spyExtractExtensionFiles.mockResolvedValueOnce();

analyzeExtensionMock.mockResolvedValueOnce({
manifest: {},
} as AnalyzedExtension);

await extensionInstaller.installFromImage(sendLog, sendError, sendEnd, imageToPull);

expect(sendLog).toHaveBeenCalledWith(`Analyzing image ${imageToPull}...`);
Expand Down Expand Up @@ -196,3 +209,52 @@ test('should report error', async () => {
// expect to have the sendError method called
expect(replyMethodMock).toHaveBeenCalledWith('extension-installer:install-from-image-error', 0, 'Error: fake error');
});

test('should install an image with extension pack', async () => {
const sendLog = vi.fn();
const sendError = vi.fn();
const sendEnd = vi.fn();

const imageToPull = 'fake.io/fake-image:fake-tag';
const analyzeFromImageSpy = vi.spyOn(extensionInstaller, 'analyzeFromImage');

const extension1 = {
manifest: {
extensionPack: ['my.other-extension'],
},
} as AnalyzedExtension;

const extension2 = {
manifest: {
extensionPack: ['my.other-extension'],
},
} as AnalyzedExtension;

analyzeFromImageSpy.mockResolvedValueOnce(extension1);
analyzeFromImageSpy.mockResolvedValueOnce(extension2);

// no installed extension
listExtensionsMock.mockResolvedValue([]);

const fetchableExtension: CatalogFetchableExtension = {
extensionId: 'my.other-extension',
link: 'my-other-extension-link',
version: 'latest',
};

getFetchableExtensionsMock.mockResolvedValueOnce([fetchableExtension]);

await extensionInstaller.installFromImage(sendLog, sendError, sendEnd, imageToPull);

// expect no error
expect(sendError).not.toHaveBeenCalled();

expect(sendEnd).toHaveBeenCalledWith('Extension Successfully installed.');

// extension started
expect(apiSenderSendMock).toHaveBeenCalledWith('extension-started', {});

// should have been called to load two extensions (current + extension pack)
// expect to have 2 arguments in array
expect(loadExtensionsMock).toHaveBeenCalledWith(expect.arrayContaining([extension1, extension2]));
});
104 changes: 94 additions & 10 deletions packages/main/src/plugin/install/extension-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,18 @@ import * as path from 'node:path';
import * as os from 'node:os';
import { cp } from 'node:fs/promises';
import * as tarFs from 'tar-fs';
import type { ExtensionLoader } from '../extension-loader.js';
import type { AnalyzedExtension, ExtensionLoader } from '../extension-loader.js';
import type { ApiSenderType } from '../api.js';
import type { ImageRegistry } from '../image-registry.js';
import type { ExtensionsCatalog } from '../extensions-catalog/extensions-catalog.js';
import type { CatalogFetchableExtension } from '../extensions-catalog/extensions-catalog-api.js';

export class ExtensionInstaller {
constructor(
private apiSender: ApiSenderType,
private extensionLoader: ExtensionLoader,
private imageRegistry: ImageRegistry,
private extensionCatalog: ExtensionsCatalog,
) {}

async extractExtensionFiles(tmpFolderPath: string, finalFolderPath: string, reportLog: (message: string) => void) {
Expand Down Expand Up @@ -92,12 +95,11 @@ export class ExtensionInstaller {
});
}

async installFromImage(
async analyzeFromImage(
sendLog: (message: string) => void,
sendError: (message: string) => void,
sendEnd: (message: string) => void,
imageName: string,
): Promise<void> {
): Promise<AnalyzedExtension | undefined> {
imageName = imageName.trim();
sendLog(`Analyzing image ${imageName}...`);
let imageConfigLabels;
Expand Down Expand Up @@ -157,17 +159,99 @@ export class ExtensionInstaller {
sendLog('Filtering image content...');
await this.extractExtensionFiles(tmpFolderPath, finalFolderPath, sendLog);

// refresh contributions
try {
const analyzedExtension = await this.extensionLoader.analyzeExtension(finalFolderPath, true);
if (analyzedExtension) {
await this.extensionLoader.loadExtension(analyzedExtension);
}
return this.extensionLoader.analyzeExtension(finalFolderPath, true);
} catch (error) {
sendError('Error while loading the extension ' + error);
sendError('Error while analyzing extension: ' + error);
return;
}
}

async installFromImage(
sendLog: (message: string) => void,
sendError: (message: string) => void,
sendEnd: (message: string) => void,
imageName: string,
): Promise<void> {
const analyzedExtension = await this.analyzeFromImage(sendLog, sendError, imageName);

if (!analyzedExtension) {
return;
}

// do we have extensionPack or extension dependencies
const dependencyExtensionIds: string[] = [];
if (analyzedExtension?.manifest?.extensionPack) {
dependencyExtensionIds.push(...analyzedExtension.manifest.extensionPack);
}
if (analyzedExtension?.manifest?.extensionDependencies) {
dependencyExtensionIds.push(...analyzedExtension.manifest.extensionDependencies);
}

// if we have dependencies, we need to install them first if not yet installed
if (dependencyExtensionIds.length > 0) {
const fetchableExtensions = await this.extensionCatalog.getFetchableExtensions();
const alreadyInstalledExtensionIds = (await this.extensionLoader.listExtensions()).map(extension => extension.id);

// need to install extensions are the one in dependency minus the one installed
const extensionsToInstall = dependencyExtensionIds.filter(
dependency => !alreadyInstalledExtensionIds.includes(dependency),
);

// check if all dependencies are in the catalog
const missingDependencies = extensionsToInstall.filter(
dependency => !fetchableExtensions.find(extension => extension.extensionId === dependency),
);
if (missingDependencies.length > 0) {
sendError(
`Extension ${
analyzedExtension.manifest.name
} has missing installable dependencies: ${missingDependencies.join(', ')} from extensionPack attribute.`,
);
return;
}

// install all extensions

// first, grab name of the OCI image for each extension
const imagesToInstall = (
extensionsToInstall
.map(extensionId => {
return fetchableExtensions.find(extension => extension.extensionId === extensionId);
})
.filter(extension => extension !== undefined) as CatalogFetchableExtension[]
).map(extension => extension.link);

// now we have all extensions, we can load them
const allAnalyzedExtensions = await Promise.all(
imagesToInstall.map(imageName => this.analyzeFromImage(sendLog, sendError, imageName)),
);

// if we have some undefined objects, it is an error, cleanup extensions
if (allAnalyzedExtensions.find(extension => extension === undefined)) {
allAnalyzedExtensions
.filter(extension => extension !== undefined)
.forEach(extension => {
extension?.path && fs.rmdirSync(extension.path, { recursive: true });
});
sendError('Error while analyzing extensions');
return;
}

// filter only analyzed extensions
const analyzedExtensions = allAnalyzedExtensions.filter(
extension => extension !== undefined,
) as AnalyzedExtension[];

// include in the list our current extension
analyzedExtensions.push(analyzedExtension);

// load all extensions
await this.extensionLoader.loadExtensions(analyzedExtensions);
} else {
await this.extensionLoader.loadExtension(analyzedExtension);
}

sendEnd('Extension Successfully installed.');
this.apiSender.send('extension-started', {});
}
Expand Down

0 comments on commit 235d2d6

Please sign in to comment.