From 3e8a681579e6f244e1ac824a89fe7bd24ff53a65 Mon Sep 17 00:00:00 2001 From: Andy Baugh Date: Thu, 10 Sep 2026 16:32:35 -0500 Subject: [PATCH 1/6] fix: honour an auth object passed to the constructor BUILDARGS built an OpenStack::Client::Auth from its arguments unconditionally and discarded everything else, so the 'auth' attribute -- declared required, and therefore apparently settable -- could not in fact be set. That shut out every authentication method OpenStack::Client::Auth does not implement. Keystone v3 application credentials are the case in hand: there is no username or password to hand over, so the only way to authenticate is to construct the auth object yourself, and that was the one thing there was no way to pass in. A lone hashref carrying a reference in 'auth' now goes straight through. Ordinary construction is new($endpoint, %args), whose first argument is a URL rather than a reference, so no existing caller matches this and none of them change behaviour. Co-Authored-By: Claude Opus 5 (1M context) --- lib/OpenStack/MetaAPI.pm | 15 +++++++++++++++ t/01_base.t | 31 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/lib/OpenStack/MetaAPI.pm b/lib/OpenStack/MetaAPI.pm index 44ef017..89f0348 100644 --- a/lib/OpenStack/MetaAPI.pm +++ b/lib/OpenStack/MetaAPI.pm @@ -43,6 +43,21 @@ 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. + return $args[0] + if scalar @args == 1 + && ref $args[0] eq 'HASH' + && ref $args[0]->{'auth'}; + # automagically build the OpenStack::Client::Auth from existing args return {auth => OpenStack::Client::Auth->new(@args)}; }; diff --git a/t/01_base.t b/t/01_base.t index 3dfca20..5af5823 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 reference, which is what tells +# BUILDARGS this is an auth object rather than an endpoint URL. +{ + + 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,24 @@ 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"); + done_testing; From 9ce60896d540985ca9b756f1a85284095f23a2de Mon Sep 17 00:00:00 2001 From: Andy Baugh Date: Thu, 10 Sep 2026 16:32:39 -0500 Subject: [PATCH 2/6] feat: add limits, server actions, flavor detail and a volume service Five things the API could not do, each needed to manage a server's whole life rather than only create and delete it. limits is the interesting one. Deciding whether another instance fits needs both the project's allowance and what it is already using, and /limits returns them together -- where /os-quota-sets gives only the allowance and leaves the caller to total up a server list to find the rest. server_action covers everything Nova models as an action on an existing server: snapshots, power state, rebuild. They are one POST to one path with a different single-key body, so they are one method. create_image is that, named, because snapshotting is the case people come looking for; it deliberately does not report the new image's id, since Nova returns it in a Location header and only puts it in the body from microversion 2.45, so neither is worth relying on. Ask Glance by name instead. flavors_detail exists because the flavor list is ids and names. Anything that has to choose a flavor by how much machine it is needs /flavors/detail. console_output is what a guest that never came up said before it gave up. There was no block storage service at all, so volumes could be neither made, found nor deleted. Its catalogue type is whatever the cloud still offers, newest first, rather than a name fixed here: 'volume' unadorned is not always present, and a current deployment may list only volumev3 and volumev2. Its two list routes are written out rather than declared in a Specs data block, because the spec package a service loads is named after its catalogue type -- so the same two routes would need one spec file per spelling of Cinder. Attaching a volume is Nova's endpoint rather than Cinder's, so attach_volume, detach_volume and server_volumes are on compute where they belong. Co-Authored-By: Claude Opus 5 (1M context) --- lib/OpenStack/MetaAPI/API/Compute.pm | 92 +++++++++++++++ lib/OpenStack/MetaAPI/API/Volume.pm | 79 +++++++++++++ lib/OpenStack/MetaAPI/Routes.pm | 26 +++++ t/service-extras.t | 163 +++++++++++++++++++++++++++ 4 files changed, 360 insertions(+) create mode 100644 lib/OpenStack/MetaAPI/API/Volume.pm create mode 100644 t/service-extras.t 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/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..28e9366 100644 --- a/lib/OpenStack/MetaAPI/Routes.pm +++ b/lib/OpenStack/MetaAPI/Routes.pm @@ -112,5 +112,31 @@ image_from_uid: service: images image_from_name: 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/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; From b470cf6d63a2e8402ccda33afbfd6eb2710774a4 Mon Sep 17 00:00:00 2001 From: Andy Baugh Date: Thu, 10 Sep 2026 16:32:42 -0500 Subject: [PATCH 3/6] feat: let create_vm hand the new server its cloud-init payload create_vm could build a server but not tell it anything, because user_data was not among the fields it passed on. That is most of the point of creating one: without it a new guest boots with no cloud-config, no users, no keys and no way to be told what it is for, and the caller's only recourse was to bypass create_vm and assemble the request itself -- losing the flavor and network lookups, the wait for ACTIVE and the floating IP along with it. Nova only accepts user_data base64 encoded, and callers have cloud-config rather than base64, so it is encoded here. Making each caller remember is the kind of detail that is discovered by a guest silently booting unconfigured. availability_zone, metadata and block_device_mapping_v2 come along for the same reason: each is an ordinary server-create field with no way to set it. They are only sent when asked for, so a request that did not mention them is unchanged. The security group was being sent as {name => $group->{id}}, which puts an id in a field that means a name. Some clouds resolve either and some do not, so it now sends the name and falls back to the id. The test asserts on the request body rather than the response, which is what required a way to see it: for a field the API encodes on the caller's behalf, the encoding is the behaviour, and the canned response would exercise none of it. Hence last_http_content in the test helper. Co-Authored-By: Claude Opus 5 (1M context) --- lib/OpenStack/MetaAPI.pm | 21 +++++++++++++++- t/lib/Test/OpenStack/MetaAPI.pm | 10 ++++++++ t/xtra-create-vm.t | 43 +++++++++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/lib/OpenStack/MetaAPI.pm b/lib/OpenStack/MetaAPI.pm index 89f0348..9081cef 100644 --- a/lib/OpenStack/MetaAPI.pm +++ b/lib/OpenStack/MetaAPI.pm @@ -3,6 +3,7 @@ package OpenStack::MetaAPI; use strict; use warnings; +use MIME::Base64 (); use OpenStack::Client::Auth (); use OpenStack::MetaAPI::Routes (); use Scalar::Util qw/weaken/; @@ -102,13 +103,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, ); 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/xtra-create-vm.t b/t/xtra-create-vm.t index d4360c4..59e34f7 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,48 @@ 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"; } done_testing; From 52b1f8fbef171e63039ed3faec442f1918ff8167 Mon Sep 17 00:00:00 2001 From: Andy Baugh Date: Thu, 10 Sep 2026 16:32:44 -0500 Subject: [PATCH 4/6] feat: allow a filtered image listing images() died pointing at image_from_uid and image_from_name, and the warning behind that is real: listing a project's images unfiltered can take fifty requests. The cloud this was written against holds 632 of them. But some questions have no single-image answer, and "which snapshots does this guest have" is one -- Glance keeps them as ordinary images, so finding them means asking for the ones that match. With no filtered listing available, the only way to answer it was to not use this module. list_images passes %query to Glance, so the filtering happens there rather than in the caller: name, owner, visibility, status, or any image property. Nova stamps its snapshots with image_type and instance_uuid, which is what makes the question answerable in one request instead of 632 images' worth of pages. Pagination is not new work either; OpenStack::Client::all already follows the 'next' link, and had no way of being pointed at images. Co-Authored-By: Claude Opus 5 (1M context) --- lib/OpenStack/MetaAPI/API/Images.pm | 23 ++++++++++++++++++++++- lib/OpenStack/MetaAPI/Routes.pm | 2 ++ 2 files changed, 24 insertions(+), 1 deletion(-) 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/Routes.pm b/lib/OpenStack/MetaAPI/Routes.pm index 28e9366..2394426 100644 --- a/lib/OpenStack/MetaAPI/Routes.pm +++ b/lib/OpenStack/MetaAPI/Routes.pm @@ -112,6 +112,8 @@ image_from_uid: service: images image_from_name: service: images +list_images: + service: images limits: service: compute server_action: From 8ae7cc1c6f3b0d0d80ef7e51d2a5e37deca3ce38 Mon Sep 17 00:00:00 2001 From: Andy Baugh Date: Thu, 10 Sep 2026 16:32:47 -0500 Subject: [PATCH 5/6] fix: do not require a floating network to create a server create_vm demanded network_for_floating_ip and always attached a floating IP, which made a whole shape of cloud impossible to build on. Not every deployment has a tenant network to escape from. Where the only network is external and shared -- which is how the cloud this was found on is arranged -- a server attached to it is given a routable address directly, and Nova reports that address as 'fixed'. There is no floating IP to allocate, no pool to allocate it from, and asking for one is an error rather than a formality. So the argument is optional now, and the floating IP is attached only when a network was named to take it from. A caller that named one is unaffected, and the existing failure for a network that does not exist still fails. Co-Authored-By: Claude Opus 5 (1M context) --- lib/OpenStack/MetaAPI.pm | 15 +++++++++------ t/xtra-create-vm.t | 16 ++++++++++++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/lib/OpenStack/MetaAPI.pm b/lib/OpenStack/MetaAPI.pm index 9081cef..ee4a085 100644 --- a/lib/OpenStack/MetaAPI.pm +++ b/lib/OpenStack/MetaAPI.pm @@ -73,8 +73,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' @@ -85,9 +83,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})) { @@ -163,7 +166,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/t/xtra-create-vm.t b/t/xtra-create-vm.t index 59e34f7..473fb39 100644 --- a/t/xtra-create-vm.t +++ b/t/xtra-create-vm.t @@ -203,6 +203,22 @@ ok $api, "got one api object" or die; 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; From d10099addfe03ce1be75490b9c36d06610c1734f Mon Sep 17 00:00:00 2001 From: Andy Baugh Date: Thu, 10 Sep 2026 17:54:02 -0500 Subject: [PATCH 6/6] fix: require a blessed auth object in BUILDARGS The auth-passthrough guard checked ref $args[0]->{'auth'}, which is true for any reference including a plain HASH. A clouds.yaml cloud entry is a hashref whose own 'auth' key is an unblessed hash (auth_url, username, password, ...), so it was accepted verbatim as the auth object: Moo stored the unblessed hash and construction succeeded, deferring the failure to the first delegated call, which died on an unblessed reference far from here. Check blessed() instead so a config hash falls through and builds a real OpenStack::Client::Auth, which dies legibly at construction. Add a test covering the clouds.yaml-shaped hash. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/OpenStack/MetaAPI.pm | 10 ++++++++-- t/01_base.t | 13 +++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/OpenStack/MetaAPI.pm b/lib/OpenStack/MetaAPI.pm index ee4a085..0068528 100644 --- a/lib/OpenStack/MetaAPI.pm +++ b/lib/OpenStack/MetaAPI.pm @@ -6,7 +6,7 @@ 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; @@ -54,10 +54,16 @@ around BUILDARGS => sub { # # 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' - && ref $args[0]->{'auth'}; + && blessed($args[0]->{'auth'}); # automagically build the OpenStack::Client::Auth from existing args return {auth => OpenStack::Client::Auth->new(@args)}; diff --git a/t/01_base.t b/t/01_base.t index 5af5823..1ae9994 100644 --- a/t/01_base.t +++ b/t/01_base.t @@ -20,8 +20,8 @@ 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 reference, which is what tells -# BUILDARGS this is an auth object rather than an endpoint URL. +# '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; @@ -77,4 +77,13 @@ like( 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;