Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: helm import #1202

Merged
merged 40 commits into from
Oct 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
dca862e
feat: helm import
vinayak-kukreja Jul 31, 2023
b0ac49a
Merge branch 'cdk8s-team:2.x' into vkukreja/helm-import
vinayak-kukreja Aug 1, 2023
63be194
Merge branch '2.x' into vkukreja/helm-import
vinayak-kukreja Aug 8, 2023
eee2666
update pr
vinayak-kukreja Aug 8, 2023
75d1648
update regex
vinayak-kukreja Aug 8, 2023
f6cf8c9
add support for subchart and global
vinayak-kukreja Aug 9, 2023
3516843
update PR
vinayak-kukreja Aug 9, 2023
f7ebbbe
update with correct interfaces
vinayak-kukreja Aug 9, 2023
79218e6
Merge branch '2.x' into vkukreja/helm-import
iliapolo Aug 24, 2023
4ca1861
address feedback
vinayak-kukreja Sep 26, 2023
4fb10d1
update PR
vinayak-kukreja Sep 26, 2023
7171f97
update PR
vinayak-kukreja Sep 27, 2023
bf2e5a5
update PR
vinayak-kukreja Sep 27, 2023
3b21416
remove comments
vinayak-kukreja Sep 27, 2023
1d332bb
Merge branch '2.x' into vkukreja/helm-import
vinayak-kukreja Sep 27, 2023
94020bb
update PR
vinayak-kukreja Sep 27, 2023
17feb41
investigate build failure
vinayak-kukreja Sep 27, 2023
0ade12e
investigate build failure
vinayak-kukreja Sep 27, 2023
af24dc7
investigate build failure
vinayak-kukreja Sep 27, 2023
8bd5102
investigate build failure
vinayak-kukreja Sep 27, 2023
372e87b
fixing build
vinayak-kukreja Sep 27, 2023
c457d9b
fixing build
vinayak-kukreja Sep 27, 2023
292df3b
update PR
vinayak-kukreja Sep 27, 2023
c2558df
update PR
vinayak-kukreja Sep 28, 2023
be67d5d
update PR
vinayak-kukreja Sep 28, 2023
814cbac
Merge branch '2.x' into vkukreja/helm-import
iliapolo Sep 28, 2023
9e6f60f
update PR
vinayak-kukreja Sep 28, 2023
343d1b1
update PR
vinayak-kukreja Sep 28, 2023
dcd019d
update PR
vinayak-kukreja Sep 29, 2023
6787b6b
update PR
vinayak-kukreja Sep 29, 2023
eee43e7
Merge branch '2.x' into vkukreja/helm-import
vinayak-kukreja Sep 29, 2023
2e28390
Merge branch '2.x' into vkukreja/helm-import
iliapolo Oct 2, 2023
c2e7513
update PR
vinayak-kukreja Oct 3, 2023
ef82b6f
Merge branch '2.x' into vkukreja/helm-import
vinayak-kukreja Oct 3, 2023
50d14bd
update PR
vinayak-kukreja Oct 3, 2023
ef166f2
update PR
vinayak-kukreja Oct 3, 2023
e4ee20a
Merge branch '2.x' into vkukreja/helm-import
iliapolo Oct 3, 2023
2995e26
update PR
vinayak-kukreja Oct 3, 2023
7016ab4
update PR
vinayak-kukreja Oct 4, 2023
a28a3f6
Merge branch '2.x' into vkukreja/helm-import
iliapolo Oct 5, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cli/cmds/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Command implements yargs.CommandModule {
.example('cdk8s import jenkins.io_jenkins_crd.yaml', 'Imports constructs for the Jenkins custom resource definition from a file')
.example('cdk8s import mattermost:=mattermost_crd.yaml', 'Imports constructs for the mattermost cluster custom resource definition using a custom module name')
.example('cdk8s import github:crossplane/[email protected]', 'Imports constructs for a GitHub repo using doc.crds.dev')
.example('cdk8s import helm:https://charts.bitnami.com/bitnami/[email protected]', 'Imports the specified version of helm chart')

.option('save', { type: 'boolean', required: false, default: true, desc: "Dont save the import URL in the 'imports' section of the cdk8s.yaml configuration file.", alias: 's' })
.option('output', { default: DEFAULT_OUTDIR, type: 'string', desc: 'Output directory', alias: 'o' })
Expand Down
199 changes: 198 additions & 1 deletion src/import/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function generateConstruct(typegen: TypeGenerator, def: ApiObjectDefiniti
// `propsTypeName` could also be "any" if we can't parse the schema for some reason
const propsTypeName = emitPropsStruct();
const groupPrefix = def.group ? `${def.group}/` : '';
const hasRequired = schema?.required && Array.isArray(schema.required) && schema.required.length > 0;
const hasRequired = hasRequiredProps(schema);
const defaultProps = hasRequired ? '' : ' = {}';
emitConstruct();

Expand Down Expand Up @@ -213,3 +213,200 @@ export function generateConstruct(typegen: TypeGenerator, def: ApiObjectDefiniti
}
});
}

/**
* Emit imports for generated helm construct
* @param code CodeMaker istance
*/
export function emitHelmHeader(code: CodeMaker) {
code.line('// generated by cdk8s');
code.line('import { Helm, HelmProps } from \'cdk8s\';');
code.line('import { Construct } from \'constructs\';');
code.line();
}

/**
* Helm Object Definition
*/
export interface HelmObjectDefinition {
/**
* `values.schema.json` for the helm chart
*/
readonly schema: JSONSchema4 | undefined;
/**
* Chart name
*/
readonly chartName: string;
/**
* Chart url
*/
readonly chartUrl: string;
/**
* Chart version
*/
readonly chartVersion: string;
/**
* Chart dependencies
*/
readonly chartDependencies: string[];
/**
* Fully qualified name for the construct
*/
readonly fqn?: string;
}

export function generateHelmConstruct(typegen: TypeGenerator, def: HelmObjectDefinition) {
const noSpecialChars = def.chartName.replace(/([^\w ]|_)/g, '');
const chartName = TypeGenerator.normalizeTypeName(noSpecialChars);
const schema = def.schema;
const repoUrl = def.chartUrl;
const chartVersion = def.chartVersion;

// Create custom type
typegen.emitCustomType(chartName, code => {

const valuesInterface = `${chartName}Values`;
if (schema !== undefined) {
// Creating values interface
emitValuesInterface();

function emitValuesInterface() {

const copyOfSchema = schema ? addAdditionalValuesToProps(schema) : undefined;

if (copyOfSchema && copyOfSchema.properties) {
// Sub charts or dependencies
for (const dependency of def.chartDependencies) {
copyOfSchema.properties[dependency] = { type: 'object', additionalProperties: { type: 'object' } };
}

copyOfSchema.properties.global = { type: 'object', additionalProperties: { type: 'object' } };
copyOfSchema.properties.additionalValues = {
type: 'object',
description: 'Values that are not available in values.schema.json will not be code generated. You can add such values to this property.',
additionalProperties: { type: 'object' },
};
}

typegen.emitType(valuesInterface, copyOfSchema, def.fqn);
}

function addAdditionalValuesToProps(schma: JSONSchema4): JSONSchema4 {
const tempSchema = schma;

if (!tempSchema.properties) {
return tempSchema;
}

Object.values(tempSchema.properties).forEach((prop) => {
if (prop.type !== 'object') {
return;
}

if (prop.properties) {
prop.properties.additionalValues = {
type: 'object',
description: 'Values that are not available in values.schema.json will not be code generated. You can add such values to this property.',
additionalProperties: { type: 'object' },
};

addAdditionalValuesToProps(prop);
}
});

return tempSchema;
}
}

// Creating construct properties
emitPropsInterface();

code.line();

// Creating construct for helm chart
emitConstruct();

function emitPropsInterface() {
code.openBlock(`export interface ${chartName}Props`);

code.line('readonly namespace?: string;');
code.line('readonly releaseName?: string;');
code.line('readonly helmExecutable?: string;');
code.line('readonly helmFlags?: string[];');

if (schema === undefined) {
code.line('readonly values?: { [key: string]: any };');
iliapolo marked this conversation as resolved.
Show resolved Hide resolved
} else {
const doValuesHaveReqProps = hasRequiredProps(schema) ? '' : '?';
code.line(`readonly values${doValuesHaveReqProps}: ${valuesInterface};`);
}

code.closeBlock();
}

function emitConstruct() {
code.openBlock(`export class ${chartName} extends Construct`);

emitInitializer();

code.line();

emitAdditionalValuesFlattenFunc();

code.closeBlock();
}

function emitInitializer() {
const propsDefinition = schema && hasRequiredProps(schema) ? `${chartName}Props` : `${chartName}Props = {}`;

code.openBlock(`public constructor(scope: Construct, id: string, props: ${propsDefinition})`);
code.line('super(scope, id)');

code.line('let updatedProps = {};');
code.line();
code.openBlock('if (props.values)');
code.line('const { additionalValues, ...valuesWithoutAdditionalValues } = props.values;');
code.open('updatedProps = {');
code.line('...props,');
code.open('values: {');
code.line('...this.flattenAdditionalValues(valuesWithoutAdditionalValues),');
code.line('...additionalValues,');
code.close('}');
code.close('};');
code.closeBlock();
code.line();

code.open('const finalProps: HelmProps = {');
code.line(`chart: \'${def.chartName}\',`);
code.line(`repo: \'${repoUrl}\',`);
code.line(`version: \'${chartVersion}\',`);
code.line('...(Object.keys(updatedProps).length !== 0 ? updatedProps : props),');
code.close('};');

code.line();
code.line('new Helm(scope, \'Helm\', finalProps)');
code.closeBlock();
}

function emitAdditionalValuesFlattenFunc() {
code.openBlock('private flattenAdditionalValues(props: { [key: string]: any }): { [key: string]: any }');
code.open('for (let prop in props) {');
code.open('if (typeof(props[prop]) === \'object\' && prop !== \'additionalValues\') {');
code.line('props[prop] = this.flattenAdditionalValues(props[prop]);');
code.close('}');
code.close('}');
code.line();
code.line('const { additionalValues, ...valuesWithoutAdditionalValues } = props;');
code.line();
code.open('return {');
code.line('...valuesWithoutAdditionalValues,');
code.line('...additionalValues,');
code.close('};');
code.closeBlock();
}
});
}

function hasRequiredProps(schema: JSONSchema4):boolean | undefined {
return schema?.required && Array.isArray(schema.required) && schema.required.length > 0;
}
7 changes: 7 additions & 0 deletions src/import/dispatch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ImportBase, ImportOptions } from './base';
import { ImportCustomResourceDefinition } from './crd';
import { matchCrdsDevUrl } from './crds-dev';
import { ImportHelm } from './helm';
import { ImportKubernetesApi } from './k8s';
import { ImportSpec, addImportToConfig } from '../config';
import { PREFIX_DELIM } from '../util';
Expand Down Expand Up @@ -35,6 +36,12 @@ export async function matchImporter(importSpec: ImportSpec, argv: any): Promise<
return new ImportKubernetesApi(k8s);
}

const prefix = importSpec.source.split(':')[0];

if (prefix === 'helm') {
return ImportHelm.fromSpec(importSpec);
}

// now check if its a crds.dev import
const crdsDevUrl = matchCrdsDevUrl(importSpec.source);
if (crdsDevUrl) {
Expand Down
156 changes: 156 additions & 0 deletions src/import/helm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { spawnSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { Yaml } from 'cdk8s';
import { CodeMaker } from 'codemaker';
import type { JSONSchema4 } from 'json-schema';
import { TypeGenerator } from 'json2jsii';
import * as semver from 'semver';
import { ImportBase } from './base';
import { emitHelmHeader, generateHelmConstruct } from './codegen';
import { ImportSpec } from '../config';

const MAX_HELM_BUFFER = 10 * 1024 * 1024;
const CHART_SCHEMA = 'values.schema.json';
const CHART_YAML = 'Chart.yaml';

export class ImportHelm extends ImportBase {
public static async fromSpec(importSpec: ImportSpec): Promise<ImportHelm> {
const { source } = importSpec;
return new ImportHelm(source);
}

private readonly chartName: string;
private readonly chartUrl: string;
private readonly chartVersion: string;
private readonly chartSchemaPath: string | undefined;
private readonly chartDependencies: string[] = [];
private readonly schema: JSONSchema4 | undefined;

private constructor(source: string) {
super();

const [chartUrl, chartName, chartVersion] = extractHelmChartDetails(source);

this.chartName = chartName;
this.chartUrl = chartUrl;
this.chartVersion = chartVersion;
const tmpDir = pullHelmRepo(chartUrl, chartName, chartVersion);

const chartYamlFilePath = path.join(tmpDir, this.chartName, CHART_YAML);
const contents = Yaml.load(chartYamlFilePath);

if (contents.length === 1 && contents[0].dependencies) {
for (const dependency of contents[0].dependencies) {
this.chartDependencies.push(dependency.name);
}
}

const potentialSchemaPath = path.join(tmpDir, this.chartName, CHART_SCHEMA);
this.chartSchemaPath = fs.existsSync(potentialSchemaPath) ? potentialSchemaPath : undefined;
this.schema = this.chartSchemaPath ? JSON.parse(fs.readFileSync(this.chartSchemaPath, 'utf-8')) : undefined;

cleanup(tmpDir);
}

public get moduleNames() {
return [this.chartName];
}

protected async generateTypeScript(code: CodeMaker) {
emitHelmHeader(code);

const types = new TypeGenerator({
definitions: this.schema?.definitions,
toJson: false,
});

generateHelmConstruct(types, {
schema: this.schema,
chartName: this.chartName,
chartUrl: this.chartUrl,
chartVersion: this.chartVersion,
chartDependencies: this.chartDependencies,
fqn: this.chartName,
});

code.line(types.render());
}
}

/**
* Gets information about the helm chart from the helm url
* @param url
* @returns chartUrl, chartName and chartVersion
*/
function extractHelmChartDetails(url: string) {
const helmRegex = /^helm:([A-Za-z0-9_.-:\-]+)\/([A-Za-z0-9_.-:\-]+)\@([0-9]+)\.([0-9]+)\.([A-Za-z0-9-+]+)$/;
const helmDetails = helmRegex.exec(url);

if (!helmDetails) {
throw Error(`Invalid helm URL: ${url}. Must match the format: 'helm:<repo-url>/<chart-name>@<chart-version>'.`);
}

const chartUrl = helmDetails[1];
const chartName = helmDetails[2];
const major = helmDetails[3];
const minor = helmDetails[4];
const patch = helmDetails[5];

const chartVersion = `${major}.${minor}.${patch}`;

if (!semver.valid(chartVersion)) {
throw new Error(`Invalid chart version (${chartVersion}) in URL: ${url}. Must follow SemVer-2 (see https://semver.org/).`);
}

return [chartUrl, chartName, chartVersion];
}

/**
* Pulls the helm chart in a temporary directory
* @param chartUrl Chart url
* @param chartName Chart name
* @param chartVersion Chart version
* @returns Temporary directory path
*/
function pullHelmRepo(chartUrl: string, chartName: string, chartVersion: string): string {
const workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdk8s-helm-'));

const args = new Array<string>();
args.push('pull');
args.push(chartName);
args.push('--repo', chartUrl);
args.push('--version', chartVersion);
args.push('--untar');
args.push('--untardir', workdir);

const command = 'helm';

const helm = spawnSync(command, args, {
maxBuffer: MAX_HELM_BUFFER,
});

if (helm.error) {
const err = helm.error.message;
if (err.includes('ENOENT')) {
throw new Error(`Unable to execute '${command}' to pull the Helm chart. Is helm installed on your system?`);
}

throw new Error(`Failed pulling helm chart from URL (${chartUrl}): ${err}`);
}

if (helm.status !== 0) {
throw new Error(helm.stderr.toString());
}

return workdir;
}

/**
* Cleanup temp directory created
* @param tmpDir Temporary directory path
*/
function cleanup(tmpDir: string) {
fs.rmSync(tmpDir, { recursive: true });
}
Loading