479 lines
16 KiB
JavaScript
479 lines
16 KiB
JavaScript
|
|
/**
|
||
|
|
* Phase 8: Helm Chart Extractor
|
||
|
|
*
|
||
|
|
* Extracts structure from Helm charts with Go template syntax:
|
||
|
|
* - Chart.yaml: metadata, dependencies
|
||
|
|
* - values.yaml: contract surface (configurable parameters)
|
||
|
|
* - templates/: K8s resource types, service interactions
|
||
|
|
*
|
||
|
|
* Strategy: strip Go templates before YAML parse, regex-extract K8s kinds from templates.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const jsYaml = require('js-yaml');
|
||
|
|
|
||
|
|
/** Strip Go template directives so js-yaml can parse the structural YAML */
|
||
|
|
function stripGoTemplates(source) {
|
||
|
|
// Replace {{- ... -}} and {{ ... }} with empty string or placeholder
|
||
|
|
// Multi-line blocks: {{- if ... }} ... {{- end }}
|
||
|
|
let out = source;
|
||
|
|
|
||
|
|
// Remove template comments {{/* ... */}}
|
||
|
|
out = out.replace(/\{\{\/\*[\s\S]*?\*\/\}\}/g, '');
|
||
|
|
|
||
|
|
// Replace {{ expr }} with a safe YAML placeholder
|
||
|
|
// For values in mapping positions, replace with a quoted string
|
||
|
|
out = out.replace(/\{\{-?\s*[\s\S]*?\s*-?\}\}/g, '"__helm_tpl__"');
|
||
|
|
|
||
|
|
// Clean up lines that are entirely template control flow (if/range/end/define/with)
|
||
|
|
// These often leave broken YAML structure
|
||
|
|
out = out.split('\n').map(line => {
|
||
|
|
const trimmed = line.trim();
|
||
|
|
// Lines that are just template placeholders or empty after stripping
|
||
|
|
if (trimmed === '"__helm_tpl__"') return '';
|
||
|
|
// Lines starting with - "__helm_tpl__" that break list structure
|
||
|
|
if (trimmed === '- "__helm_tpl__"' && !line.includes(':')) return '';
|
||
|
|
return line;
|
||
|
|
}).join('\n');
|
||
|
|
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Parse Chart.yaml — plain YAML, no templates */
|
||
|
|
function parseChartYaml(chartDir) {
|
||
|
|
const chartPath = path.join(chartDir, 'Chart.yaml');
|
||
|
|
if (!fs.existsSync(chartPath)) return null;
|
||
|
|
|
||
|
|
try {
|
||
|
|
const doc = jsYaml.load(fs.readFileSync(chartPath, 'utf8'));
|
||
|
|
return {
|
||
|
|
name: doc.name || path.basename(chartDir),
|
||
|
|
version: doc.version || '0.0.0',
|
||
|
|
appVersion: doc.appVersion || '',
|
||
|
|
description: doc.description || '',
|
||
|
|
type: doc.type || 'application',
|
||
|
|
dependencies: (doc.dependencies || []).map(d => ({
|
||
|
|
name: d.name,
|
||
|
|
version: d.version || '',
|
||
|
|
repository: d.repository || '',
|
||
|
|
condition: d.condition || '',
|
||
|
|
isLocal: (d.repository || '').startsWith('file://'),
|
||
|
|
})),
|
||
|
|
};
|
||
|
|
} catch (e) {
|
||
|
|
return { name: path.basename(chartDir), version: '0.0.0', description: '', type: 'application', dependencies: [], _parseError: e.message };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Extract top-level keys from values.yaml as the chart's contract surface */
|
||
|
|
function parseValuesYaml(chartDir) {
|
||
|
|
const valuesPath = path.join(chartDir, 'values.yaml');
|
||
|
|
if (!fs.existsSync(valuesPath)) return { keys: [], raw: '' };
|
||
|
|
|
||
|
|
const raw = fs.readFileSync(valuesPath, 'utf8');
|
||
|
|
const stripped = stripGoTemplates(raw);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const doc = jsYaml.load(stripped);
|
||
|
|
if (!doc || typeof doc !== 'object') return { keys: [], raw };
|
||
|
|
|
||
|
|
const keys = [];
|
||
|
|
for (const [key, val] of Object.entries(doc)) {
|
||
|
|
const type = Array.isArray(val) ? 'list' : typeof val === 'object' && val !== null ? 'object' : typeof val;
|
||
|
|
keys.push({
|
||
|
|
name: key,
|
||
|
|
type,
|
||
|
|
hasDefault: val !== null && val !== undefined && val !== '' && val !== '__helm_tpl__',
|
||
|
|
defaultValue: typeof val === 'string' && val === '__helm_tpl__' ? '(templated)' :
|
||
|
|
typeof val === 'object' ? undefined : val,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return { keys, raw };
|
||
|
|
} catch (e) {
|
||
|
|
// Fallback: regex extract top-level keys
|
||
|
|
const keys = [];
|
||
|
|
const keyRegex = /^([a-zA-Z_][a-zA-Z0-9_-]*):/gm;
|
||
|
|
let m;
|
||
|
|
while ((m = keyRegex.exec(raw)) !== null) {
|
||
|
|
if (!keys.find(k => k.name === m[1])) {
|
||
|
|
keys.push({ name: m[1], type: 'unknown', hasDefault: true });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return { keys, raw, _parseError: e.message };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Scan template files for K8s resource kinds and .Values references */
|
||
|
|
function parseTemplates(chartDir) {
|
||
|
|
const templatesDir = path.join(chartDir, 'templates');
|
||
|
|
if (!fs.existsSync(templatesDir)) return { resources: [], valuesRefs: new Set(), templateFiles: [] };
|
||
|
|
|
||
|
|
const resources = [];
|
||
|
|
const valuesRefs = new Set();
|
||
|
|
const templateFiles = [];
|
||
|
|
const seenResources = new Set();
|
||
|
|
|
||
|
|
function scanDir(dir) {
|
||
|
|
let entries;
|
||
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||
|
|
|
||
|
|
for (const e of entries) {
|
||
|
|
const fp = path.join(dir, e.name);
|
||
|
|
if (e.isDirectory()) { scanDir(fp); continue; }
|
||
|
|
if (!e.name.endsWith('.yaml') && !e.name.endsWith('.yml') && !e.name.endsWith('.tpl')) continue;
|
||
|
|
|
||
|
|
const relTpl = path.relative(chartDir, fp);
|
||
|
|
templateFiles.push(relTpl);
|
||
|
|
|
||
|
|
let content;
|
||
|
|
try { content = fs.readFileSync(fp, 'utf8'); } catch { continue; }
|
||
|
|
|
||
|
|
// Extract K8s resource kinds via "kind: <Kind>" pattern
|
||
|
|
const kindRegex = /^\s*kind:\s*([A-Z][a-zA-Z]+)/gm;
|
||
|
|
let km;
|
||
|
|
while ((km = kindRegex.exec(content)) !== null) {
|
||
|
|
const kind = km[1];
|
||
|
|
const key = `${kind}:${relTpl}`;
|
||
|
|
if (!seenResources.has(key)) {
|
||
|
|
seenResources.add(key);
|
||
|
|
|
||
|
|
// Try to extract the name
|
||
|
|
const nameRegex = /name:\s*(?:\{\{[^}]*\}\}|"[^"]*"|'[^']*'|([a-zA-Z0-9_.-]+))/;
|
||
|
|
const nameMatch = content.slice(Math.max(0, km.index - 200), km.index + 500).match(nameRegex);
|
||
|
|
|
||
|
|
resources.push({
|
||
|
|
kind,
|
||
|
|
file: relTpl,
|
||
|
|
name: nameMatch ? (nameMatch[1] || '(templated)') : '(unknown)',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Extract .Values.xxx references
|
||
|
|
const valRegex = /\.Values\.([a-zA-Z_][a-zA-Z0-9_.]*)/g;
|
||
|
|
let vm;
|
||
|
|
while ((vm = valRegex.exec(content)) !== null) {
|
||
|
|
valuesRefs.add(vm[1].split('.')[0]); // top-level key
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
scanDir(templatesDir);
|
||
|
|
return { resources, valuesRefs: Array.from(valuesRefs), templateFiles };
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Extract interactions: what external services/endpoints does this chart reference? */
|
||
|
|
function extractInteractions(chartDir, chartMeta) {
|
||
|
|
const interactions = [];
|
||
|
|
const templatesDir = path.join(chartDir, 'templates');
|
||
|
|
if (!fs.existsSync(templatesDir)) return interactions;
|
||
|
|
|
||
|
|
function scanDir(dir) {
|
||
|
|
let entries;
|
||
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||
|
|
|
||
|
|
for (const e of entries) {
|
||
|
|
const fp = path.join(dir, e.name);
|
||
|
|
if (e.isDirectory()) { scanDir(fp); continue; }
|
||
|
|
|
||
|
|
let content;
|
||
|
|
try { content = fs.readFileSync(fp, 'utf8'); } catch { continue; }
|
||
|
|
|
||
|
|
// Service references: <service>.<namespace>.svc.cluster.local
|
||
|
|
const svcRegex = /([a-z][a-z0-9-]+)\.(?:\{\{[^}]*\}\}|[a-z0-9-]+)\.svc\.cluster\.local/g;
|
||
|
|
let sm;
|
||
|
|
while ((sm = svcRegex.exec(content)) !== null) {
|
||
|
|
interactions.push({ type: 'k8s-service', target: sm[1], file: path.relative(chartDir, fp) });
|
||
|
|
}
|
||
|
|
|
||
|
|
// ConfigMap/Secret references in envFrom or volumeMounts
|
||
|
|
const configRefRegex = /configMapKeyRef:\s*\n\s*name:\s*([^\n]+)|secretKeyRef:\s*\n\s*name:\s*([^\n]+)/g;
|
||
|
|
let cr;
|
||
|
|
while ((cr = configRefRegex.exec(content)) !== null) {
|
||
|
|
let ref = (cr[1] || cr[2] || '').trim().replace(/["'{}]/g, '');
|
||
|
|
if (ref.includes('helm_tpl') || ref.includes('__helm')) continue;
|
||
|
|
// Strip Go template noise — if it's mostly template syntax, skip it
|
||
|
|
if (ref.includes('tpl') || ref.includes('.Values') || ref.includes('include') || ref.includes('$')) continue;
|
||
|
|
if (!ref || ref.length < 2) continue;
|
||
|
|
interactions.push({ type: 'config-ref', target: ref, file: path.relative(chartDir, fp) });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Port references (containerPort, port, targetPort)
|
||
|
|
const portRegex = /(?:containerPort|port|targetPort):\s*(\d+)/g;
|
||
|
|
let pr;
|
||
|
|
while ((pr = portRegex.exec(content)) !== null) {
|
||
|
|
interactions.push({ type: 'port', target: pr[1], file: path.relative(chartDir, fp) });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
scanDir(templatesDir);
|
||
|
|
|
||
|
|
// Deduplicate
|
||
|
|
const seen = new Set();
|
||
|
|
return interactions.filter(i => {
|
||
|
|
const key = `${i.type}:${i.target}`;
|
||
|
|
if (seen.has(key)) return false;
|
||
|
|
seen.add(key);
|
||
|
|
return true;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Discover and extract all Helm charts under a root directory.
|
||
|
|
* @param {string} rootDir - Root directory to scan
|
||
|
|
* @param {Set<string>} ignoreDirs - Directory names to skip
|
||
|
|
* @returns {Array<object>} Array of chart descriptors
|
||
|
|
*/
|
||
|
|
function discoverCharts(rootDir, ignoreDirs) {
|
||
|
|
const charts = [];
|
||
|
|
const ignore = ignoreDirs || new Set(['node_modules', '.git', 'venv', '__pycache__', '.terraform']);
|
||
|
|
|
||
|
|
function walk(dir, depth) {
|
||
|
|
if (depth > 10) return; // safety
|
||
|
|
let entries;
|
||
|
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||
|
|
|
||
|
|
// Check if this directory is a chart
|
||
|
|
const hasChart = entries.some(e => e.isFile() && e.name === 'Chart.yaml');
|
||
|
|
if (hasChart) {
|
||
|
|
const chartMeta = parseChartYaml(dir);
|
||
|
|
const values = parseValuesYaml(dir);
|
||
|
|
const templates = parseTemplates(dir);
|
||
|
|
const interactions = extractInteractions(dir, chartMeta);
|
||
|
|
|
||
|
|
charts.push({
|
||
|
|
dir: path.relative(rootDir, dir),
|
||
|
|
chart: chartMeta,
|
||
|
|
values,
|
||
|
|
templates,
|
||
|
|
interactions,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Recurse into subdirectories
|
||
|
|
for (const e of entries) {
|
||
|
|
if (!e.isDirectory()) continue;
|
||
|
|
if (ignore.has(e.name)) continue;
|
||
|
|
walk(path.join(dir, e.name), depth + 1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
walk(rootDir, 0);
|
||
|
|
return charts;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Convert chart data into graph entities and relationships for the pipeline.
|
||
|
|
* @param {Array} charts - From discoverCharts
|
||
|
|
* @param {string} rootDir - Root directory
|
||
|
|
* @returns {{ entities: Array, relationships: Array }}
|
||
|
|
*/
|
||
|
|
function chartsToGraph(charts, rootDir) {
|
||
|
|
const entities = [];
|
||
|
|
const relationships = [];
|
||
|
|
|
||
|
|
for (const c of charts) {
|
||
|
|
const chartId = `helm:${c.chart.name}@${c.dir}`;
|
||
|
|
|
||
|
|
// Chart as a module
|
||
|
|
entities.push({
|
||
|
|
id: chartId,
|
||
|
|
type: 'HelmChart',
|
||
|
|
name: c.chart.name,
|
||
|
|
kind: 'helm-chart',
|
||
|
|
visibility: 'public',
|
||
|
|
description: c.chart.description,
|
||
|
|
version: c.chart.version,
|
||
|
|
appVersion: c.chart.appVersion,
|
||
|
|
chartType: c.chart.type,
|
||
|
|
dir: c.dir,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Values as contract fields
|
||
|
|
for (const key of c.values.keys) {
|
||
|
|
const keyId = `${chartId}:values:${key.name}`;
|
||
|
|
entities.push({
|
||
|
|
id: keyId,
|
||
|
|
type: 'HelmValue',
|
||
|
|
name: key.name,
|
||
|
|
kind: 'helm-value',
|
||
|
|
visibility: 'public',
|
||
|
|
valueType: key.type,
|
||
|
|
hasDefault: key.hasDefault,
|
||
|
|
});
|
||
|
|
relationships.push({ type: 'CONTAINS', source: chartId, target: keyId });
|
||
|
|
}
|
||
|
|
|
||
|
|
// K8s resources
|
||
|
|
for (const res of c.templates.resources) {
|
||
|
|
const resId = `${chartId}:resource:${res.kind}:${res.file}`;
|
||
|
|
entities.push({
|
||
|
|
id: resId,
|
||
|
|
type: 'K8sResource',
|
||
|
|
name: `${res.kind}`,
|
||
|
|
kind: 'k8s-resource',
|
||
|
|
visibility: 'public',
|
||
|
|
resourceKind: res.kind,
|
||
|
|
file: res.file,
|
||
|
|
});
|
||
|
|
relationships.push({ type: 'PRODUCES', source: chartId, target: resId });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Dependencies (chart → chart)
|
||
|
|
for (const dep of c.chart.dependencies) {
|
||
|
|
// Find the dependency chart
|
||
|
|
const depChart = charts.find(dc => dc.chart.name === dep.name);
|
||
|
|
if (depChart) {
|
||
|
|
const depId = `helm:${depChart.chart.name}@${depChart.dir}`;
|
||
|
|
relationships.push({ type: 'DEPENDS_ON', source: chartId, target: depId, condition: dep.condition });
|
||
|
|
} else {
|
||
|
|
// External dependency
|
||
|
|
const extId = `helm-ext:${dep.name}`;
|
||
|
|
if (!entities.find(e => e.id === extId)) {
|
||
|
|
entities.push({
|
||
|
|
id: extId,
|
||
|
|
type: 'HelmChart',
|
||
|
|
name: dep.name,
|
||
|
|
kind: 'helm-chart-external',
|
||
|
|
visibility: 'public',
|
||
|
|
version: dep.version,
|
||
|
|
repository: dep.repository,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
relationships.push({ type: 'DEPENDS_ON', source: chartId, target: extId, condition: dep.condition });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Service interactions
|
||
|
|
for (const interaction of c.interactions) {
|
||
|
|
if (interaction.type === 'k8s-service') {
|
||
|
|
// Find chart that produces this service
|
||
|
|
const targetChart = charts.find(tc => {
|
||
|
|
return tc.templates.resources.some(r => r.kind === 'Service') &&
|
||
|
|
tc.chart.name.includes(interaction.target);
|
||
|
|
});
|
||
|
|
if (targetChart && targetChart.chart.name !== c.chart.name) {
|
||
|
|
const targetId = `helm:${targetChart.chart.name}@${targetChart.dir}`;
|
||
|
|
relationships.push({ type: 'CALLS', source: chartId, target: targetId, via: interaction.target });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { entities, relationships };
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Generate a Mermaid diagram showing cross-chart interactions.
|
||
|
|
* Groups charts by subsystem (top-level dir) and shows dependency/service edges.
|
||
|
|
*/
|
||
|
|
function generateHelmDiagram(charts) {
|
||
|
|
const lines = ['graph TD'];
|
||
|
|
|
||
|
|
// Use dir-based IDs to avoid collisions between same-named charts
|
||
|
|
function chartId(c) {
|
||
|
|
return c.dir.replace(/[^a-zA-Z0-9]/g, '_');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Group charts by subsystem (first path segment)
|
||
|
|
const groups = {};
|
||
|
|
for (const c of charts) {
|
||
|
|
const sub = c.dir.split('/')[0] || 'root';
|
||
|
|
if (!groups[sub]) groups[sub] = [];
|
||
|
|
groups[sub].push(c);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Emit subgraphs
|
||
|
|
for (const [sub, subCharts] of Object.entries(groups)) {
|
||
|
|
const safeSubId = sub.replace(/[^a-zA-Z0-9]/g, '_');
|
||
|
|
lines.push(` subgraph ${safeSubId}["${sub}"]`);
|
||
|
|
for (const c of subCharts) {
|
||
|
|
const id = chartId(c);
|
||
|
|
const resCount = c.templates.resources.length;
|
||
|
|
lines.push(` ${id}["${c.chart.name}<br/>${resCount} resources"]`);
|
||
|
|
}
|
||
|
|
lines.push(' end');
|
||
|
|
}
|
||
|
|
|
||
|
|
// Build lookup: chart name → chart objects (may be multiple)
|
||
|
|
const nameIndex = {};
|
||
|
|
for (const c of charts) {
|
||
|
|
if (!nameIndex[c.chart.name]) nameIndex[c.chart.name] = [];
|
||
|
|
nameIndex[c.chart.name].push(c);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Emit dependency edges
|
||
|
|
const seenEdges = new Set();
|
||
|
|
for (const c of charts) {
|
||
|
|
const srcId = chartId(c);
|
||
|
|
for (const dep of c.chart.dependencies) {
|
||
|
|
// Find dep chart — prefer one in same subsystem tree
|
||
|
|
const candidates = nameIndex[dep.name] || [];
|
||
|
|
let target = candidates.find(dc => c.dir.startsWith(dc.dir.split('/')[0])) || candidates[0];
|
||
|
|
if (!target || chartId(target) === srcId) continue;
|
||
|
|
const tgtId = chartId(target);
|
||
|
|
const edgeKey = `${srcId}->${tgtId}`;
|
||
|
|
if (!seenEdges.has(edgeKey)) {
|
||
|
|
seenEdges.add(edgeKey);
|
||
|
|
lines.push(` ${srcId} -->|depends| ${tgtId}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Emit shared-secret edges (charts that reference the same config-ref)
|
||
|
|
const configUsers = {};
|
||
|
|
for (const c of charts) {
|
||
|
|
for (const i of c.interactions) {
|
||
|
|
if (i.type === 'config-ref') {
|
||
|
|
if (!configUsers[i.target]) configUsers[i.target] = [];
|
||
|
|
configUsers[i.target].push(c);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for (const [secret, users] of Object.entries(configUsers)) {
|
||
|
|
// Deduplicate by chart dir
|
||
|
|
const unique = [...new Map(users.map(u => [u.dir, u])).values()];
|
||
|
|
if (unique.length > 1) {
|
||
|
|
for (let i = 0; i < unique.length - 1; i++) {
|
||
|
|
const srcId = chartId(unique[i]);
|
||
|
|
const tgtId = chartId(unique[i + 1]);
|
||
|
|
const edgeKey = `${srcId}<->${tgtId}:${secret}`;
|
||
|
|
if (!seenEdges.has(edgeKey)) {
|
||
|
|
seenEdges.add(edgeKey);
|
||
|
|
lines.push(` ${srcId} -.-|${secret}| ${tgtId}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Emit k8s-service edges
|
||
|
|
for (const c of charts) {
|
||
|
|
const srcId = chartId(c);
|
||
|
|
for (const i of c.interactions) {
|
||
|
|
if (i.type === 'k8s-service') {
|
||
|
|
const tgtId = i.target.replace(/[^a-zA-Z0-9]/g, '_');
|
||
|
|
const edgeKey = `${srcId}--svc-->${tgtId}`;
|
||
|
|
if (!seenEdges.has(edgeKey)) {
|
||
|
|
seenEdges.add(edgeKey);
|
||
|
|
lines.push(` ${srcId} ==>|svc: ${i.target}| ${tgtId}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return lines.join('\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
stripGoTemplates,
|
||
|
|
parseChartYaml,
|
||
|
|
parseValuesYaml,
|
||
|
|
parseTemplates,
|
||
|
|
extractInteractions,
|
||
|
|
discoverCharts,
|
||
|
|
chartsToGraph,
|
||
|
|
generateHelmDiagram
|
||
|
|
};
|