Skip to content

Commit 4812e46

Browse files
committed
feat: duplicate contexts
Signed-off-by: Philippe Martin <[email protected]>
1 parent 15fc149 commit 4812e46

File tree

6 files changed

+124
-0
lines changed

6 files changed

+124
-0
lines changed

packages/channels/src/interface/contexts-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ export const ContextsApi = Symbol.for('ContextsApi');
2121
export interface ContextsApi {
2222
setCurrentContext(contextName: string): Promise<void>;
2323
deleteContext(contextName: string): Promise<void>;
24+
duplicateContext(contextName: string): Promise<void>;
2425
}

packages/extension/src/manager/contexts-manager.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,3 +591,55 @@ test('deleteContext the current context asks for confirmation, and do nothing if
591591
const kubeconfigFile = fsScreenshot['/path/to/kube/config'];
592592
expect(kubeconfigFile).toEqual('{}');
593593
});
594+
595+
test('should duplicate context from config', async () => {
596+
const kubeConfigPath = '/path/to/kube/config';
597+
vol.fromJSON({
598+
[kubeConfigPath]: '{}',
599+
});
600+
vi.mocked(kubernetes.getKubeconfig).mockReturnValue({
601+
path: kubeConfigPath,
602+
} as Uri);
603+
const contextsManager = new ContextsManager();
604+
const kubeConfig = new KubeConfig();
605+
const kubeconfigFileContent = `{
606+
clusters: [
607+
{
608+
name: 'cluster1',
609+
cluster: {
610+
server: 'https://cluster1.example.com',
611+
},
612+
},
613+
],
614+
users: [
615+
{
616+
name: 'user1',
617+
},
618+
],
619+
contexts: [
620+
{
621+
name: 'context1',
622+
context: {
623+
cluster: 'cluster1',
624+
user: 'user1',
625+
},
626+
},
627+
],
628+
"current-context": "context1"
629+
}`;
630+
kubeConfig.loadFromString(kubeconfigFileContent);
631+
await contextsManager.update(kubeConfig);
632+
633+
expect(contextsManager.getKubeConfig().getContexts()).toHaveLength(1);
634+
635+
await contextsManager.duplicateContext(kubeConfig.contexts[0].name);
636+
let contexts = contextsManager.getKubeConfig().getContexts();
637+
expect(contexts.length).toBe(2);
638+
639+
expect(contexts[1].name).toBe('context1-1');
640+
641+
await contextsManager.duplicateContext(kubeConfig.contexts[0].name);
642+
contexts = contextsManager.getKubeConfig().getContexts();
643+
expect(contexts.length).toBe(3);
644+
expect(contexts[2].name).toBe('context1-2');
645+
});

packages/extension/src/manager/contexts-manager.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,50 @@ export class ContextsManager implements ContextsApi {
7474
await this.deleteContextInternal(contextName);
7575
}
7676

77+
findNewContextName(kubeConfig: KubeConfig, contextName: string): string {
78+
let counter = 1;
79+
let newName = `${contextName}-${counter}`;
80+
// Keep creating new name by adding 1 to name until not existing name is found
81+
while (kubeConfig.contexts.find(context => context.name === newName)) {
82+
counter += 1;
83+
newName = `${contextName}-${counter}`;
84+
}
85+
return newName;
86+
}
87+
88+
async duplicateContext(contextName: string): Promise<void> {
89+
try {
90+
const newConfig = new KubeConfig();
91+
const kubeConfig = this.getKubeConfig();
92+
const newName = this.findNewContextName(kubeConfig, contextName);
93+
const originalContext = kubeConfig.contexts.find(context => context.name === contextName);
94+
if (!originalContext) return;
95+
96+
newConfig.loadFromOptions({
97+
clusters: kubeConfig.clusters,
98+
users: kubeConfig.users,
99+
currentContext: kubeConfig.currentContext,
100+
contexts: [
101+
...kubeConfig.contexts,
102+
{
103+
...originalContext,
104+
name: newName,
105+
},
106+
],
107+
});
108+
109+
await this.update(newConfig);
110+
await this.saveKubeConfig();
111+
} catch (error: unknown) {
112+
window.showNotification({
113+
title: 'Error duplicating context',
114+
body: `Duplicating context "${contextName}" failed: ${String(error)}`,
115+
type: 'error',
116+
highlight: true,
117+
});
118+
}
119+
}
120+
77121
async deleteContextInternal(contextName: string): Promise<void> {
78122
try {
79123
this.#currentKubeConfig = this.removeContext(this.#currentKubeConfig, contextName);

packages/webview/src/component/ContextCard.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@ import { render } from '@testing-library/svelte';
2323
import ContextCard from '/@/component/ContextCard.svelte';
2424
import SetCurrentContextAction from '/@/component/actions/SetCurrentContextAction.svelte';
2525
import DeleteContextAction from '/@/component/actions/DeleteContextAction.svelte';
26+
import DuplicateContextAction from '/@/component/actions/DuplicateContextAction.svelte';
2627

2728
vi.mock(import('/@/component/actions/SetCurrentContextAction.svelte'));
2829
vi.mock(import('/@/component/actions/DeleteContextAction.svelte'));
30+
vi.mock(import('/@/component/actions/DuplicateContextAction.svelte'));
2931

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

6164
test('ContextCard should render with no current context', () => {
@@ -83,4 +86,5 @@ test('ContextCard should render with no current context', () => {
8386
expect(queryByText('https://test.cluster')).toBeInTheDocument();
8487
expect(SetCurrentContextAction).toHaveBeenCalledWith(expect.anything(), { name: 'Test Context' });
8588
expect(DeleteContextAction).toHaveBeenCalledWith(expect.anything(), { name: 'Test Context' });
89+
expect(DuplicateContextAction).toHaveBeenCalledWith(expect.anything(), { name: 'Test Context' });
8690
});

packages/webview/src/component/ContextCard.svelte

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Cluster, User } from '@kubernetes/client-node';
33
import ContextCardLine from '/@/component/ContextCardLine.svelte';
44
import SetCurrentContextAction from '/@/component/actions/SetCurrentContextAction.svelte';
55
import DeleteContextAction from '/@/component/actions/DeleteContextAction.svelte';
6+
import DuplicateContextAction from '/@/component/actions/DuplicateContextAction.svelte';
67
78
interface Props {
89
cluster: Cluster;
@@ -31,6 +32,7 @@ const { cluster, user, name, namespace, currentContext, icon }: Props = $props()
3132
{#if !currentContext}
3233
<SetCurrentContextAction name={name} />
3334
{/if}
35+
<DuplicateContextAction name={name} />
3436
<DeleteContextAction name={name} />
3537
</div>
3638
</div>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<script lang="ts">
2+
import { faCopy } from '@fortawesome/free-solid-svg-icons';
3+
import IconButton from '/@/component/button/IconButton.svelte';
4+
import { Remote } from '/@/remote/remote';
5+
import { getContext } from 'svelte';
6+
import { API_CONTEXTS } from '@kubernetes-contexts/channels';
7+
8+
interface Props {
9+
name: string;
10+
}
11+
const { name }: Props = $props();
12+
13+
const remote = getContext<Remote>(Remote);
14+
const contextsApi = remote.getProxy(API_CONTEXTS);
15+
16+
async function duplicateContext(): Promise<void> {
17+
await contextsApi.duplicateContext(name);
18+
}
19+
</script>
20+
21+
<IconButton title="Duplicate Context" icon={faCopy} onClick={duplicateContext} />

0 commit comments

Comments
 (0)