diff --git a/extensions/minikube/.gitignore b/extensions/minikube/.gitignore new file mode 100644 index 0000000000000..8b137891791fe --- /dev/null +++ b/extensions/minikube/.gitignore @@ -0,0 +1 @@ + diff --git a/extensions/minikube/icon.png b/extensions/minikube/icon.png new file mode 100644 index 0000000000000..e9dc3639f64f9 Binary files /dev/null and b/extensions/minikube/icon.png differ diff --git a/extensions/minikube/logo-dark.png b/extensions/minikube/logo-dark.png new file mode 100644 index 0000000000000..e9dc3639f64f9 Binary files /dev/null and b/extensions/minikube/logo-dark.png differ diff --git a/extensions/minikube/logo-light.png b/extensions/minikube/logo-light.png new file mode 100644 index 0000000000000..e9dc3639f64f9 Binary files /dev/null and b/extensions/minikube/logo-light.png differ diff --git a/extensions/minikube/minikube b/extensions/minikube/minikube new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/extensions/minikube/package.json b/extensions/minikube/package.json new file mode 100644 index 0000000000000..2fd39bbbaf56b --- /dev/null +++ b/extensions/minikube/package.json @@ -0,0 +1,73 @@ +{ + "name": "minikube", + "displayName": "Minikube", + "description": "Integration for minikube: a tool that makes it easy to run Kubernetes locally", + "version": "0.0.1", + "icon": "icon.png", + "publisher": "podman-desktop", + "license": "Apache-2.0", + "engines": { + "podman-desktop": "^0.0.1" + }, + "main": "./dist/extension.js", + "contributes": { + "configuration": { + "title": "Minikube", + "properties": { + "minikube.cluster.creation.name": { + "type": "string", + "default": "minikube", + "scope": "KubernetesProviderConnectionFactory", + "description": "Name" + }, + "minikube.cluster.creation.driver": { + "type": "string", + "default": "podman", + "enum": [ + "podman", + "docker" + ], + "scope": "KubernetesProviderConnectionFactory", + "description": "Driver" + }, + "minikube.cluster.creation.runtime": { + "type": "string", + "default": "cri-o", + "enum": [ + "cri-o", + "containerd", + "docker" + ], + "scope": "KubernetesProviderConnectionFactory", + "description": "Container Runtime" + } + } + }, + "menus": { + "dashboard/image": [ + { + "command": "minikube.image.move", + "title": "Push image to minikube cluster" + } + ] + } + }, + "scripts": { + "build": "npx ts-node ./scripts/download.ts && vite build && node ./scripts/build.js", + "test": "vitest run --coverage", + "test:watch": "vitest watch --coverage", + "watch": "vite build -w" + }, + "dependencies": { + "@octokit/rest": "^19.0.11", + "@podman-desktop/api": "^0.0.1", + "mustache": "^4.2.0", + "sudo-prompt": "^9.2.1" + }, + "devDependencies": { + "7zip-min": "^1.4.4", + "mkdirp": "^2.1.6", + "vite": "^4.3.8", + "zip-local": "^0.3.5" + } +} diff --git a/extensions/minikube/scripts/build.js b/extensions/minikube/scripts/build.js new file mode 100755 index 0000000000000..49ecabc149e02 --- /dev/null +++ b/extensions/minikube/scripts/build.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +/********************************************************************** + * Copyright (C) 2022-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 + ***********************************************************************/ + +const zipper = require('zip-local'); +const path = require('path'); +const packageJson = require('../package.json'); +const fs = require('fs'); +const { mkdirp } = require('mkdirp'); + +const destFile = path.resolve(__dirname, `../${packageJson.name}.cdix`); +const builtinDirectory = path.resolve(__dirname, '../builtin'); +const unzippedDirectory = path.resolve(builtinDirectory, `${packageJson.name}.cdix`); +// remove the .cdix file before zipping +if (fs.existsSync(destFile)) { + fs.rmSync(destFile); +} +// remove the builtin folder before zipping +if (fs.existsSync(builtinDirectory)) { + fs.rmSync(builtinDirectory, { recursive: true, force: true }); +} + +zipper.sync.zip(path.resolve(__dirname, '../')).compress().save(destFile); + +mkdirp(unzippedDirectory).then(() => { + zipper.sync.unzip(destFile).save(unzippedDirectory); +}); diff --git a/extensions/minikube/scripts/download.ts b/extensions/minikube/scripts/download.ts new file mode 100644 index 0000000000000..da0ef4e48ab6e --- /dev/null +++ b/extensions/minikube/scripts/download.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env node +/********************************************************************** + * 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 * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Octokit } from 'octokit'; +import type { OctokitOptions } from '@octokit/core/dist-types/types'; + +const octokitOptions: OctokitOptions = {}; +if (process.env.GITHUB_TOKEN) { + octokitOptions.auth = process.env.GITHUB_TOKEN; +} +const octokit = new Octokit(octokitOptions); + +// to make this file a module +export {}; + +async function download(tagVersion: string, repoPath: string, fileName: string): Promise { + const destDir = path.resolve(__dirname, '..', 'src-generated'); + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir); + } +} diff --git a/extensions/minikube/src/create-cluster.spec.ts b/extensions/minikube/src/create-cluster.spec.ts new file mode 100644 index 0000000000000..17755a769b036 --- /dev/null +++ b/extensions/minikube/src/create-cluster.spec.ts @@ -0,0 +1,95 @@ +/********************************************************************** + * 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, expect, test, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import { createCluster } from './create-cluster'; +import { runCliCommand } from './util'; +import type { TelemetryLogger } from '@podman-desktop/api'; +import * as extensionApi from '@podman-desktop/api'; + +vi.mock('@podman-desktop/api', async () => { + return { + Logger: {}, + kubernetes: { + createResources: vi.fn(), + }, + }; +}); + +vi.mock('./util', async () => { + return { + runCliCommand: vi.fn(), + getMinikubePath: vi.fn(), + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +const telemetryLogUsageMock = vi.fn(); +const telemetryLogErrorMock = vi.fn(); +const telemetryLoggerMock = { + logUsage: telemetryLogUsageMock, + logError: telemetryLogErrorMock, +} as unknown as TelemetryLogger; + +test('expect error is cli returns non zero exit code', async () => { + try { + (runCliCommand as Mock).mockReturnValue({ exitCode: -1, error: 'error' }); + await createCluster({}, undefined, '', telemetryLoggerMock, undefined); + } catch (err) { + expect(err).to.be.a('Error'); + expect(err.message).equal('Failed to create minikube cluster. error'); + expect(telemetryLogErrorMock).toBeCalledWith('createCluster', expect.objectContaining({ error: 'error' })); + } +}); + +test('expect cluster to be created', async () => { + (runCliCommand as Mock).mockReturnValue({ exitCode: 0 }); + await createCluster({}, undefined, '', telemetryLoggerMock, undefined); + expect(telemetryLogUsageMock).toHaveBeenNthCalledWith( + 1, + 'createCluster', + expect.objectContaining({ driver: 'docker' }), + ); + expect(telemetryLogErrorMock).not.toBeCalled(); + expect(extensionApi.kubernetes.createResources).not.toBeCalled(); +}); + +test('expect error if Kubernetes reports error', async () => { + try { + (runCliCommand as Mock).mockReturnValue({ exitCode: 0 }); + const logger = { + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }; + (extensionApi.kubernetes.createResources as Mock).mockRejectedValue(new Error('Kubernetes error')); + await createCluster({}, logger, '', telemetryLoggerMock, undefined); + } catch (err) { + expect(extensionApi.kubernetes.createResources).toBeCalled(); + expect(err).to.be.a('Error'); + expect(err.message).equal('Failed to create minikube cluster. Kubernetes error'); + expect(telemetryLogErrorMock).toBeCalledWith( + 'createCluster', + expect.objectContaining({ error: 'Kubernetes error' }), + ); + } +}); diff --git a/extensions/minikube/src/create-cluster.ts b/extensions/minikube/src/create-cluster.ts new file mode 100644 index 0000000000000..9441423867b4d --- /dev/null +++ b/extensions/minikube/src/create-cluster.ts @@ -0,0 +1,69 @@ +/********************************************************************** + * 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 { getMinikubePath, runCliCommand } from './util'; + +import type { Logger, TelemetryLogger, CancellationToken } from '@podman-desktop/api'; + +export async function createCluster( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params: { [key: string]: any }, + logger: Logger, + minikubeCli: string, + telemetryLogger: TelemetryLogger, + token?: CancellationToken, +): Promise { + let clusterName = 'minikube'; + if (params['minikube.cluster.creation.name']) { + clusterName = params['minikube.cluster.creation.name']; + } + + let driver = 'docker'; + if (params['minikube.cluster.creation.driver']) { + driver = params['minikube.cluster.creation.driver']; + } + + let runtime = 'docker'; + if (params['minikube.cluster.creation.runtime']) { + runtime = params['minikube.cluster.creation.runtime']; + } + + const env = Object.assign({}, process.env); + + // update PATH to include minikube + env.PATH = getMinikubePath(); + + // now execute the command to create the cluster + try { + await runCliCommand( + minikubeCli, + ['start', '--profile', clusterName, '--driver', driver, '--container-runtime', runtime], + { env, logger }, + token, + ); + telemetryLogger.logUsage('createCluster', { driver, runtime }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : error; + telemetryLogger.logError('createCluster', { + driver, + runtime, + error: errorMessage, + stdErr: errorMessage, + }); + throw new Error(`Failed to create minikube cluster. ${errorMessage}`); + } +} diff --git a/extensions/minikube/src/extension.spec.ts b/extensions/minikube/src/extension.spec.ts new file mode 100644 index 0000000000000..12adacdb2bd12 --- /dev/null +++ b/extensions/minikube/src/extension.spec.ts @@ -0,0 +1,59 @@ +/********************************************************************** + * 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 + ***********************************************************************/ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { beforeEach, expect, test, vi } from 'vitest'; +import * as podmanDesktopApi from '@podman-desktop/api'; +import { refreshMinikubeClustersOnProviderConnectionUpdate } from './extension'; + +vi.mock('@podman-desktop/api', async () => { + return { + provider: { + onDidUpdateContainerConnection: vi.fn(), + }, + + containerEngine: { + listContainers: vi.fn(), + }, + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('check we received notifications ', async () => { + const onDidUpdateContainerConnectionMock = vi.fn(); + (podmanDesktopApi.provider as any).onDidUpdateContainerConnection = onDidUpdateContainerConnectionMock; + + const listContainersMock = vi.fn(); + (podmanDesktopApi.containerEngine as any).listContainers = listContainersMock; + listContainersMock.mockResolvedValue([]); + + let callbackCalled = false; + onDidUpdateContainerConnectionMock.mockImplementation((callback: any) => { + callback(); + callbackCalled = true; + }); + + const fakeProvider = {} as unknown as podmanDesktopApi.Provider; + refreshMinikubeClustersOnProviderConnectionUpdate(fakeProvider); + expect(callbackCalled).toBeTruthy(); + expect(listContainersMock).toBeCalledTimes(1); +}); diff --git a/extensions/minikube/src/extension.ts b/extensions/minikube/src/extension.ts new file mode 100644 index 0000000000000..5946b28e3f2c5 --- /dev/null +++ b/extensions/minikube/src/extension.ts @@ -0,0 +1,264 @@ +/********************************************************************** + * Copyright (C) 2022 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 * as extensionApi from '@podman-desktop/api'; +import { detectMinikube, getMinikubePath, runCliCommand } from './util'; +import { MinikubeInstaller } from './minikube-installer'; +import type { CancellationToken, Logger } from '@podman-desktop/api'; +import { window } from '@podman-desktop/api'; +import { ImageHandler } from './image-handler'; +import { createCluster } from './create-cluster'; + +const API_MINIKUBE_INTERNAL_API_PORT = 8443; + +const MINIKUBE_INSTALL_COMMAND = 'minikube.install'; + +const MINIKUBE_MOVE_IMAGE_COMMAND = 'minikube.image.move'; + +export interface MinikubeCluster { + name: string; + status: extensionApi.ProviderConnectionStatus; + apiPort: number; + engineType: 'podman' | 'docker'; +} + +let minikubeClusters: MinikubeCluster[] = []; +const registeredKubernetesConnections: { + connection: extensionApi.KubernetesProviderConnection; + disposable: extensionApi.Disposable; +}[] = []; + +let minikubeCli: string | undefined; + +const imageHandler = new ImageHandler(); + +async function registerProvider( + extensionContext: extensionApi.ExtensionContext, + provider: extensionApi.Provider, + telemetryLogger: extensionApi.TelemetryLogger, +): Promise { + const disposable = provider.setKubernetesProviderConnectionFactory({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + create: (params: { [key: string]: any }, logger?: Logger, token?: CancellationToken) => + createCluster(params, logger, minikubeCli, telemetryLogger, token), + creationDisplayName: 'Minikube cluster', + }); + extensionContext.subscriptions.push(disposable); + + // search + await searchMinikubeClusters(provider); + console.log('minikube extension is active'); +} + +// search for clusters +async function updateClusters(provider: extensionApi.Provider, containers: extensionApi.ContainerInfo[]) { + const minikubeContainers = containers.map(container => { + const clusterName = container.Labels['name.minikube.sigs.k8s.io']; + const clusterStatus = container.State; + + // search the port where the cluster is listening + const listeningPort = container.Ports.find( + port => port.PrivatePort === API_MINIKUBE_INTERNAL_API_PORT && port.Type === 'tcp', + ); + let status: extensionApi.ProviderConnectionStatus; + if (clusterStatus === 'running') { + status = 'started'; + } else { + status = 'stopped'; + } + + return { + name: clusterName, + status, + apiPort: listeningPort?.PublicPort || 0, + engineType: container.engineType, + engineId: container.engineId, + id: container.Id, + }; + }); + minikubeClusters = minikubeContainers.map(container => { + return { + name: container.name, + status: container.status, + apiPort: container.apiPort, + engineType: container.engineType, + }; + }); + + minikubeContainers.forEach(cluster => { + const item = registeredKubernetesConnections.find(item => item.connection.name === cluster.name); + const status = () => { + return cluster.status; + }; + if (!item) { + const lifecycle: extensionApi.ProviderConnectionLifecycle = { + start: async (): Promise => { + try { + // start the container + await extensionApi.containerEngine.startContainer(cluster.engineId, cluster.id); + } catch (err) { + console.error(err); + // propagate the error + throw err; + } + }, + stop: async (): Promise => { + await extensionApi.containerEngine.stopContainer(cluster.engineId, cluster.id); + }, + delete: async (logger): Promise => { + const env = Object.assign({}, process.env); + env.PATH = getMinikubePath(); + await runCliCommand(minikubeCli, ['delete', '--profile', cluster.name], { env, logger }); + }, + }; + // create a new connection + const connection: extensionApi.KubernetesProviderConnection = { + name: cluster.name, + status, + endpoint: { + apiURL: `https://localhost:${cluster.apiPort}`, + }, + lifecycle, + }; + const disposable = provider.registerKubernetesProviderConnection(connection); + + registeredKubernetesConnections.push({ connection, disposable }); + } else { + item.connection.status = status; + item.connection.endpoint.apiURL = `https://localhost:${cluster.apiPort}`; + } + }); + + // do we have registeredKubernetesConnections that are not in minikubeClusters? + registeredKubernetesConnections.forEach(item => { + const cluster = minikubeClusters.find(cluster => cluster.name === item.connection.name); + if (!cluster) { + // remove the connection + item.disposable.dispose(); + + // remove the item frm the list + const index = registeredKubernetesConnections.indexOf(item); + if (index > -1) { + registeredKubernetesConnections.splice(index, 1); + } + } + }); +} + +async function searchMinikubeClusters(provider: extensionApi.Provider): Promise { + const allContainers = await extensionApi.containerEngine.listContainers(); + + // search all containers with name.minikube.sigs.k8s.io labels + const minikubeContainers = allContainers.filter(container => { + return container.Labels?.['name.minikube.sigs.k8s.io']; + }); + await updateClusters(provider, minikubeContainers); +} + +export function refreshMinikubeClustersOnProviderConnectionUpdate(provider: extensionApi.Provider) { + // when a provider is changing, update the status + extensionApi.provider.onDidUpdateContainerConnection(async () => { + // needs to search for minikube clusters + await searchMinikubeClusters(provider); + }); +} + +async function createProvider( + extensionContext: extensionApi.ExtensionContext, + telemetryLogger: extensionApi.TelemetryLogger, +): Promise { + const provider = extensionApi.provider.createProvider({ + name: 'Minikube', + id: 'minikube', + status: 'unknown', + images: { + icon: './icon.png', + logo: { + dark: './logo-dark.png', + light: './logo-light.png', + }, + }, + }); + extensionContext.subscriptions.push(provider); + await registerProvider(extensionContext, provider, telemetryLogger); + extensionContext.subscriptions.push( + extensionApi.commands.registerCommand(MINIKUBE_MOVE_IMAGE_COMMAND, async image => { + telemetryLogger.logUsage('moveImage'); + await imageHandler.moveImage(image, minikubeClusters, minikubeCli); + }), + ); + + // when containers are refreshed, update + extensionApi.containerEngine.onEvent(async event => { + if (event.Type === 'container') { + // needs to search for minikube clusters + await searchMinikubeClusters(provider); + } + }); + + // when a container provider connection is changing, search for minikube clusters + refreshMinikubeClustersOnProviderConnectionUpdate(provider); + + // search when a new container is updated or removed + extensionApi.provider.onDidRegisterContainerConnection(async () => { + await searchMinikubeClusters(provider); + }); + extensionApi.provider.onDidUnregisterContainerConnection(async () => { + await searchMinikubeClusters(provider); + }); + extensionApi.provider.onDidUpdateProvider(async () => registerProvider(extensionContext, provider, telemetryLogger)); + // search for minikube clusters on boot + await searchMinikubeClusters(provider); +} + +export async function activate(extensionContext: extensionApi.ExtensionContext): Promise { + const telemetryLogger = extensionApi.env.createTelemetryLogger(); + const installer = new MinikubeInstaller(extensionContext.storagePath, telemetryLogger); + minikubeCli = await detectMinikube(extensionContext.storagePath, installer); + + if (!minikubeCli) { + if (await installer.isAvailable()) { + const statusBarItem = extensionApi.window.createStatusBarItem(); + statusBarItem.text = 'Minikube'; + statusBarItem.tooltip = 'Minikube not found on your system, click to download and install it'; + statusBarItem.command = MINIKUBE_INSTALL_COMMAND; + statusBarItem.iconClass = 'fa fa-exclamation-triangle'; + extensionContext.subscriptions.push( + extensionApi.commands.registerCommand(MINIKUBE_INSTALL_COMMAND, () => + installer.performInstall().then( + async status => { + if (status) { + statusBarItem.dispose(); + minikubeCli = await detectMinikube(extensionContext.storagePath, installer); + await createProvider(extensionContext, telemetryLogger); + } + }, + (err: unknown) => window.showErrorMessage('Minikube installation failed ' + err), + ), + ), + ); + statusBarItem.show(); + } + } else { + await createProvider(extensionContext, telemetryLogger); + } +} + +export function deactivate(): void { + console.log('stopping minikube extension'); +} diff --git a/extensions/minikube/src/image-handler.spec.ts b/extensions/minikube/src/image-handler.spec.ts new file mode 100644 index 0000000000000..adea2a454ad43 --- /dev/null +++ b/extensions/minikube/src/image-handler.spec.ts @@ -0,0 +1,129 @@ +/********************************************************************** + * 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, expect, test, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import { ImageHandler } from './image-handler'; +import * as extensionApi from '@podman-desktop/api'; +import * as fs from 'node:fs'; +import { getMinikubePath, runCliCommand } from './util'; + +let imageHandler: ImageHandler; +vi.mock('@podman-desktop/api', async () => { + return { + containerEngine: { + saveImage: vi.fn(), + }, + window: { + showNotification: vi.fn(), + showInformationMessage: vi.fn(), + }, + }; +}); + +vi.mock('./util', async () => { + return { + runCliCommand: vi.fn().mockReturnValue({ exitCode: 0 }), + getMinikubePath: vi.fn(), + }; +}); + +beforeEach(() => { + vi.clearAllMocks(); + imageHandler = new ImageHandler(); +}); + +test('expect error to be raised if no image is given', async () => { + try { + await imageHandler.moveImage({ engineId: 'dummy' }, [], undefined); + } catch (err) { + expect(err).to.be.a('Error'); + expect(err.message).equal('Image selection not supported yet'); + } +}); + +test('expect error to be raised if no clusters are given', async () => { + try { + await imageHandler.moveImage({ engineId: 'dummy', name: 'myimage' }, [], undefined); + } catch (err) { + expect(err).to.be.a('Error'); + expect(err.message).equal('No minikube clusters to push to'); + } +}); + +test('expect image name to be given', async () => { + (extensionApi.containerEngine.saveImage as Mock).mockImplementation( + (engineId: string, id: string, filename: string) => fs.promises.open(filename, 'w'), + ); + + await imageHandler.moveImage( + { engineId: 'dummy', name: 'myimage' }, + [{ name: 'c1', engineType: 'podman', status: 'started', apiPort: 8443 }], + undefined, + ); + expect(extensionApi.containerEngine.saveImage).toBeCalledWith('dummy', 'myimage', expect.anything()); +}); + +test('expect getting showInformationMessage when image is pushed', async () => { + (extensionApi.containerEngine.saveImage as Mock).mockImplementation( + (engineId: string, id: string, filename: string) => fs.promises.open(filename, 'w'), + ); + + await imageHandler.moveImage( + { engineId: 'dummy', name: 'myimage' }, + [{ name: 'c1', engineType: 'podman', status: 'started', apiPort: 8443 }], + undefined, + ); + expect(extensionApi.window.showInformationMessage).toBeCalledWith('Image myimage pushed to minikube cluster: c1'); +}); + +test('expect image name and tag to be given', async () => { + (extensionApi.containerEngine.saveImage as Mock).mockImplementation( + (engineId: string, id: string, filename: string) => fs.promises.open(filename, 'w'), + ); + + await imageHandler.moveImage( + { engineId: 'dummy', name: 'myimage', tag: '1.0' }, + [{ name: 'c1', engineType: 'podman', status: 'started', apiPort: 8443 }], + undefined, + ); + expect(extensionApi.containerEngine.saveImage).toBeCalledWith('dummy', 'myimage:1.0', expect.anything()); +}); + +test('expect cli is called with right PATH', async () => { + (extensionApi.containerEngine.saveImage as Mock).mockImplementation( + (engineId: string, id: string, filename: string) => fs.promises.open(filename, 'w'), + ); + + (getMinikubePath as Mock).mockReturnValue('my-custom-path'); + + await imageHandler.moveImage( + { engineId: 'dummy', name: 'myimage' }, + [{ name: 'c1', engineType: 'podman', status: 'started', apiPort: 8443 }], + undefined, + ); + expect(getMinikubePath).toBeCalled(); + + expect(runCliCommand).toBeCalledTimes(1); + // grab the env parameter of the first call to runCliCommand + const props = (runCliCommand as Mock).mock.calls[0][2]; + expect(props).to.have.property('env'); + const env = props.env; + expect(env).to.have.property('PATH'); + expect(env.PATH).toBe('my-custom-path'); +}); diff --git a/extensions/minikube/src/image-handler.ts b/extensions/minikube/src/image-handler.ts new file mode 100644 index 0000000000000..f9f312f47945e --- /dev/null +++ b/extensions/minikube/src/image-handler.ts @@ -0,0 +1,99 @@ +/********************************************************************** + * 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 type { MinikubeCluster } from './extension'; +import * as extensionApi from '@podman-desktop/api'; +import { tmpName } from 'tmp-promise'; +import { getMinikubePath, runCliCommand } from './util'; +import * as fs from 'node:fs'; + +type ImageInfo = { engineId: string; name?: string; tag?: string }; + +// Handle the image move command when moving from Podman or Docker to minikube +export class ImageHandler { + // Move image from Podman or Docker to minikube + async moveImage(image: ImageInfo, minikubeClusters: MinikubeCluster[], minikubeCli: string): Promise { + // If there's no image name passed in, we can't do anything + if (!image.name) { + throw new Error('Image selection not supported yet'); + } + + // Retrieve all the minikube clusters available. + const clusters = minikubeClusters.filter(cluster => cluster.status === 'started'); + let selectedCluster: { label: string; engineType: string }; + + // Throw an error if there is no clusters, + // but if there are multiple ones, prompt the user to select one + if (clusters.length == 0) { + throw new Error('No minikube clusters to push to'); + } else if (clusters.length == 1) { + selectedCluster = { label: clusters[0].name, engineType: clusters[0].engineType }; + } else { + selectedCluster = await extensionApi.window.showQuickPick( + clusters.map(cluster => { + return { label: cluster.name, engineType: cluster.engineType }; + }), + { placeHolder: 'Select a minikube cluster to push to' }, + ); + } + + // Only proceed if a cluster was selected + if (selectedCluster) { + let name = image.name; + let filename: string; + const env = Object.assign({}, process.env); + + // Create a name:tag string for the image + if (image.tag) { + name = name + ':' + image.tag; + } + + env.PATH = getMinikubePath(); + try { + // Create a temporary file to store the image + filename = await tmpName(); + + // Save the image to the temporary file + await extensionApi.containerEngine.saveImage(image.engineId, name, filename); + + // Run the minikube image load command to push the image to the cluster + await runCliCommand(minikubeCli, ['-p', selectedCluster.label, 'image', 'load', filename], { + env: env, + }); + + // Show a dialog to the user that the image was pushed + // TODO: Change this to taskbar notification when implemented + await extensionApi.window.showInformationMessage( + `Image ${image.name} pushed to minikube cluster: ${selectedCluster.label}`, + ); + } catch (err) { + // Show a dialog error to the user that the image was not pushed + await extensionApi.window.showErrorMessage( + `Unable to push image ${image.name} to minikube cluster: ${selectedCluster.label}. Error: ${err}`, + ); + + // Throw the errors to the console aswell + throw new Error(`Unable to push image to minikube cluster: ${err}`); + } finally { + // Remove the temporary file if one was created + if (filename !== undefined) { + await fs.promises.rm(filename); + } + } + } + } +} diff --git a/extensions/minikube/src/minikube-installer.spec.ts b/extensions/minikube/src/minikube-installer.spec.ts new file mode 100644 index 0000000000000..83ebcaf78ec51 --- /dev/null +++ b/extensions/minikube/src/minikube-installer.spec.ts @@ -0,0 +1,132 @@ +/********************************************************************** + * 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 { tmpName } from 'tmp-promise'; +import { beforeEach, expect, test, vi } from 'vitest'; +import { MinikubeInstaller } from './minikube-installer'; +import * as extensionApi from '@podman-desktop/api'; +import { installBinaryToSystem } from './util'; + +let installer: MinikubeInstaller; + +vi.mock('@podman-desktop/api', async () => { + return { + window: { + showInformationMessage: vi.fn().mockReturnValue(Promise.resolve('Yes')), + showErrorMessage: vi.fn(), + withProgress: vi.fn(), + showNotification: vi.fn(), + }, + ProgressLocation: { + APP_ICON: 1, + }, + }; +}); + +vi.mock('@octokit/rest', () => { + const repos = { + getReleaseAsset: vi.fn().mockReturnValue({ name: 'minikube', data: [] }), + }; + return { + Octokit: vi.fn().mockReturnValue({ repos: repos }), + }; +}); + +const telemetryLogUsageMock = vi.fn(); +const telemetryLogErrorMock = vi.fn(); +const telemetryLoggerMock = { + logUsage: telemetryLogUsageMock, + logError: telemetryLogErrorMock, +} as unknown as extensionApi.TelemetryLogger; + +vi.mock('runCliCommand', async () => { + return vi.fn(); +}); + +beforeEach(() => { + installer = new MinikubeInstaller('.', telemetryLoggerMock); + vi.clearAllMocks(); +}); + +test.skip('expect installBinaryToSystem to succesfully pass with a binary', async () => { + // Mock process.platform to be linux + // to avoid the usage of sudo-prompt (we cannot test that in unit tests) + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + + // Create a tmp file using tmp-promise + const filename = await tmpName(); + + // "Install" the binary, this should pass sucessfully + try { + await installBinaryToSystem(filename, 'tmpBinary'); + } catch (err) { + expect(err).toBeUndefined(); + } +}); + +test('error: expect installBinaryToSystem to fail with a non existing binary', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + + // Run installBinaryToSystem with a non-binary file + try { + await installBinaryToSystem('test', 'tmpBinary'); + // Expect that showErrorMessage is called + expect(extensionApi.window.showErrorMessage).toHaveBeenCalled(); + } catch (err) { + expect(err).to.be.a('Error'); + expect(err).toBeDefined(); + } +}); + +test('expect showNotification to be called', async () => { + const progress = { + // eslint-disable-next-line @typescript-eslint/no-empty-function + report: () => {}, + }; + vi.spyOn(extensionApi.window, 'withProgress').mockImplementation((options, task) => { + return task(progress, undefined); + }); + vi.spyOn(installer, 'getAssetInfo').mockReturnValue(Promise.resolve({ id: 0, name: 'minikube' })); + // eslint-disable-next-line @typescript-eslint/no-empty-function + const spy = vi.spyOn(extensionApi.window, 'showNotification').mockImplementation(() => { + return { + // eslint-disable-next-line @typescript-eslint/no-empty-function + dispose: () => {}, + }; + }); + + // Check that install passes + const result = await installer.performInstall(); + expect(telemetryLogErrorMock).not.toBeCalled(); + expect(telemetryLogUsageMock).toHaveBeenNthCalledWith(1, 'install-minikube-prompt'); + expect(telemetryLogUsageMock).toHaveBeenNthCalledWith(2, 'install-minikube-prompt-yes'); + expect(telemetryLogUsageMock).toHaveBeenNthCalledWith(3, 'install-minikube-downloaded'); + + expect(result).toBeDefined(); + expect(result).toBeTruthy(); + + // Check that showNotification is called + expect(spy).toBeCalled(); + + // Expect showInformationMessage to be shown and be asking for installing it system wide + expect(extensionApi.window.showInformationMessage).toBeCalled(); +}); diff --git a/extensions/minikube/src/minikube-installer.ts b/extensions/minikube/src/minikube-installer.ts new file mode 100644 index 0000000000000..f1cc54cf3a1c5 --- /dev/null +++ b/extensions/minikube/src/minikube-installer.ts @@ -0,0 +1,174 @@ +/********************************************************************** + * 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 * as extensionApi from '@podman-desktop/api'; +import { ProgressLocation } from '@podman-desktop/api'; +import * as os from 'node:os'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Octokit } from '@octokit/rest'; +import { isWindows, installBinaryToSystem } from './util'; +import type { components } from '@octokit/openapi-types'; + +const githubOrganization = 'kubernetes'; +const githubRepo = 'minikube'; + +type GitHubRelease = components['schemas']['release']; + +export interface AssetInfo { + id: number; + name: string; +} + +const WINDOWS_X64_PLATFORM = 'win32-x64'; + +const LINUX_X64_PLATFORM = 'linux-x64'; + +const LINUX_ARM64_PLATFORM = 'linux-arm64'; + +const MACOS_X64_PLATFORM = 'darwin-x64'; + +const MACOS_ARM64_PLATFORM = 'darwin-arm64'; + +const WINDOWS_X64_ASSET_NAME = 'minikube-windows-amd64'; + +const LINUX_X64_ASSET_NAME = 'minikube-linux-amd64'; + +const LINUX_ARM64_ASSET_NAME = 'minikube-linux-arm64'; + +const MACOS_X64_ASSET_NAME = 'minikube-darwin-amd64'; + +const MACOS_ARM64_ASSET_NAME = 'minikube-darwin-arm64'; + +export class MinikubeInstaller { + private assetNames = new Map(); + + private assetPromise: Promise; + + constructor(private readonly storagePath: string, private telemetryLogger: extensionApi.TelemetryLogger) { + this.assetNames.set(WINDOWS_X64_PLATFORM, WINDOWS_X64_ASSET_NAME); + this.assetNames.set(LINUX_X64_PLATFORM, LINUX_X64_ASSET_NAME); + this.assetNames.set(LINUX_ARM64_PLATFORM, LINUX_ARM64_ASSET_NAME); + this.assetNames.set(MACOS_X64_PLATFORM, MACOS_X64_ASSET_NAME); + this.assetNames.set(MACOS_ARM64_PLATFORM, MACOS_ARM64_ASSET_NAME); + } + + findAssetInfo(data: GitHubRelease[], assetName: string): AssetInfo { + for (const release of data) { + for (const asset of release.assets) { + if (asset.name === assetName) { + return { + id: asset.id, + name: assetName, + }; + } + } + } + return undefined; + } + + async getAssetInfo(): Promise { + if (!(await this.assetPromise)) { + const assetName = this.assetNames.get(os.platform().concat('-').concat(os.arch())); + const octokit = new Octokit(); + this.assetPromise = octokit.repos + .listReleases({ owner: githubOrganization, repo: githubRepo }) + .then(response => this.findAssetInfo(response.data, assetName)) + .catch((error: unknown) => { + console.error(error); + return undefined; + }); + } + return this.assetPromise; + } + + async isAvailable(): Promise { + const assetInfo = await this.getAssetInfo(); + return assetInfo !== undefined; + } + + async performInstall(): Promise { + this.telemetryLogger.logUsage('install-minikube-prompt'); + const dialogResult = await extensionApi.window.showInformationMessage( + 'The minikube binary is required for local Kubernetes development, would you like to download it?', + 'Yes', + 'Cancel', + ); + if (dialogResult === 'Yes') { + this.telemetryLogger.logUsage('install-minikube-prompt-yes'); + return extensionApi.window.withProgress( + { location: ProgressLocation.TASK_WIDGET, title: 'Installing minikube' }, + async progress => { + progress.report({ increment: 5 }); + try { + const assetInfo = await this.getAssetInfo(); + if (assetInfo) { + const octokit = new Octokit(); + const asset = await octokit.repos.getReleaseAsset({ + owner: githubOrganization, + repo: githubRepo, + asset_id: assetInfo.id, + headers: { + accept: 'application/octet-stream', + }, + }); + progress.report({ increment: 80 }); + if (asset) { + const destFile = path.resolve(this.storagePath, isWindows() ? assetInfo.name + '.exe' : assetInfo.name); + if (!fs.existsSync(this.storagePath)) { + fs.mkdirSync(this.storagePath); + } + fs.appendFileSync(destFile, Buffer.from(asset.data as unknown as ArrayBuffer)); + if (!isWindows()) { + const stat = fs.statSync(destFile); + fs.chmodSync(destFile, stat.mode | fs.constants.S_IXUSR); + } + // Explain to the user that the binary has been successfully installed to the storage path + // prompt and ask if they want to install it system-wide (copied to /usr/bin/, or AppData for Windows) + const result = await extensionApi.window.showInformationMessage( + `minikube binary has been succesfully downloaded to ${destFile}.\n\nWould you like to install it system-wide for accessibility on the command line? This will require administrative privileges.`, + 'Yes', + 'Cancel', + ); + if (result === 'Yes') { + try { + // Move the binary file to the system from destFile and rename to 'minikube' + await installBinaryToSystem(destFile, 'minikube'); + await extensionApi.window.showInformationMessage( + 'minikube binary has been successfully installed system-wide.', + ); + } catch (error) { + console.error(error); + await extensionApi.window.showErrorMessage(`Unable to install minikube binary: ${error}`); + } + } + this.telemetryLogger.logUsage('install-minikube-downloaded'); + extensionApi.window.showNotification({ body: 'minikube is successfully installed.' }); + return true; + } + } + } finally { + progress.report({ increment: -1 }); + } + }, + ); + } else { + this.telemetryLogger.logUsage('install-minikube-prompt-no'); + } + return false; + } +} diff --git a/extensions/minikube/src/util.ts b/extensions/minikube/src/util.ts new file mode 100644 index 0000000000000..d0a4d25b420d6 --- /dev/null +++ b/extensions/minikube/src/util.ts @@ -0,0 +1,240 @@ +/********************************************************************** + * Copyright (C) 2022 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 * as os from 'node:os'; +import * as path from 'node:path'; +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import * as sudo from 'sudo-prompt'; +import type * as extensionApi from '@podman-desktop/api'; +import type { MinikubeInstaller } from './minikube-installer'; + +const windows = os.platform() === 'win32'; +export function isWindows(): boolean { + return windows; +} +const mac = os.platform() === 'darwin'; +export function isMac(): boolean { + return mac; +} +const linux = os.platform() === 'linux'; +export function isLinux(): boolean { + return linux; +} + +export interface SpawnResult { + stdOut: string; + stdErr: string; + error: undefined | string; +} + +export interface RunOptions { + env?: NodeJS.ProcessEnv; + logger?: extensionApi.Logger; +} + +const macosExtraPath = '/usr/local/bin:/opt/homebrew/bin:/opt/local/bin:/opt/podman/bin'; + +export function getMinikubePath(): string { + const env = process.env; + if (isMac()) { + if (!env.PATH) { + return macosExtraPath; + } else { + return env.PATH.concat(':').concat(macosExtraPath); + } + } else { + return env.PATH; + } +} + +// search if minikube is available in the path +export async function detectMinikube(pathAddition: string, installer: MinikubeInstaller): Promise { + try { + await runCliCommand('minikube', ['version'], { env: { PATH: getMinikubePath() } }); + return 'minikube'; + } catch (e) { + // ignore and try another way + } + + const assetInfo = await installer.getAssetInfo(); + if (assetInfo) { + try { + await runCliCommand(assetInfo.name, ['version'], { + env: { PATH: getMinikubePath().concat(path.delimiter).concat(pathAddition) }, + }); + return pathAddition.concat(path.sep).concat(isWindows() ? assetInfo.name + '.exe' : assetInfo.name); + } catch (e) { + console.error(e); + } + } + return undefined; +} + +export function runCliCommand( + command: string, + args: string[], + options?: RunOptions, + token?: extensionApi.CancellationToken, +): Promise { + return new Promise((resolve, reject) => { + let stdOut = ''; + let stdErr = ''; + let err = ''; + let env = Object.assign({}, process.env); // clone original env object + + // In production mode, applications don't have access to the 'user' path like brew + if (isMac() || isWindows()) { + env.PATH = getMinikubePath(); + if (isWindows()) { + // Escape any whitespaces in command + command = `"${command}"`; + } + } else if (env.FLATPAK_ID) { + // need to execute the command on the host + args = ['--host', command, ...args]; + command = 'flatpak-spawn'; + } + + if (options?.env) { + env = Object.assign(env, options.env); + } + + const spawnProcess = spawn(command, args, { shell: isWindows(), env }); + + // if the token is cancelled, kill the process and reject the promise + token?.onCancellationRequested(() => { + killProcess(spawnProcess); + options?.logger?.error('Execution cancelled'); + // reject the promise + reject(new Error('Execution cancelled')); + }); + // do not reject as we want to store exit code in the result + spawnProcess.on('error', error => { + if (options?.logger) { + options.logger.error(error); + } + stdErr += error; + err += error; + }); + + spawnProcess.stdout.setEncoding('utf8'); + spawnProcess.stdout.on('data', data => { + if (options?.logger) { + options.logger.log(data); + } + stdOut += data; + }); + spawnProcess.stderr.setEncoding('utf8'); + spawnProcess.stderr.on('data', data => { + if (args?.[0] === 'create' || args?.[0] === 'delete') { + if (options?.logger) { + options.logger.log(data); + } + if (typeof data === 'string' && data.indexOf('error') >= 0) { + stdErr += data; + } else { + stdOut += data; + } + } else { + stdErr += data; + } + }); + + spawnProcess.on('close', exitCode => { + if (exitCode == 0) { + resolve({ stdOut, stdErr, error: err }); + } else { + if (options?.logger) { + options.logger.error(stdErr); + } + reject(new Error(stdErr)); + } + }); + }); +} + +// Takes a binary path (e.g. /tmp/minikube) and installs it to the system. Renames it based on binaryName +// supports Windows, Linux and macOS +// If using Windows or Mac, we will use sudo-prompt in order to elevate the privileges +// If using Linux, we'll use pkexec and polkit support to ask for privileges. +// When running in a flatpak, we'll use flatpak-spawn to execute the command on the host +export async function installBinaryToSystem(binaryPath: string, binaryName: string): Promise { + const system = process.platform; + + // Before copying the file, make sure it's executable (chmod +x) for Linux and Mac + if (system === 'linux' || system === 'darwin') { + try { + await runCliCommand('chmod', ['+x', binaryPath]); + console.log(`Made ${binaryPath} executable`); + } catch (error) { + throw new Error(`Error making binary executable: ${error}`); + } + } + + // Create the appropriate destination path (Windows uses AppData/Local, Linux and Mac use /usr/local/bin) + // and the appropriate command to move the binary to the destination path + let destinationPath: string; + let command: string[]; + if (system == 'win32') { + destinationPath = path.join(os.homedir(), 'AppData', 'Local', 'Microsoft', 'WindowsApps', `${binaryName}.exe`); + command = ['copy', binaryPath, destinationPath]; + } else { + destinationPath = path.join('/usr/local/bin', binaryName); + command = ['cp', binaryPath, destinationPath]; + } + + // If windows or mac, use sudo-prompt to elevate the privileges + // if Linux, use sudo and polkit support + if (system === 'win32' || system === 'darwin') { + return new Promise((resolve, reject) => { + // Convert the command array to a string for sudo prompt + // the name is used for the prompt + const sudoOptions = { + name: `${binaryName} Binary Installation`, + }; + const sudoCommand = command.join(' '); + sudo.exec(sudoCommand, sudoOptions, error => { + if (error) { + console.error(`Failed to install '${binaryName}' binary: ${error}`); + reject(error); + } else { + console.log(`Successfully installed '${binaryName}' binary.`); + resolve(); + } + }); + }); + } else { + try { + // Use pkexec in order to elevate the prileges / ask for password for copying to /usr/local/bin + await runCliCommand('pkexec', command); + console.log(`Successfully installed '${binaryName}' binary.`); + } catch (error) { + console.error(`Failed to install '${binaryName}' binary: ${error}`); + throw error; + } + } +} + +function killProcess(spawnProcess: ChildProcess) { + if (isWindows()) { + spawn('taskkill', ['/pid', spawnProcess.pid?.toString(), '/f', '/t']); + } else { + spawnProcess.kill(); + } +} diff --git a/extensions/minikube/tsconfig.json b/extensions/minikube/tsconfig.json new file mode 100644 index 0000000000000..bbda357e410fb --- /dev/null +++ b/extensions/minikube/tsconfig.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "lib": [ + "ES2017", + "webworker" + ], + "sourceMap": true, + "rootDir": "src", + "outDir": "dist", + "skipLibCheck": true, + "types": [ + "node", + ] +}, +"include": [ + "src", + "types/*.d.ts", + "../../types/**/*.d.ts" +], +"ts-node": { + "compilerOptions": { + "module": "CommonJS", + "lib": [ + "ES2020", + "DOM" + ], + "types": [ + "node" + ] + } +} + +} diff --git a/extensions/minikube/types/template.d.ts b/extensions/minikube/types/template.d.ts new file mode 100644 index 0000000000000..a56db5b97feef --- /dev/null +++ b/extensions/minikube/types/template.d.ts @@ -0,0 +1,27 @@ +/********************************************************************** + * 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 + ***********************************************************************/ + +declare module '*.mustache' { + const contents: string; + export = contents; +} + +declare module '*.yaml' { + const contents: string; + export = contents; +} diff --git a/extensions/minikube/vite.config.js b/extensions/minikube/vite.config.js new file mode 100644 index 0000000000000..60aef55a5269c --- /dev/null +++ b/extensions/minikube/vite.config.js @@ -0,0 +1,62 @@ +/********************************************************************** + * 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 {join} from 'path'; +import {builtinModules} from 'module'; + +const PACKAGE_ROOT = __dirname; + +/** + * @type {import('vite').UserConfig} + * @see https://vitejs.dev/config/ + */ +const config = { + mode: process.env.MODE, + root: PACKAGE_ROOT, + envDir: process.cwd(), + resolve: { + alias: { + '/@/': join(PACKAGE_ROOT, 'src') + '/', + '/@gen/': join(PACKAGE_ROOT, 'src-generated') + '/', + }, + }, + build: { + sourcemap: 'inline', + target: 'esnext', + outDir: 'dist', + assetsDir: '.', + minify: process.env.MODE === 'production' ? 'esbuild' : false, + lib: { + entry: 'src/extension.ts', + formats: ['cjs'], + }, + rollupOptions: { + external: [ + '@podman-desktop/api', + ...builtinModules.flatMap(p => [p, `node:${p}`]), + ], + output: { + entryFileNames: '[name].js', + }, + }, + emptyOutDir: true, + reportCompressedSize: false, + }, +}; + +export default config; diff --git a/extensions/minikube/vitest.config.js b/extensions/minikube/vitest.config.js new file mode 100644 index 0000000000000..3024f16fb2ba0 --- /dev/null +++ b/extensions/minikube/vitest.config.js @@ -0,0 +1,39 @@ +/********************************************************************** + * 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 path from 'node:path'; +import { coverageConfig, testConfig } from '../../vitest-shared-extensions.config'; +import {join} from 'path'; + +const PACKAGE_ROOT = __dirname; +const PACKAGE_NAME = 'extensions/minikube'; + +const config = { + test: { + ...testConfig(), + ...coverageConfig(PACKAGE_ROOT, PACKAGE_NAME), + }, + resolve: { + alias: { + '@podman-desktop/api': path.resolve('../../', '__mocks__/@podman-desktop/api.js'), + '/@gen/': join(PACKAGE_ROOT, 'src-generated') + '/', + }, + }, +}; + +export default config; diff --git a/package.json b/package.json index 5e5b0dcf3a442..63dda526e965b 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,13 @@ "scripts": { "build": "npm run build:main && npm run build:preload && npm run build:preload-docker-extension && npm run build:renderer && npm run build:extensions", "build:main": "cd ./packages/main && vite build", - "build:extensions": "npm run build:extensions:compose && npm run build:extensions:docker && npm run build:extensions:lima && npm run build:extensions:podman && npm run build:extensions:kubecontext && npm run build:extensions:kind && npm run build:extensions:registries", + "build:extensions": "npm run build:extensions:compose && npm run build:extensions:docker && npm run build:extensions:lima && npm run build:extensions:podman && npm run build:extensions:kubecontext && npm run build:extensions:kind && npm run build:extensions:minikube && npm run build:extensions:registries", "build:extensions:compose": "cd ./extensions/compose && npm run build", "build:extensions:docker": "cd ./extensions/docker && npm run build", "build:extensions:kubecontext": "cd ./extensions/kube-context && npm run build", "build:extensions:kind": "cd ./extensions/kind && npm run build", "build:extensions:lima": "cd ./extensions/lima && npm run build", + "build:extensions:minikube": "cd ./extensions/minikube && npm run build", "build:extensions:podman": "cd ./extensions/podman && npm run build", "build:extensions:registries": "cd ./extensions/registries && npm run build", "build:extension-api": "cd ./packages/extension-api && vite build", @@ -43,8 +44,9 @@ "test:main": "vitest run -r packages/main --passWithNoTests --coverage", "test:preload": "vitest run -r packages/preload --passWithNoTests --coverage", "test:preload-docker-extension": "vitest run -r packages/preload-docker-extension --passWithNoTests --coverage", - "test:extensions": "npm run test:extensions:compose && npm run test:extensions:kind && npm run test:extensions:docker && npm run test:extensions:lima && npm run test:extensions:kube && npm run test:extensions:podman && npm run test:extensions:registries", + "test:extensions": "npm run test:extensions:compose && npm run test:extensions:kind && npm run test:extensions:minikube && npm run test:extensions:docker && npm run test:extensions:lima && npm run test:extensions:kube && npm run test:extensions:podman && npm run test:extensions:registries", "test:extensions:kind": "vitest run -r extensions/kind --passWithNoTests --coverage ", + "test:extensions:minikube": "vitest run -r extensions/minikube --passWithNoTests --coverage ", "test:extensions:compose": "vitest run -r extensions/compose --passWithNoTests --coverage", "test:extensions:docker": "vitest run -r extensions/docker --passWithNoTests --coverage ", "test:extensions:kube": "vitest run -r extensions/kube-context --passWithNoTests --coverage ", diff --git a/scripts/watch.cjs b/scripts/watch.cjs index 660d71f8fbd69..4badd963f35d1 100644 --- a/scripts/watch.cjs +++ b/scripts/watch.cjs @@ -183,6 +183,7 @@ const setupBuiltinExtensionApiWatcher = name => { setupBuiltinExtensionApiWatcher('lima'); setupBuiltinExtensionApiWatcher('podman'); setupBuiltinExtensionApiWatcher('kind'); + setupBuiltinExtensionApiWatcher('minikube'); setupBuiltinExtensionApiWatcher('registries'); for (const extension of extensions) { setupExtensionApiWatcher(extension);