mirror of
https://github.com/podman-desktop/podman-desktop
synced 2026-04-21 17:47:22 +00:00
feat: add ability to restart kubernetes pod
Signed-off-by: Vladyslav Zhukovskyi <vzhukovs@redhat.com>
This commit is contained in:
parent
8097f3c332
commit
c3561f4e06
8 changed files with 1169 additions and 35 deletions
|
|
@ -12,7 +12,7 @@ The website is built using [docusaurus](https://docusaurus.io/) and published to
|
|||
|
||||
### Tips
|
||||
|
||||
* When referring to a clickable button in the interface, **bold** the name. For example: Click on the **Extensions** button.
|
||||
- When referring to a clickable button in the interface, **bold** the name. For example: Click on the **Extensions** button.
|
||||
|
||||
## Previewing the website
|
||||
|
||||
|
|
@ -167,12 +167,12 @@ A template for describing a new feature being added for `package.json`. For exam
|
|||
|
||||
\`\`\`json
|
||||
{
|
||||
"<key>": {
|
||||
"<property>": "<value>",
|
||||
"<additional settings>": {
|
||||
"<setting>": "<setting value>"
|
||||
}
|
||||
}
|
||||
"<key>": {
|
||||
"<property>": "<value>",
|
||||
"<additional settings>": {
|
||||
"<setting>": "<setting value>"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -186,7 +186,7 @@ A template for describing a new feature being added for `package.json`. For exam
|
|||
|
||||
\`\`\`json
|
||||
{
|
||||
"<key>": "<example configuration>"
|
||||
"<key>": "<example configuration>"
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -202,4 +202,4 @@ A template for describing a new feature being added for `package.json`. For exam
|
|||
|
||||
- [External Link Description](URL)
|
||||
- Further reading or related documentation
|
||||
```
|
||||
```
|
||||
|
|
|
|||
|
|
@ -775,6 +775,9 @@ export class PluginSystem {
|
|||
return containerProviderRegistry.restartPod(engine, podId);
|
||||
},
|
||||
);
|
||||
this.ipcHandle('kubernetes-client:restartPod', async (_listener, name: string): Promise<void> => {
|
||||
return kubernetesClient.restartPod(name);
|
||||
});
|
||||
this.ipcHandle(
|
||||
'container-provider-registry:stopPod',
|
||||
async (_listener, engine: string, podId: string): Promise<void> => {
|
||||
|
|
|
|||
|
|
@ -22,13 +22,19 @@ import { Socket } from 'node:net';
|
|||
import type { Readable, Writable } from 'node:stream';
|
||||
|
||||
import {
|
||||
type AppsV1Api,
|
||||
BatchV1Api,
|
||||
CoreV1Api,
|
||||
Exec,
|
||||
KubeConfig,
|
||||
type KubernetesObject,
|
||||
type V1ConfigMap,
|
||||
type V1Deployment,
|
||||
type V1Ingress,
|
||||
type V1Job,
|
||||
type V1Node,
|
||||
type V1ObjectMeta,
|
||||
type V1OwnerReference,
|
||||
type V1Pod,
|
||||
type V1Secret,
|
||||
type V1Service,
|
||||
|
|
@ -37,7 +43,7 @@ import {
|
|||
} from '@kubernetes/client-node';
|
||||
import * as clientNode from '@kubernetes/client-node';
|
||||
import type { FileSystemWatcher } from '@podman-desktop/api';
|
||||
import { beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { beforeAll, beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
|
||||
|
||||
import { ResizableTerminalWriter } from '/@/plugin/kubernetes-exec-transmitter.js';
|
||||
import type { Telemetry } from '/@/plugin/telemetry/telemetry.js';
|
||||
|
|
@ -46,6 +52,7 @@ import type { V1Route } from '/@api/openshift-types.js';
|
|||
import type { ApiSenderType } from './api.js';
|
||||
import type { ConfigurationRegistry } from './configuration-registry.js';
|
||||
import { FilesystemMonitoring } from './filesystem-monitoring.js';
|
||||
import type { PodCreationSource, ScalableControllerType } from './kubernetes-client.js';
|
||||
import { KubernetesClient } from './kubernetes-client.js';
|
||||
|
||||
const configurationRegistry: ConfigurationRegistry = {} as unknown as ConfigurationRegistry;
|
||||
|
|
@ -86,6 +93,10 @@ spec:
|
|||
`;
|
||||
|
||||
class TestKubernetesClient extends KubernetesClient {
|
||||
declare kubeConfig;
|
||||
|
||||
public declare currentNamespace: string | undefined;
|
||||
|
||||
public createWatchObject(): Watch {
|
||||
return super.createWatchObject();
|
||||
}
|
||||
|
|
@ -93,6 +104,139 @@ class TestKubernetesClient extends KubernetesClient {
|
|||
public setCurrentNamespace(namespace: string): void {
|
||||
this.currentNamespace = namespace;
|
||||
}
|
||||
|
||||
public testGetPodController(podMetadata: V1ObjectMeta): V1OwnerReference | undefined {
|
||||
return this.getPodController(podMetadata);
|
||||
}
|
||||
|
||||
public testCheckPodCreationSource(podMetadata: V1ObjectMeta): PodCreationSource {
|
||||
return this.checkPodCreationSource(podMetadata);
|
||||
}
|
||||
|
||||
public testWaitForPodsDeletion(
|
||||
coreApi: CoreV1Api,
|
||||
namespace: string,
|
||||
selector: string,
|
||||
timeout?: number,
|
||||
): Promise<boolean> {
|
||||
return this.waitForPodsDeletion(coreApi, namespace, selector, timeout);
|
||||
}
|
||||
|
||||
public testWaitForJobDeletion(
|
||||
batchApi: BatchV1Api,
|
||||
namespace: string,
|
||||
name: string,
|
||||
timeout?: number,
|
||||
): Promise<boolean> {
|
||||
return this.waitForJobDeletion(batchApi, namespace, name, timeout);
|
||||
}
|
||||
|
||||
public testRestartJob(namespace: string, jobName: string): Promise<void> {
|
||||
return this.restartJob(namespace, jobName);
|
||||
}
|
||||
|
||||
public testScaleController(
|
||||
appsApi: AppsV1Api,
|
||||
namespace: string,
|
||||
controllerName: string,
|
||||
controllerType: 'Deployment' | 'ReplicaSet' | 'StatefulSet',
|
||||
replicas: number,
|
||||
): Promise<void> {
|
||||
return this.scaleController(appsApi, namespace, controllerName, controllerType, replicas);
|
||||
}
|
||||
|
||||
public testScaleControllerToRestartPods(
|
||||
namespace: string,
|
||||
controllerName: string,
|
||||
controllerType: ScalableControllerType,
|
||||
timeout: number = 10000,
|
||||
): Promise<void> {
|
||||
return this.scaleControllerToRestartPods(namespace, controllerName, controllerType, timeout);
|
||||
}
|
||||
|
||||
public testWaitForPodDeletion(
|
||||
coreApi: CoreV1Api,
|
||||
name: string,
|
||||
namespace: string,
|
||||
timeout: number = 60000,
|
||||
): Promise<boolean> {
|
||||
return this.waitForPodDeletion(coreApi, name, namespace, timeout);
|
||||
}
|
||||
|
||||
public testRestartManuallyCreatedPod(name: string, namespace: string, pod: V1Pod): Promise<void> {
|
||||
return this.restartManuallyCreatedPod(name, namespace, pod);
|
||||
}
|
||||
|
||||
// need only to be mocked in several test methods
|
||||
public waitForPodDeletion(
|
||||
coreApi: CoreV1Api,
|
||||
name: string,
|
||||
namespace: string,
|
||||
timeout: number = 60000,
|
||||
): Promise<boolean> {
|
||||
return super.waitForPodDeletion(coreApi, name, namespace, timeout);
|
||||
}
|
||||
|
||||
// need only to be mocked in several test methods
|
||||
public waitForPodsDeletion(
|
||||
coreApi: CoreV1Api,
|
||||
namespace: string,
|
||||
selector: string,
|
||||
timeout?: number,
|
||||
): Promise<boolean> {
|
||||
return super.waitForPodsDeletion(coreApi, namespace, selector, timeout);
|
||||
}
|
||||
|
||||
// need only to be mocked in several test methods
|
||||
public waitForJobDeletion(
|
||||
batchApi: BatchV1Api,
|
||||
name: string,
|
||||
namespace: string,
|
||||
timeout: number = 60000,
|
||||
): Promise<boolean> {
|
||||
return super.waitForJobDeletion(batchApi, name, namespace, timeout);
|
||||
}
|
||||
|
||||
// need only to be mocked in several test methods
|
||||
public scaleController(
|
||||
appsApi: AppsV1Api,
|
||||
namespace: string,
|
||||
controllerName: string,
|
||||
controllerType: 'Deployment' | 'ReplicaSet' | 'StatefulSet',
|
||||
replicas: number,
|
||||
): Promise<void> {
|
||||
return super.scaleController(appsApi, namespace, controllerName, controllerType, replicas);
|
||||
}
|
||||
|
||||
// need only to be mocked in several test methods
|
||||
public checkPodCreationSource(podMetadata: V1ObjectMeta): PodCreationSource {
|
||||
return super.checkPodCreationSource(podMetadata);
|
||||
}
|
||||
|
||||
// need only to be mocked in several test methods
|
||||
public restartManuallyCreatedPod(name: string, namespace: string, pod: V1Pod): Promise<void> {
|
||||
return super.restartManuallyCreatedPod(name, namespace, pod);
|
||||
}
|
||||
|
||||
// need only to be mocked in several test methods
|
||||
public scaleControllerToRestartPods(
|
||||
namespace: string,
|
||||
controllerName: string,
|
||||
controllerType: ScalableControllerType,
|
||||
timeout: number = 10000,
|
||||
): Promise<void> {
|
||||
return super.scaleControllerToRestartPods(namespace, controllerName, controllerType, timeout);
|
||||
}
|
||||
|
||||
// need only to be mocked in several test methods
|
||||
public getPodController(podMetadata: V1ObjectMeta): V1OwnerReference | undefined {
|
||||
return super.getPodController(podMetadata);
|
||||
}
|
||||
|
||||
// need only to be mocked in several test methods
|
||||
public restartJob(name: string, namespace: string): Promise<void> {
|
||||
return super.restartJob(name, namespace);
|
||||
}
|
||||
}
|
||||
|
||||
function createTestClient(namespace?: string): TestKubernetesClient {
|
||||
|
|
@ -116,6 +260,7 @@ beforeAll(() => {
|
|||
KubeConfig: vi.fn(),
|
||||
CoreV1Api: {},
|
||||
AppsV1Api: {},
|
||||
BatchV1Api: {},
|
||||
CustomObjectsApi: {},
|
||||
NetworkingV1Api: {},
|
||||
VersionApi: {},
|
||||
|
|
@ -129,6 +274,8 @@ beforeAll(() => {
|
|||
}
|
||||
},
|
||||
Exec: vi.fn(),
|
||||
V1DeleteOptions: vi.fn(),
|
||||
V1Job: vi.fn(),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
|
@ -1555,3 +1702,701 @@ test('Expect readNamespacedSecret to return the secret', async () => {
|
|||
expect(secret).toBeDefined();
|
||||
expect(secret?.metadata?.name).toEqual('secret');
|
||||
});
|
||||
|
||||
test('Should return undefined if no ownerReferences', () => {
|
||||
const podMetadata: V1ObjectMeta = { ownerReferences: [] };
|
||||
const client = createTestClient('default');
|
||||
const result = client.testGetPodController(podMetadata);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Should return undefined if ownerReferences present but no controller', () => {
|
||||
const ownerReference: V1OwnerReference = { controller: false } as unknown as V1OwnerReference;
|
||||
const podMetadata: V1ObjectMeta = { ownerReferences: [ownerReference] };
|
||||
const client = createTestClient('default');
|
||||
const result = client.testGetPodController(podMetadata);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test('Should return controller if present in ownerReferences', () => {
|
||||
const ownerReference: V1OwnerReference = { controller: true } as unknown as V1OwnerReference;
|
||||
const podMetadata: V1ObjectMeta = { ownerReferences: [ownerReference] };
|
||||
const client = createTestClient('default');
|
||||
const result = client.testGetPodController(podMetadata);
|
||||
expect(result).toEqual(ownerReference);
|
||||
});
|
||||
|
||||
test('Should detect manually created pod when there are no ownerReferences', () => {
|
||||
const podMetadata: V1ObjectMeta = { ownerReferences: [] };
|
||||
const client = createTestClient('default');
|
||||
const result = client.testCheckPodCreationSource(podMetadata);
|
||||
expect(result).toEqual({ isManuallyCreated: true, controllerType: undefined });
|
||||
});
|
||||
|
||||
test('Should detect pod created by a controller', () => {
|
||||
const ownerReference: V1OwnerReference = { controller: true, kind: 'Deployment' } as unknown as V1OwnerReference;
|
||||
const podMetadata: V1ObjectMeta = { ownerReferences: [ownerReference] };
|
||||
const client = createTestClient('default');
|
||||
const result = client.testCheckPodCreationSource(podMetadata);
|
||||
expect(result).toEqual({ isManuallyCreated: false, controllerType: 'Deployment' });
|
||||
});
|
||||
|
||||
test('Should return manually created if ownerReferences exist but none is a controller', () => {
|
||||
const ownerReferences: V1OwnerReference[] = [
|
||||
{ controller: false, kind: 'Deployment' } as unknown as V1OwnerReference,
|
||||
{ controller: false, kind: 'DaemonSet' } as unknown as V1OwnerReference,
|
||||
];
|
||||
const podMetadata: V1ObjectMeta = { ownerReferences: ownerReferences };
|
||||
const client = createTestClient('default');
|
||||
const result = client.testCheckPodCreationSource(podMetadata);
|
||||
expect(result).toEqual({ isManuallyCreated: true, controllerType: undefined });
|
||||
});
|
||||
|
||||
test('Should detect the controller among multiple ownerReferences', () => {
|
||||
const ownerReferences: V1OwnerReference[] = [
|
||||
{ controller: false, kind: 'Deployment' } as unknown as V1OwnerReference,
|
||||
{ controller: true, kind: 'StatefulSet' } as unknown as V1OwnerReference,
|
||||
{ controller: false, kind: 'DaemonSet' } as unknown as V1OwnerReference,
|
||||
];
|
||||
const podMetadata: V1ObjectMeta = { ownerReferences: ownerReferences };
|
||||
const client = createTestClient('default');
|
||||
const result = client.testCheckPodCreationSource(podMetadata);
|
||||
expect(result).toEqual({ isManuallyCreated: false, controllerType: 'StatefulSet' });
|
||||
});
|
||||
|
||||
test('Should return true if pods are deleted within the timeout', async () => {
|
||||
const coreApiMock = vi.fn() as unknown as CoreV1Api;
|
||||
const client = createTestClient('default');
|
||||
const namespace = 'test-namespace';
|
||||
const selector = 'app=test-app';
|
||||
|
||||
coreApiMock.listNamespacedPod = vi.fn().mockResolvedValueOnce({ body: { items: [] } });
|
||||
|
||||
const result = await client.testWaitForPodsDeletion(coreApiMock, namespace, selector);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('Should return false if the timeout is reached but pods still exist', async () => {
|
||||
const existingPodMock = {
|
||||
metadata: {
|
||||
name: 'test-pod',
|
||||
namespace: 'test-namespace',
|
||||
},
|
||||
status: {
|
||||
phase: 'Running',
|
||||
},
|
||||
};
|
||||
|
||||
const coreApiMock = vi.fn() as unknown as CoreV1Api;
|
||||
const client = createTestClient('default');
|
||||
const namespace = 'test-namespace';
|
||||
const selector = 'app=test-app';
|
||||
const timeout = 1000;
|
||||
|
||||
coreApiMock.listNamespacedPod = vi.fn().mockResolvedValueOnce({ body: { items: [existingPodMock] } });
|
||||
|
||||
const result = await client.testWaitForPodsDeletion(coreApiMock, namespace, selector, timeout);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('Should return true if the job is deleted within the timeout', async () => {
|
||||
const batchApiMock = vi.fn() as unknown as BatchV1Api;
|
||||
const client = createTestClient('default');
|
||||
const namespace = 'test-namespace';
|
||||
const jobName = 'test-job';
|
||||
|
||||
batchApiMock.readNamespacedJobStatus = vi.fn().mockRejectedValueOnce({ response: { statusCode: 404 } });
|
||||
|
||||
const result = await client.testWaitForJobDeletion(batchApiMock, namespace, jobName);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('Should return false if the timeout is reached but the job still exists', async () => {
|
||||
const existingJobMock = {
|
||||
status: {
|
||||
conditions: [
|
||||
{
|
||||
type: 'Complete',
|
||||
status: 'True',
|
||||
},
|
||||
],
|
||||
},
|
||||
metadata: {
|
||||
name: 'test-job',
|
||||
namespace: 'test-namespace',
|
||||
},
|
||||
};
|
||||
|
||||
const batchApiMock = vi.fn() as unknown as BatchV1Api;
|
||||
const client = createTestClient('default');
|
||||
const namespace = 'test-namespace';
|
||||
const jobName = 'test-job';
|
||||
const timeout = 1000;
|
||||
|
||||
batchApiMock.readNamespacedJobStatus = vi.fn().mockResolvedValueOnce({ body: existingJobMock });
|
||||
|
||||
const result = await client.testWaitForJobDeletion(batchApiMock, namespace, jobName, timeout);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('Should throw an exception if a non 404 error occurs during read namespaced job status API call', async () => {
|
||||
const batchApiMock = vi.fn() as unknown as BatchV1Api;
|
||||
const client = createTestClient('default');
|
||||
const namespace = 'test-namespace';
|
||||
const jobName = 'test-job';
|
||||
|
||||
batchApiMock.readNamespacedJobStatus = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(client.testWaitForJobDeletion(batchApiMock, namespace, jobName)).rejects.toThrow('Network error');
|
||||
});
|
||||
|
||||
test('Should throw an exception if a read namespaced job status API call returns an error other than 404', async () => {
|
||||
const batchApiMock = vi.fn() as unknown as BatchV1Api;
|
||||
const client = createTestClient('default');
|
||||
const namespace = 'test-namespace';
|
||||
const jobName = 'test-job';
|
||||
|
||||
const errorResponse = { response: { statusCode: 500 } };
|
||||
batchApiMock.readNamespacedJobStatus = vi.fn().mockRejectedValue(errorResponse);
|
||||
|
||||
await expect(client.testWaitForJobDeletion(batchApiMock, namespace, jobName)).rejects.toThrow(
|
||||
expect.objectContaining(errorResponse),
|
||||
);
|
||||
});
|
||||
|
||||
const mockV1Job: V1Job = {
|
||||
apiVersion: 'batch/v1',
|
||||
kind: 'Job',
|
||||
metadata: {
|
||||
name: 'demo-job',
|
||||
namespace: 'default',
|
||||
creationTimestamp: new Date(),
|
||||
resourceVersion: '12345',
|
||||
selfLink: '/apis/batch/v1/namespaces/default/jobs/demo-job',
|
||||
uid: 'abcde-12345-fghij',
|
||||
ownerReferences: [
|
||||
{
|
||||
apiVersion: 'batch/v1',
|
||||
kind: 'Job',
|
||||
name: 'owner-job',
|
||||
uid: 'owner-uid',
|
||||
controller: true,
|
||||
blockOwnerDeletion: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
spec: {
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
'controller-uid': 'abcde-12345-fghij',
|
||||
'job-name': 'demo-job',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
containers: [
|
||||
{
|
||||
name: 'demo-container',
|
||||
image: 'demo-image',
|
||||
},
|
||||
],
|
||||
restartPolicy: 'Never',
|
||||
},
|
||||
},
|
||||
selector: {
|
||||
matchLabels: {
|
||||
'job-name': 'demo-job',
|
||||
},
|
||||
},
|
||||
},
|
||||
status: {},
|
||||
};
|
||||
|
||||
function configureClientToTestRestartJob(): {
|
||||
client: TestKubernetesClient;
|
||||
batchApiMock: BatchV1Api;
|
||||
coreApiMock: CoreV1Api;
|
||||
} {
|
||||
const batchApiMock = vi.fn() as unknown as BatchV1Api;
|
||||
const coreApiMock = vi.fn() as unknown as CoreV1Api;
|
||||
const client = createTestClient('default');
|
||||
batchApiMock.readNamespacedJob = vi.fn().mockResolvedValue({ body: mockV1Job });
|
||||
batchApiMock.deleteNamespacedJob = vi.fn().mockResolvedValue({});
|
||||
batchApiMock.createNamespacedJob = vi.fn().mockResolvedValue({});
|
||||
|
||||
const kubeConfig = client?.kubeConfig;
|
||||
|
||||
// @ts-expect-error api can be one of two types, due to using generic it is hard to detect what exact type is used
|
||||
vi.spyOn(kubeConfig, 'makeApiClient').mockImplementation(api => {
|
||||
if (api === CoreV1Api) {
|
||||
return coreApiMock;
|
||||
} else if (api === BatchV1Api) {
|
||||
return batchApiMock;
|
||||
}
|
||||
});
|
||||
|
||||
vi.spyOn(client, 'waitForJobDeletion').mockResolvedValue(true);
|
||||
vi.spyOn(client, 'waitForPodsDeletion').mockResolvedValue(true);
|
||||
|
||||
return { client, batchApiMock, coreApiMock };
|
||||
}
|
||||
|
||||
test('Should restart a job', async () => {
|
||||
const { client, batchApiMock } = configureClientToTestRestartJob();
|
||||
|
||||
await expect(client.testRestartJob('test-job', 'test-namespace')).resolves.not.toThrow();
|
||||
|
||||
expect(batchApiMock.readNamespacedJob).toHaveBeenCalledWith('test-job', 'test-namespace');
|
||||
expect(batchApiMock.deleteNamespacedJob).toHaveBeenCalledWith(
|
||||
'test-job',
|
||||
'test-namespace',
|
||||
'true',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'Background',
|
||||
);
|
||||
expect(batchApiMock.createNamespacedJob).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Should throw an error if the job is not deleted within the expected timeframe', async () => {
|
||||
const { client } = configureClientToTestRestartJob();
|
||||
|
||||
vi.spyOn(client, 'waitForJobDeletion').mockResolvedValue(false);
|
||||
|
||||
await expect(client.testRestartJob('demo-job', 'default')).rejects.toThrow(
|
||||
'job "demo-job" in namespace "default" was not deleted within the expected timeframe',
|
||||
);
|
||||
});
|
||||
|
||||
test('Should throw an error if not all pods are deleted within the expected timeframe', async () => {
|
||||
const { client } = configureClientToTestRestartJob();
|
||||
|
||||
vi.spyOn(client, 'waitForPodsDeletion').mockResolvedValue(false);
|
||||
|
||||
await expect(client.testRestartJob('demo-job', 'default')).rejects.toThrow(
|
||||
'not all pods with selector "job-name=demo-job" in namespace "default" were deleted within the expected timeframe',
|
||||
);
|
||||
});
|
||||
|
||||
test('Should handle an error when reading the existing job fails', async () => {
|
||||
const { client, batchApiMock } = configureClientToTestRestartJob();
|
||||
|
||||
(batchApiMock.readNamespacedJob as Mock).mockRejectedValue(new Error('Error reading job'));
|
||||
await expect(client.testRestartJob('demo-job', 'default')).rejects.toThrow('Error reading job');
|
||||
});
|
||||
|
||||
test('Should handle an error when deleting the job fails', async () => {
|
||||
const { client, batchApiMock } = configureClientToTestRestartJob();
|
||||
|
||||
(batchApiMock.deleteNamespacedJob as Mock).mockRejectedValue(new Error('Error deleting job'));
|
||||
await expect(client.testRestartJob('demo-job', 'default')).rejects.toThrow('Error deleting job');
|
||||
});
|
||||
|
||||
test('Should handle an error when creating a new job fails', async () => {
|
||||
const { client, batchApiMock } = configureClientToTestRestartJob();
|
||||
|
||||
(batchApiMock.createNamespacedJob as Mock).mockRejectedValue(new Error('Error creating job'));
|
||||
await expect(client.testRestartJob('demo-job', 'default')).rejects.toThrow('Error creating job');
|
||||
});
|
||||
|
||||
test('Should correctly calls scale API for Deployments', async () => {
|
||||
const appsApiMock = { patchNamespacedDeploymentScale: vi.fn() } as unknown as AppsV1Api;
|
||||
const client = createTestClient('default');
|
||||
const namespace = 'default';
|
||||
const replicas = 3;
|
||||
const headers = { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } };
|
||||
|
||||
await client.testScaleController(appsApiMock, namespace, 'my-deployment', 'Deployment', replicas);
|
||||
|
||||
expect(appsApiMock.patchNamespacedDeploymentScale).toHaveBeenCalledOnce();
|
||||
expect(appsApiMock.patchNamespacedDeploymentScale).toHaveBeenCalledWith(
|
||||
'my-deployment',
|
||||
namespace,
|
||||
{ spec: { replicas } },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
headers,
|
||||
);
|
||||
});
|
||||
|
||||
test('correctly calls scale API for ReplicaSets', async () => {
|
||||
const appsApiMock = { patchNamespacedReplicaSetScale: vi.fn() } as unknown as AppsV1Api;
|
||||
const client = createTestClient('default');
|
||||
const namespace = 'default';
|
||||
const replicas = 3;
|
||||
const headers = { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } };
|
||||
|
||||
await client.testScaleController(appsApiMock, namespace, 'my-replicaset', 'ReplicaSet', replicas);
|
||||
|
||||
expect(appsApiMock.patchNamespacedReplicaSetScale).toHaveBeenCalledOnce();
|
||||
expect(appsApiMock.patchNamespacedReplicaSetScale).toHaveBeenCalledWith(
|
||||
'my-replicaset',
|
||||
namespace,
|
||||
{ spec: { replicas } },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
headers,
|
||||
);
|
||||
});
|
||||
|
||||
test('correctly calls scale API for StatefulSets', async () => {
|
||||
const appsApiMock = { patchNamespacedStatefulSetScale: vi.fn() } as unknown as AppsV1Api;
|
||||
const client = createTestClient('default');
|
||||
const namespace = 'default';
|
||||
const replicas = 3;
|
||||
const headers = { headers: { 'Content-Type': 'application/strategic-merge-patch+json' } };
|
||||
|
||||
await client.testScaleController(appsApiMock, namespace, 'my-statefulset', 'StatefulSet', replicas);
|
||||
|
||||
expect(appsApiMock.patchNamespacedStatefulSetScale).toHaveBeenCalledOnce();
|
||||
expect(appsApiMock.patchNamespacedStatefulSetScale).toHaveBeenCalledWith(
|
||||
'my-statefulset',
|
||||
namespace,
|
||||
{ spec: { replicas } },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
headers,
|
||||
);
|
||||
});
|
||||
|
||||
function configureClientToScalePod(): { client: TestKubernetesClient; appsApiMock: AppsV1Api } {
|
||||
const appsApiMock = vi.fn() as unknown as AppsV1Api;
|
||||
const client = createTestClient('default');
|
||||
appsApiMock.readNamespacedDeployment = vi.fn();
|
||||
appsApiMock.readNamespacedReplicaSet = vi.fn();
|
||||
appsApiMock.readNamespacedStatefulSet = vi.fn();
|
||||
client.scaleController = vi.fn().mockResolvedValue({});
|
||||
|
||||
const kubeConfig = client?.kubeConfig;
|
||||
|
||||
kubeConfig.makeApiClient = vi.fn().mockReturnValue(appsApiMock);
|
||||
|
||||
return { client, appsApiMock };
|
||||
}
|
||||
|
||||
async function callScaleControllerAndCheckExpectValues(
|
||||
client: TestKubernetesClient,
|
||||
namespace: string,
|
||||
controllerName: string,
|
||||
controllerType: 'Deployment' | 'ReplicaSet' | 'StatefulSet',
|
||||
timeout: number = 10000,
|
||||
initialReplicas: number,
|
||||
): Promise<void> {
|
||||
await client.testScaleControllerToRestartPods(namespace, controllerName, controllerType, timeout);
|
||||
|
||||
expect(client.scaleController).toHaveBeenCalledTimes(2);
|
||||
expect(client.scaleController).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.anything(),
|
||||
namespace,
|
||||
controllerName,
|
||||
controllerType,
|
||||
0,
|
||||
);
|
||||
expect(client.scaleController).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.anything(),
|
||||
namespace,
|
||||
controllerName,
|
||||
controllerType,
|
||||
initialReplicas,
|
||||
);
|
||||
}
|
||||
|
||||
test('Should correctly scale a Deployment to restart pods', async () => {
|
||||
const { client, appsApiMock } = configureClientToScalePod();
|
||||
const namespace = 'default';
|
||||
const controllerName = 'my-deployment';
|
||||
const controllerType = 'Deployment';
|
||||
const initialReplicas = 3;
|
||||
const scaleTimeout = 1000;
|
||||
|
||||
(appsApiMock.readNamespacedDeployment as Mock).mockResolvedValue({
|
||||
body: { spec: { replicas: initialReplicas } },
|
||||
});
|
||||
|
||||
await callScaleControllerAndCheckExpectValues(
|
||||
client,
|
||||
namespace,
|
||||
controllerName,
|
||||
controllerType,
|
||||
scaleTimeout,
|
||||
initialReplicas,
|
||||
);
|
||||
});
|
||||
|
||||
test('Should correctly scale a ReplicaSet to restart pods', async () => {
|
||||
const { client, appsApiMock } = configureClientToScalePod();
|
||||
const namespace = 'default';
|
||||
const controllerName = 'my-replicaset';
|
||||
const controllerType = 'ReplicaSet';
|
||||
const initialReplicas = 5;
|
||||
const scaleTimeout = 1000;
|
||||
|
||||
(appsApiMock.readNamespacedReplicaSet as Mock).mockResolvedValue({
|
||||
body: { spec: { replicas: initialReplicas } },
|
||||
});
|
||||
|
||||
await callScaleControllerAndCheckExpectValues(
|
||||
client,
|
||||
namespace,
|
||||
controllerName,
|
||||
controllerType,
|
||||
scaleTimeout,
|
||||
initialReplicas,
|
||||
);
|
||||
});
|
||||
|
||||
test('Should correctly scale a StatefulSet to restart pods', async () => {
|
||||
const { client, appsApiMock } = configureClientToScalePod();
|
||||
const namespace = 'default';
|
||||
const controllerName = 'my-statefulset';
|
||||
const controllerType = 'StatefulSet';
|
||||
const initialReplicas = 2;
|
||||
const scaleTimeout = 1000;
|
||||
|
||||
(appsApiMock.readNamespacedStatefulSet as Mock).mockResolvedValue({
|
||||
body: { spec: { replicas: initialReplicas } },
|
||||
});
|
||||
|
||||
await callScaleControllerAndCheckExpectValues(
|
||||
client,
|
||||
namespace,
|
||||
controllerName,
|
||||
controllerType,
|
||||
scaleTimeout,
|
||||
initialReplicas,
|
||||
);
|
||||
});
|
||||
|
||||
test('Should return true if the pod is successfully deleted', async () => {
|
||||
const coreApiMock = vi.fn() as unknown as CoreV1Api;
|
||||
coreApiMock.readNamespacedPodStatus = vi.fn();
|
||||
const client = createTestClient('default');
|
||||
const namespace = 'default';
|
||||
const podName = 'test-pod';
|
||||
|
||||
(coreApiMock.readNamespacedPodStatus as Mock).mockRejectedValueOnce({ response: { statusCode: 404 } });
|
||||
|
||||
const result = await client.testWaitForPodDeletion(coreApiMock as CoreV1Api, podName, namespace);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('Should return false if the timeout is exceeded', async () => {
|
||||
const terminatingPodMock = {
|
||||
metadata: {
|
||||
name: 'example-pod',
|
||||
namespace: 'default',
|
||||
deletionTimestamp: new Date().toISOString(),
|
||||
},
|
||||
status: {
|
||||
phase: 'Terminating',
|
||||
},
|
||||
};
|
||||
|
||||
const coreApiMock = vi.fn() as unknown as CoreV1Api;
|
||||
coreApiMock.readNamespacedPodStatus = vi.fn();
|
||||
const client = createTestClient('default');
|
||||
const namespace = 'default';
|
||||
const podName = 'test-pod';
|
||||
const timeout = 1000;
|
||||
|
||||
(coreApiMock.readNamespacedPodStatus as Mock).mockResolvedValueOnce({ body: terminatingPodMock });
|
||||
|
||||
const result = await client.testWaitForPodDeletion(coreApiMock as CoreV1Api, podName, namespace, timeout);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('Should throw an error if an unexpected error occurs', async () => {
|
||||
const coreApiMock = vi.fn() as unknown as CoreV1Api;
|
||||
coreApiMock.readNamespacedPodStatus = vi.fn();
|
||||
const client = createTestClient('default');
|
||||
const namespace = 'default';
|
||||
const podName = 'test-pod';
|
||||
|
||||
(coreApiMock.readNamespacedPodStatus as Mock).mockRejectedValueOnce(new Error('Unexpected error'));
|
||||
|
||||
await expect(client.testWaitForPodDeletion(coreApiMock as CoreV1Api, podName, namespace)).rejects.toThrow(
|
||||
'Unexpected error',
|
||||
);
|
||||
});
|
||||
|
||||
test('Should throw an error if an unexpected error occurs and it differs than 404', async () => {
|
||||
const coreApiMock = vi.fn() as unknown as CoreV1Api;
|
||||
coreApiMock.readNamespacedPodStatus = vi.fn();
|
||||
const client = createTestClient('default');
|
||||
const namespace = 'default';
|
||||
const podName = 'test-pod';
|
||||
|
||||
const errorResponse = { response: { statusCode: 500 } };
|
||||
(coreApiMock.readNamespacedPodStatus as Mock).mockRejectedValueOnce(errorResponse);
|
||||
|
||||
await expect(client.testWaitForPodDeletion(coreApiMock as CoreV1Api, podName, namespace)).rejects.toThrow(
|
||||
expect.objectContaining(errorResponse),
|
||||
);
|
||||
});
|
||||
|
||||
test('Should delete and recreate the pod successfully', async () => {
|
||||
const podMock = {
|
||||
metadata: {
|
||||
name: 'test-pod',
|
||||
namespace: 'test-namespace',
|
||||
resourceVersion: '123',
|
||||
uid: 'uid123',
|
||||
selfLink: '/api/v1/namespaces/test-namespace/pods/test-pod',
|
||||
creationTimestamp: new Date(),
|
||||
},
|
||||
status: {},
|
||||
};
|
||||
|
||||
const coreApiMock = vi.fn() as unknown as CoreV1Api;
|
||||
coreApiMock.deleteNamespacedPod = vi.fn();
|
||||
coreApiMock.createNamespacedPod = vi.fn();
|
||||
const client = createTestClient('default');
|
||||
const kubeConfig = client?.kubeConfig;
|
||||
|
||||
kubeConfig.makeApiClient = vi.fn().mockReturnValue(coreApiMock);
|
||||
client.waitForPodDeletion = vi.fn().mockResolvedValue(true);
|
||||
|
||||
(coreApiMock.deleteNamespacedPod as Mock).mockResolvedValueOnce({});
|
||||
|
||||
await client.testRestartManuallyCreatedPod(podMock.metadata.name, podMock.metadata.namespace, podMock);
|
||||
|
||||
expect(coreApiMock.deleteNamespacedPod).toHaveBeenCalledWith(podMock.metadata.name, podMock.metadata.namespace);
|
||||
expect(client.waitForPodDeletion).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
podMock.metadata.name,
|
||||
podMock.metadata.namespace,
|
||||
);
|
||||
expect(coreApiMock.createNamespacedPod).toHaveBeenCalledWith(
|
||||
podMock.metadata.namespace,
|
||||
expect.objectContaining({
|
||||
metadata: expect.not.objectContaining({
|
||||
resourceVersion: expect.anything(),
|
||||
uid: expect.anything(),
|
||||
selfLink: expect.anything(),
|
||||
creationTimestamp: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('Should throw an error if the pod is not deleted within the expected timeframe', async () => {
|
||||
const podMock = {
|
||||
metadata: {
|
||||
name: 'test-pod',
|
||||
namespace: 'test-namespace',
|
||||
resourceVersion: '123',
|
||||
uid: 'uid123',
|
||||
selfLink: '/api/v1/namespaces/test-namespace/pods/test-pod',
|
||||
creationTimestamp: new Date(),
|
||||
},
|
||||
status: {},
|
||||
};
|
||||
|
||||
const coreApiMock = vi.fn() as unknown as CoreV1Api;
|
||||
coreApiMock.deleteNamespacedPod = vi.fn();
|
||||
const client = createTestClient('default');
|
||||
const kubeConfig = client?.kubeConfig;
|
||||
|
||||
kubeConfig.makeApiClient = vi.fn().mockReturnValue(coreApiMock);
|
||||
client.waitForPodDeletion = vi.fn().mockResolvedValue(false);
|
||||
|
||||
await expect(
|
||||
client.testRestartManuallyCreatedPod(podMock.metadata.name, podMock.metadata.namespace, podMock),
|
||||
).rejects.toThrow(
|
||||
`pod "${podMock.metadata.name}" in namespace "${podMock.metadata.namespace}" was not deleted within the expected timeframe`,
|
||||
);
|
||||
});
|
||||
|
||||
function configureClientToPodRestart(): TestKubernetesClient {
|
||||
const client = createTestClient('default');
|
||||
client.currentNamespace = 'test-namespace';
|
||||
client.checkConnection = vi.fn().mockResolvedValue(true);
|
||||
client.checkPodCreationSource = vi.fn().mockReturnValue({ isManuallyCreated: false, controllerType: 'Deployment' });
|
||||
client.restartManuallyCreatedPod = vi.fn();
|
||||
client.readNamespacedPod = vi.fn().mockResolvedValue({
|
||||
metadata: { name: 'test-pod' },
|
||||
});
|
||||
client.scaleControllerToRestartPods = vi.fn();
|
||||
client.getPodController = vi.fn().mockReturnValue({ name: 'test-controller' });
|
||||
client.restartJob = vi.fn();
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
test('Should throw an error if there is no active namespace during pod restart', async () => {
|
||||
const client = configureClientToPodRestart();
|
||||
client.currentNamespace = '';
|
||||
|
||||
await expect(client.restartPod('test-pod')).rejects.toThrow('no active namespace');
|
||||
});
|
||||
|
||||
test('Should throw an error if there is not active connection during pod restart', async () => {
|
||||
const client = configureClientToPodRestart();
|
||||
client.checkConnection = vi.fn().mockResolvedValue(false);
|
||||
|
||||
await expect(client.restartPod('test-pod')).rejects.toThrow('not active connection');
|
||||
});
|
||||
|
||||
test('Should restart a manually created pod', async () => {
|
||||
const client = configureClientToPodRestart();
|
||||
client.checkPodCreationSource = vi.fn().mockReturnValue({ isManuallyCreated: true });
|
||||
|
||||
await client.restartPod('test-pod');
|
||||
|
||||
expect(client.restartManuallyCreatedPod).toHaveBeenCalledWith('test-pod', 'test-namespace', expect.anything());
|
||||
});
|
||||
|
||||
test('Should restart a pod controlled by a Deployment', async () => {
|
||||
const client = configureClientToPodRestart();
|
||||
|
||||
await client.restartPod('test-pod');
|
||||
|
||||
expect(client.scaleControllerToRestartPods).toHaveBeenCalledWith('test-namespace', 'test-controller', 'Deployment');
|
||||
});
|
||||
|
||||
test('Should restart a pod controlled by a Job', async () => {
|
||||
const client = configureClientToPodRestart();
|
||||
client.checkPodCreationSource = vi.fn().mockReturnValue({ isManuallyCreated: false, controllerType: 'Job' });
|
||||
|
||||
await client.restartPod('test-pod');
|
||||
|
||||
expect(client.restartJob).toHaveBeenCalledWith('test-controller', 'test-namespace');
|
||||
});
|
||||
|
||||
test('Should treats the pod as manually created if no controller is found', async () => {
|
||||
const client = configureClientToPodRestart();
|
||||
client.getPodController = vi.fn().mockReturnValue({ name: 'dummy-controller-name', kind: 'DummyKind' });
|
||||
client.checkPodCreationSource = vi.fn().mockReturnValue({ isManuallyCreated: true, controllerType: undefined });
|
||||
|
||||
await client.restartPod('test-pod');
|
||||
|
||||
expect(client.restartManuallyCreatedPod).toHaveBeenCalledWith('test-pod', 'test-namespace', expect.anything());
|
||||
});
|
||||
|
||||
test('Should throw an error if no metadata found in the pod', async () => {
|
||||
const client = configureClientToPodRestart();
|
||||
client.readNamespacedPod = vi.fn().mockResolvedValue({});
|
||||
|
||||
await expect(client.restartPod('test-pod')).rejects.toThrow('no metadata found');
|
||||
});
|
||||
|
||||
test('Should throw an error if unable to restart controlled pod', async () => {
|
||||
const client = configureClientToPodRestart();
|
||||
client.checkPodCreationSource = vi.fn().mockReturnValue({ isManuallyCreated: false, controllerType: undefined });
|
||||
|
||||
await expect(client.restartPod('test-pod')).rejects.toThrow('unable to restart controlled pod');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ import type {
|
|||
V1Ingress,
|
||||
V1NamespaceList,
|
||||
V1Node,
|
||||
V1ObjectMeta,
|
||||
V1OwnerReference,
|
||||
V1PersistentVolumeClaim,
|
||||
V1Pod,
|
||||
V1PodList,
|
||||
|
|
@ -45,6 +47,7 @@ import type {
|
|||
import {
|
||||
ApisApi,
|
||||
AppsV1Api,
|
||||
BatchV1Api,
|
||||
CoreV1Api,
|
||||
CustomObjectsApi,
|
||||
Exec,
|
||||
|
|
@ -124,6 +127,18 @@ const DEFAULT_NAMESPACE = 'default';
|
|||
|
||||
const FIELD_MANAGER = 'podman-desktop';
|
||||
|
||||
const SCALABLE_CONTROLLER_TYPES = ['Deployment', 'ReplicaSet', 'StatefulSet'];
|
||||
export type ScalableControllerType = (typeof SCALABLE_CONTROLLER_TYPES)[number];
|
||||
export type ControllerType = ScalableControllerType | 'Job' | 'DaemonSet' | 'CronJob' | undefined;
|
||||
function isScalableControllerType(string: unknown): string is ScalableControllerType {
|
||||
return typeof string === 'string' && SCALABLE_CONTROLLER_TYPES.includes(string);
|
||||
}
|
||||
|
||||
export interface PodCreationSource {
|
||||
isManuallyCreated: boolean;
|
||||
controllerType: ControllerType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle calls to kubernetes API
|
||||
*/
|
||||
|
|
@ -1436,4 +1451,283 @@ export class KubernetesClient {
|
|||
this.telemetry.track('kubernetesExecIntoContainer', telemetryOptions);
|
||||
}
|
||||
}
|
||||
|
||||
async restartPod(name: string): Promise<void> {
|
||||
let telemetryOptions = {};
|
||||
try {
|
||||
const ns = this.currentNamespace;
|
||||
const connected = await this.checkConnection();
|
||||
if (!ns) {
|
||||
throw new Error('no active namespace');
|
||||
}
|
||||
if (!connected) {
|
||||
throw new Error('not active connection');
|
||||
}
|
||||
|
||||
const pod = await this.readNamespacedPod(name, ns);
|
||||
if (!pod?.metadata) {
|
||||
throw new Error('no metadata found');
|
||||
}
|
||||
|
||||
const creationSource = this.checkPodCreationSource(pod.metadata);
|
||||
if (creationSource.isManuallyCreated) {
|
||||
await this.restartManuallyCreatedPod(name, ns, pod);
|
||||
} else {
|
||||
if (!creationSource.controllerType) {
|
||||
throw new Error('unable to restart controlled pod');
|
||||
}
|
||||
|
||||
const controller = this.getPodController(pod.metadata);
|
||||
const controllerName = controller!.name;
|
||||
|
||||
if (isScalableControllerType(creationSource.controllerType)) {
|
||||
await this.scaleControllerToRestartPods(ns, controllerName, creationSource.controllerType);
|
||||
} else if (creationSource.controllerType === 'Job') {
|
||||
await this.restartJob(controllerName, ns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
telemetryOptions = { error: error };
|
||||
throw this.wrapK8sClientError(error);
|
||||
} finally {
|
||||
this.telemetry.track('kubernetesRestartPod', telemetryOptions);
|
||||
}
|
||||
}
|
||||
|
||||
protected async restartManuallyCreatedPod(name: string, namespace: string, pod: V1Pod): Promise<void> {
|
||||
const coreApi = this.kubeConfig.makeApiClient(CoreV1Api);
|
||||
await coreApi.deleteNamespacedPod(name, namespace);
|
||||
|
||||
const isDeleted = await this.waitForPodDeletion(coreApi, name, namespace);
|
||||
if (!isDeleted) {
|
||||
throw new Error(`pod "${name}" in namespace "${namespace}" was not deleted within the expected timeframe`);
|
||||
}
|
||||
|
||||
delete pod.metadata?.resourceVersion;
|
||||
delete pod.metadata?.uid;
|
||||
delete pod.metadata?.selfLink;
|
||||
delete pod.metadata?.creationTimestamp;
|
||||
delete pod.status;
|
||||
|
||||
const newPod: V1Pod = { ...pod };
|
||||
await coreApi.createNamespacedPod(namespace, newPod);
|
||||
}
|
||||
|
||||
protected async waitForPodDeletion(
|
||||
coreApi: CoreV1Api,
|
||||
name: string,
|
||||
namespace: string,
|
||||
timeout: number = 60000,
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
await coreApi.readNamespacedPodStatus(name, namespace);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
} catch (e) {
|
||||
const error = e ?? {};
|
||||
if (typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as { response: { statusCode: number } };
|
||||
if (axiosError.response.statusCode === 404) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async scaleControllerToRestartPods(
|
||||
namespace: string,
|
||||
controllerName: string,
|
||||
controllerType: ScalableControllerType,
|
||||
timeout: number = 10000,
|
||||
): Promise<void> {
|
||||
const appsApi = this.kubeConfig.makeApiClient(AppsV1Api);
|
||||
|
||||
let currentReplicas = 0;
|
||||
if (controllerType === 'Deployment') {
|
||||
const { body: currentDeployment } = await appsApi.readNamespacedDeployment(controllerName, namespace);
|
||||
currentReplicas = currentDeployment.spec?.replicas ?? 1;
|
||||
} else if (controllerType === 'ReplicaSet') {
|
||||
const { body: currentReplicaSet } = await appsApi.readNamespacedReplicaSet(controllerName, namespace);
|
||||
currentReplicas = currentReplicaSet.spec?.replicas ?? 1;
|
||||
} else if (controllerType === 'StatefulSet') {
|
||||
const { body: currentStatefulSet } = await appsApi.readNamespacedStatefulSet(controllerName, namespace);
|
||||
currentReplicas = currentStatefulSet.spec?.replicas ?? 1;
|
||||
}
|
||||
|
||||
await this.scaleController(appsApi, namespace, controllerName, controllerType, 0);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, timeout));
|
||||
|
||||
await this.scaleController(appsApi, namespace, controllerName, controllerType, currentReplicas);
|
||||
}
|
||||
|
||||
protected async scaleController(
|
||||
appsApi: AppsV1Api,
|
||||
namespace: string,
|
||||
controllerName: string,
|
||||
controllerType: ScalableControllerType,
|
||||
replicas: number,
|
||||
): Promise<void> {
|
||||
if (controllerType === 'Deployment') {
|
||||
await appsApi.patchNamespacedDeploymentScale(
|
||||
controllerName,
|
||||
namespace,
|
||||
{ spec: { replicas } },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
|
||||
);
|
||||
} else if (controllerType === 'ReplicaSet') {
|
||||
await appsApi.patchNamespacedReplicaSetScale(
|
||||
controllerName,
|
||||
namespace,
|
||||
{ spec: { replicas } },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
|
||||
);
|
||||
} else if (controllerType === 'StatefulSet') {
|
||||
await appsApi.patchNamespacedStatefulSetScale(
|
||||
controllerName,
|
||||
namespace,
|
||||
{ spec: { replicas } },
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected async restartJob(name: string, namespace: string): Promise<void> {
|
||||
const batchApi = this.kubeConfig.makeApiClient(BatchV1Api);
|
||||
const coreApi = this.kubeConfig.makeApiClient(CoreV1Api);
|
||||
|
||||
const { body: existingJob } = await batchApi.readNamespacedJob(name, namespace);
|
||||
await batchApi.deleteNamespacedJob(name, namespace, 'true', undefined, undefined, undefined, 'Background');
|
||||
|
||||
const isJobDeleted = await this.waitForJobDeletion(batchApi, name, namespace);
|
||||
if (!isJobDeleted) {
|
||||
throw new Error(`job "${name}" in namespace "${namespace}" was not deleted within the expected timeframe`);
|
||||
}
|
||||
|
||||
const labelSelector = `job-name=${name}`;
|
||||
const isPodsDeleted = await this.waitForPodsDeletion(coreApi, namespace, labelSelector);
|
||||
if (!isPodsDeleted) {
|
||||
throw new Error(
|
||||
`not all pods with selector "${labelSelector}" in namespace "${namespace}" were deleted within the expected timeframe`,
|
||||
);
|
||||
}
|
||||
|
||||
delete existingJob.metadata!.creationTimestamp;
|
||||
delete existingJob.metadata!.resourceVersion;
|
||||
delete existingJob.metadata!.selfLink;
|
||||
delete existingJob.metadata!.uid;
|
||||
delete existingJob.metadata!.ownerReferences;
|
||||
delete existingJob.status;
|
||||
delete existingJob.spec!.selector;
|
||||
if (existingJob.spec!.template.metadata!.labels) {
|
||||
delete existingJob.spec!.template.metadata!.labels['controller-uid'];
|
||||
delete existingJob.spec!.template.metadata!.labels['batch.kubernetes.io/controller-uid'];
|
||||
delete existingJob.spec!.template.metadata!.labels['batch.kubernetes.io/job-name'];
|
||||
delete existingJob.spec!.template.metadata!.labels['job-name'];
|
||||
}
|
||||
if (existingJob.metadata?.labels) {
|
||||
delete existingJob.metadata.labels['controller-uid'];
|
||||
delete existingJob.metadata.labels['batch.kubernetes.io/controller-uid'];
|
||||
delete existingJob.metadata.labels['batch.kubernetes.io/job-name'];
|
||||
delete existingJob.metadata.labels['job-name'];
|
||||
}
|
||||
|
||||
await batchApi.createNamespacedJob(namespace, existingJob);
|
||||
}
|
||||
|
||||
protected async waitForJobDeletion(
|
||||
batchApi: BatchV1Api,
|
||||
name: string,
|
||||
namespace: string,
|
||||
timeout: number = 60000,
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
try {
|
||||
await batchApi.readNamespacedJobStatus(name, namespace);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
} catch (e) {
|
||||
const error = e ?? {};
|
||||
if (typeof error === 'object' && 'response' in error) {
|
||||
const axiosError = error as { response: { statusCode: number } };
|
||||
if (axiosError.response.statusCode === 404) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async waitForPodsDeletion(
|
||||
coreApi: CoreV1Api,
|
||||
namespace: string,
|
||||
selector: string,
|
||||
timeout: number = 60000,
|
||||
): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const { body: podList } = await coreApi.listNamespacedPod(
|
||||
namespace,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
selector,
|
||||
);
|
||||
if (podList.items.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected checkPodCreationSource(podMetadata: V1ObjectMeta): PodCreationSource {
|
||||
const controller = this.getPodController(podMetadata);
|
||||
if (controller) {
|
||||
return {
|
||||
isManuallyCreated: false,
|
||||
controllerType: controller.kind,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isManuallyCreated: true,
|
||||
controllerType: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
protected getPodController(podMetadata: V1ObjectMeta): V1OwnerReference | undefined {
|
||||
// possible check is also in pod-template-hash label:
|
||||
// pod.metadata?.labels && 'pod-template-hash' in pod.metadata.labels
|
||||
return podMetadata.ownerReferences?.find((ref: V1OwnerReference) => ref.controller === true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1882,6 +1882,10 @@ export function initExposure(): void {
|
|||
}
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('restartKubernetesPod', async (name: string): Promise<void> => {
|
||||
return ipcInvoke('kubernetes-client:restartPod', name);
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld(
|
||||
'kubernetesApplyResourcesFromFile',
|
||||
async (context: string, file: string, namespace?: string): Promise<KubernetesObject[]> => {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import '@testing-library/jest-dom/vitest';
|
|||
|
||||
import type { ContainerInfo, Port } from '@podman-desktop/api';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { V1Route } from '/@api/openshift-types';
|
||||
|
||||
|
|
@ -190,19 +190,3 @@ test('Expect kubernetes routes kebab menu to be displayed', async () => {
|
|||
const routesDropDownMenu = await screen.findByTitle('Drop Down Menu Items');
|
||||
expect(routesDropDownMenu).toBeVisible();
|
||||
});
|
||||
|
||||
describe('restart action', () => {
|
||||
test('Expect podman pod to have restart action', async () => {
|
||||
render(PodActions, { pod: podmanPod });
|
||||
|
||||
const restartPodButton = await screen.findByRole('button', { name: `Restart Pod` });
|
||||
expect(restartPodButton).toBeVisible();
|
||||
});
|
||||
|
||||
test('Expect kubernetes pod not to have restart action', async () => {
|
||||
render(PodActions, { pod: kubernetesPod });
|
||||
|
||||
const restartPodButton = screen.queryByRole('button', { name: `Restart Pod` });
|
||||
expect(restartPodButton).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -110,7 +110,11 @@ async function startPod() {
|
|||
async function restartPod() {
|
||||
inProgress(false, 'RESTARTING');
|
||||
try {
|
||||
await window.restartPod(pod.engineId, pod.id);
|
||||
if (pod.kind === 'podman') {
|
||||
await window.restartPod(pod.engineId, pod.id);
|
||||
} else {
|
||||
await window.restartKubernetesPod(pod.name);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(String(error));
|
||||
} finally {
|
||||
|
|
@ -233,13 +237,13 @@ if (dropdownMenu) {
|
|||
{/each}
|
||||
</DropdownMenu>
|
||||
{/if}
|
||||
<ListItemButtonIcon
|
||||
title="Restart Pod"
|
||||
onClick="{() => restartPod()}"
|
||||
menu="{dropdownMenu}"
|
||||
detailed="{detailed}"
|
||||
icon="{faArrowsRotate}" />
|
||||
{/if}
|
||||
<ListItemButtonIcon
|
||||
title="Restart Pod"
|
||||
onClick="{() => restartPod()}"
|
||||
menu="{dropdownMenu}"
|
||||
detailed="{detailed}"
|
||||
icon="{faArrowsRotate}" />
|
||||
{#if pod.kind === 'kubernetes'}
|
||||
{#if openingKubernetesUrls.size === 0}
|
||||
<ListItemButtonIcon
|
||||
|
|
|
|||
|
|
@ -5,4 +5,4 @@
|
|||
"frequency": "dailyPerInstance"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue