feat: Adds support of "podman play kube" through REST API

fixes https://github.com/containers/podman-desktop/issues/203

Change-Id: Id68146bb7f9d871765de7e130eaacde6de1d0834
Signed-off-by: Florent Benoit <fbenoit@redhat.com>
This commit is contained in:
Florent Benoit 2022-09-14 17:57:43 +02:00 committed by Florent BENOIT
parent 8ad6777380
commit e25b846f39
11 changed files with 327 additions and 32 deletions

View file

@ -16,7 +16,7 @@
* SPDX-License-Identifier: Apache-2.0
***********************************************************************/
import type { BrowserWindowConstructorOptions } from 'electron';
import type { BrowserWindowConstructorOptions, FileFilter } from 'electron';
import { BrowserWindow, ipcMain, app, dialog, screen } from 'electron';
import contextMenu from 'electron-context-menu';
import { join } from 'path';
@ -80,9 +80,10 @@ async function createWindow() {
});
// select a file using native widget
ipcMain.on('dialog:openFile', async (_, param: { dialogId: string; message: string }) => {
ipcMain.on('dialog:openFile', async (_, param: { dialogId: string; message: string; filter: FileFilter }) => {
const response = await dialog.showOpenDialog(browserWindow, {
properties: ['openFile'],
filters: [param.filter],
message: param.message,
});
// send the response back

View file

@ -35,6 +35,7 @@ export interface ProviderContainerConnectionInfo {
socketPath: string;
};
lifecycleMethods?: LifecycleMethod[];
type: 'docker' | 'podman';
}
export interface ProviderKubernetesConnectionInfo {

View file

@ -34,7 +34,7 @@ const tar: { pack: (dir: string) => NodeJS.ReadableStream } = require('tar-fs');
import { EventEmitter } from 'node:events';
import type { ContainerInspectInfo } from './api/container-inspect-info';
import type { HistoryInfo } from './api/history-info';
import type { LibPod, PodInfo as LibpodPodInfo } from './dockerode/libpod-dockerode';
import type { LibPod, PlayKubeInfo, PodInfo as LibpodPodInfo } from './dockerode/libpod-dockerode';
import { LibpodDockerode } from './dockerode/libpod-dockerode';
import type { ContainerStatsInfo } from './api/container-stats-info';
import type { VolumeInfo, VolumeInspectInfo, VolumeListInfo } from './api/volume-info';
@ -788,6 +788,21 @@ export class ContainerProviderRegistry {
return this.statsConsumerId;
}
async playKube(
kubernetesYamlFilePath: string,
selectedProvider: ProviderContainerConnectionInfo,
): Promise<PlayKubeInfo> {
this.telemetryService.track('playKube');
// grab all connections
const matchingContainerProvider = Array.from(this.internalProviders.values()).find(
containerProvider => containerProvider.connection.endpoint.socketPath === selectedProvider.endpoint.socketPath,
);
if (!matchingContainerProvider || !matchingContainerProvider.libpodApi) {
throw new Error('No provider with a running engine');
}
return matchingContainerProvider.libpodApi.playKube(kubernetesYamlFilePath);
}
async buildImage(
containerBuildContextDirectory: string,
relativeContainerfilePath: string,
@ -798,7 +813,9 @@ export class ContainerProviderRegistry {
this.telemetryService.track('buildImage');
// grab all connections
const matchingContainerProvider = Array.from(this.internalProviders.values()).find(
containerProvider => containerProvider.connection.endpoint.socketPath === selectedProvider.endpoint.socketPath,
containerProvider =>
containerProvider.connection.endpoint.socketPath === selectedProvider.endpoint.socketPath &&
selectedProvider.status === 'started',
);
if (!matchingContainerProvider || !matchingContainerProvider.api) {
throw new Error('No provider with a running engine');

View file

@ -37,6 +37,22 @@ export interface PodInfo {
Status: string;
}
export interface PlayKubePodInfo {
ContainerErrors: string[];
Containers: string[];
Id: string;
InitContainers: string[];
Logs: string[];
}
export interface PlayKubeInfo {
Pods: PlayKubePodInfo[];
RmReport: { Err: string; Id: string }[];
Secrets: { CreateReport: { ID: string } }[];
StopReport: { Err: string; Id: string }[];
Volumes: { Name: string }[];
}
// API of libpod that we want to expose on our side
export interface LibPod {
listPods(): Promise<PodInfo[]>;
@ -45,6 +61,7 @@ export interface LibPod {
removePod(podId: string): Promise<void>;
restartPod(podId: string): Promise<void>;
generateKube(names: string[]): Promise<string>;
playKube(yamlContentFilePath: string): Promise<PlayKubeInfo>;
}
// tweak Dockerode by adding the support of libpod API
@ -209,5 +226,29 @@ export class LibpodDockerode {
});
});
};
// add playKube
prototypeOfDockerode.playKube = function (yamlContentFilePath: string) {
const optsf = {
path: '/v4.2.0/libpod/play/kube',
method: 'POST',
file: yamlContentFilePath,
statusCodes: {
200: true,
204: true,
500: 'server error',
},
options: {},
};
return new Promise((resolve, reject) => {
this.modem.dial(optsf, (err: unknown, data: unknown) => {
if (err) {
return reject(err);
}
resolve(data);
});
});
};
}
}

View file

@ -63,6 +63,7 @@ import type { HistoryInfo } from './api/history-info';
import type { PodInfo } from './api/pod-info';
import type { VolumeInspectInfo, VolumeListInfo } from './api/volume-info';
import type { ContainerStatsInfo } from './api/container-stats-info';
import type { PlayKubeInfo } from './dockerode/libpod-dockerode';
type LogType = 'log' | 'warn' | 'trace' | 'debug' | 'error';
export class PluginSystem {
@ -281,6 +282,17 @@ export class PluginSystem {
return containerProviderRegistry.generatePodmanKube(engine, names);
},
);
this.ipcHandle(
'container-provider-registry:playKube',
async (
_listener,
yamlFilePath: string,
selectedProvider: ProviderContainerConnectionInfo,
): Promise<PlayKubeInfo> => {
return containerProviderRegistry.playKube(yamlFilePath, selectedProvider);
},
);
this.ipcHandle(
'container-provider-registry:startContainer',
async (_listener, engine: string, containerId: string): Promise<void> => {

View file

@ -377,6 +377,7 @@ export class ProviderRegistry {
const containerProviderConnection: ProviderContainerConnectionInfo = {
name: connection.name,
status: connection.status(),
type: connection.type,
endpoint: {
socketPath: connection.endpoint.socketPath,
},

View file

@ -43,6 +43,7 @@ import type { IConfigurationPropertyRecordedSchema } from '../../main/src/plugin
import type { PullEvent } from '../../main/src/plugin/api/pull-event';
import { Deferred } from './util/deferred';
import type { StatusBarEntryDescriptor } from '../../main/src/plugin/statusbar/statusbar-registry';
import type { PlayKubeInfo } from '../../main/src/plugin/dockerode/libpod-dockerode';
export type DialogResultCallback = (openDialogReturnValue: Electron.OpenDialogReturnValue) => void;
@ -142,6 +143,17 @@ function initExposure(): void {
contextBridge.exposeInMainWorld('generatePodmanKube', async (engine: string, names: string[]): Promise<string> => {
return ipcInvoke('container-provider-registry:generatePodmanKube', engine, names);
});
contextBridge.exposeInMainWorld(
'playKube',
async (
relativeContainerfilePath: string,
selectedProvider: ProviderContainerConnectionInfo,
): Promise<PlayKubeInfo> => {
return ipcInvoke('container-provider-registry:playKube', relativeContainerfilePath, selectedProvider);
},
);
contextBridge.exposeInMainWorld('stopPod', async (engine: string, podId: string): Promise<void> => {
return ipcInvoke('container-provider-registry:stopPod', engine, podId);
});
@ -650,28 +662,32 @@ function initExposure(): void {
const dialogResponses = new Map<string, DialogResultCallback>();
contextBridge.exposeInMainWorld('openFileDialog', async (message: string) => {
// generate id
const dialogId = idDialog;
idDialog++;
contextBridge.exposeInMainWorld(
'openFileDialog',
async (message: string, filter?: { extensions: string[]; name: string }) => {
// generate id
const dialogId = idDialog;
idDialog++;
// create defer object
const defer = new Deferred<Electron.OpenDialogReturnValue>();
// create defer object
const defer = new Deferred<Electron.OpenDialogReturnValue>();
// store the dialogID
dialogResponses.set(`${dialogId}`, (result: Electron.OpenDialogReturnValue) => {
defer.resolve(result);
});
// store the dialogID
dialogResponses.set(`${dialogId}`, (result: Electron.OpenDialogReturnValue) => {
defer.resolve(result);
});
// ask to open file dialog
ipcRenderer.send('dialog:openFile', {
dialogId: `${dialogId}`,
message,
});
// ask to open file dialog
ipcRenderer.send('dialog:openFile', {
dialogId: `${dialogId}`,
message,
filter,
});
// wait for response
return defer.promise;
});
// wait for response
return defer.promise;
},
);
contextBridge.exposeInMainWorld('openFolderDialog', async (message: string) => {
// generate id

View file

@ -33,6 +33,7 @@ import ImageDetails from './lib/image/ImageDetails.svelte';
import PodsList from './lib/pod/PodsList.svelte';
import VolumesList from './lib/volume/VolumesList.svelte';
import VolumeDetails from './lib/volume/VolumeDetails.svelte';
import ContainerPlayKubefile from './lib/container/ContainerPlayKubefile.svelte';
let containersCountValue;
router.mode.hash();
@ -378,6 +379,10 @@ window.events?.receive('display-help', () => {
<Route path="/containers/:id/*" let:meta>
<ContainerDetails containerID="{meta.params.id}" />
</Route>
<Route path="/containers/play">
<ContainerPlayKubefile />
</Route>
<Route path="/images">
<ImagesList />
</Route>

View file

@ -18,6 +18,7 @@ import NavPage from './ui/NavPage.svelte';
import { faChevronDown, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import Fa from 'svelte-fa/src/fa.svelte';
import ContainerGroupIcon from './container/ContainerGroupIcon.svelte';
import KubePlayIcon from './container/KubePlayIcon.svelte';
const containerUtils = new ContainerUtils();
let openChoiceModal = false;
@ -39,6 +40,13 @@ $: providerConnections = $providerInfos
.flat()
.filter(providerContainerConnection => providerContainerConnection.status === 'started');
$: providerPodmanConnections = $providerInfos
.map(provider => provider.containerConnections)
.flat()
// keep only podman providers as it is not supported by docker
.filter(providerContainerConnection => providerContainerConnection.type === 'podman')
.filter(providerContainerConnection => providerContainerConnection.status === 'started');
// number of selected items in the list
$: selectedItemsNumber =
containerGroups.reduce(
@ -212,6 +220,10 @@ function toggleCreateContainer(): void {
openChoiceModal = !openChoiceModal;
}
function runContainerYaml(): void {
router.goto('/containers/play');
}
function fromDockerfile(): void {
openChoiceModal = false;
router.goto('/images/build');
@ -237,16 +249,26 @@ function toggleAllContainerGroups(value: boolean) {
bind:searchTerm
title="containers"
subtitle="Hover over a container to view action buttons; click to open up full details.">
<button
slot="additional-actions"
on:click="{() => toggleCreateContainer()}"
class="pf-c-button pf-m-primary"
type="button">
<span class="pf-c-button__icon pf-m-start">
<i class="fas fa-plus-circle" aria-hidden="true"></i>
</span>
Create container
</button>
<div slot="additional-actions" class="space-x-2 flex flex-nowrap">
<button on:click="{() => toggleCreateContainer()}" class="pf-c-button pf-m-primary" type="button">
<span class="pf-c-button__icon pf-m-start">
<i class="fas fa-plus-circle" aria-hidden="true"></i>
</span>
Create container
</button>
{#if providerPodmanConnections.length > 0}
<button
on:click="{() => runContainerYaml()}"
class="pf-c-button pf-m-primary"
type="button"
title="Run pod/containers from kubernetes .YAML file ">
<div class="flex flex-row align-text-top justify-start items-center">
<KubePlayIcon />
Play YAML
</div>
</button>
{/if}
</div>
<div slot="bottom-additional-actions" class="flex flex-row justify-start items-center w-full">
{#if selectedItemsNumber > 0}
<button

View file

@ -0,0 +1,163 @@
<script lang="ts">
import { onMount, tick, onDestroy } from 'svelte';
import type { Unsubscriber } from 'svelte/store';
import type { ProviderContainerConnectionInfo, ProviderInfo } from '../../../../main/src/plugin/api/provider-info';
let providerUnsubscribe: Unsubscriber;
import { providerInfos } from '../../stores/providers';
import MonacoEditor from '../editor/MonacoEditor.svelte';
import NoContainerEngineEmptyScreen from '../image/NoContainerEngineEmptyScreen.svelte';
import KubePlayIcon from './KubePlayIcon.svelte';
let runStarted = false;
let runFinished = false;
let runError = '';
let kubernetesYamlFilePath = undefined;
let hasInvalidFields = true;
let playKubeResultRaw;
let providers: ProviderInfo[] = [];
$: providerConnections = providers
.map(provider => provider.containerConnections)
.flat()
// keep only podman providers as it is not supported by docker
.filter(providerContainerConnection => providerContainerConnection.type === 'podman')
.filter(providerContainerConnection => providerContainerConnection.status === 'started');
$: selectedProviderConnection = providerConnections.length > 0 ? providerConnections[0] : undefined;
let selectedProvider: ProviderContainerConnectionInfo = undefined;
$: selectedProvider = !selectedProvider && selectedProviderConnection ? selectedProviderConnection : selectedProvider;
function removeEmptyOrNull(obj: any) {
Object.keys(obj).forEach(
k =>
(obj[k] && typeof obj[k] === 'object' && removeEmptyOrNull(obj[k])) ||
(!obj[k] && obj[k] !== undefined && delete obj[k]),
);
return obj;
}
async function playKubeFile(): Promise<void> {
runStarted = true;
runFinished = false;
runError = '';
if (kubernetesYamlFilePath) {
try {
const result = await window.playKube(kubernetesYamlFilePath, selectedProvider);
// remove the null values from the result
playKubeResultRaw = JSON.stringify(removeEmptyOrNull(result), null, 2);
runFinished = true;
} catch (error) {
runError = error;
console.error('error playing kube file', error);
}
}
runStarted = false;
}
function goToContainerList(): void {
window.location.href = '#/containers';
}
onMount(() => {
providerUnsubscribe = providerInfos.subscribe(value => {
providers = value;
});
});
onDestroy(() => {
if (providerUnsubscribe) {
providerUnsubscribe();
}
});
async function getKubernetesfileLocation() {
const result = await window.openFileDialog('Select .YAML file to play', { name: 'YAML files', extensions: ['yaml'] });
if (!result.canceled && result.filePaths.length === 1) {
kubernetesYamlFilePath = result.filePaths[0];
hasInvalidFields = false;
}
}
</script>
{#if providerConnections.length === 0}
<NoContainerEngineEmptyScreen />
{/if}
{#if providerConnections.length > 0}
<div class="px-6 pb-4 space-y-6 lg:px-8 sm:pb-6 xl:pb-8">
<h3 class="text-xl font-medium :text-white">Run pod/containers from a Kubernetes .YAML file</h3>
<div hidden="{runStarted}">
<label for="containerFilePath" class="block mb-2 text-sm font-medium text-gray-300">Kubernetes .YAML file</label>
<input
on:click="{() => getKubernetesfileLocation()}"
name="containerFilePath"
id="containerFilePath"
bind:value="{kubernetesYamlFilePath}"
readonly
placeholder="Select .YAML file to run..."
class=" text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 bg-gray-600 border-gray-500 placeholder-gray-400 text-white"
required />
</div>
<div hidden="{runStarted}">
{#if providerConnections.length > 1}
<label for="providerConnectionName" class="py-6 block mb-2 text-sm font-medium text-gray-900 dark:text-gray-300"
>Container Engine
<select
class="border text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 bg-gray-600 border-gray-500 placeholder-gray-400 text-white"
name="providerChoice"
bind:value="{selectedProvider}">
{#each providerConnections as providerConnection}
<option value="{providerConnection}">{providerConnection.name}</option>
{/each}
</select>
</label>
{/if}
{#if providerConnections.length == 1}
<input type="hidden" name="providerChoice" readonly bind:value="{selectedProviderConnection.name}" />
{/if}
</div>
{#if !runFinished}
<button
on:click="{() => playKubeFile()}"
disabled="{hasInvalidFields || runStarted}"
class="w-full pf-c-button pf-m-primary"
type="button">
<div class="flex flex-row align-text-top justify-center items-center">
{#if runStarted}
<div class="mr-4">
<i class="pf-c-button__progress">
<span class="pf-c-spinner pf-m-md" role="progressbar">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</i>
</div>
{:else}
<KubePlayIcon />
{/if}
Run
</div>
</button>
{/if}
{#if runStarted}
<div class="text-gray-400 text-sm">
Please wait during the Play Kube and do not change screen. This process may take a few minutes to complete...
</div>
{/if}
{#if runError}
<div class="text-red-500 text-sm">{runError}</div>
{/if}
{#if runFinished}
<button on:click="{() => goToContainerList()}" class="w-full pf-c-button pf-m-primary">Done</button>
{/if}
</div>
{#if playKubeResultRaw}
<div class=" h-full w-full px-6 pb-4 space-y-6 lg:px-8 sm:pb-6 xl:pb-8">
<MonacoEditor content="{playKubeResultRaw}" language="json" />
</div>
{/if}
{/if}

View file

@ -0,0 +1,16 @@
<svg class="mr-1" width="13px" height="13px" version="1.1" viewBox="0 0 18.035 17.5" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-.99263 -1.1742)">
<g
transform="matrix(1.0149 0 0 1.0149 16.902 -2.6987)"
style="stroke-miterlimit:1;stroke-width:.19707;stroke:#b50000">
<path
d="m-6.8492 4.2725a1.1191 1.11 0 0 0-0.42888 0.10853l-5.8524 2.7963a1.1191 1.11 0 0 0-0.60552 0.75298l-1.4438 6.2813a1.1191 1.11 0 0 0 0.15194 0.85103 1.1191 1.11 0 0 0 0.06362 0.08832l4.0508 5.0366a1.1191 1.11 0 0 0 0.87498 0.41765l6.4961-0.0015a1.1191 1.11 0 0 0 0.87498-0.41691l4.0493-5.0373a1.1191 1.11 0 0 0 0.21631-0.93935l-1.4461-6.2813a1.1191 1.11 0 0 0-0.60552-0.75298l-5.8532-2.7948a1.1191 1.11 0 0 0-0.54265-0.10853z"
style="fill:none;stroke-miterlimit:1;stroke-width:.7;stroke:currentColor"></path>
</g>
<g transform="matrix(1.7787 0 0 1.7787 -7.5265 -6.6549)" style="fill:currentColor">
<path d="m6.2618 7.0361 3.6208-1.05 3.6208 1.05-3.6208 1.05z" style="fill-rule:evenodd;fill:currentColor"></path>
<path d="m6.2618 7.4382v3.8528l3.3736 1.8687 0.0167-4.7132z" style="fill-rule:evenodd;fill:currentColor"></path>
<path d="m13.503 7.4382v3.8528l-3.3736 1.8687-0.0167-4.7132z" style="fill-rule:evenodd;fill:currentColor"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB