diff --git a/lib/OpenStack/MetaAPI.pm b/lib/OpenStack/MetaAPI.pm index 44ef017..0068528 100644 --- a/lib/OpenStack/MetaAPI.pm +++ b/lib/OpenStack/MetaAPI.pm @@ -3,9 +3,10 @@ package OpenStack::MetaAPI; use strict; use warnings; +use MIME::Base64 (); use OpenStack::Client::Auth (); use OpenStack::MetaAPI::Routes (); -use Scalar::Util qw/weaken/; +use Scalar::Util qw/blessed weaken/; use Moo; @@ -43,6 +44,27 @@ around BUILDARGS => sub { die "Missing arguments to create Auth object" unless scalar @args; + # A caller that has already built its auth object hands it straight over. + # + # Keystone can be talked to by methods OpenStack::Client::Auth does not + # implement -- an application credential, for one -- and the only way to use + # one is to construct the auth object yourself. Building a fresh one from + # the args regardless left no way to do that, so such a caller could not use + # this module at all. + # + # Normal construction is new($endpoint, %args), whose first argument is a + # URL rather than a reference, so it does not match this. + # + # The 'auth' has to be a blessed object, not merely a reference: a + # clouds.yaml cloud entry is a plain hashref with an 'auth' key of its own + # (auth_url, username, password, ...), and letting that through would store + # an unblessed hash as the auth object -- construction would succeed and the + # first delegated call would die far from here on an unblessed reference. + return $args[0] + if scalar @args == 1 + && ref $args[0] eq 'HASH' + && blessed($args[0]->{'auth'}); + # automagically build the OpenStack::Client::Auth from existing args return {auth => OpenStack::Client::Auth->new(@args)}; }; @@ -57,8 +79,6 @@ sub create_vm { die "'image' name or id is required by create_vm" unless defined $opts{image}; die "'name' field is required by create_vm" unless defined $opts{name}; - die "'network_for_floating_ip' field is required by create_vm" - unless defined $opts{network_for_floating_ip}; $opts{security_group} //= 'default'; # optional argument fallback to 'default' @@ -69,9 +89,14 @@ sub create_vm { # get the network by id or name my $network = $self->look_by_id_or_name(networks => $opts{network}); - # get the network used to add the floating up later - my $network_for_floating_ip = - $self->look_by_id_or_name(networks => $opts{network_for_floating_ip}); + # Optional: not every cloud has a tenant network to escape from. Where the + # only network is external and shared, a server on it is given a routable + # address directly -- reported as 'fixed' -- and there is no floating IP to + # attach. Requiring one made such a cloud impossible to build on at all. + my $network_for_floating_ip; + $network_for_floating_ip = + $self->look_by_id_or_name(networks => $opts{network_for_floating_ip}) + if defined $opts{network_for_floating_ip}; my $image; if (_looks_valid_id($opts{image})) { @@ -87,13 +112,31 @@ sub create_vm { push @extra, (key_name => $opts{key_name}); } + # Nova wants user_data base64 encoded, and every caller has cloud-config + # rather than base64, so encode it here instead of making each of them + # remember to. Without this there is no way to hand a new server its + # cloud-init payload at all, which is most of the point of creating one. + if (defined $opts{user_data} && length $opts{user_data}) { + push @extra, + (user_data => MIME::Base64::encode_base64($opts{user_data}, '')); + } + + # Pass-throughs. Each is a plain server-create field that had no way of + # being set: which availability zone to build in, the metadata a later + # lookup finds the server by, and the volumes it boots or carries. + foreach my $field (qw{availability_zone metadata block_device_mapping_v2}) { + push @extra, ($field => $opts{$field}) if defined $opts{$field}; + } + my $server = $self->create_server( name => $opts{name}, imageRef => $image->{id}, flavorRef => $flavor->{id}, min_count => 1, max_count => 1, - security_groups => [{name => $security_group->{id}}], + # By name, which is what the field is. This passed the id, which + # some clouds resolve anyway and others do not. + security_groups => [{name => $security_group->{name} // $security_group->{id}}], networks => [{uuid => $network->{id}}], @extra, ); @@ -129,7 +172,7 @@ sub create_vm { unless $server_is_ready; # now add one IP to the server - { + if ($network_for_floating_ip) { # create a floating IP my $floating_ip = $self->create_floating_ip($network_for_floating_ip->{id}); diff --git a/lib/OpenStack/MetaAPI/API/Compute.pm b/lib/OpenStack/MetaAPI/API/Compute.pm index ca9ee3b..a1ca72b 100644 --- a/lib/OpenStack/MetaAPI/API/Compute.pm +++ b/lib/OpenStack/MetaAPI/API/Compute.pm @@ -56,4 +56,96 @@ sub create_server { ### helpers +# What this project may have, and what it is already using. +# +# Both halves arrive in one response, which is what makes this the right thing +# to ask when deciding whether another guest fits. /os-quota-sets gives only +# the allowance, leaving you to add the usage up yourself from the server list. +sub limits { + my ($self) = @_; + + my $out = $self->get($self->root_uri('/limits')); + + return ref $out ? $out->{limits} : $out; +} + +# Everything Nova models as an action on an existing server: snapshots, power +# state, rebuild. They are all one POST to the same place with a different +# single-key body, so they are all this. +sub server_action { + my ($self, $uid, $action) = @_; + + die "server id is required by server_action" unless defined $uid && length $uid; + die "action must be a hash reference" unless ref $action eq 'HASH'; + + return $self->post($self->root_uri("/servers/$uid/action"), $action); +} + +# Snapshot a server into a Glance image. +# +# Nova returns the new image's id in the Location header, and only puts it in +# the body from microversion 2.45 on -- so neither is worth relying on. Ask +# Glance for the name afterwards instead. +sub create_image { + my ($self, $uid, %opts) = @_; + + my $name = delete $opts{name}; + die "'name' is required by create_image" unless defined $name && length $name; + + return $self->server_action($uid, {createImage => {name => $name, %opts}}); +} + +# The flavor list is names and ids; this is the one that knows how many CPUs. +sub flavors_detail { + my ($self, %filter) = @_; + + my $out = $self->get($self->root_uri('/flavors/detail')); + my @all = ref $out ? @{$out->{flavors} // []} : (); + + foreach my $key (sort keys %filter) { + @all = grep { defined $_->{$key} && $_->{$key} eq $filter{$key} } @all; + } + + return @all; +} + +# What the guest wrote to its serial console. When a guest never comes up this +# is usually the only thing that says why. +sub console_output { + my ($self, $uid, $length) = @_; + + my $out = $self->server_action($uid, {'os-getConsoleOutput' => {length => $length}}); + + return ref $out ? $out->{output} : $out; +} + +# Volumes are Cinder's, but attaching one to a server is Nova's. +sub server_volumes { + my ($self, $uid) = @_; + + my $out = $self->get($self->root_uri("/servers/$uid/os-volume_attachments")); + + return ref $out ? @{$out->{volumeAttachments} // []} : (); +} + +sub attach_volume { + my ($self, $uid, $volume_id, %opts) = @_; + + die "volume id is required by attach_volume" unless defined $volume_id && length $volume_id; + + my $out = $self->post( + $self->root_uri("/servers/$uid/os-volume_attachments"), + {volumeAttachment => {volumeId => $volume_id, %opts}}); + + return ref $out ? $out->{volumeAttachment} : $out; +} + +sub detach_volume { + my ($self, $uid, $volume_id) = @_; + + die "volume id is required by detach_volume" unless defined $volume_id && length $volume_id; + + return $self->delete($self->root_uri("/servers/$uid/os-volume_attachments/$volume_id")); +} + 1; diff --git a/lib/OpenStack/MetaAPI/API/Images.pm b/lib/OpenStack/MetaAPI/API/Images.pm index aa93bd9..c6937dd 100644 --- a/lib/OpenStack/MetaAPI/API/Images.pm +++ b/lib/OpenStack/MetaAPI/API/Images.pm @@ -27,7 +27,28 @@ either by its 'exact name' or its 'UID' sub images { my ($self, @args) = @_; - die "Please use image_from_uid image_from_name"; + die "Please use image_from_uid, image_from_name or list_images"; +} + +# Every image matching %query, pagination followed. +# +# The warning above is about listing images *unfiltered*, which really can take +# fifty requests -- but there was no way to ask for a filtered list either, and +# some questions have no single-image answer. "Which snapshots does this +# instance have" is one: Glance holds them as ordinary images, and the only way +# to find them is to ask for the ones matching. +# +# %query goes to Glance as request parameters, so the filtering happens there +# rather than here: name, owner, visibility, status, and any image property -- +# Nova stamps its snapshots with image_type and instance_uuid, which is the +# narrow way to ask that question. +# +# Pagination is OpenStack::Client's; all() follows the 'next' link until there +# is not one. +sub list_images { + my ($self, %query) = @_; + + return $self->client->all($self->root_uri('/images'), 'images', \%query); } # API doc diff --git a/lib/OpenStack/MetaAPI/API/Volume.pm b/lib/OpenStack/MetaAPI/API/Volume.pm new file mode 100644 index 0000000..db44051 --- /dev/null +++ b/lib/OpenStack/MetaAPI/API/Volume.pm @@ -0,0 +1,79 @@ +package OpenStack::MetaAPI::API::Volume; + +use strict; +use warnings; + +use Moo; + +extends 'OpenStack::MetaAPI::API::Service'; + +with 'OpenStack::MetaAPI::Roles::Listable'; + +# Block storage is in the catalogue under whichever versions the cloud still +# offers, and 'volume' unadorned is not always one of them -- a current +# deployment may list only volumev3 and volumev2. So the type is whatever is +# actually there, newest first, rather than a name we picked in advance. +has '+name' => ( + required => 0, + lazy => 1, + default => sub { + my ($self) = @_; + + my %offered = map { $_ => 1 } $self->auth->services; + foreach my $type (qw{volumev3 volumev2 volume}) { + return $type if $offered{$type}; + } + + die "This cloud offers no block storage service (looked for volumev3, volumev2, volume)\n"; + }, +); + +# Declared here rather than in a Specs data block. +# +# The spec package a service loads is named after its catalogue type -- so a +# cloud offering volumev3 would want Specs::Volumev3, and one offering volume +# would want Specs::Volume, for the same two routes. Writing them out once is +# less work than one spec file per spelling. +sub volumes { + my ($self, @args) = @_; + + return $self->_list(['/volumes', 'volumes'], \@args); +} + +sub volume_from_uid { + my ($self, $uid) = @_; + + die "volume id is required by volume_from_uid" unless defined $uid && length $uid; + + my $out = $self->get($self->root_uri("/volumes/$uid")); + + return ref $out ? $out->{volume} : $out; +} + +sub create_volume { + my ($self, %opts) = @_; + + die "'size' is required by create_volume" unless $opts{size}; + + my $out = $self->post($self->root_uri('/volumes'), {volume => {%opts}}); + + return ref $out ? $out->{volume} : $out; +} + +sub delete_volume { + my ($self, $uid) = @_; + + die "volume id is required by delete_volume" unless defined $uid && length $uid; + + return $self->delete($self->root_uri("/volumes/$uid")); +} + +sub volume_limits { + my ($self) = @_; + + my $out = $self->get($self->root_uri('/limits')); + + return ref $out ? $out->{limits} : $out; +} + +1; diff --git a/lib/OpenStack/MetaAPI/Routes.pm b/lib/OpenStack/MetaAPI/Routes.pm index 9acce26..2394426 100644 --- a/lib/OpenStack/MetaAPI/Routes.pm +++ b/lib/OpenStack/MetaAPI/Routes.pm @@ -112,5 +112,33 @@ image_from_uid: service: images image_from_name: service: images +list_images: + service: images +limits: + service: compute +server_action: + service: compute +create_image: + service: compute +flavors_detail: + service: compute +console_output: + service: compute +server_volumes: + service: compute +attach_volume: + service: compute +detach_volume: + service: compute +volumes: + service: volume +volume_from_uid: + service: volume +create_volume: + service: volume +delete_volume: + service: volume +volume_limits: + service: volume diff --git a/t/01_base.t b/t/01_base.t index 3dfca20..1ae9994 100644 --- a/t/01_base.t +++ b/t/01_base.t @@ -19,6 +19,17 @@ use JSON; mock_lwp_useragent(); +# Stands in for an auth object somebody else built. It only has to satisfy the +# 'services' the api delegates, and to be a blessed object, which is what tells +# BUILDARGS this is an auth object rather than an endpoint URL or a config hash. +{ + + package Test::PrebuiltAuth; + + sub new { return bless {}, shift } + sub services { return qw{fake} } +} + like( dies { OpenStack::MetaAPI->new() }, qr/Missing arguments to create Auth object/, @@ -46,4 +57,33 @@ like( "list os services from auth object"; } +# An auth object that is handed over is the one that gets used. +# +# Nothing is mocked for this on purpose: constructing an OpenStack::Client::Auth +# would POST to Keystone and die on the username it was not given, so reaching +# the assertions at all is the evidence that no second auth object was built. +{ + my $api = OpenStack::MetaAPI->new({auth => Test::PrebuiltAuth->new, debug => 1}); + + is ref $api->auth, 'Test::PrebuiltAuth', + "the auth object we passed is the one used"; + is [$api->services], ['fake'], "delegated calls reach it"; + ok $api->debug, "the rest of the arguments survive alongside it"; +} + +# ... and a bare hashref with no auth in it is still not enough to build one. +like( + dies { OpenStack::MetaAPI->new({debug => 1}) }, + qr/No OpenStack tenant name provided/, + "a hashref without an auth object still goes through OpenStack::Client::Auth"); + +# A clouds.yaml cloud entry is a plain hashref whose 'auth' is itself an +# unblessed hash (auth_url, username, password, ...). That must not be +# mistaken for a prebuilt auth object: it has to fall through and build a real +# OpenStack::Client::Auth, which dies on the details it was not given. +like( + dies { OpenStack::MetaAPI->new({auth => {username => 'x'}}) }, + qr/No OpenStack tenant name provided/, + "a clouds.yaml-shaped hash with an unblessed auth is not taken as the auth object"); + done_testing; diff --git a/t/lib/Test/OpenStack/MetaAPI.pm b/t/lib/Test/OpenStack/MetaAPI.pm index c32c846..08a3c18 100644 --- a/t/lib/Test/OpenStack/MetaAPI.pm +++ b/t/lib/Test/OpenStack/MetaAPI.pm @@ -27,6 +27,7 @@ our @EXPORT_OK = qw( txt_plain last_http_request + last_http_content ); our %EXPORT_TAGS = (all => \@EXPORT_OK); @@ -54,11 +55,19 @@ sub txt_plain { } our $LAST_REQUEST; +our $LAST_CONTENT; sub last_http_request { return $LAST_REQUEST; } +# The body we sent, so a test can assert on what was asked for and not only on +# where it was asked. For anything the API encodes on the caller's behalf, the +# encoding *is* the behaviour under test. +sub last_http_content { + return $LAST_CONTENT; +} + sub mock_lwp_useragent { $mock_lwp = Test::MockModule->new('LWP::UserAgent'); @@ -76,6 +85,7 @@ sub mock_lwp_useragent { my $uri = $req->uri; $LAST_REQUEST = $method . " " . $uri; + $LAST_CONTENT = $req->content; note "LWP: ", $LAST_REQUEST; diff --git a/t/service-extras.t b/t/service-extras.t new file mode 100644 index 0000000..593fed6 --- /dev/null +++ b/t/service-extras.t @@ -0,0 +1,163 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use OpenStack::MetaAPI (); + +use Test2::Bundle::Extended; +use Test2::Tools::Explain; +use Test2::Plugin::NoWarnings; + +use FindBin; +use lib "$FindBin::Bin/lib"; + +use Test::OpenStack::MetaAPI qw{:all}; +use Test::OpenStack::MetaAPI::Auth qw{:all}; + +use JSON; + +mock_lwp_useragent(); + +my $NOVA = 'http://127.0.0.1:8774/v2.1'; +my $CINDER = 'http://127.0.0.1:8776/v3/76fb18aec577491bb676b482f5671352'; + +my $api = get_api_object(use_env => 0); +ok $api, "got one api object" or die; + +{ + note "limits: the allowance and the usage in one answer"; + + mock_get_request( + "$NOVA/limits", + application_json( + encode_json( + { limits => { + absolute => { + maxTotalRAMSize => 51200, + totalRAMUsed => 8192, + maxTotalCores => 40, + totalCoresUsed => 6, + maxTotalInstances => 20, + totalInstancesUsed => 3, + } + } + } + ) + ), + ); + + my $limits = $api->limits; + is $limits->{absolute}{maxTotalRAMSize}, 51200, "the allowance"; + is $limits->{absolute}{totalRAMUsed}, 8192, "and the usage, without adding up a server list"; +} + +{ + note "server_action: one POST, one single-key body"; + + mock_post_request("$NOVA/servers/abc123/action", application_json('{}')); + + ok $api->server_action('abc123', {reboot => {type => 'SOFT'}}), "an action goes through"; + is last_http_request(), "POST $NOVA/servers/abc123/action", "to the action endpoint"; + + like dies { $api->server_action('', {reboot => {}}) }, qr/server id is required/, + "and it wants a server"; + like dies { $api->server_action('abc123', 'reboot') }, qr/must be a hash reference/, + "and an action it can encode"; +} + +{ + note "create_image: a snapshot is an action"; + + mock_post_request("$NOVA/servers/abc123/action", application_json('{}')); + + ok $api->create_image('abc123', name => 'before-upgrade'), "snapshotting goes through"; + like dies { $api->create_image('abc123') }, qr/'name' is required/, + "an image with no name is not a snapshot anyone can find again"; +} + +{ + note "flavors_detail: the one that knows how many CPUs"; + + mock_get_request( + "$NOVA/flavors/detail", + application_json( + encode_json( + { flavors => [ + {id => '1', name => 'm1.small', vcpus => 1, ram => 2048, disk => 20}, + {id => '2', name => 'm1.medium', vcpus => 2, ram => 4096, disk => 40}, + ] + } + ) + ), + ); + + my @all = $api->flavors_detail; + is scalar @all, 2, "both flavors"; + is $all[1]{vcpus}, 2, "with the numbers the summary list leaves out"; + + my @one = $api->flavors_detail(name => 'm1.medium'); + is scalar @one, 1, "filtered by name"; + is $one[0]{id}, '2', "the right one"; +} + +{ + note "console_output: what the guest said before it gave up"; + + mock_post_request( + "$NOVA/servers/abc123/action", + application_json( encode_json( {output => "cloud-init done\n"} ) ), + ); + + is $api->console_output('abc123', 50), "cloud-init done\n", "the text, not the envelope"; +} + +{ + note "volume attachments are Nova's, not Cinder's"; + + mock_get_request( + "$NOVA/servers/abc123/os-volume_attachments", + application_json( encode_json( {volumeAttachments => [{id => 'v1', device => '/dev/vdb'}]} ) ), + ); + + my @attached = $api->server_volumes('abc123'); + is scalar @attached, 1, "one attachment"; + is $attached[0]{device}, '/dev/vdb', "and where it landed"; + + mock_post_request( + "$NOVA/servers/abc123/os-volume_attachments", + application_json( encode_json( {volumeAttachment => {id => 'v1', device => '/dev/vdb'}} ) ), + ); + is $api->attach_volume('abc123', 'v1')->{device}, '/dev/vdb', "attaching returns the attachment"; + + like dies { $api->attach_volume('abc123', '') }, qr/volume id is required/, "and wants a volume"; + like dies { $api->detach_volume('abc123', '') }, qr/volume id is required/, "so does detaching"; +} + +{ + note "the volume service resolves to whichever cinder the catalogue offers"; + + mock_get_request( + "$CINDER/volumes", + application_json( encode_json( {volumes => [{id => 'v1', name => 'data'}]} ) ), + ); + + # The fixture catalogue lists volumev3, volumev2 and volume; newest wins, + # which is what puts this request at the /v3/ endpoint. + my $vol = $api->volumes; + is $vol->{name}, 'data', "listed through the v3 endpoint"; + like last_http_request(), qr{\Q$CINDER/volumes\E}, "which is the one that got asked"; + + mock_post_request( + "$CINDER/volumes", + application_json( encode_json( {volume => {id => 'v2', size => 10}} ) ), + ); + is $api->create_volume(size => 10)->{id}, 'v2', "creating one"; + like dies { $api->create_volume() }, qr/'size' is required/, "a volume needs a size"; + + mock_delete_request("$CINDER/volumes/v2", application_json('{}')); + ok $api->delete_volume('v2'), "deleting one"; + like dies { $api->delete_volume('') }, qr/volume id is required/, "and it wants to know which"; +} + +done_testing; diff --git a/t/xtra-create-vm.t b/t/xtra-create-vm.t index d4360c4..473fb39 100644 --- a/t/xtra-create-vm.t +++ b/t/xtra-create-vm.t @@ -16,6 +16,7 @@ use Test::OpenStack::MetaAPI qw{:all}; use Test::OpenStack::MetaAPI::Auth qw{:all}; use JSON; +use MIME::Base64 (); mock_lwp_useragent(); @@ -160,6 +161,64 @@ ok $api, "got one api object" or die; }, "create a vm"; + note "what actually gets sent to Nova"; + + # Re-registered as a sub so the request body can be captured. For a field + # the API encodes on the caller's behalf, the encoding is the behaviour, and + # asserting on the response would not exercise it at all. + my $posted; + mock_post_request( + 'http://127.0.0.1:8774/v2.1/servers', + sub { + my ($request) = @_; + $posted = decode_json($request->content); + return {code => 200, msg => 'created', %{application_json(json_create_server())}}; + }, + ); + + my $cloud_config = "#cloud-config\nhostname: vm.example.com\n"; + + $api->create_vm( + name => $SERVER_NAME, + image => $IMAGE_UID, + flavor => 'small', + key_name => 'My SSH Key', + network => 'net1', + network_for_floating_ip => $FLOATING_IP_NETWORK, + user_data => $cloud_config, + availability_zone => 'nova', + metadata => {domain => 'vm.example.com'}, + ); + + ok $posted, "the create request was captured" or return; + + is MIME::Base64::decode_base64($posted->{server}{user_data}), $cloud_config, + "user_data arrives base64 encoded, which is the only form Nova takes"; + is $posted->{server}{availability_zone}, 'nova', "availability_zone is passed through"; + is $posted->{server}{metadata}, {domain => 'vm.example.com'}, "and metadata"; + is $posted->{server}{key_name}, 'My SSH Key', "alongside what already worked"; + + is $posted->{server}{security_groups}, [{name => 'default'}], + "the security group goes by name, which is what the field is"; + + ok !exists $posted->{server}{block_device_mapping_v2}, + "and a pass-through nobody asked for is not sent at all"; + + note "a cloud with no tenant network to escape from"; + + # Where the only network is external and shared, a server on it gets a + # routable address directly and there is no floating IP to attach. Such a + # cloud could not be built on at all while this argument was required. + my $no_float = $api->create_vm( + name => $SERVER_NAME, + image => $IMAGE_UID, + flavor => 'small', + network => 'net1', + ); + + ok $no_float, "a server is created without a floating network"; + ok !exists $no_float->{floating_ip_address}, + "and no floating IP was attached, because none was asked for"; } done_testing;