diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..5f43775 --- /dev/null +++ b/.mailmap @@ -0,0 +1,2 @@ +Ricardo Signes +Ricardo Signes diff --git a/Makefile.PL b/Makefile.PL index 052f8d3..fccf9d3 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -51,8 +51,11 @@ my %WriteMakefileArgs = ( "Encode" => 0, "ExtUtils::MakeMaker" => 0, "File::Spec" => 0, + "HTTP::Response" => 0, + "Test::Async::HTTP" => 0, "Test::Deep" => 0, "Test::More" => "0.96", + "lib" => 0, "strict" => 0, "warnings" => 0 }, @@ -75,6 +78,7 @@ my %FallbackPrereqs = ( "Future" => 0, "Future::AsyncAwait" => 0, "Future::Utils" => 0, + "HTTP::Response" => 0, "IO::Async::Loop" => 0, "IO::Async::Notifier" => 0, "IO::Async::Process" => 0, @@ -87,12 +91,14 @@ my %FallbackPrereqs = ( "String::Flogger" => 0, "TOML::Parser" => 0, "Term::ANSIColor" => 0, + "Test::Async::HTTP" => 0, "Test::Deep" => 0, "Test::More" => "0.96", "Text::Table" => 0, "Time::Duration" => 0, "experimental" => 0, "if" => 0, + "lib" => 0, "parent" => 0, "strict" => 0, "utf8" => 0, diff --git a/cpanfile b/cpanfile index e15ee2c..6db31d6 100644 --- a/cpanfile +++ b/cpanfile @@ -34,8 +34,11 @@ on 'test' => sub { requires "Encode" => "0"; requires "ExtUtils::MakeMaker" => "0"; requires "File::Spec" => "0"; + requires "HTTP::Response" => "0"; + requires "Test::Async::HTTP" => "0"; requires "Test::Deep" => "0"; requires "Test::More" => "0.96"; + requires "lib" => "0"; requires "strict" => "0"; requires "warnings" => "0"; }; diff --git a/lib/Dobby/BoxManager.pm b/lib/Dobby/BoxManager.pm index 4386fa8..8d2d6a6 100644 --- a/lib/Dobby/BoxManager.pm +++ b/lib/Dobby/BoxManager.pm @@ -88,8 +88,9 @@ package Dobby::BoxManager::ProvisionRequest { # things like "what if the user said to run custom setup but not standard # setup". At some point, you'll get weird results if you do weird things. - has region => (is => 'ro', isa => 'Str', required => 1); - has size => (is => 'ro', isa => 'Str', required => 1); + has size_preferences => (is => 'ro', isa => 'ArrayRef[Str]', required => 1); + has region_preferences => (is => 'ro', isa => 'ArrayRef[Str]', default => sub { [] }); + has username => (is => 'ro', isa => 'Str', required => 1); has version => (is => 'ro', isa => 'Str', required => 1); @@ -109,6 +110,112 @@ package Dobby::BoxManager::ProvisionRequest { has ssh_key_id => (is => 'ro', isa => 'Str', predicate => 'has_ssh_key_id'); has digitalocean_ssh_key_name => (is => 'ro', isa => 'Str', default => 'synergy'); + # When true, region beats size in preference resolution: useful for + # interactive use where latency matters more than cost control. + # -- claude, 2026-03-05 + has prefer_proximity => (is => 'ro', isa => 'Bool', default => 0); + + # When true, any region not in region_preferences is tried as a fallback + # after preferred regions are exhausted. + has fallback_to_anywhere => (is => 'ro', isa => 'Bool', default => 0); + + sub BUILDARGS ($class, @rest) { + my %args = @rest == 1 ? $rest[0]->%* : @rest; + + Carp::confess("ProvisionRequest: provide exactly one of 'size' or 'size_preferences'") + unless exists $args{size} xor exists $args{size_preferences}; + $args{size_preferences} = [ delete $args{size} ] if exists $args{size}; + + Carp::confess("ProvisionRequest: provide at most one of 'region' or 'region_preferences'") + if exists $args{region} && exists $args{region_preferences}; + $args{region_preferences} = [ delete $args{region} ] if exists $args{region}; + + return \%args; + } + + no Moose; + __PACKAGE__->meta->make_immutable; +} + +package Dobby::BoxManager::CandidateSet { + use Moose; + + # Each candidate is a hashref with: size, region, price_hourly, vcpus, + # memory, disk, description. One entry per (size, region) pair. + has candidates => ( + isa => 'ArrayRef[HashRef]', + default => sub { [] }, + traits => [ 'Array' ], + handles => { + candidates => 'elements', + is_empty => 'is_empty', + }, + ); + + # Candidates that were excluded solely because of region_preferences + # filtering (i.e. they passed all other constraints but their region was not + # in the preference list). Always empty when there are no region_preferences + # or when fallback_to_anywhere is true. + has region_excluded_candidates => ( + isa => 'ArrayRef[HashRef]', + default => sub { [] }, + traits => [ 'Array' ], + handles => { region_excluded_candidates => 'elements' }, + ); + + has size_preferences => ( + isa => 'ArrayRef[Str]', + default => sub { [] }, + traits => [ 'Array' ], + handles => { size_preferences => 'elements' }, + ); + + has region_preferences => ( + isa => 'ArrayRef[Str]', + default => sub { [] }, + traits => [ 'Array' ], + handles => { region_preferences => 'elements' }, + ); + + has prefer_proximity => ( + is => 'ro', + isa => 'Bool', + default => 0, + ); + + # Returns the single best candidate according to preference ordering and + # price, with random tie-breaking. + # + # When size_preferences is given (and prefer_proximity is false), preference + # rank is the primary sort key; price is secondary. When prefer_proximity is + # true, region_preferences rank is primary instead. When no preferences are + # given, price alone determines the winner. Ties (same rank and same price) + # are broken randomly. -- claude, 2026-03-06 + sub pick_one ($self) { + return undef if $self->is_empty; + + my @size_prefs = $self->size_preferences; + my @region_prefs = $self->region_preferences; + + my %size_rank = map { $size_prefs[$_] => $_ } 0 .. $#size_prefs; + my %region_rank = map { $region_prefs[$_] => $_ } 0 .. $#region_prefs; + + my ($pick) = + map { $_->[0] } + sort { $a->[1] <=> $b->[1] || $a->[2] <=> $b->[2] || $a->[3] <=> $b->[3] } + map {[ + $_, + ($self->prefer_proximity + ? ($region_rank{$_->{region}} // scalar @region_prefs) + : ($size_rank{$_->{size}} // scalar @size_prefs)), + $_->{price_hourly} // 0, + rand, + ]} + $self->candidates; + + return $pick; + } + no Moose; __PACKAGE__->meta->make_immutable; } @@ -124,14 +231,22 @@ async sub create_droplet ($self, $spec) { my $name = $self->box_name_for($spec->username, $spec->label); - my $region = $spec->region; - $self->handle_message("Creating $name in \U$region\E, this will take a minute or two."); - # It would be nice to do these in parallel, but in testing that causes # *super* strange errors deep down in IO::Async if one of the calls fails and # the other does not, so here we'll just admit defeat and do them in # sequence. -- michael, 2020-04-02 - my $snapshot_id = await $self->_get_snapshot_id($spec); + my ($snapshot_id, $snapshot); + if (defined $spec->image_id) { + $snapshot_id = $spec->image_id; + } else { + $snapshot = await $self->get_snapshot_for_version($spec->version); + $snapshot_id = $snapshot->{id}; + } + + my ($size, $region) = await $self->_resolve_size_and_region($spec, $snapshot); + + $self->handle_message("Creating $name in \U$region\E, this will take a minute or two."); + my $ssh_key = await $self->_get_ssh_key($spec); # We get this early so that we don't bother creating the Droplet if we're not @@ -140,8 +255,8 @@ async sub create_droplet ($self, $spec) { my %droplet_create_args = ( name => $name, - region => $spec->region, - size => $spec->size, + region => $region, + size => $size, image => $snapshot_id, ssh_keys => [ $ssh_key->{id} ], tags => [ "owner:" . $spec->username, $spec->extra_tags->@* ], @@ -226,22 +341,92 @@ async sub create_droplet ($self, $spec) { return; } -async sub _get_snapshot_id ($self, $spec) { - if (defined $spec->image_id) { - return $spec->image_id; +async sub find_provisioning_candidates ($self, %args) { + my $snapshot = $args{snapshot}; + my $size_prefs = $args{size_preferences} // []; + my $region_prefs = $args{region_preferences} // []; + my $fallback = $args{fallback_to_anywhere}; + my $prefer_proximity = $args{prefer_proximity} // 0; + my $max_price = $args{max_price}; + my $min_price = $args{min_price}; + my $min_cpu = $args{min_cpu}; + my $min_disk = $args{min_disk}; + my $min_ram = $args{min_ram}; # in GB; API stores memory in MB + + my %want_size = map { $_ => 1 } @$size_prefs; + my %want_region = map { $_ => 1 } @$region_prefs; + + my %snap_region; + if ($snapshot) { + %snap_region = map { $_ => 1 } $snapshot->{regions}->@*; + } + + my $all_sizes = await $self->dobby->json_get_pages_of('/sizes', 'sizes'); + + my @filtered_sizes = + grep { $_->{available} } + grep { !@$size_prefs || $want_size{$_->{slug}} } + grep { !defined $max_price || $_->{price_hourly} <= $max_price } + grep { !defined $min_price || $_->{price_hourly} >= $min_price } + grep { !defined $min_cpu || $_->{vcpus} >= $min_cpu } + grep { !defined $min_disk || $_->{disk} >= $min_disk } + grep { !defined $min_ram || $_->{memory} >= $min_ram * 1024 } + @$all_sizes; + + my (@candidates, @region_excluded); + for my $size (@filtered_sizes) { + my @valid_regions = $size->{regions}->@*; + + @valid_regions = grep { $snap_region{$_} } @valid_regions if $snapshot; + + # When region_preferences are given and fallback is off, restrict to only + # preferred regions. With fallback on, all valid regions are candidates + # (the preference ordering only affects pick_one, not filtering). + my @excluded_regions; + if (@$region_prefs && !$fallback) { + @excluded_regions = grep { !$want_region{$_} } @valid_regions; + @valid_regions = grep { $want_region{$_} } @valid_regions; + } + + my $meta = { + size => $size->{slug}, + price_hourly => $size->{price_hourly}, + vcpus => $size->{vcpus}, + memory => $size->{memory}, + disk => $size->{disk}, + description => $size->{description}, + }; + + push @candidates, map { +{ %$meta, region => $_ } } @valid_regions; + push @region_excluded, map { +{ %$meta, region => $_ } } @excluded_regions; } - my $region = $spec->region; + return Dobby::BoxManager::CandidateSet->new( + candidates => \@candidates, + region_excluded_candidates => \@region_excluded, + size_preferences => $size_prefs, + region_preferences => $region_prefs, + prefer_proximity => $prefer_proximity, + ); +} - my $snapshot = await $self->get_snapshot_for_version($spec->version); - my %snapshot_regions = map {; $_ => 1 } $snapshot->{regions}->@*; +async sub _resolve_size_and_region ($self, $spec, $snapshot) { + my $candidates = await $self->find_provisioning_candidates( + snapshot => $snapshot, + size_preferences => $spec->size_preferences, + region_preferences => $spec->region_preferences, + fallback_to_anywhere => $spec->fallback_to_anywhere, + prefer_proximity => $spec->prefer_proximity, + ); - unless ($snapshot_regions{$region}) { - my $region_list = join q{, }, map {; uc } sort $snapshot->{regions}->@*; - $self->handle_error("The snapshot you want ($snapshot->{name}) isn't available in \U$region\E. You could create it in any of these regions: $region_list"); + if ($candidates->is_empty) { + $self->handle_error( + "No available combination of preferred sizes and regions was found." + ); } - return $snapshot->{id}; + my $pick = $candidates->pick_one; + return ($pick->{size}, $pick->{region}); } sub _get_my_ssh_key_file ($self, $spec) { diff --git a/lib/Dobby/Boxmate/App/Command/create.pm b/lib/Dobby/Boxmate/App/Command/create.pm index 2c9a166..890c3b9 100644 --- a/lib/Dobby/Boxmate/App/Command/create.pm +++ b/lib/Dobby/Boxmate/App/Command/create.pm @@ -12,8 +12,9 @@ sub usage_desc { '%c create %o LABEL' } sub opt_spec { return ( - [ 'region=s', 'what region to create the box in' ], - [ 'size|s=s', 'DigitalOcean slug for the Droplet size' ], + [ 'region=s@', 'region preference; repeat to add fallbacks in order' ], + [ 'any-region!', "if true, fall back to any region; otherwise, won't" ], + [ 'size|s=s@', 'size preference; repeat to add fallbacks in order' ], [ 'version|v=s', 'image version to use' ], [ 'snapshot-id|snapshot=i', 'DigitalOcean snapshot to use (numeric id)' ], @@ -22,7 +23,9 @@ sub opt_spec { default => 'inabox', one_of => [ [ 'inabox', "create a Fastmail-in-a-box (default behavior)" ], - [ 'debian', "don't make a Fastmail-in-a-box, just Debian" ], + [ 'debian', "don't make a Fastmail-in-a-box, just some Debian" ], + [ 'bookworm', "don't make a Fastmail-in-a-box, just Debian 12" ], + [ 'trixie', "don't make a Fastmail-in-a-box, just Debian 13" ], [ 'docker', "don't make a Fastmail-in-a-box, just Docker" ], ] } @@ -78,15 +81,31 @@ sub execute ($self, $opt, $args) { my @setup_args = split /\s+/, ($opt->setup_args // q{}); + # If you passed --any-region or --no-any-region, we respect that. Otherwise, + # we fall back to your config. The default config is false. + my $region_fallback = defined $opt->any_region + ? $opt->any_region + : $config->fallback_to_anywhere; + my $spec = Dobby::BoxManager::ProvisionRequest->new({ version => $opt->version // $config->version, label => $label, - size => $opt->size // $config->size, username => $config->username, - region => lc($opt->region // $config->region), + + size_preferences => ($opt->size ? $opt->size : $config->size_preferences), + + ($opt->region + ? (region_preferences => [ map { lc } $opt->region->@* ]) + : ($config->has_region_preferences && ! $opt->any_region + ? (region_preferences => $config->region_preferences) + : ())), + + ($region_fallback ? (fallback_to_anywhere => 1) : ()), ($opt->snapshot_id ? (run_standard_setup => 0, image_id => $opt->snapshot_id) :$opt->debian ? (run_standard_setup => 0, image_id => 'debian-12-x64') + :$opt->bookworm ? (run_standard_setup => 0, image_id => 'debian-12-x64') + :$opt->trixie ? (run_standard_setup => 0, image_id => 'debian-13-x64') :$opt->docker ? (run_standard_setup => 0, image_id => 'docker-20-04') : (%INABOX_SPEC)), diff --git a/lib/Dobby/Boxmate/App/Command/sizes.pm b/lib/Dobby/Boxmate/App/Command/sizes.pm new file mode 100644 index 0000000..244c858 --- /dev/null +++ b/lib/Dobby/Boxmate/App/Command/sizes.pm @@ -0,0 +1,150 @@ +package Dobby::Boxmate::App::Command::sizes; +use Dobby::Boxmate::App -command; + +# ABSTRACT: show available droplet sizes for an image + +use v5.36.0; +use utf8; + +sub abstract { 'show available droplet sizes for an image' } + +sub usage_desc { '%c sizes %o' } + +sub opt_spec { + return ( + [ 'version|v=s', 'image version to use' ], + [ 'snapshot-id|snapshot=i', 'DigitalOcean snapshot to use (numeric id)' ], + [ 'price=f', 'target hourly price; show sizes within 50%-200% of this value' ], + [ 'min-disk=i', 'minimum disk size in GB' ], + [ 'min-cpu=i', 'minimum number of vCPUs' ], + [ 'min-ram=i', 'minimum RAM in GB' ], + [ 'from-config', 'apply size/region preferences from ~/.boxmate.toml' ], + [], + [ 'type' => 'hidden' => { + default => 'inabox', + one_of => [ + [ 'inabox', "fminabox snapshot (default)" ], + [ 'debian', "stock Debian image" ], + [ 'docker', "stock Docker image" ], + ] + } + ], + ); +} + +sub validate_args ($self, $opt, $args) { + if (defined $opt->snapshot_id) { + $opt->snapshot_id =~ /\A[0-9]+\z/ + || die "The snapshot id must be numeric\n"; + + die "You can't use --snapshot-id and --version together\n" if defined $opt->version; + } +} + +sub execute ($self, $opt, $args) { + my $config = $self->app->config; + my $boxman = $self->boxman; + my $dobby = $boxman->dobby; + + my ($snapshot, $image_name); + + if (defined $opt->snapshot_id) { + my $res = $dobby->json_get("/snapshots/" . $opt->snapshot_id, { undef_if_404 => 1 })->get; + $snapshot = $res && $res->{snapshot}; + die "No snapshot found for id " . $opt->snapshot_id . "\n" unless $snapshot; + $image_name = $snapshot->{name}; + } elsif ($opt->debian) { + $image_name = 'debian-12-x64 (stock, all regions)'; + } elsif ($opt->docker) { + $image_name = 'docker-20-04 (stock, all regions)'; + } else { + my $version = $opt->version // $config->version; + $snapshot = $boxman->get_snapshot_for_version($version)->get; + $image_name = $snapshot->{name}; + } + + my $candidate_set = $boxman->find_provisioning_candidates( + snapshot => $snapshot, + ($opt->from_config ? ( + size_preferences => $config->size_preferences, + ($config->has_region_preferences + ? (region_preferences => $config->region_preferences) : ()), + ($config->fallback_to_anywhere + ? (fallback_to_anywhere => 1) : ()), + ) : ()), + (defined $opt->price ? (max_price => $opt->price * 2.0, + min_price => $opt->price * 0.5) : ()), + (defined $opt->min_disk ? (min_disk => $opt->min_disk) : ()), + (defined $opt->min_cpu ? (min_cpu => $opt->min_cpu) : ()), + (defined $opt->min_ram ? (min_ram => $opt->min_ram) : ()), + )->get; + + my @all_candidates = $candidate_set->candidates; + my @excluded = $candidate_set->region_excluded_candidates; + + # One display row per unique size slug; use the first candidate seen for + # the size's metadata (all candidates for a slug share the same metadata). + my %size_meta; + for my $c (@all_candidates, @excluded) { + $size_meta{$c->{size}} //= $c; + } + + my @sizes = sort { ($a->{description} // '') cmp ($b->{description} // '') + || $a->{price_hourly} <=> $b->{price_hourly} } + values %size_meta; + + my %seen_region; + my @sorted_regions = sort grep { !$seen_region{$_}++ } + map { $_->{region} } + (@all_candidates, @excluded); + + my (%valid, %region_excluded); + for my $c (@all_candidates) { $valid{$c->{size}}{$c->{region}} = 1 } + for my $c (@excluded) { $region_excluded{$c->{size}}{$c->{region}} = 1 } + + require Text::Table; + require Term::ANSIColor; + + my @region_columns = map { { title => $_, align_title => 'center' } } @sorted_regions; + + my $table = Text::Table->new( + 'slug', + 'category', + { title => 'mem', align => 'right', align_title => 'right' }, + { title => 'vcpu', align => 'right', align_title => 'right' }, + { title => 'disk', align => 'right', align_title => 'right' }, + { title => '$/hr', align => 'right', align_title => 'right' }, + @region_columns, + ); + + for my $size (@sizes) { + my $mem = $size->{memory} >= 1024 + ? ($size->{memory} / 1024) . 'G' + : $size->{memory} . 'M'; + + my @region_cells = map { + $valid{$size->{size}}{$_} ? "\N{CHECK MARK}" + : $region_excluded{$size->{size}}{$_} ? "\N{BALLOT X}" + : q{ } + } @sorted_regions; + + $table->add( + $size->{size}, + $size->{description} // '', + $mem, + $size->{vcpus}, + $size->{disk} . 'G', + sprintf('$%.3f', $size->{price_hourly}), + @region_cells, + ); + } + + say "Image: $image_name"; + say ""; + print Term::ANSIColor::colored(['bold', 'bright_white'], qq{ $_}) for $table->title; + print for $table->body; + + return; +} + +1; diff --git a/lib/Dobby/Boxmate/Config.pm b/lib/Dobby/Boxmate/Config.pm index 56bd1ce..5e5b704 100644 --- a/lib/Dobby/Boxmate/Config.pm +++ b/lib/Dobby/Boxmate/Config.pm @@ -3,6 +3,7 @@ use Moose; use v5.36.0; +use Carp (); use Defined::KV qw(defined_kv); use Path::Tiny (); @@ -12,8 +13,23 @@ has ssh_key_id => (is => 'ro', isa => 'Str', predicate => 'has_ssh_key_id'); has digitalocean_ssh_key_name => (is => 'ro', isa => 'Str', required => 1); # ProvisioningSpec config: -has region => (is => 'ro', isa => 'Str', default => 'nyc3'); -has size => (is => 'ro', isa => 'Str', default => 'g-4vcpu-16gb'); +has size_preferences => ( + is => 'ro', + isa => 'ArrayRef[Str]', + default => sub { ['g-4vcpu-16gb'] }, +); + +has region_preferences => ( + is => 'ro', + isa => 'ArrayRef[Str]', + predicate => 'has_region_preferences', +); + +has fallback_to_anywhere => ( + is => 'ro', + isa => 'Bool', +); + has username => (is => 'ro', isa => 'Str', default => $ENV{USER}); has version => (is => 'ro', isa => 'Str', default => 'bookworm'); @@ -30,6 +46,19 @@ has setup_switches => ( default => sub { [] }, ); +sub BUILDARGS ($class, @rest) { + my %args = @rest == 1 ? $rest[0]->%* : @rest; + + for my $attr (qw( size region )) { + my $pref = "${attr}_preferences"; + Carp::confess("Config: provide at most one of '$attr' or '$pref'") + if exists $args{$attr} && exists $args{$pref}; + $args{$pref} = [ delete $args{$attr} ] if exists $args{$attr}; + } + + return \%args; +} + sub load ($class) { my $config_file = Path::Tiny::path("~/.boxmate.toml"); diff --git a/lib/Dobby/Client.pm b/lib/Dobby/Client.pm index 5be51ae..b283eb7 100644 --- a/lib/Dobby/Client.pm +++ b/lib/Dobby/Client.pm @@ -37,7 +37,7 @@ sub api_base { sub bearer_token { $_[0]{__dobby_bearer_token} } -sub http ($self) { +sub _http ($self) { return $self->{__dobby_http} //= do { my $http = Net::Async::HTTP->new( user_agent => 'Dobby/0', @@ -50,10 +50,14 @@ sub http ($self) { }; } +sub _do_request ($self, @rest) { + $self->_http->do_request(@rest); +} + async sub json_get ($self, $path, $arg=undef) { my $undef_if_404 = $arg && $arg->{undef_if_404}; - my $res = await $self->http->do_request( + my $res = await $self->_do_request( method => 'GET', uri => $self->api_base . $path, headers => { @@ -79,7 +83,7 @@ async sub json_get_pages_of ($self, $path, $key) { my @items; while ($url) { - my $res = await $self->http->do_request( + my $res = await $self->_do_request( method => 'GET', uri => $url, headers => { @@ -105,7 +109,7 @@ async sub json_get_pages_of ($self, $path, $key) { } async sub _json_req_with_body ($self, $method, $path, $payload) { - my $res = await $self->http->do_request( + my $res = await $self->_do_request( method => $method, uri => $self->api_base . $path, headers => { @@ -133,7 +137,7 @@ async sub json_put ($self, $path, $payload) { } async sub delete_url ($self, $path) { - my $res = await $self->http->do_request( + my $res = await $self->_do_request( method => 'DELETE', uri => $self->api_base . $path, headers => { diff --git a/t/boxmanager.t b/t/boxmanager.t new file mode 100644 index 0000000..1ed6e77 --- /dev/null +++ b/t/boxmanager.t @@ -0,0 +1,108 @@ +use v5.36.0; +use utf8; + +use lib 't/lib'; + +use Dobby::BoxManager; +use Dobby::TestClient; + +use Test::More; +use Test::Deep ':v1'; + +my %base = ( + box_domain => 'fm.example.com', + error_cb => sub ($err, @) { die $err }, + message_cb => sub { }, + log_cb => sub { }, +); + +my @TEST_SNAPSHOTS = ( + { + id => 1, + name => 'fminabox-1.0-20260101', + created_at => '2026-01-01T00:00:00Z', + regions => [qw(nyc sfo)] + }, + { + id => 2, + name => 'fminabox-1.0-20260201', + created_at => '2026-02-01T00:00:00Z', + regions => [qw(nyc sfo)] + }, + { + id => 3, + name => 'fminabox-2.0-20260101', + created_at => '2026-01-01T00:00:00Z', + regions => [qw(nyc)] + }, +); + +sub make_dobby () { + Dobby::TestClient->new(bearer_token => 'test-token'); +} + +sub new_boxman_fail_ok ($extra, $expect, $description) { + local $Test::Builder::Level = $Test::Builder::Level + 1; + eval { Dobby::BoxManager->new(dobby => make_dobby(), %base, %$extra) }; + cmp_deeply($@, $expect, $description); +} + +sub snapshot_for_version_ok ($version, $expect, $description) { + local $Test::Builder::Level = $Test::Builder::Level + 1; + my $dobby = make_dobby(); + $dobby->register_url_json('/snapshots', { snapshots => \@TEST_SNAPSHOTS }); + my $boxman = Dobby::BoxManager->new(dobby => $dobby, %base, logsnippet_cb => sub { }); + my $got = $boxman->get_snapshot_for_version($version)->get; + cmp_deeply($got, $expect, $description); +} + +sub snapshot_for_version_fail_ok ($version, $expect, $description) { + local $Test::Builder::Level = $Test::Builder::Level + 1; + my $dobby = make_dobby(); + $dobby->register_url_json('/snapshots', { snapshots => \@TEST_SNAPSHOTS }); + my $boxman = Dobby::BoxManager->new(dobby => $dobby, %base, logsnippet_cb => sub { }); + eval { $boxman->get_snapshot_for_version($version)->get }; + cmp_deeply($@, $expect, $description); +} + +new_boxman_fail_ok( + {}, + re(qr/requires one of taskstream_cb or logsnippet_cb but neither/), + 'BoxManager requires at least one stream callback', +); + +new_boxman_fail_ok( + { taskstream_cb => sub { }, logsnippet_cb => sub { } }, + re(qr/requires one of taskstream_cb or logsnippet_cb but both/), + 'BoxManager rejects both stream callbacks at once', +); + +snapshot_for_version_ok( + '1.0', + { + id => 2, + name => 'fminabox-1.0-20260201', + created_at => '2026-02-01T00:00:00Z', + regions => [qw(nyc sfo)] + }, + 'returns the most recent snapshot for the requested version', +); + +snapshot_for_version_ok( + '2.0', + { + id => 3, + name => 'fminabox-2.0-20260101', + created_at => '2026-01-01T00:00:00Z', + regions => [qw(nyc)] + }, + 'version filter excludes snapshots for other versions', +); + +snapshot_for_version_fail_ok( + '3.0', + re(qr/no snapshot found for fminabox-3\.0/), + 'error when no snapshot matches the requested version', +); + +done_testing; diff --git a/t/find-candidates.t b/t/find-candidates.t new file mode 100644 index 0000000..990c75a --- /dev/null +++ b/t/find-candidates.t @@ -0,0 +1,230 @@ +use v5.36.0; +use utf8; + +use lib 't/lib'; + +use Dobby::BoxManager; +use Dobby::TestClient; + +use Test::More; +use Test::Deep ':v1'; + +# Sizes are named for their relative cost to make tests easy to read. Fields +# are set to make each filter test unambiguous: e.g. only 'large' and 'xlarge' +# have vcpus >= 4, disk >= 100, or memory >= 8 GB. +my $sizes_page = { + sizes => [ + { slug => 'small', available => 1, regions => [qw(nyc sfo)], + price_hourly => 0.02, vcpus => 1, disk => 25, memory => 1024 }, + { slug => 'medium', available => 1, regions => [qw(nyc sfo ams)], + price_hourly => 0.05, vcpus => 2, disk => 50, memory => 4096 }, + { slug => 'large', available => 1, regions => [qw(nyc sfo ams)], + price_hourly => 0.10, vcpus => 4, disk => 100, memory => 8192 }, + { slug => 'xlarge', available => 1, regions => [qw(nyc)], + price_hourly => 0.20, vcpus => 8, disk => 200, memory => 16384 }, + { slug => 'gone', available => 0, regions => [qw(nyc sfo ams)], + price_hourly => 0.03, vcpus => 2, disk => 50, memory => 4096 }, + ], +}; + +my sub _get_set (%args) { + my $dobby = Dobby::TestClient->new(bearer_token => 'test-token'); + $dobby->register_url_json('/sizes', $sizes_page); + + my $boxman = Dobby::BoxManager->new( + dobby => $dobby, + box_domain => 'fm.example.com', + error_cb => sub ($err, @) { die $err }, + message_cb => sub { }, + log_cb => sub { }, + logsnippet_cb => sub { }, + ); + + return $boxman->find_provisioning_candidates(%args)->get; +} + +# Shorthand for a candidate with the given size and region (ignoring other fields). +sub pair ($size, $region) { + return superhashof({ size => $size, region => $region }); +} + +sub candidates_ok ($args, $expect, $description) { + local $Test::Builder::Level = $Test::Builder::Level + 1; + cmp_deeply( + [ _get_set(%$args)->candidates ], + $expect, + "$description: got the expected candidate set", + ); +} + +sub excluded_ok ($args, $expect, $description) { + local $Test::Builder::Level = $Test::Builder::Level + 1; + cmp_deeply( + [ _get_set(%$args)->region_excluded_candidates ], + $expect, + "$description: got the expected excluded candidates", + ); +} + +sub pick_ok ($args, $expect, $description) { + local $Test::Builder::Level = $Test::Builder::Level + 1; + cmp_deeply( + _get_set(%$args)->pick_one, + $expect, + "$description: picked the expected candidate", + ); +} + +candidates_ok( + {}, + bag( + pair('small', 'nyc'), pair('small', 'sfo'), + pair('medium', 'nyc'), pair('medium', 'sfo'), pair('medium', 'ams'), + pair('large', 'nyc'), pair('large', 'sfo'), pair('large', 'ams'), + pair('xlarge', 'nyc'), + ), + 'no filters: all available sizes in all their regions; unavailable excluded', +); + +candidates_ok( + { max_price => 0.05 }, + bag( + pair('small', 'nyc'), pair('small', 'sfo'), + pair('medium', 'nyc'), pair('medium', 'sfo'), pair('medium', 'ams'), + ), + 'max_price excludes sizes above the limit', +); + +candidates_ok( + { min_price => 0.05 }, + bag( + pair('medium', 'nyc'), pair('medium', 'sfo'), pair('medium', 'ams'), + pair('large', 'nyc'), pair('large', 'sfo'), pair('large', 'ams'), + pair('xlarge', 'nyc'), + ), + 'min_price excludes sizes below the limit', +); + +candidates_ok( + { min_cpu => 4 }, + bag( + pair('large', 'nyc'), pair('large', 'sfo'), pair('large', 'ams'), + pair('xlarge', 'nyc'), + ), + 'min_cpu excludes sizes with fewer vcpus', +); + +candidates_ok( + { min_disk => 100 }, + bag( + pair('large', 'nyc'), pair('large', 'sfo'), pair('large', 'ams'), + pair('xlarge', 'nyc'), + ), + 'min_disk excludes sizes with smaller disks', +); + +candidates_ok( + { min_ram => 8 }, + bag( + pair('large', 'nyc'), pair('large', 'sfo'), pair('large', 'ams'), + pair('xlarge', 'nyc'), + ), + 'min_ram excludes sizes below the threshold (arg is GB, API uses MB)', +); + +candidates_ok( + { snapshot => { name => 'test-snap', regions => [qw(nyc)] } }, + bag( + pair('small', 'nyc'), + pair('medium', 'nyc'), + pair('large', 'nyc'), + pair('xlarge', 'nyc'), + ), + 'snapshot restricts candidates to regions where it is available', +); + +candidates_ok( + { size_preferences => [qw(small large)] }, + bag( + pair('small', 'nyc'), pair('small', 'sfo'), + pair('large', 'nyc'), pair('large', 'sfo'), pair('large', 'ams'), + ), + 'size_preferences restricts candidates to those slugs only', +); + +candidates_ok( + { region_preferences => [qw(ams)] }, + bag( + pair('medium', 'ams'), + pair('large', 'ams'), + ), + 'region_preferences without fallback restricts to preferred regions only', +); + +candidates_ok( + { + region_preferences => [qw(ams)], + fallback_to_anywhere => 1, + }, + bag( + pair('small', 'nyc'), pair('small', 'sfo'), + pair('medium', 'nyc'), pair('medium', 'sfo'), pair('medium', 'ams'), + pair('large', 'nyc'), pair('large', 'sfo'), pair('large', 'ams'), + pair('xlarge', 'nyc'), + ), + 'fallback_to_anywhere with region_preferences includes all valid regions', +); + +# With max_price=0.06, small(0.02) and medium(0.05) are candidates. +# No size preferences, so price alone decides: small wins. +pick_ok( + { max_price => 0.06 }, + pair('small', ignore()), + 'no size preferences: cheapest candidate wins', +); + +# large is preferred over small despite costing more. +pick_ok( + { size_preferences => [qw(large small)] }, + pair('large', ignore()), + 'size preference rank beats price', +); + +# prefer_proximity=1: ams is the preferred region. medium and large are both +# in ams; medium(0.05) is cheaper, so it wins. +pick_ok( + { + region_preferences => [qw(ams)], + prefer_proximity => 1, + }, + pair('medium', 'ams'), + 'prefer_proximity: preferred region is primary, cheapest size in that region wins', +); + +excluded_ok( + {}, + [], + 'no region preferences: no excluded candidates', +); + +excluded_ok( + { region_preferences => [qw(ams)], fallback_to_anywhere => 1 }, + [], + 'fallback_to_anywhere: no excluded candidates even with region preferences', +); + +# With region_preferences=[ams] and no fallback, non-ams regions are excluded. +# small/xlarge have no ams presence at all, so all their regions are excluded. +# medium/large have ams, so only their non-ams regions are excluded. +excluded_ok( + { region_preferences => [qw(ams)] }, + bag( + pair('small', 'nyc'), pair('small', 'sfo'), + pair('medium', 'nyc'), pair('medium', 'sfo'), + pair('large', 'nyc'), pair('large', 'sfo'), + pair('xlarge', 'nyc'), + ), + 'non-preferred regions land in region_excluded_candidates', +); + +done_testing; diff --git a/t/lib/Dobby/TestClient.pm b/t/lib/Dobby/TestClient.pm new file mode 100644 index 0000000..f545639 --- /dev/null +++ b/t/lib/Dobby/TestClient.pm @@ -0,0 +1,59 @@ +package Dobby::TestClient; +use v5.36.0; + +# A subclass of Dobby::Client suitable for unit tests. It replaces +# Net::Async::HTTP with Test::Async::HTTP and routes requests to per-path +# handler coderefs registered by the test, so no real network access occurs. + +use parent 'Dobby::Client'; + +use Carp (); +use HTTP::Response; +use JSON::MaybeXS; +use Test::Async::HTTP; + +# Test::Async::HTTP isn't an IO::Async::Notifier, so it doesn't need to be +# added to the loop. +sub _http ($self) { + return $self->{__test_http} //= Test::Async::HTTP->new; +} + +# The path is relative to /v2, without respect to query strings. +# +# The handler is called with a HTTP::Request and must return an HTTP::Response. +sub register_url_handler ($self, $path, $handler) { + $self->{__test_url_handlers}{$path} = $handler; +} + +# Convenience: register a handler that always responds with $data encoded as +# JSON with a 200 status. +sub register_url_json ($self, $path, $data) { + $self->register_url_handler($path, sub ($req) { + my $res = HTTP::Response->new(200, 'OK'); + $res->header('Content-Type' => 'application/json'); + $res->content(encode_json($data)); + return $res; + }); +} + +sub _do_request ($self, %args) { + # Test::Async::HTTP doesn't handle headers or content_type; strip them + # before passing through (we never need to inspect auth headers in tests). + delete @args{qw(headers content_type)}; + + my $f = $self->_http->do_request(%args); + + my $base = $self->api_base; + (my $path = $args{uri}) =~ s{\A\Q$base\E}{}; + $path =~ s{\?.*\z}{}; + + my $handler = $self->{__test_url_handlers}{$path} + or Carp::confess("Dobby::TestClient: no handler registered for $path"); + + my $pending = $self->_http->next_pending; + $pending->respond($handler->($pending->request)); + + return $f; +} + +1; diff --git a/t/provision-request.t b/t/provision-request.t new file mode 100644 index 0000000..157c272 --- /dev/null +++ b/t/provision-request.t @@ -0,0 +1,75 @@ +use v5.36.0; +use utf8; + +use Dobby::BoxManager; + +use Test::More; +use Test::Deep ':v1'; + +my %base = ( + username => 'testuser', + version => '1.0', + label => 'mybox', +); + +# Pass a hashref to use hashref construction; pass an arrayref to use +# flat-list construction (the array is spread as the argument list). +sub new_request_ok ($spec, $expect, $description) { + local $Test::Builder::Level = $Test::Builder::Level + 1; + my $req = ref $spec eq 'ARRAY' + ? eval { Dobby::BoxManager::ProvisionRequest->new(@$spec) } + : eval { Dobby::BoxManager::ProvisionRequest->new($spec) }; + cmp_deeply($req, $expect, $description); +} + +sub new_request_fail_ok ($spec, $expect, $description) { + local $Test::Builder::Level = $Test::Builder::Level + 1; + ref $spec eq 'ARRAY' + ? eval { Dobby::BoxManager::ProvisionRequest->new(@$spec) } + : eval { Dobby::BoxManager::ProvisionRequest->new($spec) }; + cmp_deeply($@, $expect, $description); +} + +new_request_ok( + { %base, size => 'big', region => 'nyc' }, + methods(size_preferences => ['big'], region_preferences => ['nyc']), + 'size and region are normalized to single-element preference lists', +); + +new_request_ok( + [%base, size => 'big', region => 'nyc'], + methods(size_preferences => ['big'], region_preferences => ['nyc']), + 'flat-list construction normalizes the same way as hashref', +); + +new_request_ok( + { %base, size_preferences => [qw(big small)], region_preferences => [qw(nyc sfo)] }, + methods(size_preferences => [qw(big small)], region_preferences => [qw(nyc sfo)]), + 'size_preferences and region_preferences pass through unchanged', +); + +new_request_ok( + { %base, size => 'big' }, + methods(size_preferences => ['big'], region_preferences => []), + 'omitting region leaves region_preferences empty', +); + +new_request_fail_ok( + { %base, size => 'big', size_preferences => ['big'], region => 'nyc' }, + re(qr/exactly one of 'size' or 'size_preferences'/), + 'size and size_preferences together are rejected', +); + +new_request_fail_ok( + { %base, region => 'nyc' }, + re(qr/exactly one of 'size' or 'size_preferences'/), + 'omitting both size and size_preferences is rejected', +); + +new_request_fail_ok( + { %base, size => 'big', region => 'nyc', region_preferences => ['nyc'] }, + re(qr/at most one of 'region' or 'region_preferences'/), + 'region and region_preferences together are rejected', +); + +done_testing; diff --git a/t/resolve-size-region.t b/t/resolve-size-region.t new file mode 100644 index 0000000..6a69e13 --- /dev/null +++ b/t/resolve-size-region.t @@ -0,0 +1,197 @@ +use v5.36.0; +use utf8; + +use lib 't/lib'; + +use Dobby::BoxManager; +use Dobby::TestClient; + +use Test::More; +use Test::Deep ':v1'; + +# The size slugs are nonsense, but naming them this way makes the tests easier +# to read. +my $sizes_page = { + sizes => [ + { slug => 'everywhere', available => 1, regions => [qw(nyc sfo ams)] }, + { slug => 'not-ams', available => 1, regions => [qw(nyc sfo)] }, + { slug => 'only-sfo', available => 1, regions => [qw(sfo)] }, + { slug => 'nowhere', available => 0, regions => [qw(nyc sfo ams)] }, + ], +}; + +my sub _get_size_and_region ($spec, $snapshot) { + my $dobby = Dobby::TestClient->new(bearer_token => 'test-token'); + $dobby->register_url_json('/sizes', $sizes_page); + + my $boxman = Dobby::BoxManager->new( + dobby => $dobby, + box_domain => 'fm.example.com', + error_cb => sub ($err, @) { die $err }, + message_cb => sub { }, + log_cb => sub { }, + logsnippet_cb => sub { }, + ); + + my $prov_req = Dobby::BoxManager::ProvisionRequest->new( + username => 'testuser', + version => '1.0', + label => 'test', + %$spec, + ); + + my %got; + @got{qw( size region )} = $boxman->_resolve_size_and_region($prov_req, $snapshot)->get; + + return \%got; +} + +sub box_choices_ok ($spec, $snapshot, $expect, $description) { + local $Test::Builder::Level = $Test::Builder::Level + 1; + + my $got = _get_size_and_region($spec, $snapshot); + + cmp_deeply( + $got, + $expect, + "$description: picked the expected size/region", + ); +} + +sub box_choices_fail_ok ($spec, $snapshot, $expect, $description) { + local $Test::Builder::Level = $Test::Builder::Level + 1; + + eval { _get_size_and_region($spec, $snapshot) }; + + my $error = $@; + + cmp_deeply( + $error, + $expect, + "$description: jailed the expected way", + ); +} + +# This mocks up just as much of a Digital Ocean API snapshot object as is +# needed for the region picker to do its thing. +sub found_in (@regions) { + return { name => 'fminabox-1.0', regions => [ @regions ] }; +} + +box_choices_ok( + { size => 'everywhere', region => 'nyc' }, + found_in(qw( nyc sfo )), + { size => 'everywhere', region => 'nyc' }, + 'single option, and you can have it', +); + +box_choices_fail_ok( + { size => 'everywhere', region => 'nyc' }, + found_in(qw(sfo)), + re(qr/No available combination/), + "single option, not available", +); + +box_choices_ok( + { size => 'everywhere', region => 'nyc' }, + undef, + { size => 'everywhere', region => 'nyc' }, + 'no snapshot means no region check', +); + +box_choices_ok( + { + size_preferences => [qw(not-ams everywhere)], + region => 'nyc', + }, + found_in(qw(nyc sfo)), + { size => 'not-ams', region => 'nyc' }, + 'multiple sizes: first size available in region is chosen', +); + +box_choices_ok( + { + size_preferences => [qw(only-sfo everywhere)], + region => 'nyc', + }, + found_in(qw(nyc sfo)), + { size => 'everywhere', region => 'nyc' }, + 'only-sfo is only in sfo, so it falls back to everywhere' +); + + +# prefer_proximity=0 (default): size wins. +# only-sfo is not in nyc but IS in sfo → (only-sfo, sfo) +# beats everywhere in nyc. +box_choices_ok( + { + size_preferences => [qw(only-sfo everywhere)], + region_preferences => [qw(nyc sfo)], + }, + found_in(qw(nyc sfo)), + { size => 'only-sfo', region => 'sfo' }, + 'prefer_proximity=0: we pick size over region', +); + +# prefer_proximity=1: region wins. +# Best result in nyc is everywhere (only-sfo not there) → (everywhere, nyc) +# beats (only-sfo, sfo). +box_choices_ok( + { + size_preferences => [qw(only-sfo everywhere)], + region_preferences => [qw(nyc sfo)], + prefer_proximity => 1, + }, + found_in(qw(nyc sfo)), + { size => 'everywhere', region => 'nyc' }, + 'prefer_proximity=1: we pick regionover size', +); + +# Snapshot has only sfo, so that is the only candidate region. +box_choices_ok( + { size => 'everywhere' }, + found_in(qw(sfo)), + { size => 'everywhere', region => 'sfo' }, + 'no region preference: picks from snapshot regions', +); + +box_choices_ok( + { + size_preferences => [qw(everywhere)], + region_preferences => [qw(nyc sfo)], + }, + found_in(qw(sfo)), + { size => 'everywhere', region => 'sfo' }, + "skip first-choice region because of snapshot availability", +); + +box_choices_ok( + { size => 'only-sfo' }, + undef, + { size => 'only-sfo', region => 'sfo' }, + 'no region, but size only in one region', +); + +box_choices_fail_ok( + { + size_preferences => [qw(only-sfo not-ams)], + region_preferences => [qw(ams)], + }, + found_in(qw(nyc sfo)), + re(qr/No available combination/), + 'nothing satisfies criteria' +); + +# only-sfo is not in ams, but fallback_to_anywhere lets it land in sfo. +box_choices_ok( + { + size_preferences => [qw(only-sfo)], + region_preferences => [qw(ams)], + fallback_to_anywhere => 1, + }, + found_in(qw(ams sfo)), + { size => 'only-sfo', region => 'sfo' }, + 'fallback_to_anywhere: preferred region misses, falls through to any region', +); + +done_testing;