Sunday, March 31, 2019

AWS AutoScalingGroup to Route53 update record function via Lambda

Sometimes you want to have an Auto Scaling Group keep a single server online and you don't want to worry about connecting EIP's to them or have them kept as a Pet which needs to be kept at all costs.

Or you need to allow UDP access which the Elastic Load Balancers (ELB) and Network Load Balancers (NLB) don't allow. This is for you.

What this does is listen to a Simple Notification Service (SNS) to any published events which the ASG would send for adding an instance to the pool or terminating an instance to the pool. It then queries the asg looking for the tag DomainMeta and then with the list of ec2 instances it goes and collects the public ip address and goes to the route53 zone that is recorded and updates the domain attached.

The tag should be in the format DomainMeta: : I.E DomainMeta : Z10MWC8V7JDDX1:www.mydomain.com
Where the first part is the hosted zone it needs to end the command to and the second part is the a record it is going to change.


This is based on the work that objectpartners.com did back in 2005. I've improved it to include security so that only one hosted zone is looked after or allows full account control if you are 100% in control of the tags on the ASG's.

This could easily be updated to include a coma delimited list on the tag to update multiple a records if required.

Please note: If the last instance is taken out of the pool the old ip address will be left since it route53 records can't be null/empty.


---
AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Description: AutoScaling Group to Route 53 record update
#ensure you have the Tag DomainMeta set which a value of <HostedZoneId>:<Domain> on the ASG i.e. DomainMeta: Z10MWC8V7JDDU1:www.mydomain.com
Parameters:
Service:
Type: String
Default: 'asgToRoute53'
Description: Service name for this product
HostedZone:
Type: AWS::Route53::HostedZone::Id
Description: This is the hosted zone which this lambda function can edit, unless allHostedZone is true i.e. arn:aws:route53:::hostedzone/${HostedZone}
AllowAllHostedZoneControl:
Type: String
Default: "false"
AllowedValues :
- "true"
- "false"
Description: If true, then HostedZone is not used and wild card will be set for iam policy
Conditions:
hasRestrictedRoute53Control:
!Equals [ !Ref AllowAllHostedZoneControl, 'false' ]
hasWildCardRoute53Control:
!Equals [ !Ref AllowAllHostedZoneControl, 'true' ]
Resources:
ASGtoRoute53UpdateSNSTopic:
Type: AWS::SNS::Topic
Properties:
DisplayName: ASGtoRoute53Update
LambdaASGtoRoute53Update:
Type: 'AWS::Serverless::Function'
DependsOn:
- LambdaASGtoRoute53UpdateRole
Properties:
#FunctionName: LambdaASGtoRoute53Update
InlineCode: |
var AWS=require("aws-sdk"),nextTick=function(e){"function"==typeof setImmediate?setImmediate(e):"undefined"!=typeof process&&process.nextTick?process.nextTick(e):setTimeout(e,0)},makeIterator=function(e){var n=function(o){var t=function(){return e.length&&e[o].apply(null,arguments),t.next()};return t.next=function(){return o<e.length-1?n(o+1):null},t};return n(0)},_isArray=Array.isArray||function(e){return"[object Array]"===Object.prototype.toString.call(e)},waterfall=function(e,n){if(n=n||function(){},!_isArray(e)){var o=new Error("First argument to waterfall must be an array of functions");return n(o)}if(!e.length)return n();var t=function(e){return function(o){if(o)n.apply(null,arguments),n=function(){};else{var r=Array.prototype.slice.call(arguments,1),s=e.next();s?r.push(t(s)):r.push(n),nextTick(function(){e.apply(null,r)})}}};t(makeIterator(e))()};
exports.handler=function(e,n){console.log(e);var o=JSON.parse(e.Records[0].Sns.Message),t=o.AutoScalingGroupName,r=(o.EC2InstanceId,o.Event);if(console.log(r),"autoscaling:EC2_INSTANCE_LAUNCH"===r||"autoscaling:EC2_INSTANCE_TERMINATE"===r){console.log("Handling Launch/Terminate Event for "+t);var s=process.env.AWS_DEFAULT_REGION;console.log(s);var a=new AWS.AutoScaling({region:s}),c=new AWS.EC2({region:s}),l=new AWS.Route53;
waterfall([function(e){console.log("Describing ASG Tags"),a.describeTags({Filters:[{Name:"auto-scaling-group",Values:[t]},{Name:"key",Values:["DomainMeta"]}],MaxRecords:1},e)},
function(e,n){console.log("Processing ASG Tags"),console.log(e.Tags),0==e.Tags.length&&n("ASG: "+t+" does not define Route53 DomainMeta tag.");var o=e.Tags[0].Value.split(":"),r={HostedZoneId:o[0],RecordName:o[1]};console.log(r),n(null,r)},
function(e,n){console.log("Retrieving Instances in ASG"),a.describeAutoScalingGroups({AutoScalingGroupNames:[t],MaxRecords:1},function(o,t){n(o,e,t)})},
function(e,n,o){console.log(n.AutoScalingGroups[0]);var t=n.AutoScalingGroups[0].Instances.map(function(e){return e.InstanceId});c.describeInstances({DryRun:!1,InstanceIds:t},
function(n,t){o(n,e,t)})},function(e,n,o){console.log(n.Reservations);var t=n.Reservations.map(function(e){return{Value:e.Instances[0].NetworkInterfaces[0].Association.PublicIp}});console.log(t),l.changeResourceRecordSets({ChangeBatch:{Changes:[{Action:"UPSERT",ResourceRecordSet:{Name:e.RecordName,Type:"A",TTL:10,ResourceRecords:t}}]},HostedZoneId:e.HostedZoneId},o)}],
function(e){e?console.error("Failed to process DNS updates for ASG event: ",e):console.log("Successfully processed DNS updates for ASG event."),n.done(e)})}else console.log("Unsupported ASG event: "+t,r),n.done("Unsupported ASG event: "+t,r)};
Description: !Sub 'Asg to Route53 record domain update'
Handler: index.handler
MemorySize: 128
Role: !GetAtt LambdaASGtoRoute53UpdateRole.Arn
Runtime: nodejs8.10
Timeout: 20
Events:
SNS1:
Type: SNS
Properties:
Topic:
Ref: ASGtoRoute53UpdateSNSTopic
# Has either AsgToRoute53EditPolicy attached for route53 edit rights
LambdaASGtoRoute53UpdateRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service: [lambda.amazonaws.com]
Action:
- sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
- arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess
#is run when restricted access is set
AsgToRoute53EditPolicy:
Condition: hasRestrictedRoute53Control
DependsOn: LambdaASGtoRoute53UpdateRole
Type: AWS::IAM::Policy
Properties:
PolicyName: "AsgToRoute53EditPolicy"
Roles:
-
!Ref LambdaASGtoRoute53UpdateRole
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- route53:ListHostedZones
- route53:GetChange
Resource:
- "*"
- Effect: Allow
Action:
- route53:ChangeResourceRecordSets
Resource:
- !Sub "arn:aws:route53:::hostedzone/${HostedZone}"
#is run when allowed to change all route53 in this account
AsgToRoute53EditPolicy:
Condition: hasWildCardRoute53Control
DependsOn: LambdaASGtoRoute53UpdateRole
Type: AWS::IAM::Policy
Properties:
PolicyName: "AsgToRoute53EditPolicy"
Roles:
-
!Ref LambdaASGtoRoute53UpdateRole
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- route53:ListHostedZones
- route53:GetChange
Resource:
- "*"
- Effect: Allow
Action:
- route53:ChangeResourceRecordSets
Resource:
- !Sub "arn:aws:route53:::hostedzone/*"
Outputs:
ASGtoRoute53UpdateSNSTopic:
Description: ARN of newly created SNS Topic
Value:
Ref: ASGtoRoute53UpdateSNSTopic
Export:
Name: !Sub "${Service}-sns"
QueueName:
Description: Name of newly created SNS Topic
Value:
Fn::GetAtt:
- ASGtoRoute53UpdateSNSTopic
- TopicName
//# ==================================================================================================
//# Function: Asg to Route53 record domain update
//# Purpose: Update domain with new list of public ip addresses of the asg
//# ==================================================================================================
var AWS=require("aws-sdk");
// MIT license (by Elan Shanker).
//https://objectpartners.com/2015/07/07/aws-tricks-updating-route53-dns-for-autoscalinggroup-using-lambda/
// with https://github.com/es128/async-waterfall/blob/master/index.js
'use strict';
var nextTick = function (fn) {
if (typeof setImmediate === 'function') {
setImmediate(fn);
} else if (typeof process !== 'undefined' && process.nextTick) {
process.nextTick(fn);
} else {
setTimeout(fn, 0);
}
};
var makeIterator = function (tasks) {
var makeCallback = function (index) {
var fn = function () {
if (tasks.length) {
tasks[index].apply(null, arguments);
}
return fn.next();
};
fn.next = function () {
return (index < tasks.length - 1) ? makeCallback(index + 1): null;
};
return fn;
};
return makeCallback(0);
};
var _isArray = Array.isArray || function(maybeArray){
return Object.prototype.toString.call(maybeArray) === '[object Array]';
};
var waterfall = function (tasks, callback) {
callback = callback || function () {};
if (!_isArray(tasks)) {
var err = new Error('First argument to waterfall must be an array of functions');
return callback(err);
}
if (!tasks.length) {
return callback();
}
var wrapIterator = function (iterator) {
return function (err) {
if (err) {
callback.apply(null, arguments);
callback = function () {};
} else {
var args = Array.prototype.slice.call(arguments, 1);
var next = iterator.next();
if (next) {
args.push(wrapIterator(next));
} else {
args.push(callback);
}
nextTick(function () {
iterator.apply(null, args);
});
}
};
};
wrapIterator(makeIterator(tasks))();
};
exports.handler = function (event, context) {
console.log(event);
var asg_msg = JSON.parse(event.Records[0].Sns.Message);
var asg_name = asg_msg.AutoScalingGroupName;
var instance_id = asg_msg.EC2InstanceId;
var asg_event = asg_msg.Event;
console.log(asg_event);
if (asg_event === "autoscaling:EC2_INSTANCE_LAUNCH" || asg_event === "autoscaling:EC2_INSTANCE_TERMINATE") {
console.log("Handling Launch/Terminate Event for " + asg_name);
var region = process.env.AWS_DEFAULT_REGION
console.log(region)
var autoscaling = new AWS.AutoScaling({region: region}); // ${AWS::Region}
var ec2 = new AWS.EC2({region: region}); // ${AWS::Region}
var route53 = new AWS.Route53();
waterfall([
function describeTags(next) {
console.log("Describing ASG Tags");
autoscaling.describeTags({
Filters: [
{
Name: "auto-scaling-group",
Values: [
asg_name
]
},
{
Name: "key",
Values: ['DomainMeta']
}
],
MaxRecords: 1
}, next);
},
function processTags(response, next) {
console.log("Processing ASG Tags");
console.log(response.Tags);
if (response.Tags.length == 0) {
next("ASG: " + asg_name + " does not define Route53 DomainMeta tag.");
}
var tokens = response.Tags[0].Value.split(':');
var route53Tags = {
HostedZoneId: tokens[0],
RecordName: tokens[1]
};
console.log(route53Tags);
next(null, route53Tags);
},
function retrieveASGInstances(route53Tags, next) {
console.log("Retrieving Instances in ASG");
autoscaling.describeAutoScalingGroups({
AutoScalingGroupNames: [asg_name],
MaxRecords: 1
}, function(err, data) {
next(err, route53Tags, data);
});
},
function retrieveInstanceIds(route53Tags, asgResponse, next) {
console.log(asgResponse.AutoScalingGroups[0]);
var instance_ids = asgResponse.AutoScalingGroups[0].Instances.map(function(instance) {
return instance.InstanceId
});
ec2.describeInstances({
DryRun: false,
InstanceIds: instance_ids
}, function(err, data) {
next(err, route53Tags, data);
});
},
function updateDNS(route53Tags, ec2Response, next) {
console.log(ec2Response.Reservations);
var resource_records = ec2Response.Reservations.map(function(reservation) {
return {
Value: reservation.Instances[0].NetworkInterfaces[0].Association.PublicIp
};
});
console.log(resource_records);
route53.changeResourceRecordSets({
ChangeBatch: {
Changes: [
{
Action: 'UPSERT',
ResourceRecordSet: {
Name: route53Tags.RecordName,
Type: 'A',
TTL: 10,
ResourceRecords: resource_records
}
}
]
},
HostedZoneId: route53Tags.HostedZoneId
}, next);
}
], function (err) {
if (err) {
console.error('Failed to process DNS updates for ASG event: ', err);
} else {
console.log("Successfully processed DNS updates for ASG event.");
}
context.done(err);
})
} else {
console.log("Unsupported ASG event: " + asg_name, asg_event);
context.done("Unsupported ASG event: " + asg_name, asg_event);
}
};