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