-
Notifications
You must be signed in to change notification settings - Fork 4k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(eks): helm chart support (#5390)
* 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
1 parent
0adf6c7
commit 394313e
Showing
11 changed files
with
1,769 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.