diff --git a/packages/extension-api/src/extension-api.d.ts b/packages/extension-api/src/extension-api.d.ts index 739098c15f355..d394c2731df20 100644 --- a/packages/extension-api/src/extension-api.d.ts +++ b/packages/extension-api/src/extension-api.d.ts @@ -136,6 +136,35 @@ declare module '@podman-desktop/api' { readonly storagePath: string; } + /** + * A provider result represents the values a provider, like the {@linkcode ImageCheckerProvider}, + * may return. For once this is the actual result type `T`, like `ImageChecks`, or a Promise that resolves + * to that type `T`. In addition, `null` and `undefined` can be returned - either directly or from a + * Promise. + * + * The snippets below are all valid implementations of the {@linkcode ImageCheckerProvider}: + * + * ```ts + * let a: ImageCheckerProvider = { + * check(image: ImageInfo, token?: CancellationToken): ProviderResult { + * return new ImageChecks(); + * } + * + * let b: ImageCheckerProvider = { + * async check(image: ImageInfo, token?: CancellationToken): ProviderResult { + * return new ImageChecks(); + * } + * } + * + * let c: ImageCheckerProvider = { + * check(image: ImageInfo, token?: CancellationToken): ProviderResult { + * return; // undefined + * } + * } + * ``` + */ + export type ProviderResult = T | undefined | Promise; + export type ProviderStatus = | 'not-installed' | 'installed' @@ -2488,4 +2517,30 @@ declare module '@podman-desktop/api' { */ export function createCliTool(options: CliToolOptions): CliTool; } + + export interface ImageCheck { + name: string; + status: 'success' | 'failed'; + severity?: 'low' | 'medium' | 'high' | 'critical'; + markdownDescription?: string; + } + + export interface ImageChecks { + checks: ImageCheck[]; + } + + export interface ImageCheckerProvider { + check(image: ImageInfo, token?: CancellationToken): ProviderResult; + } + + export interface ImageCheckerProviderMetadata { + readonly label: string; + } + + export namespace imageChecker { + export function registerImageCheckerProvider( + imageCheckerProvider: ImageCheckerProvider, + metadata?: ImageCheckerProviderMetadata, + ): Disposable; + } } diff --git a/packages/main/src/plugin/api/image-checker-info.ts b/packages/main/src/plugin/api/image-checker-info.ts new file mode 100644 index 0000000000000..96ae1e9128177 --- /dev/null +++ b/packages/main/src/plugin/api/image-checker-info.ts @@ -0,0 +1,9 @@ +export type ImageCheckerExtensionInfo = { + id: string; + label: string; +}; + +export interface ImageCheckerInfo { + id: string; + label: string; +} diff --git a/packages/main/src/plugin/authentication.spec.ts b/packages/main/src/plugin/authentication.spec.ts index 96b3ab8b8b46a..bd0b34cecef15 100644 --- a/packages/main/src/plugin/authentication.spec.ts +++ b/packages/main/src/plugin/authentication.spec.ts @@ -55,6 +55,7 @@ import type { Exec } from './util/exec.js'; import type { KubeGeneratorRegistry } from '/@/plugin/kube-generator-registry.js'; import type { CliToolRegistry } from './cli-tool-registry.js'; import type { NotificationRegistry } from './notification-registry.js'; +import type { ImageCheckerImpl } from './image-checker.js'; vi.mock('../util.js', async () => { return { @@ -276,6 +277,7 @@ suite('Authentication', () => { vi.fn() as unknown as KubeGeneratorRegistry, vi.fn() as unknown as CliToolRegistry, vi.fn() as unknown as NotificationRegistry, + vi.fn() as unknown as ImageCheckerImpl, ); providerMock = { onDidChangeSessions: vi.fn(), diff --git a/packages/main/src/plugin/cli-tool-registry.spec.ts b/packages/main/src/plugin/cli-tool-registry.spec.ts index 4c5353828248b..8fd8f18f503a1 100644 --- a/packages/main/src/plugin/cli-tool-registry.spec.ts +++ b/packages/main/src/plugin/cli-tool-registry.spec.ts @@ -47,6 +47,7 @@ import type { Proxy } from './proxy.js'; import { afterEach } from 'node:test'; import type { CliToolOptions } from '@podman-desktop/api'; import type { NotificationRegistry } from './notification-registry.js'; +import type { ImageCheckerImpl } from './image-checker.js'; const apiSender: ApiSenderType = { send: vi.fn(), @@ -92,6 +93,7 @@ suite('cli module', () => { vi.fn() as unknown as KubeGeneratorRegistry, cliToolRegistry, vi.fn() as unknown as NotificationRegistry, + vi.fn() as unknown as ImageCheckerImpl, ); }); diff --git a/packages/main/src/plugin/extension-loader.spec.ts b/packages/main/src/plugin/extension-loader.spec.ts index 93a1f209fc271..1069de1f34de1 100644 --- a/packages/main/src/plugin/extension-loader.spec.ts +++ b/packages/main/src/plugin/extension-loader.spec.ts @@ -51,6 +51,7 @@ import { Exec } from './util/exec.js'; import type { KubeGeneratorRegistry } from '/@/plugin/kube-generator-registry.js'; import type { CliToolRegistry } from './cli-tool-registry.js'; import type { NotificationRegistry } from './notification-registry.js'; +import type { ImageCheckerImpl } from './image-checker.js'; class TestExtensionLoader extends ExtensionLoader { public async setupScanningDirectory(): Promise { @@ -150,6 +151,8 @@ const exec = new Exec(proxy); const notificationRegistry: NotificationRegistry = {} as unknown as NotificationRegistry; +const imageCheckerImpl: ImageCheckerImpl = {} as unknown as ImageCheckerImpl; + /* eslint-disable @typescript-eslint/no-empty-function */ beforeAll(() => { extensionLoader = new TestExtensionLoader( @@ -180,6 +183,7 @@ beforeAll(() => { kubernetesGeneratorRegistry, cliToolRegistry, notificationRegistry, + imageCheckerImpl, ); }); diff --git a/packages/main/src/plugin/extension-loader.ts b/packages/main/src/plugin/extension-loader.ts index b4675c94f0a09..af9aaaecd7c29 100644 --- a/packages/main/src/plugin/extension-loader.ts +++ b/packages/main/src/plugin/extension-loader.ts @@ -68,6 +68,7 @@ import { ExtensionLoaderSettings } from './extension-loader-settings.js'; import type { KubeGeneratorRegistry, KubernetesGeneratorProvider } from '/@/plugin/kube-generator-registry.js'; import type { CliToolRegistry } from './cli-tool-registry.js'; import type { NotificationRegistry } from './notification-registry.js'; +import type { ImageCheckerImpl } from './image-checker.js'; /** * Handle the loading of an extension @@ -158,6 +159,7 @@ export class ExtensionLoader { private kubeGeneratorRegistry: KubeGeneratorRegistry, private cliToolRegistry: CliToolRegistry, private notificationRegistry: NotificationRegistry, + private imageCheckerProvider: ImageCheckerImpl, ) { this.pluginsDirectory = directories.getPluginsDirectory(); this.pluginsScanDirectory = directories.getPluginsScanDirectory(); @@ -1036,6 +1038,17 @@ export class ExtensionLoader { }, }; + const imageCheckerProvider = this.imageCheckerProvider; + console.log('imageCheckerProvider', this.imageCheckerProvider); + const imageChecker: typeof containerDesktopAPI.imageChecker = { + registerImageCheckerProvider: ( + provider: containerDesktopAPI.ImageCheckerProvider, + metadata?: containerDesktopAPI.ImageCheckerProviderMetadata, + ): containerDesktopAPI.Disposable => { + return imageCheckerProvider.registerImageCheckerProvider(extensionInfo, provider, metadata); + }, + }; + return { // Types Disposable: Disposable, @@ -1064,6 +1077,7 @@ export class ExtensionLoader { authentication, context: contextAPI, cli, + imageChecker, }; } diff --git a/packages/main/src/plugin/image-checker.spec.ts b/packages/main/src/plugin/image-checker.spec.ts new file mode 100644 index 0000000000000..3fc0d00f25fca --- /dev/null +++ b/packages/main/src/plugin/image-checker.spec.ts @@ -0,0 +1,244 @@ +/********************************************************************** + * Copyright (C) 2023 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { beforeEach, test, expect, vi, suite } from 'vitest'; +import { ExtensionLoader } from './extension-loader.js'; +import type { Exec } from './util/exec.js'; +import type { ApiSenderType } from './api.js'; +import type { AuthenticationImpl } from './authentication.js'; +import type { CliToolRegistry } from './cli-tool-registry.js'; +import type { CommandRegistry } from './command-registry.js'; +import type { ConfigurationRegistry } from './configuration-registry.js'; +import type { ContainerProviderRegistry } from './container-registry.js'; +import type { CustomPickRegistry } from './custompick/custompick-registry.js'; +import type { Directories } from './directories.js'; +import type { FilesystemMonitoring } from './filesystem-monitoring.js'; +import type { IconRegistry } from './icon-registry.js'; +import type { ImageRegistry } from './image-registry.js'; +import type { InputQuickPickRegistry } from './input-quickpick/input-quickpick-registry.js'; +import type { KubeGeneratorRegistry } from './kube-generator-registry.js'; +import type { KubernetesClient } from './kubernetes-client.js'; +import type { MenuRegistry } from './menu-registry.js'; +import type { MessageBox } from './message-box.js'; +import type { OnboardingRegistry } from './onboarding-registry.js'; +import type { ProgressImpl } from './progress-impl.js'; +import type { ProviderRegistry } from './provider-registry.js'; +import type { StatusBarRegistry } from './statusbar/statusbar-registry.js'; +import type { Telemetry } from './telemetry/telemetry.js'; +import type { TrayMenuRegistry } from './tray-menu-registry.js'; +import type { ViewRegistry } from './view-registry.js'; +import type { Context } from './context/context.js'; +import type { Proxy } from './proxy.js'; +import { afterEach } from 'node:test'; +import type { CancellationToken, ImageChecks, ImageInfo, ProviderResult } from '@podman-desktop/api'; +import type { NotificationRegistry } from './notification-registry.js'; +import { ImageCheckerImpl } from './image-checker.js'; + +const apiSender: ApiSenderType = { + send: vi.fn(), + receive: vi.fn(), +}; + +const directories = { + getPluginsDirectory: () => '/fake-plugins-directory', + getPluginsScanDirectory: () => '/fake-plugins-scanning-directory', + getExtensionsStorageDirectory: () => '/fake-extensions-storage-directory', +} as unknown as Directories; + +let imageChecker: ImageCheckerImpl; +suite('image checker module', () => { + let extLoader: ExtensionLoader; + beforeEach(() => { + imageChecker = new ImageCheckerImpl(apiSender); + extLoader = new ExtensionLoader( + vi.fn() as unknown as CommandRegistry, + vi.fn() as unknown as MenuRegistry, + vi.fn() as unknown as ProviderRegistry, + vi.fn() as unknown as ConfigurationRegistry, + vi.fn() as unknown as ImageRegistry, + vi.fn() as unknown as ApiSenderType, + vi.fn() as unknown as TrayMenuRegistry, + vi.fn() as unknown as MessageBox, + vi.fn() as unknown as ProgressImpl, + vi.fn() as unknown as StatusBarRegistry, + vi.fn() as unknown as KubernetesClient, + vi.fn() as unknown as FilesystemMonitoring, + vi.fn() as unknown as Proxy, + vi.fn() as unknown as ContainerProviderRegistry, + vi.fn() as unknown as InputQuickPickRegistry, + vi.fn() as unknown as CustomPickRegistry, + vi.fn() as unknown as AuthenticationImpl, + vi.fn() as unknown as IconRegistry, + vi.fn() as unknown as OnboardingRegistry, + vi.fn() as unknown as Telemetry, + vi.fn() as unknown as ViewRegistry, + vi.fn() as unknown as Context, + directories, + vi.fn() as unknown as Exec, + vi.fn() as unknown as KubeGeneratorRegistry, + vi.fn() as unknown as CliToolRegistry, + vi.fn() as unknown as NotificationRegistry, + imageChecker, + ); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + const extManifest = { + publisher: 'ext-publisher', + name: 'ext-name', + displayName: 'ext-display-name', + version: 'ext-version', + }; + suite('create ImageChecker', () => { + test('creates Imagechecker instance', () => { + const api = extLoader.createApi('/path', extManifest); + const provider1 = { + check: (_image: ImageInfo, _token?: CancellationToken): ProviderResult => { + return { checks: [] }; + }, + }; + const provider2 = { + check: (_image: ImageInfo, _token?: CancellationToken): ProviderResult => { + return { checks: [] }; + }, + }; + const metadata1 = { + label: 'Provider label', + }; + api.imageChecker.registerImageCheckerProvider(provider1, metadata1); + api.imageChecker.registerImageCheckerProvider(provider2); + const providers = imageChecker.getImageCheckerProviders(); + expect(providers.length).toBe(2); + + expect(providers[0].id).equals(`${extManifest.publisher}.${extManifest.name}-0`); + expect(providers[0].label).equals('Provider label'); + + expect(providers[1].id).equals(`${extManifest.publisher}.${extManifest.name}-1`); + expect(providers[1].label).equals(`${extManifest.displayName}`); + }); + + test('Image checker sends "image-checker-provider-update" event when new provider is added', () => { + const api = extLoader.createApi('/path', extManifest); + const provider = { + check: (_image: ImageInfo, _token?: CancellationToken): ProviderResult => { + return { checks: [] }; + }, + }; + api.imageChecker.registerImageCheckerProvider(provider); + expect(apiSender.send).toBeCalledWith('image-checker-provider-update', { + id: `${extManifest.publisher}.${extManifest.name}-0`, + }); + }); + + test('sends "image-checker-provider-remove" event when provider is disposed', () => { + const api = extLoader.createApi('/path', extManifest); + const provider = { + check: (_image: ImageInfo, _token?: CancellationToken): ProviderResult => { + return { checks: [] }; + }, + }; + const dispo = api.imageChecker.registerImageCheckerProvider(provider); + dispo.dispose(); + expect(apiSender.send).toBeCalledWith('image-checker-provider-remove', { + id: `${extManifest.publisher}.${extManifest.name}-0`, + }); + }); + + test('removes image checker from the registry when disposed', () => { + const api = extLoader.createApi('/path', extManifest); + const provider1 = { + check: (_image: ImageInfo, _token?: CancellationToken): ProviderResult => { + return { checks: [] }; + }, + }; + const provider2 = { + check: (_image: ImageInfo, _token?: CancellationToken): ProviderResult => { + return { checks: [] }; + }, + }; + const metadata1 = { + label: 'Provider label', + }; + const dispo1 = api.imageChecker.registerImageCheckerProvider(provider1, metadata1); + api.imageChecker.registerImageCheckerProvider(provider2); + const providers = imageChecker.getImageCheckerProviders(); + expect(providers.length).toBe(2); + dispo1.dispose(); + const providersAfterDispose = imageChecker.getImageCheckerProviders(); + expect(providersAfterDispose.length).toBe(1); + }); + + test('calls check method', async () => { + const api = extLoader.createApi('/path', extManifest); + const provider = { + check: (_image: ImageInfo, _token?: CancellationToken): ProviderResult => { + return { + checks: [ + { + name: 'check1', + status: 'failed', + }, + ], + }; + }, + }; + api.imageChecker.registerImageCheckerProvider(provider); + const providers = imageChecker.getImageCheckerProviders(); + expect(providers.length).toBe(1); + const imageInfo: ImageInfo = { + engineId: 'eng-id', + engineName: 'eng-name', + Id: 'id', + ParentId: 'parent-id', + RepoTags: undefined, + Created: 0, + Size: 1, + VirtualSize: 1, + SharedSize: 1, + Labels: {}, + Containers: 1, + }; + const result = await imageChecker.check(providers[0].id, imageInfo); + expect(result).toBeDefined(); + expect(result!.checks.length).toBe(1); + expect(result!.checks[0].name).toBe('check1'); + expect(result!.checks[0].status).toBe('failed'); + }); + + test('check method throws an error if provider is unknown', async () => { + const imageInfo: ImageInfo = { + engineId: 'eng-id', + engineName: 'eng-name', + Id: 'id', + ParentId: 'parent-id', + RepoTags: undefined, + Created: 0, + Size: 1, + VirtualSize: 1, + SharedSize: 1, + Labels: {}, + Containers: 1, + }; + await expect(() => imageChecker.check('unknown-id', imageInfo)).rejects.toThrow( + 'provider not found with id unknown-id', + ); + }); + }); +}); diff --git a/packages/main/src/plugin/image-checker.ts b/packages/main/src/plugin/image-checker.ts new file mode 100644 index 0000000000000..f76c1a04bbae3 --- /dev/null +++ b/packages/main/src/plugin/image-checker.ts @@ -0,0 +1,75 @@ +import type { + CancellationToken, + Disposable, + ImageCheckerProvider, + ImageCheckerProviderMetadata, + ImageChecks, + ImageInfo, +} from '@podman-desktop/api'; +import type { ApiSenderType } from './api.js'; +import type { ImageCheckerExtensionInfo, ImageCheckerInfo } from './api/image-checker-info.js'; + +export interface ImageCheckerProviderWithMetadata { + id: string; + label: string; + provider: ImageCheckerProvider; +} + +export class ImageCheckerImpl { + private _imageCheckerProviders: Map = new Map< + string, + ImageCheckerProviderWithMetadata + >(); + + constructor(private apiSender: ApiSenderType) {} + + registerImageCheckerProvider( + extensionInfo: ImageCheckerExtensionInfo, + provider: ImageCheckerProvider, + metadata?: ImageCheckerProviderMetadata, + ): Disposable { + const label = metadata?.label ?? extensionInfo.label; + const idBase = `${extensionInfo.id}-`; + let id: string = ''; + for (let i = 0; ; i++) { + const newId = idBase + i; + if (!this._imageCheckerProviders.get(newId)) { + id = newId; + break; + } + } + if (id === '') { + throw new Error(`Unable to register an image checker for extension '${extensionInfo.id}'.`); + } + this._imageCheckerProviders.set(id, { + id, + label, + provider, + }); + this.apiSender.send('image-checker-provider-update', { id }); + return { + dispose: () => { + this._imageCheckerProviders.delete(id); + this.apiSender.send('image-checker-provider-remove', { id }); + }, + }; + } + + getImageCheckerProviders(): ImageCheckerInfo[] { + return Array.from(this._imageCheckerProviders.keys()).map(k => { + const el = this._imageCheckerProviders.get(k)!; + return { + id: k, + label: el.label, + }; + }); + } + + async check(providerId: string, image: ImageInfo, token?: CancellationToken): Promise { + const provider = this._imageCheckerProviders.get(providerId); + if (provider === undefined) { + throw new Error('provider not found with id ' + providerId); + } + return provider.provider.check(image, token); + } +} diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index 61c1a4630cb59..afab0b630a8ab 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -141,6 +141,8 @@ import { CliToolRegistry } from './cli-tool-registry.js'; import type { CliToolInfo } from './api/cli-tool-info.js'; import type { NotificationCard, NotificationCardOptions } from './api/notification.js'; import { NotificationRegistry } from './notification-registry.js'; +import { ImageCheckerImpl } from './image-checker.js'; +import type { ImageCheckerInfo } from './api/image-checker-info.js'; type LogType = 'log' | 'warn' | 'trace' | 'debug' | 'error'; @@ -707,6 +709,8 @@ export class PluginSystem { const cliToolRegistry = new CliToolRegistry(apiSender, exec, telemetry); + const imageChecker = new ImageCheckerImpl(apiSender); + this.extensionLoader = new ExtensionLoader( commandRegistry, menuRegistry, @@ -735,6 +739,7 @@ export class PluginSystem { kubeGeneratorRegistry, cliToolRegistry, notificationRegistry, + imageChecker, ); await this.extensionLoader.init(); @@ -1979,6 +1984,27 @@ export class PluginSystem { return notificationRegistry.removeAll(); }); + this.ipcHandle('image-checker:getProviders', async (): Promise => { + return imageChecker.getImageCheckerProviders(); + }); + + this.ipcHandle( + 'image-checker:check', + async ( + _listener, + id: string, + image: ImageInfo, + tokenId?: number, + ): Promise => { + let token; + if (tokenId) { + const tokenSource = cancellationTokenRegistry.getCancellationTokenSource(tokenId); + token = tokenSource?.token; + } + return imageChecker.check(id, image, token); + }, + ); + const dockerDesktopInstallation = new DockerDesktopInstallation( apiSender, containerProviderRegistry, diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index c10f0ab30c4a9..1ea650d1786aa 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -54,6 +54,7 @@ import type { ProviderKubernetesConnectionInfo, } from '../../main/src/plugin/api/provider-info'; import type { CliToolInfo } from '../../main/src/plugin/api/cli-tool-info'; +import type { ImageCheckerInfo } from '../../main/src/plugin/api/image-checker-info'; import type { IConfigurationPropertyRecordedSchema } from '../../main/src/plugin/configuration-registry'; import type { PullEvent } from '../../main/src/plugin/api/pull-event'; import { Deferred } from './util/deferred'; @@ -1708,6 +1709,21 @@ function initExposure(): void { contextBridge.exposeInMainWorld('clearNotificationsQueue', async (): Promise => { return ipcInvoke('notificationRegistry:clearNotificationsQueue'); }); + + contextBridge.exposeInMainWorld('getImageCheckerProviders', async (): Promise => { + return ipcInvoke('image-checker:getProviders'); + }); + + contextBridge.exposeInMainWorld( + 'imageCheck', + async ( + id: string, + image: string, + cancellationToken?: number, + ): Promise => { + return ipcInvoke('image-checker:check', id, image, cancellationToken); + }, + ); } // expose methods