Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/channels/src/interface/contexts-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ export const ContextsApi = Symbol.for('ContextsApi');
export interface ContextsApi {
setCurrentContext(contextName: string): Promise<void>;
deleteContext(contextName: string): Promise<void>;
duplicateContext(contextName: string): Promise<void>;
}
52 changes: 52 additions & 0 deletions packages/extension/src/manager/contexts-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,3 +591,55 @@ test('deleteContext the current context asks for confirmation, and do nothing if
const kubeconfigFile = fsScreenshot['/path/to/kube/config'];
expect(kubeconfigFile).toEqual('{}');
});

test('should duplicate context from config', async () => {
const kubeConfigPath = '/path/to/kube/config';
vol.fromJSON({
[kubeConfigPath]: '{}',
});
vi.mocked(kubernetes.getKubeconfig).mockReturnValue({
path: kubeConfigPath,
} as Uri);
const contextsManager = new ContextsManager();
const kubeConfig = new KubeConfig();
const kubeconfigFileContent = `{
clusters: [
{
name: 'cluster1',
cluster: {
server: 'https://cluster1.example.com',
},
},
],
users: [
{
name: 'user1',
},
],
contexts: [
{
name: 'context1',
context: {
cluster: 'cluster1',
user: 'user1',
},
},
],
"current-context": "context1"
}`;
kubeConfig.loadFromString(kubeconfigFileContent);
await contextsManager.update(kubeConfig);

expect(contextsManager.getKubeConfig().getContexts()).toHaveLength(1);

await contextsManager.duplicateContext(kubeConfig.contexts[0].name);
let contexts = contextsManager.getKubeConfig().getContexts();
expect(contexts.length).toBe(2);

expect(contexts[1].name).toBe('context1-1');

await contextsManager.duplicateContext(kubeConfig.contexts[0].name);
contexts = contextsManager.getKubeConfig().getContexts();
expect(contexts.length).toBe(3);
expect(contexts[2].name).toBe('context1-2');
});
44 changes: 44 additions & 0 deletions packages/extension/src/manager/contexts-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,50 @@ export class ContextsManager implements ContextsApi {
await this.deleteContextInternal(contextName);
}

findNewContextName(kubeConfig: KubeConfig, contextName: string): string {
let counter = 1;
let newName = `${contextName}-${counter}`;
// Keep creating new name by adding 1 to name until not existing name is found
while (kubeConfig.contexts.find(context => context.name === newName)) {
counter += 1;
newName = `${contextName}-${counter}`;
}
return newName;
}

async duplicateContext(contextName: string): Promise<void> {
try {
const newConfig = new KubeConfig();
const kubeConfig = this.getKubeConfig();
const newName = this.findNewContextName(kubeConfig, contextName);
const originalContext = kubeConfig.contexts.find(context => context.name === contextName);
if (!originalContext) return;

newConfig.loadFromOptions({
clusters: kubeConfig.clusters,
users: kubeConfig.users,
currentContext: kubeConfig.currentContext,
contexts: [
...kubeConfig.contexts,
{
...originalContext,
name: newName,
},
],
});

await this.update(newConfig);
await this.saveKubeConfig();
} catch (error: unknown) {
window.showNotification({
title: 'Error duplicating context',
body: `Duplicating context "${contextName}" failed: ${String(error)}`,
type: 'error',
highlight: true,
});
}
}

async deleteContextInternal(contextName: string): Promise<void> {
try {
this.#currentKubeConfig = this.removeContext(this.#currentKubeConfig, contextName);
Expand Down
4 changes: 4 additions & 0 deletions packages/webview/src/component/ContextCard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ import { render } from '@testing-library/svelte';
import ContextCard from '/@/component/ContextCard.svelte';
import SetCurrentContextAction from '/@/component/actions/SetCurrentContextAction.svelte';
import DeleteContextAction from '/@/component/actions/DeleteContextAction.svelte';
import DuplicateContextAction from '/@/component/actions/DuplicateContextAction.svelte';

vi.mock(import('/@/component/actions/SetCurrentContextAction.svelte'));
vi.mock(import('/@/component/actions/DeleteContextAction.svelte'));
vi.mock(import('/@/component/actions/DuplicateContextAction.svelte'));

beforeEach(() => {
vi.resetAllMocks();
Expand Down Expand Up @@ -56,6 +58,7 @@ test('ContextCard should render with current context', () => {
expect(queryByText('https://test.cluster')).toBeInTheDocument();
expect(SetCurrentContextAction).not.toHaveBeenCalled();
expect(DeleteContextAction).toHaveBeenCalledWith(expect.anything(), { name: 'Test Context' });
expect(DuplicateContextAction).toHaveBeenCalledWith(expect.anything(), { name: 'Test Context' });
});

test('ContextCard should render with no current context', () => {
Expand Down Expand Up @@ -83,4 +86,5 @@ test('ContextCard should render with no current context', () => {
expect(queryByText('https://test.cluster')).toBeInTheDocument();
expect(SetCurrentContextAction).toHaveBeenCalledWith(expect.anything(), { name: 'Test Context' });
expect(DeleteContextAction).toHaveBeenCalledWith(expect.anything(), { name: 'Test Context' });
expect(DuplicateContextAction).toHaveBeenCalledWith(expect.anything(), { name: 'Test Context' });
});
2 changes: 2 additions & 0 deletions packages/webview/src/component/ContextCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Cluster, User } from '@kubernetes/client-node';
import ContextCardLine from '/@/component/ContextCardLine.svelte';
import SetCurrentContextAction from '/@/component/actions/SetCurrentContextAction.svelte';
import DeleteContextAction from '/@/component/actions/DeleteContextAction.svelte';
import DuplicateContextAction from '/@/component/actions/DuplicateContextAction.svelte';

interface Props {
cluster: Cluster;
Expand Down Expand Up @@ -31,6 +32,7 @@ const { cluster, user, name, namespace, currentContext, icon }: Props = $props()
{#if !currentContext}
<SetCurrentContextAction name={name} />
{/if}
<DuplicateContextAction name={name} />
<DeleteContextAction name={name} />
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<script lang="ts">
import { faCopy } from '@fortawesome/free-solid-svg-icons';
import IconButton from '/@/component/button/IconButton.svelte';
import { Remote } from '/@/remote/remote';
import { getContext } from 'svelte';
import { API_CONTEXTS } from '@kubernetes-contexts/channels';

interface Props {
name: string;
}
const { name }: Props = $props();

const remote = getContext<Remote>(Remote);
const contextsApi = remote.getProxy(API_CONTEXTS);

async function duplicateContext(): Promise<void> {
await contextsApi.duplicateContext(name);
}
</script>

<IconButton title="Duplicate Context" icon={faCopy} onClick={duplicateContext} />