Skip to content

Commit

Permalink
feat(eks): helm chart support (#5390)
Browse files Browse the repository at this point in the history
* Added HelmRelease construct

* feat(eks): Add HelmRelease construct

* Fix some linting problems

* Remove trailing whitespace

* Add the possibility to specify the chart version

* Changes after code review

* Add shell=True to command execution

* Execute helm command in /tmp

* Write a correct values.yaml

* Add resources to integration tests

* Change require to import

* Lazy add HelmChartHandler

* Add integration tests for Helm

* Added convenience addChart to Cluster

* Fix integration test.

* Change addChart method to use options pattern

* Added @default and truncate default chart name

* Added the Helm entry to the README.md

Co-authored-by: Elad Ben-Israel <benisrae@amazon.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
  • Loading branch information
3 people committed Dec 23, 2019
1 parent 0adf6c7 commit 394313e
Show file tree
Hide file tree
Showing 11 changed files with 1,769 additions and 3 deletions.
39 changes: 38 additions & 1 deletion packages/@aws-cdk/aws-eks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,44 @@ When kubectl is disabled, you should be aware of the following:
edit the [aws-auth ConfigMap](https://docs.aws.amazon.com/eks/latest/userguide/add-user-role.html)
when you add capacity in order to map the IAM instance role to RBAC to allow nodes to join the cluster.
3. Any `eks.Cluster` APIs that depend on programmatic kubectl support will fail
with an error: `cluster.addResource`, `cluster.awsAuth`, `props.mastersRole`.
with an error: `cluster.addResource`, `cluster.addChart`, `cluster.awsAuth`, `props.mastersRole`.

### Helm Charts

The `HelmChart` construct or `cluster.addChart` method can be used
to add Kubernetes resources to this cluster using Helm.

The following example will install the [NGINX Ingress Controller](https://kubernetes.github.io/ingress-nginx/)
to you cluster using Helm.

```ts
// option 1: use a construct
new HelmChart(this, 'NginxIngress', {
cluster,
chart: 'nginx-ingress',
repository: 'https://helm.nginx.com/stable',
namespace: 'kube-system'
});

// or, option2: use `addChart`
cluster.addChart('NginxIngress', {
chart: 'nginx-ingress',
repository: 'https://helm.nginx.com/stable',
namespace: 'kube-system'
});
```

Helm charts will be installed and updated using `helm upgrade --install`.
This means that if the chart is added to CDK with the same release name, it will try to update
the chart in the cluster. The chart will exists as CloudFormation resource.

Helm charts are implemented as CloudFormation resources in CDK.
This means that if the chart is deleted from your code (or the stack is
deleted), the next `cdk deploy` will issue a `helm uninstall` command and the
Helm chart will be deleted.

When there is no `release` defined, the chart will be installed using the `node.uniqueId`,
which will be lower cassed and truncated to the last 63 characters.

### Roadmap

Expand Down
17 changes: 16 additions & 1 deletion packages/@aws-cdk/aws-eks/lib/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as path from 'path';
import { AwsAuth } from './aws-auth';
import { ClusterResource } from './cluster-resource';
import { CfnCluster, CfnClusterProps } from './eks.generated';
import { HelmChart, HelmChartOptions } from './helm-chart';
import { KubernetesResource } from './k8s-resource';
import { KubectlLayer } from './kubectl-layer';
import { spotInterruptHandler } from './spot-interrupt-handler';
Expand Down Expand Up @@ -309,8 +310,10 @@ export class Cluster extends Resource implements ICluster {
* automatically added by Amazon EKS to the `system:masters` RBAC group of the
* cluster. Use `addMastersRole` or `props.mastersRole` to define additional
* IAM roles as administrators.
*
* @internal
*/
private readonly _defaultMastersRole?: iam.IRole;
public readonly _defaultMastersRole?: iam.IRole;

/**
* Manages the aws-auth config map.
Expand Down Expand Up @@ -579,6 +582,18 @@ export class Cluster extends Resource implements ICluster {
return new KubernetesResource(this, `manifest-${id}`, { cluster: this, manifest });
}

/**
* Defines a Helm chart in this cluster.
*
* @param id logical id of this chart.
* @param options options of this chart.
* @returns a `HelmChart` object
* @throws If `kubectlEnabled` is `false`
*/
public addChart(id: string, options: HelmChartOptions) {
return new HelmChart(this, `chart-${id}`, { cluster: this, ...options });
}

private createKubernetesResourceHandler() {
if (!this.kubectlEnabled) {
return undefined;
Expand Down
123 changes: 123 additions & 0 deletions packages/@aws-cdk/aws-eks/lib/helm-chart.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { CustomResource, CustomResourceProvider } from '@aws-cdk/aws-cloudformation';
import * as lambda from '@aws-cdk/aws-lambda';
import { Construct, Duration, Stack } from '@aws-cdk/core';
import * as path from 'path';
import { Cluster } from './cluster';
import { KubectlLayer } from './kubectl-layer';

/**
* Helm Chart options.
*/

export interface HelmChartOptions {
/**
* The name of the chart.
*/
readonly chart: string;

/**
* The name of the release.
* @default - If no release name is given, it will use the last 63 characters of the node's unique id.
*/
readonly release?: string;

/**
* The chart version to install.
* @default - If this is not specified, the latest version is installed
*/
readonly version?: string;

/**
* The repository which contains the chart. For example: https://kubernetes-charts.storage.googleapis.com/
* @default - No repository will be used, which means that the chart needs to be an absolute URL.
*/
readonly repository?: string;

/**
* The Kubernetes namespace scope of the requests.
* @default default
*/
readonly namespace?: string;

/**
* The values to be used by the chart.
* @default - No values are provided to the chart.
*/
readonly values?: {[key: string]: any};
}

/**
* Helm Chart properties.
*/
export interface HelmChartProps extends HelmChartOptions {
/**
* The EKS cluster to apply this configuration to.
*
* [disable-awslint:ref-via-interface]
*/
readonly cluster: Cluster;
}

/**
* Represents a helm chart within the Kubernetes system.
*
* Applies/deletes the resources using `kubectl` in sync with the resource.
*/
export class HelmChart extends Construct {
/**
* The CloudFormation reosurce type.
*/
public static readonly RESOURCE_TYPE = 'Custom::AWSCDK-EKS-HelmChart';

constructor(scope: Construct, id: string, props: HelmChartProps) {
super(scope, id);

const stack = Stack.of(this);

// we maintain a single manifest custom resource handler for each cluster
const handler = this.getOrCreateHelmChartHandler(props.cluster);
if (!handler) {
throw new Error(`Cannot define a Helm chart on a cluster with kubectl disabled`);
}

new CustomResource(this, 'Resource', {
provider: CustomResourceProvider.lambda(handler),
resourceType: HelmChart.RESOURCE_TYPE,
properties: {
Release: props.release || this.node.uniqueId.slice(-63).toLowerCase(), // Helm has a 63 character limit for the name
Chart: props.chart,
Version: props.version,
Values: (props.values ? stack.toJsonString(props.values) : undefined),
Namespace: props.namespace || 'default',
Repository: props.repository
}
});
}

private getOrCreateHelmChartHandler(cluster: Cluster): lambda.IFunction | undefined {
if (!cluster.kubectlEnabled) {
return undefined;
}

let handler = cluster.node.tryFindChild('HelmChartHandler') as lambda.IFunction;
if (!handler) {
handler = new lambda.Function(cluster, 'HelmChartHandler', {
code: lambda.Code.fromAsset(path.join(__dirname, 'helm-chart')),
runtime: lambda.Runtime.PYTHON_3_7,
handler: 'index.handler',
timeout: Duration.minutes(15),
layers: [ KubectlLayer.getOrCreate(this, { version: "2.0.0-beta1" }) ],
memorySize: 256,
environment: {
CLUSTER_NAME: cluster.clusterName,
},

// NOTE: we must use the default IAM role that's mapped to "system:masters"
// as the execution role of this custom resource handler. This is the only
// way to be able to interact with the cluster after it's been created.
role: cluster._defaultMastersRole,
});
}
return handler;
}
}
136 changes: 136 additions & 0 deletions packages/@aws-cdk/aws-eks/lib/helm-chart/index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import subprocess
import os
import json
import logging
import boto3
from uuid import uuid4
from botocore.vendored import requests

logger = logging.getLogger()
logger.setLevel(logging.INFO)

# these are coming from the kubectl layer
os.environ['PATH'] = '/opt/helm:/opt/awscli:' + os.environ['PATH']

outdir = os.environ.get('TEST_OUTDIR', '/tmp')
kubeconfig = os.path.join(outdir, 'kubeconfig')

CFN_SUCCESS = "SUCCESS"
CFN_FAILED = "FAILED"

def handler(event, context):

def cfn_error(message=None):
logger.error("| cfn_error: %s" % message)
cfn_send(event, context, CFN_FAILED, reason=message)

try:
logger.info(json.dumps(event))

request_type = event['RequestType']
props = event['ResourceProperties']
physical_id = event.get('PhysicalResourceId', None)
release = props['Release']
chart = props['Chart']
version = props.get('Version', None)
namespace = props.get('Namespace', None)
repository = props.get('Repository', None)
values_text = props.get('Values', None)

cluster_name = os.environ.get('CLUSTER_NAME', None)
if cluster_name is None:
cfn_error("CLUSTER_NAME is missing in environment")
return

subprocess.check_call([ 'aws', 'eks', 'update-kubeconfig',
'--name', cluster_name,
'--kubeconfig', kubeconfig
])

# Write out the values to a file and include them with the install and upgrade
values_file = None
if not request_type == "Delete" and not values_text is None:
values = json.loads(values_text)
values_file = os.path.join(outdir, 'values.yaml')
with open(values_file, "w") as f:
f.write(json.dumps(values, indent=2))

if request_type == 'Create' or request_type == 'Update':
helm('upgrade', release, chart, repository, values_file, namespace, version)
elif request_type == "Delete":
try:
helm('uninstall', release, namespace=namespace)
except Exception as e:
logger.info("delete error: %s" % e)

# if we are creating a new resource, allocate a physical id for it
# otherwise, we expect physical id to be relayed by cloudformation
if request_type == 'Create':
physical_id = "%s/%s" % (cluster_name, str(uuid4()))
else:
if not physical_id:
cfn_error("invalid request: request type is '%s' but 'PhysicalResourceId' is not defined" % request_type)
return

cfn_send(event, context, CFN_SUCCESS, physicalResourceId=physical_id)
return

except KeyError as e:
cfn_error("invalid request. Missing '%s'" % str(e))
except Exception as e:
logger.exception(e)
cfn_error(str(e))

def helm(verb, release, chart = None, repo = None, file = None, namespace = None, version = None):
import subprocess
try:
cmnd = ['helm', verb, release]
if not chart is None:
cmnd.append(chart)
if verb == 'upgrade':
cmnd.append('--install')
if not repo is None:
cmnd.extend(['--repo', repo])
if not file is None:
cmnd.extend(['--values', file])
if not version is None:
cmnd.extend(['--version', version])
if not namespace is None:
cmnd.extend(['--namespace', namespace])
cmnd.extend(['--kubeconfig', kubeconfig])
output = subprocess.check_output(cmnd, stderr=subprocess.STDOUT, cwd=outdir)
logger.info(output)
except subprocess.CalledProcessError as exc:
raise Exception(exc.output)

#---------------------------------------------------------------------------------------------------
# sends a response to cloudformation
def cfn_send(event, context, responseStatus, responseData={}, physicalResourceId=None, noEcho=False, reason=None):

responseUrl = event['ResponseURL']
logger.info(responseUrl)

responseBody = {}
responseBody['Status'] = responseStatus
responseBody['Reason'] = reason or ('See the details in CloudWatch Log Stream: ' + context.log_stream_name)
responseBody['PhysicalResourceId'] = physicalResourceId or context.log_stream_name
responseBody['StackId'] = event['StackId']
responseBody['RequestId'] = event['RequestId']
responseBody['LogicalResourceId'] = event['LogicalResourceId']
responseBody['NoEcho'] = noEcho
responseBody['Data'] = responseData

body = json.dumps(responseBody)
logger.info("| response body:\n" + body)

headers = {
'content-type' : '',
'content-length' : str(len(body))
}

try:
response = requests.put(responseUrl, data=body, headers=headers)
logger.info("| status code: " + response.reason)
except Exception as e:
logger.error("| unable to send response to CloudFormation")
logger.exception(e)
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-eks/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './cluster';
export * from './aws-auth-mapping';
export * from './k8s-resource';
export * from './helm-chart';
export * from './aws-auth';

// AWS::EKS CloudFormation Resources:
Expand Down
2 changes: 1 addition & 1 deletion packages/@aws-cdk/aws-eks/lib/kubectl-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class KubectlLayer extends Construct implements lambda.ILayerVersion {
*/
public static getOrCreate(scope: Construct, props: KubectlLayerProps = {}): KubectlLayer {
const stack = Stack.of(scope);
const id = 'kubectl-layer-8C2542BC-BF2B-4DFE-B765-E181FD30A9A0';
const id = 'kubectl-layer-' + (props.version ? props.version : "8C2542BC-BF2B-4DFE-B765-E181FD30A9A0");
const exists = stack.node.tryFindChild(id) as KubectlLayer;
if (exists) {
return exists;
Expand Down
Loading

0 comments on commit 394313e

Please sign in to comment.