1#
2# (c) Ferenc Erki <erkiferenc@gmail.com>
3#
4# vim: set ts=2 sw=2 tw=0:
5# vim: set expandtab:
6
7package Rex::Cloud::OpenStack;
8
9use 5.010001;
10use strict;
11use warnings;
12
13our $VERSION = '1.13.4'; # VERSION
14
15use Rex::Logger;
16
17use base 'Rex::Cloud::Base';
18
19BEGIN {
20  use Rex::Require;
21  JSON::MaybeXS->use;
22  HTTP::Request::Common->use(qw(:DEFAULT DELETE));
23  LWP::UserAgent->use;
24}
25use Data::Dumper;
26use Carp;
27use MIME::Base64 qw(decode_base64);
28use Digest::MD5 qw(md5_hex);
29use File::Basename;
30
31sub new {
32  my $that  = shift;
33  my $proto = ref($that) || $that;
34  my $self  = {@_};
35
36  bless( $self, $proto );
37
38  $self->{_agent} = LWP::UserAgent->new;
39  $self->{_agent}->env_proxy;
40
41  return $self;
42}
43
44sub set_auth {
45  my ( $self, %auth ) = @_;
46
47  $self->{auth} = \%auth;
48}
49
50sub _request {
51  my ( $self, $method, $url, %params ) = @_;
52  my $response;
53
54  Rex::Logger::debug("Sending request to $url");
55  Rex::Logger::debug("  $_ => $params{$_}") for keys %params;
56
57  {
58    no strict 'refs';
59    $response = $self->{_agent}->request( $method->( $url, %params ) );
60  }
61
62  Rex::Logger::debug( Dumper($response) );
63
64  if ( $response->is_error ) {
65    Rex::Logger::info( 'Response indicates an error', 'warn' );
66    Rex::Logger::debug( $response->content );
67  }
68
69  return decode_json( $response->content ) if $response->content;
70}
71
72sub _authenticate {
73  my $self = shift;
74
75  my $auth_data = {
76    auth => {
77      tenantName          => $self->{auth}{tenant_name} || '',
78      passwordCredentials => {
79        username => $self->{auth}{username},
80        password => $self->{auth}{password},
81      }
82    }
83  };
84
85  my $content = $self->_request(
86    POST         => $self->{__endpoint} . '/tokens',
87    content_type => 'application/json',
88    content      => encode_json($auth_data),
89  );
90
91  $self->{auth}{tokenId} = $content->{access}{token}{id};
92
93  $self->{_agent}->default_header( 'X-Auth-Token' => $self->{auth}{tokenId} );
94
95  $self->{_catalog} = $content->{access}{serviceCatalog};
96}
97
98sub get_nova_url {
99  my $self = shift;
100
101  $self->_authenticate unless $self->{auth}{tokenId};
102
103  my @nova_services =
104    grep { $_->{type} eq 'compute' } @{ $self->{_catalog} };
105
106  return $nova_services[0]{endpoints}[0]{publicURL};
107}
108
109sub get_cinder_url {
110  my $self = shift;
111
112  $self->_authenticate unless $self->{auth}{tokenId};
113
114  my @cinder_services =
115    grep { $_->{type} eq 'volume' } @{ $self->{_catalog} };
116  return $cinder_services[0]{endpoints}[0]{publicURL};
117}
118
119sub run_instance {
120  my ( $self, %data ) = @_;
121  my $nova_url = $self->get_nova_url;
122
123  Rex::Logger::debug('Trying to start a new instance with data:');
124  Rex::Logger::debug("  $_ => $data{$_}") for keys %data;
125
126  my $request_data = {
127    server => {
128      flavorRef => $data{plan_id},
129      imageRef  => $data{image_id},
130      name      => $data{name},
131      key_name  => $data{key},
132    }
133  };
134
135  my $content = $self->_request(
136    POST         => $nova_url . '/servers',
137    content_type => 'application/json',
138    content      => encode_json($request_data),
139  );
140
141  my $id = $content->{server}{id};
142  my $info;
143
144  until ( ($info) = grep { $_->{id} eq $id } $self->list_running_instances ) {
145    Rex::Logger::debug('Waiting for instance to be created...');
146    sleep 1;
147  }
148
149  if ( exists $data{volume} ) {
150    $self->attach_volume(
151      instance_id => $id,
152      volume_id   => $data{volume},
153    );
154  }
155
156  if ( exists $data{floating_ip} ) {
157    $self->associate_floating_ip(
158      instance_id => $id,
159      floating_ip => $data{floating_ip},
160    );
161
162    ($info) = grep { $_->{id} eq $id } $self->list_running_instances;
163  }
164
165  return $info;
166}
167
168sub terminate_instance {
169  my ( $self, %data ) = @_;
170  my $nova_url = $self->get_nova_url;
171
172  Rex::Logger::debug("Terminating instance $data{instance_id}");
173
174  $self->_request( DELETE => $nova_url . '/servers/' . $data{instance_id} );
175
176  until ( !grep { $_->{id} eq $data{instance_id} }
177      $self->list_running_instances )
178  {
179    Rex::Logger::debug('Waiting for instance to be deleted...');
180    sleep 1;
181  }
182}
183
184sub list_instances {
185  my $self    = shift;
186  my %options = @_;
187
188  $options{private_network} ||= "private";
189  $options{public_network}  ||= "public";
190  $options{public_ip_type}  ||= "floating";
191  $options{private_ip_type} ||= "fixed";
192
193  my $nova_url = $self->get_nova_url;
194  my @instances;
195
196  my $content = $self->_request( GET => $nova_url . '/servers/detail' );
197
198  for my $instance ( @{ $content->{servers} } ) {
199    my %networks;
200    for my $net ( keys %{ $instance->{addresses} } ) {
201      for my $ip_conf ( @{ $instance->{addresses}->{$net} } ) {
202        push @{ $networks{$net} },
203          {
204          mac  => $ip_conf->{'OS-EXT-IPS-MAC:mac_addr'},
205          ip   => $ip_conf->{addr},
206          type => $ip_conf->{'OS-EXT-IPS:type'},
207          };
208      }
209    }
210
211    push @instances, {
212      ip => (
213        [
214          map {
215                $_->{"OS-EXT-IPS:type"} eq $options{public_ip_type}
216              ? $_->{'addr'}
217              : ()
218          } @{ $instance->{addresses}{ $options{public_network} } }
219        ]->[0]
220          || undef
221      ),
222      id           => $instance->{id},
223      architecture => undef,
224      type         => $instance->{flavor}{id},
225      dns_name     => undef,
226      state   => ( $instance->{status} eq 'ACTIVE' ? 'running' : 'stopped' ),
227      __state => $instance->{status},
228      launch_time => $instance->{'OS-SRV-USG:launched_at'},
229      name        => $instance->{name},
230      private_ip  => (
231        [
232          map {
233                $_->{"OS-EXT-IPS:type"} eq $options{private_ip_type}
234              ? $_->{'addr'}
235              : ()
236          } @{ $instance->{addresses}{ $options{private_network} } }
237        ]->[0]
238          || undef
239      ),
240      security_groups =>
241        ( join ',', map { $_->{name} } @{ $instance->{security_groups} } ),
242      networks => \%networks,
243    };
244  }
245
246  return @instances;
247}
248
249sub list_running_instances {
250  my $self = shift;
251
252  return grep { $_->{state} eq 'running' } $self->list_instances;
253}
254
255sub stop_instance {
256  my ( $self, %data ) = @_;
257  my $nova_url = $self->get_nova_url;
258
259  Rex::Logger::debug("Suspending instance $data{instance_id}");
260
261  $self->_request(
262    POST         => $nova_url . '/servers/' . $data{instance_id} . '/action',
263    content_type => 'application/json',
264    content      => encode_json( { suspend => 'null' } ),
265  );
266
267  while ( grep { $_->{id} eq $data{instance_id} }
268    $self->list_running_instances )
269  {
270    Rex::Logger::debug('Waiting for instance to be stopped...');
271    sleep 5;
272  }
273}
274
275sub start_instance {
276  my ( $self, %data ) = @_;
277  my $nova_url = $self->get_nova_url;
278
279  Rex::Logger::debug("Resuming instance $data{instance_id}");
280
281  $self->_request(
282    POST         => $nova_url . '/servers/' . $data{instance_id} . '/action',
283    content_type => 'application/json',
284    content      => encode_json( { resume => 'null' } ),
285  );
286
287  until ( grep { $_->{id} eq $data{instance_id} }
288      $self->list_running_instances )
289  {
290    Rex::Logger::debug('Waiting for instance to be started...');
291    sleep 5;
292  }
293}
294
295sub list_flavors {
296  my $self     = shift;
297  my $nova_url = $self->get_nova_url;
298
299  Rex::Logger::debug('Listing flavors');
300
301  my $flavors = $self->_request( GET => $nova_url . '/flavors' );
302  confess "Error getting cloud flavors." if ( !exists $flavors->{flavors} );
303  return @{ $flavors->{flavors} };
304}
305
306sub list_plans { return shift->list_flavors; }
307
308sub list_images {
309  my $self     = shift;
310  my $nova_url = $self->get_nova_url;
311
312  Rex::Logger::debug('Listing images');
313
314  my $images = $self->_request( GET => $nova_url . '/images' );
315  confess "Error getting cloud images." if ( !exists $images->{images} );
316  return @{ $images->{images} };
317}
318
319sub create_volume {
320  my ( $self, %data ) = @_;
321  my $cinder_url = $self->get_cinder_url;
322
323  Rex::Logger::debug('Creating a new volume');
324
325  my $request_data = {
326    volume => {
327      size              => $data{size} || 1,
328      availability_zone => $data{zone},
329    }
330  };
331
332  my $content = $self->_request(
333    POST         => $cinder_url . '/volumes',
334    content_type => 'application/json',
335    content      => encode_json($request_data),
336  );
337
338  my $id = $content->{volume}{id};
339
340  until ( grep { $_->{id} eq $id and $_->{status} eq 'available' }
341      $self->list_volumes )
342  {
343    Rex::Logger::debug('Waiting for volume to become available...');
344    sleep 1;
345  }
346
347  return $id;
348}
349
350sub delete_volume {
351  my ( $self, %data ) = @_;
352  my $cinder_url = $self->get_cinder_url;
353
354  Rex::Logger::debug('Trying to delete a volume');
355
356  $self->_request( DELETE => $cinder_url . '/volumes/' . $data{volume_id} );
357
358  until ( !grep { $_->{id} eq $data{volume_id} } $self->list_volumes ) {
359    Rex::Logger::debug('Waiting for volume to be deleted...');
360    sleep 1;
361  }
362
363}
364
365sub list_volumes {
366  my $self       = shift;
367  my $cinder_url = $self->get_cinder_url;
368  my @volumes;
369
370  my $content = $self->_request( GET => $cinder_url . '/volumes' );
371
372  for my $volume ( @{ $content->{volumes} } ) {
373    push @volumes,
374      {
375      id          => $volume->{id},
376      status      => $volume->{status},
377      zone        => $volume->{availability_zone},
378      size        => $volume->{size},
379      attached_to => join ',',
380      map { $_->{server_id} } @{ $volume->{attachments} },
381      };
382  }
383
384  return @volumes;
385}
386
387sub attach_volume {
388  my ( $self, %data ) = @_;
389  my $nova_url = $self->get_nova_url;
390
391  Rex::Logger::debug('Trying to attach a new volume');
392
393  my $request_data = {
394    volumeAttachment => {
395      volumeId => $data{volume_id},
396      name     => $data{name},
397    }
398  };
399
400  $self->_request(
401    POST => $nova_url
402      . '/servers/'
403      . $data{instance_id}
404      . '/os-volume_attachments',
405    content_type => 'application/json',
406    content      => encode_json($request_data),
407  );
408}
409
410sub detach_volume {
411  my ( $self, %data ) = @_;
412  my $nova_url = $self->get_nova_url;
413
414  Rex::Logger::debug('Trying to detach a volume');
415
416  $self->_request( DELETE => $nova_url
417      . '/servers/'
418      . $data{instance_id}
419      . '/os-volume_attachments/'
420      . $data{volume_id} );
421}
422
423sub get_floating_ip {
424  my $self     = shift;
425  my $nova_url = $self->get_nova_url;
426
427  # look for available floating IP
428  my $floating_ips = $self->_request( GET => $nova_url . '/os-floating-ips' );
429
430  for my $floating_ip ( @{ $floating_ips->{floating_ips} } ) {
431    return $floating_ip->{ip} if ( !$floating_ip->{instance_id} );
432  }
433  confess "No floating IP available.";
434}
435
436sub associate_floating_ip {
437  my ( $self, %data ) = @_;
438  my $nova_url = $self->get_nova_url;
439
440  # associate available floating IP to instance id
441  my $request_data = {
442    addFloatingIp => {
443      address => $data{floating_ip}
444    }
445  };
446
447  Rex::Logger::debug('Associating floating IP to instance');
448
449  my $content = $self->_request(
450    POST         => $nova_url . '/servers/' . $data{instance_id} . '/action',
451    content_type => 'application/json',
452    content      => encode_json($request_data),
453  );
454}
455
456sub list_keys {
457  my $self     = shift;
458  my $nova_url = $self->get_nova_url;
459
460  my $content = $self->_request( GET => $nova_url . '/os-keypairs' );
461
462  # remove ':' from fingerprint string
463  foreach ( @{ $content->{keypairs} } ) {
464    $_->{keypair}->{fingerprint} =~ s/://g;
465  }
466  return @{ $content->{keypairs} };
467}
468
469sub upload_key {
470  my ($self) = shift;
471  my $nova_url = $self->get_nova_url;
472
473  my $public_key = glob( Rex::Config->get_public_key );
474  my ( $public_key_name, undef, undef ) = fileparse( $public_key, qr/\.[^.]*/ );
475
476  my ( $type, $key, $comment );
477
478  # read public key
479  my $fh;
480  unless ( open( $fh, "<", glob($public_key) ) ) {
481    Rex::Logger::debug("Cannot read $public_key");
482    return;
483  }
484
485  { local $/ = undef; ( $type, $key, $comment ) = split( /\s+/, <$fh> ); }
486  close $fh;
487
488  # calculate key fingerprint so we can compare them
489  my $fingerprint = md5_hex( decode_base64($key) );
490  Rex::Logger::debug("Public key fingerprint is $fingerprint");
491
492  # upoad only new key
493  my $online_key = pop @{
494    [
495      map { $_->{keypair}->{fingerprint} eq $fingerprint ? $_ : () }
496        $self->list_keys()
497    ]
498  };
499  if ($online_key) {
500    Rex::Logger::debug("Public key already uploaded");
501    return $online_key->{keypair}->{name};
502  }
503
504  my $request_data = {
505    keypair => {
506      public_key => "$type $key",
507      name       => $public_key_name,
508    }
509  };
510
511  Rex::Logger::info('Uploading public key');
512  $self->_request(
513    POST         => $nova_url . '/os-keypairs',
514    content_type => 'application/json',
515    content      => encode_json($request_data),
516  );
517
518  return $public_key_name;
519}
520
5211;
522