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(eks): helm chart support #5390

Merged
merged 25 commits into from
Dec 23, 2019
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c94a6cc
Added HelmRelease construct
vlesierse Dec 12, 2019
8409275
feat(eks): Add HelmRelease construct
vlesierse Dec 12, 2019
aeb1ed1
Merge branch 'eks-helm' of github.com:vlesierse/aws-cdk into eks-helm
vlesierse Dec 12, 2019
b3c0674
Fix some linting problems
vlesierse Dec 12, 2019
3a3b6b9
Remove trailing whitespace
vlesierse Dec 12, 2019
6c60136
Add the possibility to specify the chart version
vlesierse Dec 12, 2019
43fd60b
Changes after code review
vlesierse Dec 16, 2019
b449bf0
Add shell=True to command execution
vlesierse Dec 16, 2019
cf5e7b7
Execute helm command in /tmp
vlesierse Dec 17, 2019
0111ea7
Write a correct values.yaml
vlesierse Dec 17, 2019
131aea3
Add resources to integration tests
vlesierse Dec 17, 2019
767e274
Merge branch 'master' into eks-helm
vlesierse Dec 18, 2019
3910ff2
Change require to import
vlesierse Dec 18, 2019
599ae53
Lazy add HelmChartHandler
vlesierse Dec 19, 2019
2bf99c4
Add integration tests for Helm
vlesierse Dec 20, 2019
b96cde0
Added convenience addChart to Cluster
vlesierse Dec 20, 2019
db0255a
Fix integration test.
vlesierse Dec 21, 2019
98e0a36
Merge branch 'master' into eks-helm
vlesierse Dec 21, 2019
fa873c0
Change addChart method to use options pattern
vlesierse Dec 22, 2019
679b1cb
Added @default and truncate default chart name
vlesierse Dec 23, 2019
484e197
Merge branch 'master' into eks-helm
vlesierse Dec 23, 2019
58d863b
Merge branch 'master' into eks-helm
Dec 23, 2019
7880041
Added the Helm entry to the README.md
vlesierse Dec 23, 2019
5712c1d
Merge branch 'master' into eks-helm
vlesierse Dec 23, 2019
deee84e
Merge branch 'master' into eks-helm
mergify[bot] Dec 23, 2019
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
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.
*/
vlesierse marked this conversation as resolved.
Show resolved Hide resolved
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