Using Jython to Manage EC2 Resources: Part 3

In part 2, we started up an instance, created a volume, and attached it to the instance - all from the REPL. In this installment, we'll start building a library to aggregate these isolated functions into a complete implementation of our use case.

Remember, we're going to move faster now so the code samples will get larger and the REPL examples fewer and farther between.

We're going to be proactive and split off general functionality we can use elsewhere from our specific use case. Let's start on the general functionality. We essentially want a wrapper class for the portions of com.amazonaws.services.ec2.AmazonEC2Client that we'll be using regularly.

import com.amazonaws.services.ec2 as ec2
import com.amazonaws.auth.BasicAWSCredentials as cred                    
import com.amazonaws.ClientConfiguration as conf
import com.amazonaws.services.ec2.model as model
import pickle
"""
Copyright 2011 Jeremy Ulstad

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
   http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
   limitations under the License.
"""
class ec2Client:
    def __init__(self, accessKey, secureKey):
        self.accessKey = accessKey
        self.secureKey = secureKey
        self.conf = conf()
        self.setup()
        self.instance = None
        self.instanceId = None

    def setup(self):
        self.client=ec2.AmazonEC2Client(cred(self.accessKey,self.secureKey), self.conf)

    def runInstance(self, ami, securityGroup, keyName, instanceType='m1.large'):
        rir = self.client.runInstances((model.RunInstancesRequest(ami, 1,1)
                                        .withSecurityGroups([securityGroup])
                                        .withKeyName(keyName)
                                        .withInstanceType(instanceType)
                                        .withInstanceInitiatedShutdownBehavior('stop')))
        self.instance = rir.getReservation().getInstances()[0]
        self.instanceId = self.instance.getInstanceId()

    def instanceState(self):
        if self.instance: return self.instance.getState().getName()
        return None

    def createImage(self, name, desc=None):
        cir = self.client.createImage(model.CreateImageRequest()
                                         .withInstanceId(self.instanceId)
                                         .withName(name)
                                         .withDescription(desc))
        return cir.getImageId()

    def describeInstances(self):
        instances = []
        for r in self.client.describeInstances().getReservations():
            for i in r.getInstances():
                instances.append(i)
        return instances

    def runningInstances(self):
        runners = []
        for i in self.describeInstances():
            if i.getState().getName() == 'running': runners.append(i)
        return runners

    def describeVolumes(self, vols=[]):
        return self.client.describeVolumes(model.DescribeVolumesRequest(vols)).getVolumes()

    def createVolume(self, sizeGB, zone):
        return self.client.createVolume(model.CreateVolumeRequest(sizeGB, zone))

    def attachVolume(self, vol, inst, device):
        return self.client.attachVolume(model.AttachVolumeRequest(vol, inst, device))

    def detachVolume(self, vol):
        return self.client.detachVolume(model.DetachVolumeRequest(vol))

    def deleteVolume(self, vol):
        self.client.deleteVolume(model.DeleteVolumeRequest(vol))

    def terminateInstance(self):
        self.client.terminateInstances(model.TerminateInstancesRequest([self.instanceId]))

    # based on https://forums.aws.amazon.com/message.jspa?messageID=180016
    def toggleVolumePersistence(self, i, v=False):
        "set deleteOnTermination to v (default: False)"
        ebsVols = [ b.getEbs() for b in i.getBlockDeviceMappings() ]
        for e in ebsVols:
            if e.isDeleteOnTermination():
                # note use of parens to allow multi-line method chaining
                # inspired by http://stackoverflow.com/questions/4546694/method-chaining-how-many-chained-methods-are-enough
                spec = (model.EbsInstanceBlockDeviceSpecification()
                        .withDeleteOnTermination(v)
                        .withVolumeId(e.getVolumeId()))
                ispec = (model.InstanceBlockDeviceMappingSpecification()
                         .withDeviceName(b.getDeviceName()).withEbs(spec))
                mreq = (model.ModifyInstanceAttributeRequest()
                        .withAttribute('blockDeviceMapping')
                        .withBlockDeviceMappings(ispec)
                        .withInstanceId(i.getInstanceId()))
                self.client.modifyInstanceAttribute(mreq)

Now we have neatly encapsulated everything we have done in previous installments into one class. We can start an instance, create and attach volumes. Nice and general. There's also some extra goodies in there we didn't do from the REPL previously that can come in handy. Finding those can be an exercise for you.

Now let's get specific and create a subclass which will implement our specific use case. To recapitulate: we want to start an instance with six EBS volumes and attach them to the instance automatically.

class ec2Datanode(ec2Client):
    def __init__(self, name, proxy=None, proxyPort=80):
        self.name = name
        self.volumes = []
        self.volumeIds = []
        self.ami = 'ami-e4a3578d'
        self.securityGroup = 'datanode'
        self.keyName = 'redacted'
        self.sizeGB = 1
        self.zone = 'us-east-1b'
        self.instanceType='m2.2xlarge'
        self.proxy = proxy
        self.proxyPort = proxyPort
        self.accessKey='redacted'
        self.secureKey='redacted'
        ec2Client.__init__(self, self.accessKey, self.secureKey)

    def setup(self):
        if self.proxy:
            self.conf = (conf()
                         .withProxyHost(self.proxy)
                         .withProxyPort(self.proxyPort))
        ec2Client.setup(self)

    def runInstance(self):
        ec2Client.runInstance(self, self.ami, self.securityGroup, self.keyName)   
        self.instance.setTags([model.Tag('Name', self.name)])

    def createVolume(self):
        return ec2Client.createVolume(self, self.sizeGB, self.zone)

    def createVolset(self, count=6):
        for i in range(0,count):
            vrsp = self.createVolume()
            vol = vrsp.getVolume()
            self.volumes.append(vol)
            self.volumeIds.append(vol.getVolumeId())
            nametag = '%s vol%s' % (self.name, i)
            vol.setTags([model.Tag('Name', nametag)])

    def attachVolset(self):
        pairs = zip(self.volumeIds, ['f','g','h','i','j','k'])
        for k,v in pairs:
            self.attachVolume(k, self.instanceId, '/dev/sd%s' % v)

    def detachVolset(self):
        for v in self.volumeIds:
            self.detachVolume(v)

    def pickle(self):
        "pickle state of object in case we need to restart the instance after a shutdown"
        filename = '%s-configuration' % self.name
        f = open(filename, 'w')
        pickle.dump({'name':self.name,
                     'ip':self.privateIP,
                     'volumeIds':self.volumeIds}, f)
        f.close()

    def unpickle(self):
        "restore state of instance with volume mappings"
        filename = '%s-configuration' % self.name
        f = open(filename, 'r')
        d = pickle.load(f)
        f.close()
        for k,v in d.items():
            setattr(self, k, v)

You'll notice that we use instance variables to encapsulate all the arguments (availability zone, instance type, etc.) that we need to pass to the general class. Note especially the sizeGB at line 9: when you're getting your code working it's a good idea to use small volumes/instances to minimize the charges you will incur in the process of getting a full working system. I spun up about 30 volumes before I got it right. You don't want to be using a terabyte volume there.

We also code up our specific use cases for the volume creation, attachment, etc. You'll also note the pickle method at the end. EBS volume names are not particularly legible, so if we ever shutdown our instance and want to restart it and reattach the volumes it's nice to be able to unpickle the grouping of them so they can be reattached in the right place. Ideally the tags (lines 38-39) would help with that, but while they can be seen by calling getTags() on the volume for some reason they aren't visible in the AWS console.

Now let's try out our use case.

>>> import aws
>>> d = aws.ec2Datanode('testnode')
>>> d.runInstance()
INFO: Received successful response: 200, AWS Request ID: baaee963-4a02-4ccd-b0ae-03a5bbb84e4e
>>> d.instanceState()
u'pending'
>>> d.createVolset()
INFO: Received successful response: 200, AWS Request ID: 0dcc7744-504f-4929-a83a-61d3b198ab3f
INFO: Received successful response: 200, AWS Request ID: af71b717-f85f-46c5-8681-838a6b6bfb53
INFO: Received successful response: 200, AWS Request ID: 8e7fcb56-b023-4f94-9cb3-c718f0e9f2fe
INFO: Received successful response: 200, AWS Request ID: 43bfbb7c-4a81-4e25-adfe-06c1cb279436
INFO: Received successful response: 200, AWS Request ID: 885b065d-6253-4a80-8cca-943df6961c20
INFO: Received successful response: 200, AWS Request ID: 38043789-f55e-4f2f-a432-1fbc845f629f
>>> d.attachVolset()
INFO: Received error response: Status Code: 400, 
AWS Request ID: 2b9a2e92-4f9e-4648-aa9d-8fa0f42b9ca4, 
AWS Error Code: InvalidVolume.ZoneMismatch, 
AWS Error Message: The volume 'vol-d876eeb0' is not in the same 
availability zone as instance 'i-17a7c27b'
>>> d.zone
'us-east-1b'
>>> d.zone='us-east-1c'
>>> d.volumes = []
>>> d.volumeIds = []
>>> d.createVolset()
Feb 6, 2011 3:21:26 PM com.amazonaws.http.HttpClient execute
INFO: Received successful response: 200, AWS Request ID: 07e17565-2871-4525-80f9-6fee8add6c32
INFO: Received successful response: 200, AWS Request ID: bf3fdf64-c3da-41b7-9bad-c74f57d93834
INFO: Received successful response: 200, AWS Request ID: 6bdcd7ec-af7f-4a26-a32a-4de92c831654
INFO: Received successful response: 200, AWS Request ID: 9ec38e11-228a-4fb6-963e-c1e30357c1c7
INFO: Received successful response: 200, AWS Request ID: 4e4f05a8-8860-4d8b-a12c-da3cfe058e0a
INFO: Received successful response: 200, AWS Request ID: a45eaba9-5212-4cd2-8861-42cff49c2c85
>>> d.attachVolset()
Feb 6, 2011 3:21:33 PM com.amazonaws.http.HttpClient execute
INFO: Received successful response: 200, AWS Request ID: a5660cbe-2af5-41e6-9fce-b3cbc55e4e91
INFO: Received successful response: 200, AWS Request ID: 684b9c2d-9145-4601-9615-d7375d9fabf6
INFO: Received successful response: 200, AWS Request ID: 7ba9818d-9fda-4e2e-a448-522150288fae
INFO: Received successful response: 200, AWS Request ID: 258e5530-36a6-4641-8f8d-fe24fa03535a
INFO: Received successful response: 200, AWS Request ID: fbcb1f93-50dc-408b-95a3-71a31416e616
INFO: Received successful response: 200, AWS Request ID: fed78d01-2ca5-4621-b5ba-40a395127255
>>> d.instance.getBlockDeviceMappings()
[]
>>> d.instanceId
u'i-17a7c27b'
>>> d.pickle()

Mission accomplished, but not without some caveats. We specified the availability zone for our volumes, but not our instance - see the error at line 18. Oops. The nice thing about writing this up is that my oversight gives me a chance to show how easy it is to clean things up when using the REPL. Simply change the zone, reset the volume state (lines 22-24) and we're back in business. I cleaned up the volumes in the wrong zone using the console.

You'll also notice that when we checked the blockDeviceMapping to see if our attachment succeeded, it showed an empty array. That's a caching artifact, which also bit us at line 6 if you were paying attention. The instance was actually running, or our volume attachment would have failed. Just to prove it worked, we check the shell.

julstad$ ec2-describe-instances i-17a7c27b
RESERVATION     r-4635f02b      923332273044    web
INSTANCE        i-17a7c27b      ami-e4a3578d    ec2-50-16-169-181.compute-1.amazonaws.com       ip-10-116-161-3.ec2.internal        running redacted  0               m1.large        2011-02-06T21:18:55+0000        us-east-1c      aki-427d952b                        monitoring-disabled     50.16.169.181   10.116.161.3                    ebs        paravirtual
BLOCKDEVICE     /dev/sda1       vol-e876ee80    2011-02-06T21:19:04.000Z
BLOCKDEVICE     /dev/sdf        vol-5477ef3c    2011-02-06T21:21:28.000Z
BLOCKDEVICE     /dev/sdg        vol-5677ef3e    2011-02-06T21:21:28.000Z
BLOCKDEVICE     /dev/sdh        vol-2877ef40    2011-02-06T21:21:29.000Z
BLOCKDEVICE     /dev/sdi        vol-2a77ef42    2011-02-06T21:21:29.000Z
BLOCKDEVICE     /dev/sdj        vol-2c77ef44    2011-02-06T21:21:29.000Z
BLOCKDEVICE     /dev/sdk        vol-2e77ef46    2011-02-06T21:21:29.000Z

Yep - they're there. The next iteration of this code will need to specify the availability zone for the instance as well as call back to EC2 to refresh the instance state. It would also be nice to have it poll until the state changes to running and create and/or attach the volumes at that point. Even better would be using a Java SSH client to mount them on the host automatically after attachment. That will remain a future exercise.

Hopefully you get the idea from this series how handy jython and the REPL can be when working with java API's as well as the gist of using the EC2 API.

Jeremy Ulstad

Dad, IT Architect, Musician, Sailor

Minneapolis, Minnesota http://jeremyulstad.com