Repository: zalando-incubator/kube-ingress-aws-controller
Pull request #272 (2019-09-13 13:23:05)

Support for CloudWatch Alarm configuration
This PR adds support for CloudWatch Alarm configuration (#271) for each load
balancer created by the controller. This is an opt-in feature. To use
it, the `--cloudwatch-alarms-config-map` CLI flag has to be set and
should be pointed to a ConfigMap that contains the alarm configuration.
If the ConfigMap does not exist or contains malformed data, the
controller will just ignore it. Check out `cloudwatch.md` for a full
documentation of the feature.

szuecs: Thanks @martinohmann for the PR. Have to carefully review, which I hopefully do next week.
coveralls: 
[![Coverage Status](https://coveralls.io/builds/25699728/badge)](https://coveralls.io/builds/25699728)

Coverage decreased (-0.2%) to 46.381% when pulling **7f5a794ddd630cb9c0bb080d28c147d1275819d4 on Bonial-International-GmbH:feature/cloudwatch-alarms** into **ba1b49e31e9d8310aa522fa0cb31c8c1f39c40ba on zalando-incubator:master**.

martinohmann: If you want to try out the PR on a real cluster, you can use the following example ConfigMap which creates a CloudWatch Alarm that triggers when the TargetGroup of the LoadBalancer has no healthy hosts:

```yaml
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: kube-ingress-aws-controller-cw
  namespace: kube-system
data:
  alarms: |
    - ActionsEnabled: false
      AlarmDescription: "Triggers once the TargetGroup of the LoadBalancer has no healthy hosts."
      AlarmName: "HealthyHosts"
      ComparisonOperator: LessThanThreshold
      Dimensions:
      - Name: LoadBalancer
      - Name: TargetGroup
      EvaluationPeriods: 1
      MetricName: HealthyHostCount
      Period: 60
      Statistic: Average
      Threshold: 1
      TreatMissingData: notBreaching
      Unit: Count
```

Be sure to also grant the controller the necessary IAM permissions as written in the docs and deploy the adjusted ClusterRole which allows reading ConfigMaps. The ConfigMap just sets up the Alarm but does not set up any alarm actions (e.g. push to SNS) as this would require more permission setup and is out of scope.
szuecs: :+1: 
szuecs: @mikkeloscar please review next week, thanks
szuecs: @martinohmann great PR, thanks!
mikkeloscar: :+1: 
szuecs: :+1:

Patch:
diff --git a/README.md b/README.md
index 309d418..bff9c30 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,7 @@ This information is used to manage AWS resources for each ingress objects of the
 - Support for multiple Auto Scaling Groups
 - Support for instances that are not part of Auto Scaling Group
 - Support for SSLPolicy, set default and per ingress
+- Support for [CloudWatch Alarm configuration](cloudwatch.md)
 - Can be used in clusters created by [Kops](https://github.com/kubernetes/kops), see our [deployment guide for Kops](deploy/kops.md)
 - [Support Multiple TLS Certificates per ALB (SNI)](https://aws.amazon.com/blogs/aws/new-application-load-balancer-sni/).
 
diff --git a/aws/adapter.go b/aws/adapter.go
index 77789d4..a1da090 100644
--- a/aws/adapter.go
+++ b/aws/adapter.go
@@ -436,7 +436,7 @@ func (a *Adapter) UpdateTargetGroupsAndAutoScalingGroups(stacks []*Stack) {
 // All the required resources (listeners and target group) are created in a
 // transactional fashion.
 // Failure to create the stack causes it to be deleted automatically.
-func (a *Adapter) CreateStack(certificateARNs []string, scheme, securityGroup, owner, sslPolicy, ipAddressType string) (string, error) {
+func (a *Adapter) CreateStack(certificateARNs []string, scheme, securityGroup, owner, sslPolicy, ipAddressType string, cwAlarms CloudWatchAlarmList) (string, error) {
 	certARNs := make(map[string]time.Time, len(certificateARNs))
 	for _, arn := range certificateARNs {
 		certARNs[arn] = time.Time{}
@@ -469,12 +469,13 @@ func (a *Adapter) CreateStack(certificateARNs []string, scheme, securityGroup, o
 		albLogsS3Bucket:              a.albLogsS3Bucket,
 		albLogsS3Prefix:              a.albLogsS3Prefix,
 		wafWebAclId:                  a.wafWebAclId,
+		cwAlarms:                     cwAlarms,
 	}
 
 	return createStack(a.cloudformation, spec)
 }
 
-func (a *Adapter) UpdateStack(stackName string, certificateARNs map[string]time.Time, scheme, sslPolicy, ipAddressType string) (string, error) {
+func (a *Adapter) UpdateStack(stackName string, certificateARNs map[string]time.Time, scheme, sslPolicy, ipAddressType string, cwAlarms CloudWatchAlarmList) (string, error) {
 	if _, ok := validSSLPolicy[sslPolicy]; !ok {
 		sslPolicy = a.sslPolicy
 	}
@@ -502,6 +503,7 @@ func (a *Adapter) UpdateStack(stackName string, certificateARNs map[string]time.
 		albLogsS3Bucket:              a.albLogsS3Bucket,
 		albLogsS3Prefix:              a.albLogsS3Prefix,
 		wafWebAclId:                  a.wafWebAclId,
+		cwAlarms:                     cwAlarms,
 	}
 
 	return updateStack(a.cloudformation, spec)
diff --git a/aws/cf.go b/aws/cf.go
index 58a93f6..c864050 100644
--- a/aws/cf.go
+++ b/aws/cf.go
@@ -3,7 +3,6 @@ package aws
 import (
 	"fmt"
 	"strings"
-
 	"time"
 
 	"github.com/aws/aws-sdk-go/aws"
@@ -15,21 +14,23 @@ const (
 	certificateARNTagLegacy = "ingress:certificate-arn"
 	certificateARNTagPrefix = "ingress:certificate-arn/"
 	ingressOwnerTag         = "ingress:owner"
+	cwAlarmConfigHashTag    = "cloudwatch:alarm-config-hash"
 )
 
 // Stack is a simple wrapper around a CloudFormation Stack.
 type Stack struct {
-	Name            string
-	status          string
-	DNSName         string
-	Scheme          string
-	SecurityGroup   string
-	SSLPolicy       string
-	IpAddressType   string
-	TargetGroupARN  string
-	CertificateARNs map[string]time.Time
-	OwnerIngress    string
-	tags            map[string]string
+	Name              string
+	status            string
+	DNSName           string
+	Scheme            string
+	SecurityGroup     string
+	SSLPolicy         string
+	IpAddressType     string
+	OwnerIngress      string
+	CWAlarmConfigHash string
+	TargetGroupARN    string
+	CertificateARNs   map[string]time.Time
+	tags              map[string]string
 }
 
 // IsComplete returns true if the stack status is a complete state.
@@ -132,6 +133,7 @@ type stackSpec struct {
 	albLogsS3Bucket              string
 	albLogsS3Prefix              string
 	wafWebAclId                  string
+	cwAlarms                     CloudWatchAlarmList
 }
 
 type healthCheck struct {
@@ -141,7 +143,7 @@ type healthCheck struct {
 }
 
 func createStack(svc cloudformationiface.CloudFormationAPI, spec *stackSpec) (string, error) {
-	template, err := generateTemplate(spec.certificateARNs, spec.idleConnectionTimeoutSeconds, spec.albLogsS3Bucket, spec.albLogsS3Prefix, spec.wafWebAclId)
+	template, err := generateTemplate(spec)
 	if err != nil {
 		return "", err
 	}
@@ -183,6 +185,10 @@ func createStack(svc cloudformationiface.CloudFormationAPI, spec *stackSpec) (st
 		params.Tags = append(params.Tags, cfTag(ingressOwnerTag, spec.ownerIngress))
 	}
 
+	if len(spec.cwAlarms) > 0 {
+		params.Tags = append(params.Tags, cfTag(cwAlarmConfigHashTag, spec.cwAlarms.Hash()))
+	}
+
 	resp, err := svc.CreateStack(params)
 	if err != nil {
 		return spec.name, err
@@ -192,7 +198,7 @@ func createStack(svc cloudformationiface.CloudFormationAPI, spec *stackSpec) (st
 }
 
 func updateStack(svc cloudformationiface.CloudFormationAPI, spec *stackSpec) (string, error) {
-	template, err := generateTemplate(spec.certificateARNs, spec.idleConnectionTimeoutSeconds, spec.albLogsS3Bucket, spec.albLogsS3Prefix, spec.wafWebAclId)
+	template, err := generateTemplate(spec)
 	if err != nil {
 		return "", err
 	}
@@ -231,6 +237,10 @@ func updateStack(svc cloudformationiface.CloudFormationAPI, spec *stackSpec) (st
 		params.Tags = append(params.Tags, cfTag(ingressOwnerTag, spec.ownerIngress))
 	}
 
+	if len(spec.cwAlarms) > 0 {
+		params.Tags = append(params.Tags, cfTag(cwAlarmConfigHashTag, spec.cwAlarms.Hash()))
+	}
+
 	if spec.stackTerminationProtection {
 		params := &cloudformation.UpdateTerminationProtectionInput{
 			StackName:                   aws.String(spec.name),
@@ -342,17 +352,18 @@ func mapToManagedStack(stack *cloudformation.Stack) *Stack {
 	}
 
 	return &Stack{
-		Name:            aws.StringValue(stack.StackName),
-		DNSName:         outputs.dnsName(),
-		TargetGroupARN:  outputs.targetGroupARN(),
-		Scheme:          parameters[parameterLoadBalancerSchemeParameter],
-		SecurityGroup:   parameters[parameterLoadBalancerSecurityGroupParameter],
-		SSLPolicy:       parameters[parameterListenerSslPolicyParameter],
-		IpAddressType:   parameters[parameterIpAddressTypeParameter],
-		CertificateARNs: certificateARNs,
-		tags:            tags,
-		OwnerIngress:    ownerIngress,
-		status:          aws.StringValue(stack.StackStatus),
+		Name:              aws.StringValue(stack.StackName),
+		DNSName:           outputs.dnsName(),
+		TargetGroupARN:    outputs.targetGroupARN(),
+		Scheme:            parameters[parameterLoadBalancerSchemeParameter],
+		SecurityGroup:     parameters[parameterLoadBalancerSecurityGroupParameter],
+		SSLPolicy:         parameters[parameterListenerSslPolicyParameter],
+		IpAddressType:     parameters[parameterIpAddressTypeParameter],
+		CertificateARNs:   certificateARNs,
+		tags:              tags,
+		OwnerIngress:      ownerIngress,
+		status:            aws.StringValue(stack.StackStatus),
+		CWAlarmConfigHash: tags[cwAlarmConfigHashTag],
 	}
 }
 
diff --git a/aws/cf_template.go b/aws/cf_template.go
index 9844d59..42594d4 100644
--- a/aws/cf_template.go
+++ b/aws/cf_template.go
@@ -3,7 +3,6 @@ package aws
 import (
 	"encoding/json"
 	"fmt"
-	"time"
 
 	"crypto/sha256"
 	"sort"
@@ -22,7 +21,7 @@ func hashARNs(certARNs []string) []byte {
 	return hash.Sum(nil)
 }
 
-func generateTemplate(certs map[string]time.Time, idleConnectionTimeoutSeconds uint, albLogsS3Bucket, albLogsS3Prefix string, wafWebAclId string) (string, error) {
+func generateTemplate(spec *stackSpec) (string, error) {
 	template := cloudformation.NewTemplate()
 	template.Description = "Load Balancer for Kubernetes Ingress"
 	template.Parameters = map[string]*cloudformation.Parameter{
@@ -87,10 +86,10 @@ func generateTemplate(certs map[string]time.Time, idleConnectionTimeoutSeconds u
 		Protocol:        cloudformation.String("HTTP"),
 	})
 
-	if len(certs) > 0 {
+	if len(spec.certificateARNs) > 0 {
 		// Sort the certificate names so we have a stable order.
-		certificateARNs := make([]string, 0, len(certs))
-		for certARN := range certs {
+		certificateARNs := make([]string, 0, len(spec.certificateARNs))
+		for certARN := range spec.certificateARNs {
 			certificateARNs = append(certificateARNs, certARN)
 		}
 		sort.Slice(certificateARNs, func(i, j int) bool {
@@ -138,10 +137,10 @@ func generateTemplate(certs map[string]time.Time, idleConnectionTimeoutSeconds u
 	albAttrList = append(albAttrList,
 		cloudformation.ElasticLoadBalancingV2LoadBalancerLoadBalancerAttribute{
 			Key:   cloudformation.String("idle_timeout.timeout_seconds"),
-			Value: cloudformation.String(fmt.Sprintf("%d", idleConnectionTimeoutSeconds)),
+			Value: cloudformation.String(fmt.Sprintf("%d", spec.idleConnectionTimeoutSeconds)),
 		},
 	)
-	if albLogsS3Bucket != "" {
+	if spec.albLogsS3Bucket != "" {
 		albAttrList = append(albAttrList,
 			cloudformation.ElasticLoadBalancingV2LoadBalancerLoadBalancerAttribute{
 				Key:   cloudformation.String("access_logs.s3.enabled"),
@@ -151,14 +150,14 @@ func generateTemplate(certs map[string]time.Time, idleConnectionTimeoutSeconds u
 		albAttrList = append(albAttrList,
 			cloudformation.ElasticLoadBalancingV2LoadBalancerLoadBalancerAttribute{
 				Key:   cloudformation.String("access_logs.s3.bucket"),
-				Value: cloudformation.String(albLogsS3Bucket),
+				Value: cloudformation.String(spec.albLogsS3Bucket),
 			},
 		)
-		if albLogsS3Prefix != "" {
+		if spec.albLogsS3Prefix != "" {
 			albAttrList = append(albAttrList,
 				cloudformation.ElasticLoadBalancingV2LoadBalancerLoadBalancerAttribute{
 					Key:   cloudformation.String("access_logs.s3.prefix"),
-					Value: cloudformation.String(albLogsS3Prefix),
+					Value: cloudformation.String(spec.albLogsS3Prefix),
 				},
 			)
 		}
@@ -194,10 +193,34 @@ func generateTemplate(certs map[string]time.Time, idleConnectionTimeoutSeconds u
 		VPCID:                      cloudformation.Ref(parameterTargetGroupVPCIDParameter).String(),
 	})
 
-	if wafWebAclId != "" {
+	if spec.wafWebAclId != "" {
 		template.AddResource("WAFAssociation", &cloudformation.WAFRegionalWebACLAssociation{
 			ResourceArn: cloudformation.Ref("LB").String(),
-			WebACLID:    cloudformation.String(wafWebAclId),
+			WebACLID:    cloudformation.String(spec.wafWebAclId),
+		})
+	}
+
+	for idx, alarm := range spec.cwAlarms {
+		resourceName := fmt.Sprintf("CloudWatchAlarm%d", idx)
+		template.AddResource(resourceName, &cloudformation.CloudWatchAlarm{
+			ActionsEnabled:                   alarm.ActionsEnabled,
+			AlarmActions:                     alarm.AlarmActions,
+			AlarmDescription:                 alarm.AlarmDescription,
+			AlarmName:                        normalizeCloudWatchAlarmName(alarm.AlarmName),
+			ComparisonOperator:               alarm.ComparisonOperator,
+			Dimensions:                       normalizeCloudWatchAlarmDimensions(alarm.Dimensions),
+			EvaluateLowSampleCountPercentile: alarm.EvaluateLowSampleCountPercentile,
+			EvaluationPeriods:                alarm.EvaluationPeriods,
+			ExtendedStatistic:                alarm.ExtendedStatistic,
+			InsufficientDataActions:          alarm.InsufficientDataActions,
+			MetricName:                       alarm.MetricName,
+			Namespace:                        normalizeCloudWatchAlarmNamespace(alarm.Namespace),
+			OKActions:                        alarm.OKActions,
+			Period:                           alarm.Period,
+			Statistic:                        alarm.Statistic,
+			Threshold:                        alarm.Threshold,
+			TreatMissingData:                 alarm.TreatMissingData,
+			Unit:                             alarm.Unit,
 		})
 	}
 
diff --git a/aws/cloudwatch.go b/aws/cloudwatch.go
new file mode 100644
index 0000000..e38d4a8
--- /dev/null
+++ b/aws/cloudwatch.go
@@ -0,0 +1,104 @@
+package aws
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"encoding/json"
+
+	"github.com/ghodss/yaml"
+	cloudformation "github.com/mweagle/go-cloudformation"
+	log "github.com/sirupsen/logrus"
+)
+
+// CloudWatchAlarmList represents a list of CloudWatch Alarms directly usable
+// in Cloudformation Stacks.
+type CloudWatchAlarmList []cloudformation.CloudWatchAlarm
+
+// Hash computes a hash of the CloudWatchAlarmList which can be used to detect
+// changes between two versions. The hash string will be empty if c is empty or
+// there was an error while encoding.
+func (c CloudWatchAlarmList) Hash() string {
+	if len(c) == 0 {
+		return ""
+	}
+
+	buf, err := json.Marshal(c)
+	if err != nil {
+		log.Errorf("failed to marshal cloudwatch alarm list: %v", err)
+		return ""
+	}
+
+	hash := sha256.New()
+	hash.Write(buf)
+
+	return hex.EncodeToString(hash.Sum(nil))
+}
+
+// NewCloudWatchAlarmListFromYAML parses a raw slice of yaml bytes into a new
+// CloudWatchAlarmList.
+func NewCloudWatchAlarmListFromYAML(b []byte) (CloudWatchAlarmList, error) {
+	alarmList := CloudWatchAlarmList{}
+
+	err := yaml.Unmarshal(b, &alarmList)
+	if err != nil {
+		return nil, err
+	}
+
+	return alarmList, nil
+}
+
+// normalizeCloudWatchAlarmName prefixes the alarm name (if it is non-nil) with
+// the stack name to minimize the probability for collisions as the alarm name
+// has to be unique across the AWS account.
+func normalizeCloudWatchAlarmName(alarmName *cloudformation.StringExpr) *cloudformation.StringExpr {
+	if alarmName != nil {
+		return cloudformation.Join("-", cloudformation.Ref("AWS::StackName"), alarmName)
+	}
+
+	return alarmName
+}
+
+// normalizeCloudWatchAlarmNamespace sets the alarm namespace to sane default
+// (AWS/ApplicationELB) if it is not set.
+func normalizeCloudWatchAlarmNamespace(alarmNamespace *cloudformation.StringExpr) *cloudformation.StringExpr {
+	if alarmNamespace == nil {
+		return cloudformation.String("AWS/ApplicationELB")
+	}
+
+	return alarmNamespace
+}
+
+// normalizeCloudWatchAlarmDimensions replaces the values of the dimensions
+// LoadBalancer and TargetGroup (if set) with the generated names of these
+// resources. If alarmDimensions is nil or empty, it will return a sane default
+// that includes the dimension LoadBalancer with its generated name.
+func normalizeCloudWatchAlarmDimensions(alarmDimensions *cloudformation.CloudWatchAlarmDimensionList) *cloudformation.CloudWatchAlarmDimensionList {
+	if alarmDimensions == nil || len(*alarmDimensions) == 0 {
+		return &cloudformation.CloudWatchAlarmDimensionList{
+			{
+				Name:  cloudformation.String("LoadBalancer"),
+				Value: cloudformation.GetAtt("LB", "LoadBalancerFullName").String(),
+			},
+		}
+	}
+
+	dimensions := make(cloudformation.CloudWatchAlarmDimensionList, len(*alarmDimensions))
+
+	for i, dimension := range *alarmDimensions {
+		value := dimension.Value
+
+		switch dimension.Name.Literal {
+		case "LoadBalancer":
+			value = cloudformation.GetAtt("LB", "LoadBalancerFullName").String()
+		case "TargetGroup":
+			value = cloudformation.GetAtt("TG", "TargetGroupFullName").String()
+		}
+
+		dimensions[i] = cloudformation.CloudWatchAlarmDimension{
+			Name:  dimension.Name,
+			Value: value,
+		}
+	}
+
+	return &dimensions
+}
diff --git a/cloudwatch.md b/cloudwatch.md
new file mode 100644
index 0000000..0171fea
--- /dev/null
+++ b/cloudwatch.md
@@ -0,0 +1,113 @@
+# CloudWatch Alarm configuration in the Kubernetes Ingress Controller for AWS
+
+The controller provides the option to create CloudWatch Alarms for the ALBs
+created by it. These alarms can be configured in a ConfigMap.
+
+## Setup
+
+To make use of CloudWatch Alarms you need to provide the
+`--cloudwatch-alarms-config-map` CLI flag to the controller and point it to a
+ConfigMap resource consisting of namespace and name seperated by a `/`, e.g.:
+`kube-system/kube-ingress-aws-controller-cw-alarms`.
+
+The controller will re-read the ConfigMap in the interval configured by the
+`--polling-interval` flag (default: 30 seconds) and apply potential changes in
+the alarm configuration to all load balancer Cloudformation Stacks.
+
+Also make sure the IAM role of the controller includes the following
+permissions required to [manage CloudWatch
+Alarms](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/permissions-reference-cw.html#cw-permissions-table):
+
+* `cloudwatch:DeleteAlarms`
+* `cloudwatch:DescribeAlarms`
+* `cloudwatch:DisableAlarmActions`
+* `cloudwatch:EnableAlarmActions`
+* `cloudwatch:PutMetricAlarm`
+
+
+## Configuration
+
+The CloudWatch Alarm configuration supports all properties listed in the
+[Cloudformation documentation for the CloudWatch Alarm
+resource](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-cw-alarm.html#aws-properties-cw-alarm-syntax.yaml)
+in YAML format with a few exceptions (see [below](#special-configuration-properties)). This is an example for a
+ConfigMap that contains CloudWatch Alarm configuration:
+
+```yaml
+---
+kind: ConfigMap
+apiVersion: v1
+metadata:
+  name: kube-ingress-aws-controller-cw-alarms
+  namespace: kube-system
+data:
+  alarms: |
+    - ActionsEnabled: true
+      AlarmActions:
+      - arn:aws:sns:region:account-id:sns-topic-name
+      AlarmDescription: "There are no healthy backends"
+      AlarmName: "no-healthy-backends"
+      ComparisonOperator: LessThanThreshold
+      Dimensions:
+      - Name: LoadBalancer
+      - Name: TargetGroup
+      EvaluationPeriods: 1
+      InsufficientDataActions: 
+      - arn:aws:sns:region:account-id:sns-topic-name
+      MetricName: HealthyHostCount
+      OKActions:
+      - arn:aws:sns:region:account-id:sns-topic-name
+      Period: 60
+      Statistic: Average
+      Threshold: 1
+      TreatMissingData: notBreaching
+      Unit: Count
+  some-other-config-key: |
+    - AlarmDescription: "Increased rate of 5xx errors"
+      ComparisonOperator: GreaterThanOrEqualToThreshold
+      EvaluationPeriods: 1
+      MetricName: HTTPCode_ELB_5XX_Count
+      Period: 60
+      Statistic: Sum
+      Threshold: 100
+      TreatMissingData: notBreaching
+      Unit: Count
+    - ActionsEnabled: true
+      AlarmActions:
+      - arn:aws:sns:region:account-id:sns-topic-name
+      AlarmDescription: "Some description of the alarm"
+      AlarmName: "and-other-alarm"
+      [...]
+```
+
+Refer to the [CloudWatch Metrics documentation for
+ALBs](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-cloudwatch-metrics.html#load-balancer-metrics-alb)
+to get a list of available metrics that can be used to set up alarms.
+
+The controller will treat all of the ConfigMap's data attributes as YAML arrays
+containing CloudWatch Alarm configuration and will try to parse them as such.
+The names of the keys can be arbitrary and are ignored. Keys not containing
+valid YAML arrays matching the CloudWatch Alarm configuration structure will be
+ignored.
+
+### Special configuration properties
+
+The following CloudWatch Alarm properties will receive special treatment:
+* If set, `AlarmName` will be prefixed with the name of the Cloudformation
+  Stack of each load balancer to avoid name clashes since AWS requires alarm
+  names to be unique across the whole account.
+* `DatapointsToAlarm` will be ignored if set as it is currently not supported
+  by the cloudformation library used.
+* If absent or empty, `Dimensions` will be set to
+  `[{Name: LoadBalancer, Value: <generated-alb-name>}]`. If `LoadBalancer` or
+  `TargetGroup` is used as the name of a dimension, its value will be replaced
+  by the generated name of the load balancer/target group.
+* If unset, `Namespace` will default to `AWS/ApplicationELB`.
+
+## Alarm configuration versions
+
+The controller keeps track of changes in the alarm configuration by adding the
+tag `cloudwatch:alarm-config-hash` to the load balancer's CloudFormation Stack.
+The tag's value is a checksum of the currently applied alarm configuration. The
+CloudFormation Stack is updated once the hash of a newer alarm configuration
+differs from this value.
diff --git a/controller.go b/controller.go
index c71ba14..096a260 100644
--- a/controller.go
+++ b/controller.go
@@ -60,6 +60,8 @@ var (
 	debugFlag                  bool
 	quietFlag                  bool
 	firstRun                   bool = true
+	cwAlarmConfigMap           string
+	cwAlarmConfigMapLocation   *kubernetes.ResourceLocation
 )
 
 func loadSettings() error {
@@ -105,6 +107,7 @@ func loadSettings() error {
 	flag.StringVar(&albLogsS3Bucket, "logs-s3-bucket", aws.DefaultAlbS3LogsBucket, "S3 bucket to be used for ALB logging")
 	flag.StringVar(&albLogsS3Prefix, "logs-s3-prefix", aws.DefaultAlbS3LogsPrefix, "Prefix within S3 bucket to be used for ALB logging")
 	flag.StringVar(&wafWebAclId, "aws-waf-web-acl-id", aws.DefaultWafWebAclId, "Waf web acl id to be associated with the ALB")
+	flag.StringVar(&cwAlarmConfigMap, "cloudwatch-alarms-config-map", "", "ConfigMap location of the form 'namespace/config-map-name' where to read CloudWatch Alarm configuration from. Ignored if empty.")
 
 	flag.Parse()
 
@@ -152,6 +155,15 @@ func loadSettings() error {
 		return fmt.Errorf("invalid max number of certificates per ALB: %d. AWS does not allow more than %d", maxCertsPerALB, aws.DefaultMaxCertsPerALB)
 	}
 
+	if cwAlarmConfigMap != "" {
+		loc, err := kubernetes.ParseResourceLocation(cwAlarmConfigMap)
+		if err != nil {
+			return fmt.Errorf("failed to parse cloudwatch alarm config map location: %v", err)
+		}
+
+		cwAlarmConfigMapLocation = loc
+	}
+
 	if quietFlag && debugFlag {
 		log.Warn("--quiet and --debug flags are both set. Debug will be used as logging level.")
 	}
@@ -277,6 +289,7 @@ func main() {
 	log.Infof("\tIngress class filters: %s", kubeAdapter.IngressFiltersString())
 	log.Infof("\tALB Logging S3 Bucket: %s", awsAdapter.S3Bucket())
 	log.Infof("\tALB Logging S3 Prefix: %s", awsAdapter.S3Prefix())
+	log.Infof("\tCloudWatch Alarm ConfigMap: %s", cwAlarmConfigMapLocation)
 
 	ctx, cancel := context.WithCancel(context.Background())
 	go handleTerminationSignals(cancel, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
diff --git a/deploy/ingress-serviceaccount.yaml b/deploy/ingress-serviceaccount.yaml
index 001ae39..f431ff2 100644
--- a/deploy/ingress-serviceaccount.yaml
+++ b/deploy/ingress-serviceaccount.yaml
@@ -25,6 +25,12 @@ rules:
   verbs:
   - patch
   - update
+- apiGroups:
+  - ""
+  resources:
+  - configmaps
+  verbs:
+  - get
 
 ---
 apiVersion: rbac.authorization.k8s.io/v1
diff --git a/go.mod b/go.mod
index a2b3f1b..077cab2 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.12
 require (
 	github.com/aws/aws-sdk-go v1.23.17
 	github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 // indirect
+	github.com/ghodss/yaml v1.0.0
 	github.com/go-playground/locales v0.12.1 // indirect
 	github.com/go-playground/universal-translator v0.0.0-20170327191703-71201497bace // indirect
 	github.com/golang/protobuf v0.0.0-20171113180720-1e59b77b52bf // indirect
@@ -25,4 +26,5 @@ require (
 	gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
 	gopkg.in/go-playground/assert.v1 v1.2.1 // indirect
 	gopkg.in/go-playground/validator.v9 v9.19.0 // indirect
+	gopkg.in/yaml.v2 v2.2.2
 )
diff --git a/go.sum b/go.sum
index d0e7a64..6468e4c 100644
--- a/go.sum
+++ b/go.sum
@@ -4,6 +4,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLM
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
+github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc=
 github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
 github.com/go-playground/universal-translator v0.0.0-20170327191703-71201497bace h1:vfBaUX49VsqTxXGADDIWvTPvaU4AbQyX/yENHE0f7AY=
@@ -54,9 +56,12 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM=
 gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
 gopkg.in/go-playground/validator.v9 v9.19.0 h1:PTE3gBwP61sZfxOsROkUZbXONv+7N5Mw1Et6S+4NGBA=
 gopkg.in/go-playground/validator.v9 v9.19.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
+gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/kubernetes/adapter.go b/kubernetes/adapter.go
index 42a3c14..c2a0b0e 100644
--- a/kubernetes/adapter.go
+++ b/kubernetes/adapter.go
@@ -56,6 +56,19 @@ func (i *Ingress) String() string {
 	return fmt.Sprintf("%s/%s", i.Namespace, i.Name)
 }
 
+// ConfigMap is the ingress-controller's representation of a Kubernetes
+// ConfigMap
+type ConfigMap struct {
+	Namespace string
+	Name      string
+	Data      map[string]string
+}
+
+// String implements fmt.Stringer.
+func (c *ConfigMap) String() string {
+	return fmt.Sprintf("%s/%s", c.Namespace, c.Name)
+}
+
 // NewAdapter creates an Adapter for Kubernetes using a given configuration.
 func NewAdapter(config *Config, ingressClassFilters []string, ingressDefaultSecurityGroup, ingressDefaultSSLPolicy string) (*Adapter, error) {
 	if config == nil || config.BaseURL == "" {
@@ -193,3 +206,17 @@ func (a *Adapter) UpdateIngressLoadBalancer(ingress *Ingress, loadBalancerDNSNam
 
 	return updateIngressLoadBalancer(a.kubeClient, newIngressForKube(ingress), loadBalancerDNSName)
 }
+
+// GetConfigMap retrieves the ConfigMap with name from namespace.
+func (a *Adapter) GetConfigMap(namespace, name string) (*ConfigMap, error) {
+	cm, err := getConfigMap(a.kubeClient, namespace, name)
+	if err != nil {
+		return nil, err
+	}
+
+	return &ConfigMap{
+		Name:      cm.Metadata.Name,
+		Namespace: cm.Metadata.Namespace,
+		Data:      cm.Data,
+	}, nil
+}
diff --git a/kubernetes/config_map.go b/kubernetes/config_map.go
new file mode 100644
index 0000000..725333c
--- /dev/null
+++ b/kubernetes/config_map.go
@@ -0,0 +1,46 @@
+package kubernetes
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+)
+
+const (
+	configMapResource = "/api/v1/namespaces/%s/configmaps/%s"
+)
+
+type configMapMetadata struct {
+	Name      string `json:"name"`
+	Namespace string `json:"namespace"`
+}
+
+type configMap struct {
+	Kind       string            `json:"kind"`
+	APIVersion string            `json:"apiVersion"`
+	Metadata   configMapMetadata `json:"metadata"`
+	Data       map[string]string `json:"data"`
+}
+
+func getConfigMap(c client, namespace, name string) (*configMap, error) {
+	resource := fmt.Sprintf(configMapResource, namespace, name)
+
+	r, err := c.get(resource)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get ConfigMap %s/%s: %v", namespace, name, err)
+	}
+
+	defer r.Close()
+
+	b, err := ioutil.ReadAll(r)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read ConfigMap %s/%s: %v", namespace, name, err)
+	}
+
+	var result configMap
+	if err := json.Unmarshal(b, &result); err != nil {
+		return nil, fmt.Errorf("failed to unmarshal ConfigMap %s/%s: %v", namespace, name, err)
+	}
+
+	return &result, nil
+}
diff --git a/kubernetes/resource.go b/kubernetes/resource.go
new file mode 100644
index 0000000..c4d78f2
--- /dev/null
+++ b/kubernetes/resource.go
@@ -0,0 +1,39 @@
+package kubernetes
+
+import (
+	"fmt"
+	"strings"
+)
+
+// ResourceLocation defines the location of Kubernetes resource in a particular
+// namespace.
+type ResourceLocation struct {
+	Name      string
+	Namespace string
+}
+
+// String implements fmt.Stringer.
+func (r *ResourceLocation) String() string {
+	if r == nil {
+		return ""
+	}
+
+	return fmt.Sprintf("%s/%s", r.Namespace, r.Name)
+}
+
+// ParseResourceLocation parses a Kubernetes resource location from string.
+// Returns an error if the string does not match the expected format of
+// `namespace/name`.
+func ParseResourceLocation(s string) (*ResourceLocation, error) {
+	parts := strings.Split(strings.Trim(s, "/"), "/")
+	if len(parts) != 2 {
+		return nil, fmt.Errorf(`invalid resource location, expected format "namespace/name" but got %q`, s)
+	}
+
+	ref := &ResourceLocation{
+		Namespace: parts[0],
+		Name:      parts[1],
+	}
+
+	return ref, nil
+}
diff --git a/worker.go b/worker.go
index bc3ecd0..74672c1 100644
--- a/worker.go
+++ b/worker.go
@@ -29,6 +29,7 @@ type loadBalancer struct {
 	sslPolicy     string
 	ipAddressType string
 	certTTL       time.Duration
+	cwAlarms      aws.CloudWatchAlarmList
 }
 
 const (
@@ -55,11 +56,12 @@ func (l *loadBalancer) Status() int {
 	return ready
 }
 
-// inSync checks if the loadBalancer is in sync with the backing CF stack.
-// It's considered in sync when certs found for the ingresses match those
-// already defined on the stack.
+// inSync checks if the loadBalancer is in sync with the backing CF stack. It's
+// considered in sync when certs found for the ingresses match those already
+// defined on the stack and the cloudwatch alarm config is up-to-date.
 func (l *loadBalancer) inSync() bool {
-	return reflect.DeepEqual(l.CertificateARNs(), l.stack.CertificateARNs)
+	return reflect.DeepEqual(l.CertificateARNs(), l.stack.CertificateARNs) &&
+		l.stack.CWAlarmConfigHash == l.cwAlarms.Hash()
 }
 
 // AddIngress adds an ingress object to the load balancer.
@@ -242,14 +244,20 @@ func doWork(certsProvider certs.CertificatesProvider, certsPerALB int, certTTL t
 		return fmt.Errorf("doWork failed to get certificates: %v", err)
 	}
 
+	cwAlarms, err := getCloudWatchAlarms(kubeAdapter, cwAlarmConfigMapLocation)
+	if err != nil {
+		return fmt.Errorf("doWork failed to retrieve cloudwatch alarm configuration: %v", err)
+	}
+
 	awsAdapter.UpdateTargetGroupsAndAutoScalingGroups(stacks)
 	log.Infof("Found %d auto scaling group(s)", len(awsAdapter.AutoScalingGroupNames()))
 	log.Infof("Found %d single instance(s)", len(awsAdapter.SingleInstances()))
 	log.Infof("Found %d EC2 instance(s)", awsAdapter.CachedInstances())
 	log.Infof("Found %d certificate(s)", len(certificateSummaries))
+	log.Infof("Found %d cloudwatch alarm configuration(s)", len(cwAlarms))
 
 	certs := &Certificates{certificateSummaries: certificateSummaries}
-	model := buildManagedModel(certs, certsPerALB, certTTL, ingresses, stacks)
+	model := buildManagedModel(certs, certsPerALB, certTTL, ingresses, stacks, cwAlarms)
 	log.Debugf("Have %d model(s)", len(model))
 	for _, loadBalancer := range model {
 		switch loadBalancer.Status() {
@@ -356,10 +364,24 @@ func matchIngressesToLoadBalancers(loadBalancers []*loadBalancer, certs Certific
 	return loadBalancers
 }
 
-func buildManagedModel(certs CertificatesFinder, certsPerALB int, certTTL time.Duration, ingresses []*kubernetes.Ingress, stacks []*aws.Stack) []*loadBalancer {
+// addCloudWatchAlarms attaches CloudWatch Alarms to each load balancer model
+// in the list. It ensures that the alarm config is copied so that it can be
+// adjusted safely for each load balancer.
+func attachCloudWatchAlarms(loadBalancers []*loadBalancer, cwAlarms aws.CloudWatchAlarmList) {
+	for _, loadBalancer := range loadBalancers {
+		lbAlarms := make(aws.CloudWatchAlarmList, len(cwAlarms))
+
+		copy(lbAlarms, cwAlarms)
+
+		loadBalancer.cwAlarms = lbAlarms
+	}
+}
+
+func buildManagedModel(certs CertificatesFinder, certsPerALB int, certTTL time.Duration, ingresses []*kubernetes.Ingress, stacks []*aws.Stack, cwAlarms aws.CloudWatchAlarmList) []*loadBalancer {
 	sortStacks(stacks)
 	model := getAllLoadBalancers(certTTL, stacks)
 	model = matchIngressesToLoadBalancers(model, certs, certsPerALB, ingresses)
+	attachCloudWatchAlarms(model, cwAlarms)
 
 	return model
 }
@@ -372,7 +394,7 @@ func createStack(awsAdapter *aws.Adapter, lb *loadBalancer) {
 
 	log.Infof("creating stack for certificates %q / ingress %q", certificates, lb.ingresses)
 
-	stackId, err := awsAdapter.CreateStack(certificates, lb.scheme, lb.securityGroup, lb.Owner(), lb.sslPolicy, lb.ipAddressType)
+	stackId, err := awsAdapter.CreateStack(certificates, lb.scheme, lb.securityGroup, lb.Owner(), lb.sslPolicy, lb.ipAddressType, lb.cwAlarms)
 	if err != nil {
 		if isAlreadyExistsError(err) {
 			lb.stack, err = awsAdapter.GetStack(stackId)
@@ -391,7 +413,7 @@ func updateStack(awsAdapter *aws.Adapter, lb *loadBalancer) {
 
 	log.Infof("updating %q stack for %d certificates / %d ingresses", lb.scheme, len(certificates), len(lb.ingresses))
 
-	stackId, err := awsAdapter.UpdateStack(lb.stack.Name, certificates, lb.scheme, lb.sslPolicy, lb.ipAddressType)
+	stackId, err := awsAdapter.UpdateStack(lb.stack.Name, certificates, lb.scheme, lb.sslPolicy, lb.ipAddressType, lb.cwAlarms)
 	if isNoUpdatesToBePerformedError(err) {
 		log.Debugf("stack(%q) is already up to date", certificates)
 	} else if err != nil {
@@ -447,3 +469,50 @@ func deleteStack(awsAdapter *aws.Adapter, lb *loadBalancer) {
 		log.Infof("deleted orphaned stack %q", stackName)
 	}
 }
+
+// getCloudWatchAlarms retrieves CloudWatch Alarm configuration from a
+// ConfigMap described by configMapLoc. If configMapLoc is nil, an empty alarm
+// configuration will be returned. Returns any error that might occur while
+// retrieving the configuration.
+func getCloudWatchAlarms(kubeAdapter *kubernetes.Adapter, configMapLoc *kubernetes.ResourceLocation) (aws.CloudWatchAlarmList, error) {
+	if configMapLoc == nil {
+		return aws.CloudWatchAlarmList{}, nil
+	}
+
+	configMap, err := kubeAdapter.GetConfigMap(configMapLoc.Namespace, configMapLoc.Name)
+	if err != nil {
+		return nil, err
+	}
+
+	return getCloudWatchAlarmsFromConfigMap(configMap), nil
+}
+
+// getCloudWatchAlarmsFromConfigMap extracts cloudwatch alarm configuration
+// from ConfigMap data. It will collect alarm configuration from all ConfigMap
+// data keys it finds. If a ConfigMap data key contains invalid data, an error
+// is logged and the key will be ignored. The sort order of the resulting slice
+// is guaranteed to be stable.
+func getCloudWatchAlarmsFromConfigMap(configMap *kubernetes.ConfigMap) aws.CloudWatchAlarmList {
+	configList := aws.CloudWatchAlarmList{}
+
+	keys := make([]string, 0, len(configMap.Data))
+	for k := range configMap.Data {
+		keys = append(keys, k)
+	}
+
+	sort.Strings(keys)
+
+	for _, key := range keys {
+		data := []byte(configMap.Data[key])
+
+		list, err := aws.NewCloudWatchAlarmListFromYAML(data)
+		if err != nil {
+			log.Warnf("ignoring cloudwatch alarm configuration from config map key %q due to error: %v", key, err)
+			continue
+		}
+
+		configList = append(configList, list...)
+	}
+
+	return configList
+}


Test patch:
diff --git a/aws/cf_template_test.go b/aws/cf_template_test.go
new file mode 100644
index 0000000..5661616
--- /dev/null
+++ b/aws/cf_template_test.go
@@ -0,0 +1,114 @@
+package aws
+
+import (
+	"encoding/json"
+	"testing"
+
+	cloudformation "github.com/mweagle/go-cloudformation"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestGenerateTemplate(t *testing.T) {
+	for _, test := range []struct {
+		name     string
+		spec     *stackSpec
+		validate func(t *testing.T, template *cloudformation.Template)
+	}{
+		{
+			name: "contains no cloudwatch alarm resources if spec has none",
+			spec: &stackSpec{},
+			validate: func(t *testing.T, template *cloudformation.Template) {
+				require.Nil(t, template.Resources["CloudWatchAlarm0"])
+				require.Nil(t, template.Resources["CloudWatchAlarm1"])
+			},
+		},
+		{
+			name: "contains cloudwatch alarm resources #1",
+			spec: &stackSpec{
+				cwAlarms: CloudWatchAlarmList{
+					{
+						AlarmName: cloudformation.String("foo"),
+					},
+				},
+			},
+			validate: func(t *testing.T, template *cloudformation.Template) {
+				require.NotNil(t, template.Resources["CloudWatchAlarm0"])
+
+				alarm := template.Resources["CloudWatchAlarm0"]
+
+				props := alarm.Properties.(*cloudformation.CloudWatchAlarm)
+
+				assert.Equal(
+					t,
+					cloudformation.Join(
+						"-",
+						cloudformation.Ref("AWS::StackName"),
+						cloudformation.String("foo"),
+					),
+					props.AlarmName,
+				)
+				assert.Equal(
+					t,
+					cloudformation.String("AWS/ApplicationELB"),
+					props.Namespace,
+				)
+			},
+		},
+		{
+			name: "contains cloudwatch alarm resources #1",
+			spec: &stackSpec{
+				cwAlarms: CloudWatchAlarmList{
+					{},
+					{
+						Namespace: cloudformation.String("AWS/Whatever"),
+						Dimensions: &cloudformation.CloudWatchAlarmDimensionList{
+							{
+								Name:  cloudformation.String("LoadBalancer"),
+								Value: cloudformation.String("foo-lb"),
+							},
+						},
+					},
+				},
+			},
+			validate: func(t *testing.T, template *cloudformation.Template) {
+				require.NotNil(t, template.Resources["CloudWatchAlarm0"])
+				require.NotNil(t, template.Resources["CloudWatchAlarm1"])
+
+				alarm := template.Resources["CloudWatchAlarm1"]
+
+				props := alarm.Properties.(*cloudformation.CloudWatchAlarm)
+
+				assert.Equal(
+					t,
+					&cloudformation.CloudWatchAlarmDimensionList{
+						{
+							Name:  cloudformation.String("LoadBalancer"),
+							Value: cloudformation.GetAtt("LB", "LoadBalancerFullName").String(),
+						},
+					},
+					props.Dimensions,
+				)
+				assert.Equal(
+					t,
+					cloudformation.String("AWS/Whatever"),
+					props.Namespace,
+				)
+			},
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			generated, err := generateTemplate(test.spec)
+
+			require.NoError(t, err)
+
+			var template *cloudformation.Template
+
+			err = json.Unmarshal([]byte(generated), &template)
+
+			require.NoError(t, err)
+
+			test.validate(t, template)
+		})
+	}
+}
diff --git a/aws/cloudwatch_test.go b/aws/cloudwatch_test.go
new file mode 100644
index 0000000..33b0123
--- /dev/null
+++ b/aws/cloudwatch_test.go
@@ -0,0 +1,157 @@
+package aws
+
+import (
+	"testing"
+
+	cloudformation "github.com/mweagle/go-cloudformation"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestCloudWatchAlarmList_Hash(t *testing.T) {
+	for _, test := range []struct {
+		name     string
+		list     CloudWatchAlarmList
+		expected string
+	}{
+		{
+			name:     "nil list produces empty hash",
+			list:     nil,
+			expected: "",
+		},
+		{
+			name:     "empty list produces empty hash",
+			list:     CloudWatchAlarmList{},
+			expected: "",
+		},
+		{
+			name: "list produces hash",
+			list: CloudWatchAlarmList{
+				{},
+			},
+			expected: "e10808d43975dc400731053386849f864f297e6c4f7519c380f3dbaf7067a840",
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			assert.Equal(t, test.expected, test.list.Hash())
+		})
+	}
+}
+
+func TestNormalizeCloudWatchAlarmName(t *testing.T) {
+	for _, test := range []struct {
+		name      string
+		alarmName *cloudformation.StringExpr
+		expected  *cloudformation.StringExpr
+	}{
+		{
+			name:      "prefix name with stack name if set",
+			alarmName: cloudformation.String("foo-alarm"),
+			expected: cloudformation.Join(
+				"-",
+				cloudformation.Ref("AWS::StackName"),
+				cloudformation.String("foo-alarm"),
+			),
+		},
+		{
+			name:      "nil gets just passed through",
+			alarmName: nil,
+			expected:  nil,
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			result := normalizeCloudWatchAlarmName(test.alarmName)
+
+			assert.Equal(t, test.expected, result)
+		})
+	}
+}
+
+func TestNormalizeCloudWatchAlarmNamespace(t *testing.T) {
+	for _, test := range []struct {
+		name           string
+		alarmNamespace *cloudformation.StringExpr
+		expected       *cloudformation.StringExpr
+	}{
+		{
+			name:           "namespace will not be altered if set",
+			alarmNamespace: cloudformation.String("AWS/NetworkELB"),
+			expected:       cloudformation.String("AWS/NetworkELB"),
+		},
+		{
+			name:           "namespace is set to AWS/ApplicationELB if nil",
+			alarmNamespace: nil,
+			expected:       cloudformation.String("AWS/ApplicationELB"),
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			result := normalizeCloudWatchAlarmNamespace(test.alarmNamespace)
+
+			assert.Equal(t, test.expected, result)
+		})
+	}
+}
+
+func TestNormalizeCloudWatchAlarmDimensions(t *testing.T) {
+	for _, test := range []struct {
+		name            string
+		alarmDimensions *cloudformation.CloudWatchAlarmDimensionList
+		expected        *cloudformation.CloudWatchAlarmDimensionList
+	}{
+		{
+			name:            "set to default if nil",
+			alarmDimensions: nil,
+			expected: &cloudformation.CloudWatchAlarmDimensionList{
+				{
+					Name:  cloudformation.String("LoadBalancer"),
+					Value: cloudformation.GetAtt("LB", "LoadBalancerFullName").String(),
+				},
+			},
+		},
+		{
+			name:            "set to default if empty",
+			alarmDimensions: &cloudformation.CloudWatchAlarmDimensionList{},
+			expected: &cloudformation.CloudWatchAlarmDimensionList{
+				{
+					Name:  cloudformation.String("LoadBalancer"),
+					Value: cloudformation.GetAtt("LB", "LoadBalancerFullName").String(),
+				},
+			},
+		},
+		{
+			name: "replaces LoadBalancer and TargetGroup with references",
+			alarmDimensions: &cloudformation.CloudWatchAlarmDimensionList{
+				{
+					Name:  cloudformation.String("LoadBalancer"),
+					Value: cloudformation.String("foo-lb"),
+				},
+				{
+					Name: cloudformation.String("TargetGroup"),
+				},
+				{
+					Name:  cloudformation.String("AvailabilityZone"),
+					Value: cloudformation.String("eu-west-1a"),
+				},
+			},
+			expected: &cloudformation.CloudWatchAlarmDimensionList{
+				{
+					Name:  cloudformation.String("LoadBalancer"),
+					Value: cloudformation.GetAtt("LB", "LoadBalancerFullName").String(),
+				},
+				{
+					Name:  cloudformation.String("TargetGroup"),
+					Value: cloudformation.GetAtt("TG", "TargetGroupFullName").String(),
+				},
+				{
+					Name:  cloudformation.String("AvailabilityZone"),
+					Value: cloudformation.String("eu-west-1a"),
+				},
+			},
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			result := normalizeCloudWatchAlarmDimensions(test.alarmDimensions)
+
+			assert.Equal(t, test.expected, result)
+		})
+	}
+}
diff --git a/kubernetes/adapter_test.go b/kubernetes/adapter_test.go
index 4faae89..b3623e4 100644
--- a/kubernetes/adapter_test.go
+++ b/kubernetes/adapter_test.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"io"
 	"io/ioutil"
+	"reflect"
 	"strings"
 	"testing"
 
@@ -184,14 +185,25 @@ type mockClient struct {
 }
 
 func (c *mockClient) get(res string) (io.ReadCloser, error) {
-	if !c.broken && res == ingressListResource {
-		buf, err := ioutil.ReadFile("testdata/fixture01.json")
-		if err != nil {
-			return nil, err
-		}
-		return ioutil.NopCloser(bytes.NewReader(buf)), nil
+	if c.broken {
+		return nil, errors.New("mocked error")
 	}
-	return nil, errors.New("mocked error")
+
+	var fixture string
+	switch res {
+	case ingressListResource:
+		fixture = "testdata/fixture01.json"
+	case fmt.Sprintf(configMapResource, "foo-ns", "foo-name"):
+		fixture = "testdata/fixture02.json"
+	default:
+		return nil, fmt.Errorf("unexpected resource: %s", res)
+	}
+
+	buf, err := ioutil.ReadFile(fixture)
+	if err != nil {
+		return nil, err
+	}
+	return ioutil.NopCloser(bytes.NewReader(buf)), nil
 }
 
 func (c *mockClient) patch(res string, payload []byte) (io.ReadCloser, error) {
@@ -262,3 +274,26 @@ func TestBrokenConfig(t *testing.T) {
 		})
 	}
 }
+
+func TestAdapter_GetConfigMap(t *testing.T) {
+	a, _ := NewAdapter(testConfig, testIngressFilter, testIngressDefaultSecurityGroup, testSSLPolicy)
+	client := &mockClient{}
+	a.kubeClient = client
+
+	cm, err := a.GetConfigMap("foo-ns", "foo-name")
+	if err != nil {
+		t.Error(err)
+	}
+
+	expectedData := map[string]string{"some-key": "key1: val1\nkey2: val2\n"}
+
+	if !reflect.DeepEqual(cm.Data, expectedData) {
+		t.Fatalf("unexpected ConfigMap data, got %+v, want %+v", cm.Data, expectedData)
+	}
+
+	client.broken = true
+	_, err = a.GetConfigMap("foo-ns", "foo-name")
+	if err == nil {
+		t.Error("expected an error")
+	}
+}
diff --git a/kubernetes/config_map_test.go b/kubernetes/config_map_test.go
new file mode 100644
index 0000000..8a49bc1
--- /dev/null
+++ b/kubernetes/config_map_test.go
@@ -0,0 +1,73 @@
+package kubernetes
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"reflect"
+	"testing"
+)
+
+func TestGetConfigMap(t *testing.T) {
+	testServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+		f, _ := os.Open("testdata/fixture02.json")
+		defer f.Close()
+		rw.WriteHeader(http.StatusOK)
+		io.Copy(rw, f)
+	}))
+	defer testServer.Close()
+
+	kubeClient, _ := newSimpleClient(&Config{BaseURL: testServer.URL})
+
+	want := newConfigMap("foo-ns", "foo-name", map[string]string{
+		"some-key": "key1: val1\nkey2: val2\n",
+	})
+
+	got, err := getConfigMap(kubeClient, "foo-ns", "foo-name")
+	if err != nil {
+		t.Errorf("unexpected error from getConfigMap: %v", err)
+	} else {
+		if !reflect.DeepEqual(got, want) {
+			t.Errorf("unexpected result from getConfigMap. wanted %v, got %v", want, got)
+		}
+	}
+}
+
+func TestGetConfigMapFailureScenarios(t *testing.T) {
+	for _, test := range []struct {
+		statusCode int
+		body       string
+	}{
+		{http.StatusInternalServerError, "{}"},
+		{http.StatusOK, "`"},
+	} {
+		t.Run(fmt.Sprintf("%v", test.statusCode), func(t *testing.T) {
+			testServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
+				rw.WriteHeader(test.statusCode)
+				fmt.Fprintln(rw, test.body)
+			}))
+			defer testServer.Close()
+
+			kubeClient, _ := newSimpleClient(&Config{BaseURL: testServer.URL})
+
+			_, err := getConfigMap(kubeClient, "foo-ns", "foo-name")
+			if err == nil {
+				t.Error("expected an error but getConfigMap call succeeded")
+			}
+		})
+	}
+}
+
+func newConfigMap(namespace, name string, data map[string]string) *configMap {
+	return &configMap{
+		Kind:       "ConfigMap",
+		APIVersion: "v1",
+		Metadata: configMapMetadata{
+			Name:      name,
+			Namespace: namespace,
+		},
+		Data: data,
+	}
+}
diff --git a/kubernetes/resource_test.go b/kubernetes/resource_test.go
new file mode 100644
index 0000000..44ff36a
--- /dev/null
+++ b/kubernetes/resource_test.go
@@ -0,0 +1,48 @@
+package kubernetes
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParseResourceLocation(t *testing.T) {
+	for _, test := range []struct {
+		name          string
+		s             string
+		expected      *ResourceLocation
+		expectedError string
+	}{
+		{
+			name:          "empty string",
+			expectedError: `invalid resource location, expected format "namespace/name" but got ""`,
+		},
+		{
+			name:          "missing namespace",
+			s:             "/foo-name",
+			expectedError: `invalid resource location, expected format "namespace/name" but got "/foo-name"`,
+		},
+		{
+			name:          "missing name",
+			s:             "foo-ns/",
+			expectedError: `invalid resource location, expected format "namespace/name" but got "foo-ns/"`,
+		},
+		{
+			name:     "valid resource location",
+			s:        "foo-ns/foo-name",
+			expected: &ResourceLocation{Namespace: "foo-ns", Name: "foo-name"},
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			result, err := ParseResourceLocation(test.s)
+			if test.expectedError != "" {
+				require.Error(t, err)
+				assert.Equal(t, test.expectedError, err.Error())
+			} else {
+				require.NoError(t, err)
+				assert.Equal(t, test.expected, result)
+			}
+		})
+	}
+}
diff --git a/kubernetes/testdata/fixture02.json b/kubernetes/testdata/fixture02.json
new file mode 100644
index 0000000..662baf9
--- /dev/null
+++ b/kubernetes/testdata/fixture02.json
@@ -0,0 +1,11 @@
+{
+  "kind": "ConfigMap",
+  "apiVersion": "v1",
+  "metadata": {
+    "name": "foo-name",
+    "namespace": "foo-ns"
+  },
+  "data": {
+    "some-key": "key1: val1\nkey2: val2\n"
+  }
+}
diff --git a/worker_test.go b/worker_test.go
index 36bb72e..ce85945 100644
--- a/worker_test.go
+++ b/worker_test.go
@@ -5,7 +5,9 @@ import (
 	"testing"
 	"time"
 
+	cloudformation "github.com/mweagle/go-cloudformation"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 	"github.com/zalando-incubator/kube-ingress-aws-controller/aws"
 	"github.com/zalando-incubator/kube-ingress-aws-controller/certs"
 	"github.com/zalando-incubator/kube-ingress-aws-controller/kubernetes"
@@ -287,3 +289,98 @@ func TestGetAllLoadBalancers(tt *testing.T) {
 		})
 	}
 }
+
+func TestGetCloudWatchAlarmConfigFromConfigMap(t *testing.T) {
+	for _, test := range []struct {
+		name     string
+		cm       *kubernetes.ConfigMap
+		expected aws.CloudWatchAlarmList
+	}{
+		{
+			name:     "empty config map",
+			cm:       &kubernetes.ConfigMap{},
+			expected: aws.CloudWatchAlarmList{},
+		},
+		{
+			name: "config map with one data key",
+			cm: &kubernetes.ConfigMap{
+				Data: map[string]string{
+					"some-key": "- AlarmName: foo\n- AlarmName: bar\n",
+				},
+			},
+			expected: aws.CloudWatchAlarmList{
+				{AlarmName: cloudformation.String("foo")},
+				{AlarmName: cloudformation.String("bar")},
+			},
+		},
+		{
+			name: "config map with multiple data keys",
+			cm: &kubernetes.ConfigMap{
+				Data: map[string]string{
+					"some-other-key": "- AlarmName: baz\n",
+					"some-key":       "- AlarmName: foo\n- AlarmName: bar\n",
+				},
+			},
+			expected: aws.CloudWatchAlarmList{
+				{AlarmName: cloudformation.String("foo")},
+				{AlarmName: cloudformation.String("bar")},
+				{AlarmName: cloudformation.String("baz")},
+			},
+		},
+		{
+			name: "config map with invalid yaml data",
+			cm: &kubernetes.ConfigMap{
+				Data: map[string]string{
+					"some-key": "{",
+				},
+			},
+			expected: aws.CloudWatchAlarmList{},
+		},
+		{
+			name: "config map with partially invalid yaml data",
+			cm: &kubernetes.ConfigMap{
+				Data: map[string]string{
+					"some-key":       "{",
+					"some-other-key": "- AlarmName: baz\n",
+				},
+			},
+			expected: aws.CloudWatchAlarmList{
+				{AlarmName: cloudformation.String("baz")},
+			},
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			result := getCloudWatchAlarmsFromConfigMap(test.cm)
+
+			assert.Equal(t, test.expected, result)
+		})
+	}
+}
+
+func TestAttachCloudWatchAlarmsCopy(t *testing.T) {
+	lbOne := &loadBalancer{scheme: "foo"}
+	lbTwo := &loadBalancer{scheme: "bar"}
+
+	lbs := []*loadBalancer{
+		lbOne,
+		lbTwo,
+	}
+
+	alarms := aws.CloudWatchAlarmList{
+		{AlarmName: cloudformation.String("baz")},
+	}
+
+	attachCloudWatchAlarms(lbs, alarms)
+
+	expected := []*loadBalancer{
+		{scheme: "foo", cwAlarms: alarms},
+		{scheme: "bar", cwAlarms: alarms},
+	}
+
+	require.Equal(t, expected, lbs)
+
+	// This should not modify the alarms of lbTwo.
+	lbOne.cwAlarms[0].AlarmName = cloudformation.String("qux")
+
+	assert.Equal(t, cloudformation.String("baz"), lbTwo.cwAlarms[0].AlarmName)
+}