Skip to content

Commit

Permalink
feat: adding port forwarding actions to kube container details (podma…
Browse files Browse the repository at this point in the history
…n-desktop#9643)

* feat: adding port forwarding actions to kube container details

Signed-off-by: axel7083 <[email protected]>

* refactor: managing actions in a specific component

Signed-off-by: axel7083 <[email protected]>

* feat: adding error handling

Signed-off-by: axel7083 <[email protected]>

* test: fix missing mock

Signed-off-by: axel7083 <[email protected]>

---------

Signed-off-by: axel7083 <[email protected]>
  • Loading branch information
axel7083 authored Oct 30, 2024
1 parent 255e4ae commit 7670df0
Show file tree
Hide file tree
Showing 8 changed files with 317 additions and 14 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export let pod: V1Pod | undefined;
{#if pod}
<KubeObjectMetaArtifact artifact={pod.metadata} />
<KubePodStatusArtifact artifact={pod.status} />
<KubePodSpecArtifact artifact={pod.spec} />
<KubePodSpecArtifact podName={pod.metadata?.name} namespace={pod.metadata?.namespace} artifact={pod.spec} />
{:else}
<p class="text-[var(--pd-state-info)] font-medium">Loading ...</p>
{/if}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,14 @@ import '@testing-library/jest-dom/vitest';

import type { V1Container } from '@kubernetes/client-node';
import { render, screen } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import { readable } from 'svelte/store';
import { beforeEach, expect, test, vi } from 'vitest';

import KubeContainerArtifact from './KubeContainerArtifact.svelte'; // Adjust the import path as necessary
import * as kubeContextStore from '/@/stores/kubernetes-contexts-state'; // Adjust the import path as necessary

import KubeContainerArtifact from './KubeContainerArtifact.svelte';

vi.mock('/@/stores/kubernetes-contexts-state', async () => ({}));

const fakeContainer: V1Container = {
name: 'fakeContainerName',
Expand All @@ -42,6 +47,12 @@ const fakeContainer: V1Container = {
],
};

beforeEach(() => {
vi.resetAllMocks();

vi.mocked(kubeContextStore).kubernetesCurrentContextPortForwards = readable([]);
});

test('Container artifact renders with correct values', async () => {
render(KubeContainerArtifact, { artifact: fakeContainer });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import type { V1Container } from '@kubernetes/client-node';
import Cell from '/@/lib/details/DetailsCell.svelte';
import KubeContainerPorts from '/@/lib/kube/details/KubeContainerPorts.svelte';
export let artifact: V1Container | undefined;
interface Props {
artifact?: V1Container;
podName?: string;
namespace?: string;
}
let { artifact, podName, namespace }: Props = $props();
</script>

{#if artifact}
Expand All @@ -20,7 +25,7 @@ export let artifact: V1Container | undefined;
<Cell>Image Pull Policy</Cell>
<Cell>{artifact.imagePullPolicy}</Cell>
</tr>
<KubeContainerPorts ports={artifact.ports ?? []}/>
<KubeContainerPorts namespace={namespace} podName={podName} ports={artifact.ports}/>
{#if artifact.env}
<tr>
<Cell>Environment Variables</Cell>
Expand Down
174 changes: 174 additions & 0 deletions packages/renderer/src/lib/kube/details/KubeContainerPort.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**********************************************************************
* Copyright (C) 2024 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 '@testing-library/jest-dom/vitest';

import { fireEvent, render } from '@testing-library/svelte';
import { beforeEach, describe, expect, test, vi } from 'vitest';

import KubeContainerPort from '/@/lib/kube/details/KubeContainerPort.svelte';
import { type UserForwardConfig, WorkloadKind } from '/@api/kubernetes-port-forward-model';

beforeEach(() => {
vi.resetAllMocks();

(window.getFreePort as unknown) = vi.fn().mockResolvedValue(55_001);
(window.createKubernetesPortForward as unknown) = vi.fn();
(window.openExternal as unknown) = vi.fn();
(window.deleteKubernetesPortForward as unknown) = vi.fn();
});

const DUMMY_FORWARD_CONFIG: UserForwardConfig = {
name: 'dummy-pod-name',
namespace: 'dummy-ns',
kind: WorkloadKind.POD,
forwards: [
{
localPort: 55_076,
remotePort: 80,
},
],
displayName: 'dummy name',
};

describe('port forwarding', () => {
test('forward button should be visible and unique for each container port', async () => {
const { getByTitle } = render(KubeContainerPort, {
namespace: 'dummy-ns',
port: {
containerPort: 80,
protocol: 'TCP',
},
forwardConfig: undefined,
podName: 'dummy-pod-name',
});

const port80 = getByTitle('Forward container port 80');
expect(port80).toBeDefined();
});

test('forward button should call ', async () => {
const { getByTitle } = render(KubeContainerPort, {
namespace: 'dummy-ns',
port: {
containerPort: 80,
protocol: 'TCP',
},
forwardConfig: undefined,
podName: 'dummy-pod-name',
});

const forwardBtn = getByTitle('Forward container port 80');
await fireEvent.click(forwardBtn);

await vi.waitFor(() => {
expect(window.getFreePort).toHaveBeenCalled();
expect(window.createKubernetesPortForward).toHaveBeenCalledWith({
displayName: 'dummy-pod-name/undefined',
forward: {
localPort: 55001,
remotePort: 80,
},
kind: 'pod',
name: 'dummy-pod-name',
namespace: 'dummy-ns',
});
});
});

test('existing forward should display actions', async () => {
const { getByTitle } = render(KubeContainerPort, {
namespace: 'dummy-ns',
port: {
containerPort: 80,
protocol: 'TCP',
},
forwardConfig: DUMMY_FORWARD_CONFIG,
podName: 'dummy-pod-name',
});

const openBtn = getByTitle('Open in browser');
expect(openBtn).toBeDefined();

const removeBtn = getByTitle('Remove port forward');
expect(removeBtn).toBeDefined();
});

test('open button should use window.openExternal with proper local port', async () => {
const { getByTitle } = render(KubeContainerPort, {
namespace: 'dummy-ns',
port: {
containerPort: 80,
protocol: 'TCP',
},
forwardConfig: DUMMY_FORWARD_CONFIG,
podName: 'dummy-pod-name',
});

const openBtn = getByTitle('Open in browser');
await fireEvent.click(openBtn);

await vi.waitFor(() => {
expect(window.openExternal).toHaveBeenCalledWith('http://localhost:55076');
});
});

test('remove button should use window.deleteKubernetesPortForward with proper local port', async () => {
const { getByTitle } = render(KubeContainerPort, {
namespace: 'dummy-ns',
port: {
containerPort: 80,
protocol: 'TCP',
},
forwardConfig: DUMMY_FORWARD_CONFIG,
podName: 'dummy-pod-name',
});

const removeBtn = getByTitle('Remove port forward');
await fireEvent.click(removeBtn);

await vi.waitFor(() => {
expect(window.deleteKubernetesPortForward).toHaveBeenCalledWith(
DUMMY_FORWARD_CONFIG,
DUMMY_FORWARD_CONFIG.forwards[0],
);
});
});

test('error from createKubernetesPortForward should be displayed', async () => {
vi.mocked(window.createKubernetesPortForward).mockRejectedValue('Dummy error');

const { getByTitle, getByRole } = render(KubeContainerPort, {
namespace: 'dummy-ns',
port: {
containerPort: 80,
protocol: 'TCP',
},
forwardConfig: undefined,
podName: 'dummy-pod-name',
});

const port80 = getByTitle('Forward container port 80');
await fireEvent.click(port80);

await vi.waitFor(() => {
const error = getByRole('alert', { name: 'Error Message Content' });
expect(error.textContent).toBe('Dummy error');
});
});
});
90 changes: 90 additions & 0 deletions packages/renderer/src/lib/kube/details/KubeContainerPort.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<script lang="ts">
import { faSquareUpRight, faTrash } from '@fortawesome/free-solid-svg-icons';
import type { V1ContainerPort } from '@kubernetes/client-node';
import { Button, ErrorMessage } from '@podman-desktop/ui-svelte';
import { type PortMapping, type UserForwardConfig, WorkloadKind } from '/@api/kubernetes-port-forward-model';
interface Props {
port: V1ContainerPort;
forwardConfig?: UserForwardConfig;
podName?: string;
namespace?: string;
}
let { port, forwardConfig, podName, namespace }: Props = $props();
let mapping: PortMapping | undefined = $derived(
forwardConfig?.forwards.find(mapping => mapping.remotePort === port.containerPort),
);
let loading: boolean = $state(false);
let error: string | undefined = $state(undefined);
async function onForwardRequest(port: V1ContainerPort): Promise<void> {
if (!podName) throw new Error('pod name is undefined');
loading = true;
error = undefined;
// get a free port starting from 50k
const freePort = await window.getFreePort(50_000);
// snapshot the object as Proxy cannot be serialized
const snapshot = $state.snapshot(port);
try {
await window.createKubernetesPortForward({
displayName: `${podName}/${snapshot.name}`,
name: podName,
kind: WorkloadKind.POD,
namespace: namespace ?? 'default',
forward: {
localPort: freePort,
remotePort: snapshot.containerPort,
},
});
error = undefined;
} catch (err: unknown) {
console.error(err);
error = String(err);
} finally {
loading = false;
}
}
async function openExternal(): Promise<void> {
if (!mapping) return;
return window.openExternal(`http://localhost:${mapping.localPort}`);
}
async function removePortForward(): Promise<void> {
if (!mapping) return;
if (!forwardConfig) return;
loading = true;
try {
await window.deleteKubernetesPortForward(forwardConfig, mapping);
} catch (err: unknown) {
console.error(err);
} finally {
loading = false;
}
}
</script>

<span aria-label="container port {port.containerPort}" class="flex gap-x-2 items-center">
{port.containerPort}/{port.protocol}
{#if mapping}
<Button title="Open in browser" disabled={loading} icon={faSquareUpRight} on:click={openExternal.bind(undefined)} class="px-1 py-0.5" padding="0">
Open
</Button>
<Button title="Remove port forward" disabled={loading} icon={faTrash} on:click={removePortForward.bind(undefined)} class="px-1 py-0.5" padding="0">
Remove
</Button>
{:else}
<Button title="Forward container port {port.containerPort}" disabled={loading} on:click={onForwardRequest.bind(undefined, port)} class="px-1 py-0.5" padding="0">
Forward...
</Button>
{/if}
{#if error}
<ErrorMessage error={error} />
{/if}
</span>
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,19 @@
import '@testing-library/jest-dom/vitest';

import { render } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import { readable } from 'svelte/store';
import { beforeEach, expect, test, vi } from 'vitest';

import KubeContainerPorts from '/@/lib/kube/details/KubeContainerPorts.svelte';
import * as kubeContextStore from '/@/stores/kubernetes-contexts-state';

vi.mock('/@/stores/kubernetes-contexts-state', async () => ({}));

beforeEach(() => {
vi.resetAllMocks();

vi.mocked(kubeContextStore).kubernetesCurrentContextPortForwards = readable([]);
});

test('expect port title not to be visible when no ports provided', async () => {
const { queryByText } = render(KubeContainerPorts);
Expand Down
20 changes: 14 additions & 6 deletions packages/renderer/src/lib/kube/details/KubeContainerPorts.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,34 @@
import type { V1ContainerPort } from '@kubernetes/client-node';
import Cell from '/@/lib/details/DetailsCell.svelte';
import KubeContainerPort from '/@/lib/kube/details/KubeContainerPort.svelte';
import { kubernetesCurrentContextPortForwards } from '/@/stores/kubernetes-contexts-state';
import { type UserForwardConfig, WorkloadKind } from '/@api/kubernetes-port-forward-model';
interface Props {
ports?: V1ContainerPort[];
podName?: string;
namespace?: string;
}
let { ports }: Props = $props();
let { ports, podName, namespace }: Props = $props();
let userForwardConfig: UserForwardConfig | undefined = $derived(
$kubernetesCurrentContextPortForwards.find(
forward => forward.kind === WorkloadKind.POD && forward.name === podName && forward.namespace === namespace,
),
);
</script>

{#if ports && ports.length > 0}
<tr>
<Cell class="flex">Ports</Cell>
<Cell>
<div class="flex gap-y-1 flex-col">
{#each ports as port (port.containerPort)}
<span>
{port.containerPort}/{port.protocol}
</span>
{#each ports as port}
<KubeContainerPort namespace={namespace} podName={podName} port={port} forwardConfig={userForwardConfig}/>
{/each}
</div>
</Cell>
</tr>
{/if}

Loading

0 comments on commit 7670df0

Please sign in to comment.