|
| 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