Skip to content

Commit cde9b93

Browse files
committed
ARTESCA-14623 // implement Metalk8sVolumeProvider
feat: implement K8SLocalVolumeProvider and remove Metalk8sLocalVolumeProvider feat: add K8SLocalVolumeProvider implementation and associated tests
1 parent acd43e8 commit cde9b93

File tree

4 files changed

+228
-1
lines changed

4 files changed

+228
-1
lines changed

ui/public/.well-known/micro-app-configuration

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
"scope": "metalk8s"
3434
}
3535
},
36+
"providers": {
37+
"K8SLocalVolumeProvider": {
38+
"module": "./K8SLocalVolumeProvider",
39+
"scope": "metalk8s"
40+
}
41+
},
3642
"components": {
3743
"TODO_AlertProvider": {
3844
"module": "",

ui/rspack.config.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ const config: Configuration = {
117117
'./platformLibrary': './src/services/platformlibrary/k8s.ts',
118118
'./AlertsNavbarUpdater':
119119
'./src/components/AlertNavbarUpdaterComponent.tsx',
120+
'./K8SLocalVolumeProvider':
121+
'./src/services/k8s/K8SLocalVolumeProvider.ts',
120122
},
121123
remotes: !isProduction
122124
? {
@@ -148,7 +150,17 @@ const config: Configuration = {
148150
},
149151
}),
150152
new rspack.CopyRspackPlugin({
151-
patterns: [{ from: 'public' }],
153+
patterns: [
154+
{ from: 'public' },
155+
{
156+
from: path.resolve(__dirname, 'build/static/js/@mf-types.zip'),
157+
to: '@mf-types.zip',
158+
},
159+
{
160+
from: path.resolve(__dirname, 'build/static/js/@mf-types.d.ts'),
161+
to: '@mf-types.d.ts',
162+
},
163+
],
152164
}),
153165
new rspack.DefinePlugin({
154166
NODE_ENV: process.env.NODE_ENV,
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { CoreV1Api, CustomObjectsApi } from '@kubernetes/client-node';
2+
import K8SLocalVolumeProvider from './K8SLocalVolumeProvider';
3+
import { Metalk8sV1alpha1VolumeClient } from './Metalk8sVolumeClient.generated';
4+
import { updateApiServerConfig } from '../k8s/api';
5+
6+
jest.mock('../k8s/api', () => ({
7+
updateApiServerConfig: jest.fn(),
8+
}));
9+
10+
describe('K8SLocalVolumeProvider', () => {
11+
let provider: K8SLocalVolumeProvider;
12+
const mockUrl = 'mock-url';
13+
const mockToken = 'mock-token';
14+
15+
const mockCustomObjectsApi = {
16+
listClusterCustomObject: jest.fn(),
17+
} as unknown as CustomObjectsApi;
18+
19+
const mockVolumeClient = new Metalk8sV1alpha1VolumeClient(
20+
mockCustomObjectsApi,
21+
);
22+
23+
const mockCoreV1Api = {
24+
listNode: jest.fn(),
25+
listPersistentVolume: jest.fn(),
26+
} as unknown as CoreV1Api;
27+
28+
beforeEach(() => {
29+
(updateApiServerConfig as jest.Mock).mockReturnValue({
30+
coreV1: mockCoreV1Api,
31+
customObjects: mockCustomObjectsApi,
32+
});
33+
34+
provider = new K8SLocalVolumeProvider(mockUrl, mockToken);
35+
provider.k8sClient = mockCoreV1Api;
36+
provider.volumeClient = mockVolumeClient;
37+
});
38+
39+
describe('listLocalPersistentVolumes', () => {
40+
it('should return local persistent volumes for a given node.', async () => {
41+
(mockCoreV1Api.listNode as jest.Mock).mockResolvedValue({
42+
body: {
43+
items: [
44+
{
45+
metadata: { name: 'test-node' },
46+
status: {
47+
addresses: [
48+
{ type: 'Hostname', address: 'test-node' },
49+
{ type: 'InternalIP', address: '192.168.1.100' },
50+
],
51+
},
52+
},
53+
],
54+
},
55+
});
56+
57+
(mockCoreV1Api.listPersistentVolume as jest.Mock).mockResolvedValue({
58+
body: {
59+
items: [
60+
{
61+
metadata: { name: 'test-volume' },
62+
spec: {},
63+
},
64+
],
65+
},
66+
});
67+
68+
(
69+
mockCustomObjectsApi.listClusterCustomObject as jest.Mock
70+
).mockResolvedValue({
71+
body: {
72+
items: [
73+
{
74+
metadata: { name: 'test-volume' },
75+
spec: {
76+
nodeName: 'test-node',
77+
rawBlockDevice: { devicePath: '/dev/sda' },
78+
},
79+
},
80+
],
81+
},
82+
});
83+
84+
const volumes = await provider.listLocalPersistentVolumes('test-node');
85+
86+
expect(volumes).toHaveLength(1);
87+
expect(volumes[0]).toMatchObject({
88+
IP: '192.168.1.100',
89+
devicePath: '/dev/sda',
90+
metadata: { name: 'test-volume' },
91+
});
92+
});
93+
94+
it('should raise an error if the node cannot be found', async () => {
95+
(mockCoreV1Api.listNode as jest.Mock).mockResolvedValue({
96+
body: { items: [] },
97+
});
98+
99+
await expect(
100+
provider.listLocalPersistentVolumes('non-existent-node'),
101+
).rejects.toThrow('Failed to find IP for node non-existent-node');
102+
});
103+
104+
it('should raise an error if volume recovery fails', async () => {
105+
(mockCoreV1Api.listNode as jest.Mock).mockResolvedValue({
106+
body: {
107+
items: [
108+
{
109+
metadata: { name: 'test-node' },
110+
status: {
111+
addresses: [
112+
{ type: 'Hostname', address: 'test-node' },
113+
{ type: 'InternalIP', address: '192.168.1.100' },
114+
],
115+
},
116+
},
117+
],
118+
},
119+
});
120+
121+
(
122+
mockCustomObjectsApi.listClusterCustomObject as jest.Mock
123+
).mockRejectedValue(new Error('Failed to fetch volumes'));
124+
125+
await expect(
126+
provider.listLocalPersistentVolumes('test-node'),
127+
).rejects.toThrow('Failed to fetch metalk8s volumes');
128+
});
129+
});
130+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { CoreV1Api, V1PersistentVolume } from '@kubernetes/client-node';
2+
import * as ApiK8s from '../k8s/api';
3+
import {
4+
Metalk8sV1alpha1VolumeClient,
5+
Result,
6+
} from './Metalk8sVolumeClient.generated';
7+
8+
function isError<T>(result: Result<T>): result is { error: any } {
9+
return (result as { error: any }).error !== undefined;
10+
}
11+
12+
type LocalPersistentVolume = V1PersistentVolume & {
13+
IP: string;
14+
devicePath: string;
15+
};
16+
17+
export interface K8SLocalVolumeAdapter {
18+
listLocalPersistentVolumes(
19+
serverName: string,
20+
): Promise<LocalPersistentVolume[]>;
21+
}
22+
23+
export default class K8SLocalVolumeProvider implements K8SLocalVolumeAdapter {
24+
volumeClient: Metalk8sV1alpha1VolumeClient;
25+
k8sClient: CoreV1Api;
26+
constructor(private url: string, private token: string) {
27+
const { coreV1, customObjects } = ApiK8s.updateApiServerConfig(url, token);
28+
this.volumeClient = new Metalk8sV1alpha1VolumeClient(customObjects);
29+
this.k8sClient = coreV1;
30+
}
31+
public async listLocalPersistentVolumes(
32+
serverName: string,
33+
): Promise<LocalPersistentVolume[]> {
34+
try {
35+
const nodes = await this.k8sClient.listNode();
36+
const nodeIP = nodes.body.items
37+
.find((node) => node.metadata.name === serverName)
38+
?.status.addresses.find((address) => address.type === 'InternalIP');
39+
40+
if (!nodeIP) {
41+
throw new Error(`Failed to find IP for node ${serverName}`);
42+
}
43+
44+
const volumes = await this.volumeClient.getMetalk8sV1alpha1VolumeList();
45+
46+
if (!isError(volumes)) {
47+
const nodeVolumes = volumes.body.items.filter(
48+
(volume) => volume.spec.nodeName === serverName,
49+
);
50+
const pv = await this.k8sClient.listPersistentVolume();
51+
52+
const localPv: LocalPersistentVolume[] = nodeVolumes.reduce(
53+
(acc, item) => {
54+
const isLocalPv = pv.body.items.find(
55+
(p) => p.metadata.name === item.metadata['name'],
56+
);
57+
return [
58+
...acc,
59+
{
60+
...isLocalPv,
61+
IP: nodeIP.address,
62+
devicePath: item.spec?.rawBlockDevice?.devicePath,
63+
},
64+
];
65+
},
66+
[],
67+
);
68+
69+
return localPv;
70+
} else {
71+
throw new Error(`Failed to fetch metalk8s volumes: ${volumes.error}`);
72+
}
73+
} catch (error) {
74+
throw new Error(
75+
`Failed to fetch local persistent volumes: ${error.message}`,
76+
);
77+
}
78+
}
79+
}

0 commit comments

Comments
 (0)