Here's a quick background on the goal of this effort.
- AWS is organized into several regions, each totally separate from the others
- we want to be able to deploy software in any region
- in each region we will create EC2 instance to create other instances and deploy software from
- by default instances have an unfriendly public domain name e.g.
ec2-54-234-123-12.us-west-2.compute.amazonaws.com
- we want that instance to have an easy to remember domain name, e.g.
deploy.region.example.com
- we want that domain name to be assigned programatically from the instance itself
Understanding this journey will probably be clearest if we start with a peek at the end. Domain
names can be assigned via Route53, which has an API. We're writing all our stuff for node.js so
here's the Javascript code for the final request we want to make to Route53.
var AWS = require("aws-sdk");
AWS.config.update({
region: "us-west-2",
credentials: {
accessKeyId: "XXXXXXXXXXXXXXXXXXXX",
secretAccessKey: "XxXxxXxxx1xXxXXXxxXXX/XXXxx1XxxXx1XxXxXX",
sessionToken: "XXxXX ...400 more characters... XxxX='
}
});
AWS.Route53.client.changeResourceRecordSets({
"HostedZoneId": "/hostedzone/Z15D5CDO6LK4GG",
"ChangeBatch": {
"Changes": [
{
"Action": "CREATE",
"ResourceRecordSet": {
"Name": "deploy.us-west-2.example.com.",
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [
{
"Value": "ec2-54-234-123-12.us-west-2.compute.amazonaws.com"
}
]
}
}
]
}
});
Most of this is boiler plate, except for the region
and credentials
config values, and the HostedZoneId
,
Name
(our desired domain name), and Value
(the automatically
assigned domain name) as parameters to Route53.
To make this work, all we have to do is find a way for the instance to know each
of these things.
Item 1: the new domain name
This is going to be composed of 3 pieces: the fixed string "deploy",
the name of the region the instance is running in (will be "us-west-2"
in these examples), and our organization's domain, "example.com".
There are actually a lot of ways for EC2 instances to know their
region (some hackier than others) so I'll focus on the other two fields for
now. My first thought was "hey, can't you assign tags to EC2 instances
when you launch them? I'll just add a tag that says what domain to use."
This was easy to try out, and my tags show up nicely in the EC2 console,
but how is code running on the instance going to access the tags?
Digression 1: EC2 instance meta-data
My naive hope that tags were somehow magically present on the instance as environment
variables or in a config file was quickly dashed. However I knew that instances had access to
some meta-data about themselves via
making HTTP
requests to a magic IP address (169.254.169.254). "Oh cool," I thought, "tags will be in there somewhere."
Digression 1.1: meta-data paths
The data available through this API is
organized hierarchically, that is if you fetch a URL like /latest/dynamic/
it
returns a list of "sub-folders" like instance-identity/
, and you can
then ask for /latest/dynamic/instance-identity/
and so on.
The interesting bits are actually buried pretty deep, for example region
is inside a JSON document found at
/latest/dynamic/instance-identity/document
(you can find
the availability-zone in there too, but that's also available as a plain
string at /latest/meta-data/placement/availability-zone
).
There are command line tools
available that allow access to more commonly used values,
and a variety of existing
modules for node as well.
None of them seemed to include tags, and I wasn't sure where'd those might be,
so I thought "I'll write a module that starts at the root and spiders it's way down!".
Exactly how much code-that-sucks was involved in that effort will require another blog post,
but the result was a new module: ec2-instance-data.
Read the readme for details, but the basic idea is you get an object that will have all
of the instance's meta-data as nested objects, so you could then get the region via something like
var region = instance.latest.dynamic.instanceIdentity.document.region;
Ok, where were we? Oh, right, trying to get the value of an instances tags from
the meta-data. You can probably guess the next part: they're not in there. Well
how do you get them? Googling led me to this Stack Overflow
question
and from the answers I discovered there's an API for that:
DescribeTags.
Amazon recently released an "official" node module aws-sdk, which includes this API, so I thought,
hey cool I can use that. Except, as pointed out in some comments on Stack Overflow, you need credentials
to call that API.
Digression 2: IAM Roles for EC2 Instances
Hey, didn't I read something a while ago about automagically giving EC2 instances
permissions to call AWS APIs? Why yes I did,
it's called IAM Roles for EC2 Instances.
In short an IAM Role is a named group of permissions (a "policy" in AWS-speak), and you can specify one when
you create an instance. So I did the obvious, created a role with permissions to call DescribeTags (and the Route53 APIs), named
it "my-role" and assigned it to my instance. (Well I would have, except you can only assign roles when
you launch an instance, so I actually terminated my instance and started over with a new one.) Ok, goody,
now I can automagically call the DescribeTags API right? Um no, at least not with the AWS SDK for Node.
The AWS SDK for Ruby gets some IAM love however:
If the client constructor does not find credentials in AWS.config, or in the environment, it retrieves temporary
credentials that have the same permissions as those associated with the IAM role. The credentials are retrieved from the Instance Meta Data Service (IMDS).
Who has two thumbs and just wrote a client for the "IMDS" (
and no it's not called that anywhere else)?
This guy. Which led to the following code:
var instance = require("ec2-instance-data");
var AWS = require("aws-sdk");
instance.init(function (error, metadata) {
var credentials = new AWS.Credentials(metadata.iamSecurityCredentials());
AWS.config.update({ credentials: credentials, region: metadata.region() });
var ec2 = new AWS.EC2.Client();
ec2.describeTags({}, console.log);
});
Which produces a whole ton of output because it gets all the tags for every instance you have in the region,
not just the current instance's tags. To get just that you have to add a filter to describeTags() call. Which
leads me to a classic moment of "how-many-names-for-the-same-thing-can-you-work-into-a-single-function-call":
ec2.describeTags({
Filters: [ {
Name: "resource-id", // name 1
Values: [
data["latest"]["meta-data"]["instance-id"]] // name 2
}]
}, console.log);
>>>
{ Tags: [
{ ResourceId: 'i-528ed860', // name 3!!!
ResourceType: 'instance',
Key: 'Name',
Value: 'deploy-test' },
{ ResourceId: 'i-528ed860',
ResourceType: 'instance',
Key: 'HostedZone',
Value: 'example.com.' },
{ ResourceId: 'i-528ed860',
ResourceType: 'instance',
Key: 'purpose',
Value: 'deploy' } ] }
Which actually has the tags!, but a quick transform makes it saner:
ec2.describeTags(..., function (err, response) {
var tags = {};
response.Tags.forEach(function (tag) { tags[tag.Key] = tag.Value; });
console.log(JSON.stringify(tags, null, " "));
});
>>>
{
"Name": "deploy-test",
"HostedZone": "watchfrog.co.",
"purpose": "deploy"
}
Ok, making progress. Back to our domain name, we can now get
the "deploy" part from the "purpose" tag, the domain from the "HostedZone"
tag, and we've already pulled the region from the meta-data. Putting them all together yields
var ResourceRecordSetName = [tags.purpose, metadata.region()].concat(HostedZone.split('.')).join('.');
>>>
deploy.us-west-2.example.com.
Item 2: AWS Credentials
Actually already had to do this in order to call describeTags, but glossed over it a bit above.
The magic is in metadata.iamSecurityCredentials(), which is defined in ec2-instance-data.
The credentials can be found at /latest/meta-data/iam/security-credentials/my-role/
.
Except it turns out be not as simple as one would hope because the meta-data service actually sticks
the name of the role into the path but the instance doesn't know it's role. This means
we have to get it in two steps, first get all the roles, then using the name of the first one
(currently there is only one), then get the credentials for that role. Here's that method,
which also clocks in with 3 superfluous renamings.
// NOTE: maps meta-data names to the frustratingly similar ones expected by aws-sdk
self.iamSecurityCredentials = function () {
var role = Object.keys(deep_get(self, "/latest/meta-data/iam/security-credentials"))[0];
if (!role) return undefined;
var securityCredentials = deep_get(self, "/latest/meta-data/iam/security-credentials")[role];
return {
accessKeyId: securityCredentials.AccessKeyId,
secretAccessKey: securityCredentials.SecretAccessKey,
sessionToken: securityCredentials.Token
}
};
Item 3: HostedZoneId
While we now have the HostedZone (== domain) name, what we need for
our call to Route53 is the id. Fortunately Route53 let's us look that up
with AWS.Route53.client.listHostedZones(...)
, but for some inscrutable
reason it doesn't accept a filter, so we're left looping over the result.
AWS.Route53.listHostedZones({}, function (err, zones) {
var zone = null;
// find the zone matching our HostedZone tag
for (i = 0; i < zones.HostedZones.length; i++) {
if (zones.HostedZones[i].Name === tags.HostedZone) {
zone = zones.HostedZones[i];
break;
}
}
console.log(zone);
}
>>>
{ Id: '/hostedzone/Z1XXXXXXXXXXGG',
Name: 'watchfrog.co.',
CallerReference: '0A35A3DD-F107-E33C-89C9-D2F3D9967815',
Config: { Comment: 'example.com. domain' },
ResourceRecordSetCount: 11 }
Woot, there's our HostedZoneId.
Items 4 and 5: current domain name and region
The current domain name is readily available from the meta-data (/latest/meta-data/public-hostname
),
and we've already pulled the region from meta-data, so we're now good to go. Our final call looks like this
// note: credentials were already set for the call describe tags
AWS.Route53.client.changeResourceRecordSets({
"HostedZoneId": zone.Id
"ChangeBatch": {
"Changes": [
{
"Action": "CREATE",
"ResourceRecordSet": {
"Name": ResourceRecordSetName, // the new domain name we defined above
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [
{
"Value": metadata.latest["meta-data"]["public-hostname"];
}
]
}
}
]
}
});
Shortly after that command we can then log into our deploy server at deploy.us-west-2.example.com
!