File Coverage

blib/lib/Net/Amazon/R53.pm
Criterion Covered Total %
statement 7 9 77.7
branch n/a
condition n/a
subroutine 3 3 100.0
pod n/a
total 10 12 83.3


line stmt bran cond sub pod time code
1             #
2             # This file is part of Net-Amazon-R53
3             #
4             # This software is Copyright (c) 2012 by Campus Explorer, Inc.
5             #
6             # This is free software, licensed under:
7             #
8             # The GNU Lesser General Public License, Version 2.1, February 1999
9             #
10             package Net::Amazon::R53;
11             {
12             $Net::Amazon::R53::VERSION = '0.002'; # TRIAL
13             }
14              
15             # ABSTRACT: An interface to Amazon's Route53
16              
17 3     3   204834 use utf8;
  3         30  
  3         19  
18 3     3   121 use v5.10;
  3         11  
  3         125  
19              
20 3     3   1584 use Moose;
  0            
  0            
21             use namespace::autoclean;
22             use autobox::Core;
23             use MooseX::AlwaysCoerce;
24             use MooseX::AttributeShortcuts 0.017;
25             use MooseX::Params::Validate;
26             use MooseX::Types::Common::String ':all';
27             use MooseX::Types::Path::Class ':all';
28              
29             use Data::UUID;
30             use File::ShareDir::ProjectDistDir;
31             use HTTP::Request;
32             use List::AllUtils 'first';
33             use LWP::UserAgent::Determined;
34             use Template;
35             use XML::Simple;
36              
37             use aliased 'Net::Amazon::Signature::V3';
38              
39             # debugging...
40             #use Smart::Comments '###';
41              
42             with 'MooseX::RelatedClasses' => {
43             names => [ qw{
44             AtomicChange
45             ChangeInfo
46             HostedZone
47             ResourceRecordSet
48             ResourceRecordSet::Stub
49             ResourceRecordSet::Change
50             } ],
51             };
52              
53              
54             has $_ => (is => 'ro', required => 1, isa => NonEmptySimpleStr)
55             for qw{ id key };
56              
57              
58             has signer => (
59             is => 'lazy',
60             isa => 'Net::Amazon::Signature::V3',
61             builder => sub { V3->new(id => $_[0]->id, key => $_[0]->key) },
62             );
63              
64             has ua => (
65             is => 'lazy',
66             isa => 'LWP::UserAgent::Determined',
67             builder => sub { LWP::UserAgent::Determined->new },
68             );
69              
70             has endpoint_base => (
71             is => 'lazy',
72             isa => NonEmptySimpleStr,
73             builder => sub { 'https://route53.amazonaws.com/2012-02-29/' },
74             );
75              
76              
77             has hosted_zones_hash => (
78             traits => ['Hash'],
79             is => 'lazy',
80             isa => 'HashRef[Net::Amazon::R53::HostedZone]',
81             clearer => 1,
82             predicate => 'has_fetched_hosted_zones',
83             handles => {
84             # predicate not for the attribute, but content
85             has_hosted_zones => 'count',
86             has_hosted_zone => 'exists',
87             hosted_zone_ids => 'keys',
88             hosted_zones => 'values',
89             hosted_zone => 'get',
90             hosted_zone_by_id => 'get',
91             hosted_zones_count => 'count',
92             _add_hosted_zone => 'set',
93             _delete_hosted_zone => 'delete',
94             },
95             );
96              
97             sub _build_hosted_zones_hash {
98             my $self = shift @_;
99              
100             my $part = 'hostedzone?maxitems=100';
101             my $resp = $self->get_request($part, undef);
102              
103             ### parsing content: $resp->content
104             my $zones = XMLin(
105             $resp->content,
106             ForceArray => [ qw{ HostedZone Config } ],
107             GroupTags => { HostedZones => 'HostedZone' },
108             );
109              
110             ### $zones
111             my $hz_class = $self->hosted_zone_class;
112             my @zones =
113             map { $hz_class->new_from_raw_data($self, $_) }
114             $zones->{HostedZones}->flatten
115             ;
116              
117             return { map { $_->plain_id => $_ } @zones };
118             }
119              
120              
121             sub hosted_zone_by_caller_reference {
122             my ($self, $caller_ref) = @_;
123              
124             # implementation note: caller references are guaranteed unique by Amazon,
125             # so using first() here isn't going to have any weird side-effects.
126              
127             ### searching for: $caller_ref
128             my $hz =
129             first { $_->caller_reference eq $caller_ref }
130             $self->hosted_zones
131             ;
132              
133             return $hz;
134             }
135              
136              
137             # TODO move into HZ class?
138             sub get_resource_record_sets {
139             my ($self, $hz_id) = @_;
140              
141             ### fetching records for hostedzone: $hz_id
142             my $base_part = my $part = "hostedzone/$hz_id/rrset?maxitems=100";
143             my @unparsed_rrs;
144              
145             while (1) {
146              
147             ### getting rrs via: $part
148             my $resp = $self->get_request($part, undef);
149              
150             ### parsing content: $resp->content
151             my $rrs_set = XMLin(
152             $resp->content,
153             ForceArray => [ qw{ ResourceRecordSet ResourceRecord } ],
154             GroupTags => {
155             ResourceRecordSets => 'ResourceRecordSet',
156             ResourceRecords => 'ResourceRecord',
157             },
158             );
159              
160             # I can't _quite_ figure out how to get XML::Simple to do this for me
161             $rrs_set->{ResourceRecordSets} ||= [];
162             for my $rrs ($rrs_set->{ResourceRecordSets}->flatten) {
163              
164             next unless $rrs->{ResourceRecords};
165             my @values =
166             map { $_->{Value} }
167             $rrs->{ResourceRecords}->flatten;
168             $rrs->{ResourceRecords} = \@values;
169             }
170              
171             push @unparsed_rrs, $rrs_set->{ResourceRecordSets}->flatten;
172              
173             last unless $rrs_set->{IsTruncated} eq 'true';
174              
175             # prep and create our next part...
176             my $query = q{};
177             my $_val = sub { $rrs_set->{'NextRecord' . $_[0]->ucfirst} };
178              
179             do { $query .= "&$_=" . $_val->($_) if $_val->($_) }
180             for qw{ name type identifier };
181              
182             $part = $base_part . $query;
183             }
184              
185             my @rrs =
186             map { $self->resource_record_set_class->new_from_raw_data($self, $_) }
187             @unparsed_rrs
188             ;
189              
190             return \@rrs;
191             }
192              
193              
194             sub create_hosted_zone {
195             my $self = shift @_;
196              
197             my %opts = validated_hash(\@_,
198             name => { isa => 'Str' },
199             comment => { isa => 'Str', optional => 1 },
200             caller_reference => { isa => 'Str', optional => 1 },
201             );
202              
203             # MX::Params::Validate doesn't deal with dynamic defaults very well yet
204              
205             $opts{comment} //= 'Created ' . localtime;
206             $opts{caller_reference} //= Data::UUID->new->create_str;
207              
208             my $part = 'hostedzone';
209             my $tmpl = 'create_hosted_zone.tt';
210              
211             $self->tt->process('create_hosted_zone.tt', { %opts }, \(my $req_content));
212              
213             ### $req_content
214             my $resp = $self->post_request($part, $req_content);
215              
216             ### response: $resp->content
217             my $info = XMLin($resp->content, ForceArray => [ qw{ Config } ]);
218             ### $info
219             my $change = $self->change_info_class->new_from_raw_data($self, $info->{ChangeInfo});
220             my $hz = $self->hosted_zone_class->new_from_raw_data($self, $info->{HostedZone});
221             # TODO delegtion info
222              
223             $self->_add_hosted_zone($hz->plain_id => $hz)
224             if $self->has_fetched_hosted_zones;
225              
226             return wantarray ? ($hz, $change) : $hz;
227             }
228              
229              
230             sub copy_hosted_zone {
231             my ($self, $hz) = @_;
232              
233             #my $self = shift @_;
234             #my ($hz) = validated_list
235              
236             confess '$hz is not a HostedZone instance!'
237             unless blessed $hz && $hz->isa('Net::Amazon::R53::HostedZone');
238              
239             my $comment = 'Creating copy of zone ' . $hz->plain_id;
240              
241             my ($new_hz, $change) = $self->create_hosted_zone(
242             name => $hz->name,
243             comment => $comment,
244             );
245              
246             # if we haven't died yet, then the zone creation was queued/executed successfully
247              
248             my $copy_change = $new_hz->submit_resource_records_change_request(
249             comment => $comment,
250             multi_batch_ok => 1,
251             changes => [
252             map { { action => 'CREATE', record => $_ } }
253             grep { $_->type !~ /^(NS|SOA)$/ }
254             $hz->resource_record_sets->flatten
255             ],
256             );
257              
258             return $new_hz;
259             }
260              
261              
262             sub delete_hosted_zone {
263             my ($self, $hz_thing) = @_;
264              
265             my $path
266             = blessed $hz_thing ? $hz_thing->id
267             : $hz_thing =~ m!^/hostedzone/! ? $hz_thing
268             : "/hostedzone/$hz_thing"
269             ;
270              
271             my $resp = $self->delete_request($path, undef);
272              
273             # OK if we make it here w/o dying
274             $self->_delete_hosted_zone($path->split(qr!/!)->pop)
275             if $self->has_fetched_hosted_zones;
276              
277             # so here we do something a little different. We use the ChangeInfo data
278             # to construct our change object; this is the raw data that gets passed in
279             # via new_from_raw_data(), but we also pass 'raw_data => $x', where $x is
280             # the full set of returned data from Amazon.
281             #
282             # I could be easily convinced that some other approach is better.
283              
284             ### response: $resp->content
285             my $info = XMLin($resp->content, KeepRoot => 1);
286             return $self->change_info_class->new_from_raw_data(
287             $self,
288             $info->{DeleteHostedZoneResponse}->{ChangeInfo},
289             raw_data => $info,
290             );
291             }
292              
293              
294             has template_dir => (
295             is => 'lazy',
296             isa => Dir,
297             builder => sub { dist_dir 'Net-Amazon-R53' },
298             );
299              
300             has tt => (
301             is => 'lazy',
302             isa_class => 'Template',
303             builder => sub { Template->new(DEBUG => 1, INCLUDE_PATH => shift->template_dir) },
304             );
305              
306             # build a full url
307             sub _full_endpoint { shift->endpoint_base . shift }
308              
309              
310             sub get_request { shift->request(GET => @_) }
311             sub post_request { shift->request(POST => @_) }
312             sub delete_request { shift->request(DELETE => @_) }
313              
314             sub request {
315             my ($self, $method, $part, $content) = @_;
316              
317             # wtf lwp?
318             $content ||= undef;
319              
320             my $req = HTTP::Request->new(
321             $method,
322             $self->_full_endpoint($part),
323             [
324             $self->signer->signed_headers,
325             Host => 'route53.amazonaws.com',
326             ],
327             $content,
328             );
329              
330             ### request: $req->as_string
331             my $resp = $self->ua->request($req);
332              
333             ### request status: $resp->status_line
334             confess "Fail! " . $resp->content
335             if $resp->is_error;
336              
337             return $resp;
338             }
339              
340             __PACKAGE__->meta->make_immutable;
341             !!42;
342              
343             __END__
344              
345             =pod
346              
347             =encoding utf-8
348              
349             =for :stopwords Chris Weyl Campus Explorer, Inc AWS DNS
350              
351             =head1 NAME
352              
353             Net::Amazon::R53 - An interface to Amazon's Route53
354              
355             =head1 VERSION
356              
357             This document describes version 0.002 of Net::Amazon::R53 - released January 09, 2013 as part of Net-Amazon-R53.
358              
359             =head1 SYNOPSIS
360              
361             use Net::Amazon::R53;
362              
363             my $r53 = Net::Amazon::R53->new(id => $aws_id, key => $aws_key);
364              
365             $r53
366             ->get_hosted_zone('Z1345....')
367             ->purge
368             ->delete
369             ;
370              
371             # ...etc.
372              
373             =head1 DESCRIPTION
374              
375             This is an interface to Amazon's Route53 DNS service. It aims to be simple,
376             reliable, well tested, easily extensible, and capable of rescuing kittens from
377             volcanoes.
378              
379             Well, maybe not that last part.
380              
381             =head1 REQUIRED ATTRIBUTES
382              
383             These attributes are required, and must have their values supplied during object construction.
384              
385             =head2 id
386              
387             Your AWS id.
388              
389             =head2 key
390              
391             ...and the corresponding AWS secret key.
392              
393             =head1 LAZY ATTRIBUTES
394              
395             These attributes are lazily constructed from another source (e.g. required attributes, external source, a BUILD() method, or some combo thereof). You can set these values at construction time, though this is generally neither required nor recommended.
396              
397             =head2 signer
398              
399             The logic that authenticates your requests to Route53.
400              
401             =head1 ATTRIBUTES
402              
403             =head2 hosted_zones_hash
404              
405             Contains a list of all C<HostedZones> associated with this AWS key/id; lazily
406             built. Right now we fetch at most 100 records.
407              
408             =head2 template_dir
409              
410             The directory we expect to find our templates in.
411              
412             =head1 METHODS
413              
414             =head2 signed_headers
415              
416             Returns a list of headers (key/value pairs) suitable for direct inclusion in
417             the headers of a Route53 request.
418              
419             =head2 has_fetched_hosted_zones
420              
421             True if the C<hosted_zones> attribute is currently populated (that is, we've
422             fetched some at some point from Amazon.
423              
424             =head2 clear_hosted_zones
425              
426             Deletes our cached set of hosted zones, if we have any.
427              
428             =head2 has_hosted_zones
429              
430             True if we currently have any hosted zones.
431              
432             =head2 has_hosted_zone($plain_id)
433              
434             True if we have a zone with a plain id as passed to us.
435              
436             =head2 hosted_zone_ids
437              
438             Returns all of the hosted zone ids we know about.
439              
440             =head2 hosted_zones
441              
442             Returns a list of all known hosted zones; that is, a list of
443             L<Net::Amazon::R53::HostedZone> instances.
444              
445             =head2 hosted_zones_count
446              
447             Returns the number of hosted zones Route53 thinks we have.
448             =method hosted_zone_by_id($id)
449              
450             Looks for a hosted zone with the passed value as its id. Note that we're
451             talking about the so-called "plain" id, not the fully qualified one (e.g.
452             'Z12345', not '/hostedzone/Z12345').
453              
454             =head2 hosted_zone_by_caller_reference($caller_reference)
455              
456             Looks for a zone with the passed string as its caller reference. Returns
457             nothing if no such zone is found.
458              
459             =head2 get_resource_record_sets(<hosted zone id>)
460              
461             Given a hosted zone id, we fetch all its associated resource record sets.
462              
463             =head2 create_hosted_zone(name => ..., caller_reference => ..., comment => ...)
464              
465             Creates a hosted zone.
466              
467             C<name> is the domain name this zone holds records for, e.g. 'test.com.'.
468              
469             C<caller_reference> is some unique client-chosen (aka you) identifier.
470              
471             C<comment> is, well, the comment used for zone creation.
472              
473             Only the C<name> parameter is mandatory; suitable values will be generated for
474             the other options if they are omitted.
475              
476             Returns the new hosted zone object if called in scalar context; the change
477             and hosted zone objects if called in list context; that is:
478              
479             my ($hz, $change) = $r53->create_hosted_zone(...);
480             my $hz = $r53->create_hosted_zone(...);
481              
482             Dies on error. For more information, see the Route53 API and Developer's
483             Guide.
484              
485             =head2 copy_hosted_zone($hz)
486              
487             Given a hosted zone object, create a new hosted zone and copy the contents of
488             the given zone to the new zone.
489              
490             Returns the new hosted zone instance.
491              
492             =head2 delete_hosted_zone($hz_id | $hz)
493              
494             Delete a hosted zone, by its id; both the plain id (e.g. C<ZIQB30DSWGWG6>)
495             or the full one Amazon returns (e.g. C</hostedzone/ZIQB30DSWGWG6>) are
496             acceptable ids.
497              
498             We do not perform any validation. If the zone doesn't exist, or is not
499             pristine (contains any non-Amazon record sets), or anything else goes
500             sideways, we'll just die.
501              
502             =head2 request($method, $uri_part, $content)
503              
504             Make a request to Route53.
505              
506             =head2 get_request
507              
508             Same as request(), but as a GET.
509              
510             =head2 post_request
511              
512             Same as request(), but as a POST.
513              
514             =head2 delete_request
515              
516             Same as request(), but as a DELETE.
517              
518             =head1 SEE ALSO
519              
520             Please see those modules/websites for more information related to this module.
521              
522             =over 4
523              
524             =item *
525              
526             L<Amazon's docs and Route53 information, particularly:|Amazon's docs and Route53 information, particularly:>
527              
528             =item *
529              
530             L<http://docs.amazonwebservices.com/Route53/latest/DeveloperGuide/Welcome.html|http://docs.amazonwebservices.com/Route53/latest/DeveloperGuide/Welcome.html>
531              
532             =item *
533              
534             L<L<Net::Amazon::Route53> is a prior implementation of an older Route53 API.|L<Net::Amazon::Route53> is a prior implementation of an older Route53 API.>
535              
536             =back
537              
538             =head1 AUTHOR
539              
540             Chris Weyl <cweyl@campusexplorer.com>
541              
542             =head1 CONTRIBUTOR
543              
544             Chris Weyl <cweyl@alumni.drew.edu>
545              
546             =head1 COPYRIGHT AND LICENSE
547              
548             This software is Copyright (c) 2012 by Campus Explorer, Inc.
549              
550             This is free software, licensed under:
551              
552             The GNU Lesser General Public License, Version 2.1, February 1999
553              
554             =cut