Skip to content

Commit b77a25b

Browse files
authored
Merge pull request #220 from mgalesloot/flux/helm-chart-resources
Flux: show resources deployed by Helm chart
2 parents bbc813f + 110123a commit b77a25b

File tree

2 files changed

+318
-0
lines changed

2 files changed

+318
-0
lines changed

flux/src/helm-releases/HelmReleaseSingle.tsx

+6
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import StatusLabel from '../common/StatusLabel';
2121
import { getSourceNameAndPluralKind, ObjectEvents } from '../helpers/index';
2222
import { GetSource } from '../sources/Source';
2323
import { helmReleaseClass } from './HelmReleaseList';
24+
import { HelmInventory } from './Inventory';
2425

2526
export function FluxHelmReleaseDetailView(props: { name?: string; namespace?: string }) {
2627
const params = useParams<{ namespace: string; name: string }>();
@@ -156,6 +157,11 @@ function CustomResourceDetails(props) {
156157
/>
157158
</SectionBox>
158159
)}
160+
161+
<SectionBox title="Inventory">
162+
<HelmInventory name={name} namespace={namespace} />
163+
</SectionBox>
164+
159165
<SectionBox title="Dependencies">
160166
<Table
161167
data={cr?.jsonData?.spec?.dependsOn}

flux/src/helm-releases/Inventory.tsx

+312
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import { K8s } from '@kinvolk/headlamp-plugin/lib';
2+
import { request } from '@kinvolk/headlamp-plugin/lib/ApiProxy';
3+
import { DateLabel, Link } from '@kinvolk/headlamp-plugin/lib/components/common';
4+
import { Base64 } from 'js-base64';
5+
import React from 'react';
6+
import Table from '../common/Table';
7+
import { PluralName } from '../helpers/pluralName';
8+
9+
/**
10+
* Displays a table of resources deployed by a Helm chart.
11+
*
12+
* @param {Object} props - The properties for the component.
13+
* @param {string} props.name - The name of the Helm chart.
14+
* @param {string} props.namespace - The namespace of the Helm chart.
15+
*
16+
* The component fetches secrets related to the Helm chart, decodes the release data,
17+
* and extracts the resource kinds from the templates. It then fetches the resources
18+
* for each kind and displays them in a table with columns for name, namespace, kind,
19+
* readiness status, and age.
20+
*/
21+
export function HelmInventory(props: Readonly<{ name: string; namespace: string }>) {
22+
const { name: chartName, namespace } = props;
23+
const [resources, setResources] = React.useState([]);
24+
25+
// Fetch the secrets for the helm chart
26+
const [secrets] = K8s.ResourceClasses.Secret.useList({
27+
namespace,
28+
labelSelector: `name=${chartName},owner=helm`,
29+
});
30+
31+
// Query the deployed resources for the helm chart.
32+
// First make a list of resource kinds which are deployed by this helm chart:
33+
// - decode the `release` from the helm secret (double base64), unzip it, next parse with json to get the templates
34+
// - for each template get the resource kind and put it in a list
35+
// Finally fetch the resources for each kind, using labelselector for the chart
36+
React.useEffect(() => {
37+
if (secrets?.length > 0) {
38+
const secret = getLatestSecret(secrets);
39+
const releaseData = secret.data.release;
40+
const b64dk8s = Base64.decode(releaseData);
41+
const b64dhelm = Buffer.from(b64dk8s, 'base64');
42+
const cs = new DecompressionStream('gzip');
43+
const writer = cs.writable.getWriter();
44+
writer.write(b64dhelm);
45+
writer.close();
46+
47+
new Response(cs.readable)
48+
.arrayBuffer()
49+
.then(function (arrayBuffer) {
50+
return new TextDecoder().decode(arrayBuffer);
51+
})
52+
.then(data => {
53+
const helmData = JSON.parse(data);
54+
const resourceKinds = helmData.chart.templates.map(template =>
55+
parseHelmTemplate(template)
56+
);
57+
const filteredResourceKinds = [];
58+
for (const resourceKind of resourceKinds) {
59+
if (
60+
resourceKind.kind &&
61+
resourceKind.apiVersion &&
62+
!filteredResourceKinds.find(
63+
item =>
64+
item.kind === resourceKind.kind && item.apiVersion === resourceKind.apiVersion
65+
)
66+
) {
67+
filteredResourceKinds.push(resourceKind);
68+
}
69+
}
70+
71+
fetchResources(filteredResourceKinds, chartName, namespace).then(data => {
72+
setResources(data);
73+
});
74+
});
75+
}
76+
}, [secrets]);
77+
78+
return (
79+
<Table
80+
data={Array.from(resources)}
81+
columns={[
82+
{
83+
header: 'Name',
84+
accessorKey: 'metadata.name',
85+
Cell: ({ row: { original: item } }) => inventoryNameLink(item),
86+
},
87+
{
88+
header: 'Namespace',
89+
accessorFn: item => item.metadata?.namespace ?? '',
90+
Cell: ({ cell }) => {
91+
if (cell.getValue())
92+
return (
93+
<Link
94+
routeName="namespace"
95+
params={{
96+
name: cell.getValue(),
97+
}}
98+
>
99+
{cell.getValue()}
100+
</Link>
101+
);
102+
},
103+
},
104+
{
105+
header: 'Kind',
106+
accessorFn: item => item.kind,
107+
},
108+
{
109+
header: 'Ready',
110+
accessorFn: item => {
111+
if (item.status) {
112+
return item.status.conditions?.findIndex(
113+
c => c.type === 'Ready' || c.type === 'Available' || c.type === 'NamesAccepted'
114+
) !== -1
115+
? 'True'
116+
: 'False';
117+
}
118+
return '';
119+
},
120+
},
121+
{
122+
header: 'Age',
123+
accessorFn: item => {
124+
if (item.metadata.creationTimestamp) {
125+
return <DateLabel date={item.metadata?.creationTimestamp} />;
126+
}
127+
},
128+
},
129+
]}
130+
/>
131+
);
132+
}
133+
134+
/**
135+
* Retrieves the latest Helm secret based on the version label.
136+
*
137+
* Iterates over an array of secret objects and determines the latest secret
138+
* by comparing the version label (`metadata.labels.version`) of each secret.
139+
* The secret with the highest version number is returned.
140+
*
141+
* @param secrets - An array of secret objects, each containing metadata with a version label.
142+
* @returns The secret object with the highest version number, or null if no secrets are provided.
143+
*/
144+
function getLatestSecret(secrets) {
145+
let latestSecret = null;
146+
let latestVersion = -1;
147+
148+
for (const secret of secrets) {
149+
const version = parseInt(secret.metadata.labels.version, 10);
150+
151+
if (version > latestVersion) {
152+
latestSecret = secret;
153+
latestVersion = version;
154+
}
155+
}
156+
157+
return latestSecret;
158+
}
159+
160+
/**
161+
* Parses a Helm template string and returns an object with the following properties:
162+
* - `kind`: The kind of resource the template will create.
163+
* - `apiVersion`: The API version of the resource.
164+
* - `hasNamespace`: A boolean indicating whether the resource has a namespace.
165+
*
166+
* The Helm template string is expected to contain a GoLang template for a YAML document with the
167+
* standard Kubernetes metadata fields. The function returns the first
168+
* occurrence of each of the above properties. If the properties are not found,
169+
* the function returns an object with empty strings for `kind` and `apiVersion`,
170+
* and `false` for `hasNamespace`.
171+
*
172+
* @param template A Helm template string encoded in base64.
173+
* @returns An object with the kind, apiVersion, and hasNamespace of the resource.
174+
*/
175+
function parseHelmTemplate(template): {
176+
kind: string;
177+
apiVersion: string;
178+
hasNamespace: boolean;
179+
} {
180+
const decodedTemplate = Base64.decode(template.data);
181+
const lines = decodedTemplate.split(/\r?\n/);
182+
183+
let apiVersion = '';
184+
let kind = '';
185+
let hasNamespace = false;
186+
let isParsingMetadata = false;
187+
188+
for (const line of lines) {
189+
if (line.startsWith('apiVersion: ')) {
190+
apiVersion = apiVersion || line.replace('apiVersion: ', '');
191+
} else if (line.startsWith('kind: ')) {
192+
kind = kind || line.replace('kind: ', '');
193+
} else if (line.startsWith('metadata:')) {
194+
isParsingMetadata = true;
195+
} else if (isParsingMetadata) {
196+
if (line.includes('namespace: ')) {
197+
hasNamespace = true;
198+
break;
199+
} else if (!line.startsWith(' ')) {
200+
break;
201+
}
202+
}
203+
}
204+
205+
return { kind, apiVersion, hasNamespace };
206+
}
207+
208+
/**
209+
* Fetches Kubernetes resources associated with a Helm release using the specified resource kinds, chart name, and namespace.
210+
* Constructs API requests for each resource kind, appending a label selector for the Helm chart name and namespace.
211+
* Adds metadata including kind, API version, and group name to each resource for further processing.
212+
* Logs a message if no resources of a kind are found or an error occurs.
213+
*
214+
* @param {Array<{ kind: string, apiVersion: string, hasNamespace: boolean }>} resourceKinds - Array of resource kind objects containing kind, apiVersion, and hasNamespace properties.
215+
* @param {string} chartName - The name of the Helm chart.
216+
* @param {string} namespace - The namespace in which the Helm chart is deployed.
217+
* @returns {Promise<Array<Object>>} - A promise that resolves to an array of fetched resources with additional metadata.
218+
*/
219+
220+
async function fetchResources(
221+
resourceKinds,
222+
chartName: string,
223+
namespace: string
224+
): Promise<Array<Object>> {
225+
const resources = [];
226+
227+
const queryParams = new URLSearchParams();
228+
queryParams.append(
229+
'labelSelector',
230+
`helm.toolkit.fluxcd.io/name=${chartName},helm.toolkit.fluxcd.io/namespace=${namespace}`
231+
);
232+
233+
for (const resourceKind of resourceKinds) {
234+
const { kind, apiVersion, hasNamespace } = resourceKind;
235+
236+
const groupName = apiVersion.includes('/') ? apiVersion.split('/')[0] : '';
237+
const version = apiVersion.split('/').slice(-1)[0];
238+
const pluralName = PluralName(kind);
239+
240+
const api = groupName ? `/apis/${groupName}` : '/api';
241+
const namespaceFilter = hasNamespace ? `namespaces/${namespace}/` : '';
242+
243+
request(`${api}/${version}/${namespaceFilter}${pluralName}?${queryParams.toString()}`)
244+
.then(response => {
245+
response.items.forEach(item => {
246+
resources.push({ ...item, kind, apiVersion, groupName }); // add kind, apiVersion, groupName for link
247+
});
248+
})
249+
.catch(error => {
250+
if (error.status === 404) {
251+
console.log(`No ${pluralName} found for chart ${chartName}`);
252+
} else {
253+
console.error(error);
254+
}
255+
});
256+
}
257+
258+
return resources;
259+
}
260+
261+
/**
262+
* Returns a link to the specified resource, using the correct route name and
263+
* parameters depending on the resource's kind and group name.
264+
*
265+
* @param {Object} item - A Kubernetes resource object.
266+
* @returns {JSX.Element} - A link to the resource.
267+
*/
268+
function inventoryNameLink(item): JSX.Element {
269+
const kind = item.kind;
270+
const groupName = item.groupName;
271+
const pluralName = PluralName(kind);
272+
273+
// Flux types
274+
if (groupName.endsWith('toolkit.fluxcd.io')) {
275+
return (
276+
<Link
277+
routeName={groupName.substring(0, groupName.indexOf('.'))}
278+
params={{
279+
pluralName: pluralName,
280+
name: item.metadata.name,
281+
namespace: item.metadata.namespace,
282+
}}
283+
>
284+
{item.metadata.name}
285+
</Link>
286+
);
287+
}
288+
289+
// Standard k8s types
290+
const resourceClass = K8s.ResourceClasses[kind];
291+
if (resourceClass) {
292+
const resource = new resourceClass(item);
293+
if (resource?.getDetailsLink?.()) {
294+
return <Link kubeObject={resource}>{item.metadata.name}</Link>;
295+
}
296+
return item.metadata.name;
297+
}
298+
299+
// Custom resources
300+
return (
301+
<Link
302+
routeName="customresource"
303+
params={{
304+
crName: item.metadata.name,
305+
crd: `${pluralName}.${groupName}`,
306+
namespace: item.metadata.namespace || '-',
307+
}}
308+
>
309+
{item.metadata.name}
310+
</Link>
311+
);
312+
}

0 commit comments

Comments
 (0)