From 59d7244e1a04cfea7c5ebc923a99dcf7fdf1abe6 Mon Sep 17 00:00:00 2001 From: Ricardo Signes Date: Thu, 8 May 2025 12:16:12 -0400 Subject: [PATCH 1/5] Command base: add maybe_droplet_from_prefix that doesn't assert --- lib/Dobby/Boxmate/App/Command.pm | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/Dobby/Boxmate/App/Command.pm b/lib/Dobby/Boxmate/App/Command.pm index 04a48a2..bd91ca1 100644 --- a/lib/Dobby/Boxmate/App/Command.pm +++ b/lib/Dobby/Boxmate/App/Command.pm @@ -9,7 +9,7 @@ sub boxman ($self) { $self->app->boxman; } -sub droplet_from_prefix ($self, $boxprefix) { +sub maybe_droplet_from_prefix ($self, $boxprefix) { length $boxprefix || $self->usage->die({ pre_text => "No box prefix provided.\n\n" }); @@ -21,6 +21,12 @@ sub droplet_from_prefix ($self, $boxprefix) { my $want_name = join q{.}, $boxprefix, $username, $boxman->box_domain; my ($droplet) = grep {; $_->{name} eq $want_name } @$droplets; + return $droplet; +} + +sub droplet_from_prefix ($self, $boxprefix) { + my $droplet = $self->maybe_droplet_from_prefix($boxprefix); + unless ($droplet) { die "Couldn't find box for $boxprefix\n"; } From d0ee146cf4ca5a1fde849510b483adf353403c0f Mon Sep 17 00:00:00 2001 From: Ricardo Signes Date: Thu, 8 May 2025 12:16:43 -0400 Subject: [PATCH 2/5] list: move the list printer into the base class This way, other commands can use it to print listings if they need to. --- lib/Dobby/Boxmate/App/Command.pm | 73 +++++++++++++++++++++++++++ lib/Dobby/Boxmate/App/Command/list.pm | 71 +------------------------- 2 files changed, 75 insertions(+), 69 deletions(-) diff --git a/lib/Dobby/Boxmate/App/Command.pm b/lib/Dobby/Boxmate/App/Command.pm index bd91ca1..a5c7965 100644 --- a/lib/Dobby/Boxmate/App/Command.pm +++ b/lib/Dobby/Boxmate/App/Command.pm @@ -34,4 +34,77 @@ sub droplet_from_prefix ($self, $boxprefix) { return $droplet; } +sub print_droplet_list ($self, $droplets, $username = undef) { + require DateTime::Format::RFC3339; + require Term::ANSIColor; + require Text::Table; + require Time::Duration; + + my $parser = DateTime::Format::RFC3339->new; + my $boxman = $self->boxman; + + # Ugh, should sort out the ME:: table formatter for general use. + my $table = Text::Table->new( + '', # Status + 'region', + ' ', # Type + ' ', # Default + 'name', + 'ip', + { title => 'age', align => 'right' }, + { title => 'cost', align => 'right' }, + { title => 'img age', align => 'right', align_title => 'right' }, + ); + + my $default; + if ($username) { + my ($rec) = grep {; $_->{type} eq 'CNAME' && $_->{name} eq $username } + $boxman->dobby->get_all_domain_records_for_domain($boxman->box_domain)->get; + + $default = $rec->{data}; + } + + for my $droplet (@$droplets) { + my $name = $droplet->{name}; + my $status = $droplet->{status}; + my $ip = $boxman->_ip_address_for_droplet($droplet); # XXX _method + my $image = $droplet->{image}; + + my $created = $parser->parse_datetime($droplet->{created_at}); + my $age_secs = time - $created->epoch; + + my $img_created = $parser->parse_datetime($image->{created_at}); + my $img_age_secs = time - $img_created->epoch; + + my $cost = sprintf '%4s', + '$' . builtin::ceil($droplet->{size}{price_hourly} * $age_secs / 3600); + + my $icon = ($image->{slug} && $image->{slug} =~ /^debian/) ? "\N{CYCLONE}" + : (($image->{description}//'') =~ /^Debian/) ? "\N{CYCLONE}" # Deb 11 + : ($image->{name} =~ /\Afminabox/ ) ? "\N{PACKAGE}" + : "\N{BLACK QUESTION MARK ORNAMENT}"; + + my $default = $default && $default eq $name + ? "\N{SPARKLES}" + : "\N{IDEOGRAPHIC SPACE}"; + + $table->add( + ($status eq 'active' ? "\N{LARGE GREEN CIRCLE}" : "\N{HEAVY MINUS SIGN}"), + $droplet->{region}{slug}, + "$icon\N{INVISIBLE SEPARATOR}", + "$default\N{INVISIBLE SEPARATOR}", + $name, + $ip, + Time::Duration::concise(Time::Duration::duration($age_secs, 1)), + $cost, + Time::Duration::concise(Time::Duration::duration($img_age_secs, 1)), + ); + } + + # This leading space is *bananas* and is here because Text::Table will think + # about LARGE GREEN CIRCLE as being one wide, but it's two. + print Term::ANSIColor::colored(['bold', 'bright_white'], qq{ $_}) for $table->title; + print qq{$_} for $table->body; +} + 1; diff --git a/lib/Dobby/Boxmate/App/Command/list.pm b/lib/Dobby/Boxmate/App/Command/list.pm index 86e131c..7d1f0bc 100644 --- a/lib/Dobby/Boxmate/App/Command/list.pm +++ b/lib/Dobby/Boxmate/App/Command/list.pm @@ -58,75 +58,8 @@ sub execute ($self, $opt, $args) { return; } - require DateTime::Format::RFC3339; - require Term::ANSIColor; - require Text::Table; - require Time::Duration; - - my $parser = DateTime::Format::RFC3339->new; - - # Ugh, should sort out the ME:: table formatter for general use. - my $table = Text::Table->new( - '', # Status - 'region', - ' ', # Type - ' ', # Default - 'name', - 'ip', - { title => 'age', align => 'right' }, - { title => 'cost', align => 'right' }, - { title => 'img age', align => 'right', align_title => 'right' }, - ); - - my $default; - unless ($opt->everything) { - my ($rec) = grep {; $_->{type} eq 'CNAME' && $_->{name} eq $username } - $boxman->dobby->get_all_domain_records_for_domain($boxman->box_domain)->get; - - $default = $rec->{data}; - } - - for my $droplet (@$droplets) { - my $name = $droplet->{name}; - my $status = $droplet->{status}; - my $ip = $self->boxman->_ip_address_for_droplet($droplet); # XXX _method - my $image = $droplet->{image}; - - my $created = $parser->parse_datetime($droplet->{created_at}); - my $age_secs = time - $created->epoch; - - my $img_created = $parser->parse_datetime($image->{created_at}); - my $img_age_secs = time - $img_created->epoch; - - my $cost = sprintf '%4s', - '$' . builtin::ceil($droplet->{size}{price_hourly} * $age_secs / 3600); - - my $icon = ($image->{slug} && $image->{slug} =~ /^debian/) ? "\N{CYCLONE}" - : (($image->{description}//'') =~ /^Debian/) ? "\N{CYCLONE}" # Deb 11 - : ($image->{name} =~ /\Afminabox/ ) ? "\N{PACKAGE}" - : "\N{BLACK QUESTION MARK ORNAMENT}"; - - my $default = $default && $default eq $name - ? "\N{SPARKLES}" - : "\N{IDEOGRAPHIC SPACE}"; - - $table->add( - ($status eq 'active' ? "\N{LARGE GREEN CIRCLE}" : "\N{HEAVY MINUS SIGN}"), - $droplet->{region}{slug}, - "$icon\N{INVISIBLE SEPARATOR}", - "$default\N{INVISIBLE SEPARATOR}", - $name, - $ip, - Time::Duration::concise(Time::Duration::duration($age_secs, 1)), - $cost, - Time::Duration::concise(Time::Duration::duration($img_age_secs, 1)), - ); - } - - # This leading space is *bananas* and is here because Text::Table will think - # about LARGE GREEN CIRCLE as being one wide, but it's two. - print Term::ANSIColor::colored(['bold', 'bright_white'], qq{ $_}) for $table->title; - print qq{$_} for $table->body; + $self->print_droplet_list($droplets, $username); + return; } 1; From d43f0343549c88e8fd630b507be370941c2e622e Mon Sep 17 00:00:00 2001 From: Ricardo Signes Date: Thu, 8 May 2025 12:17:25 -0400 Subject: [PATCH 3/5] Client: allow json_get to not throw on 404 Sometimes, a 404 is just a 404. --- lib/Dobby/Client.pm | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/Dobby/Client.pm b/lib/Dobby/Client.pm index 40b295c..86fc11f 100644 --- a/lib/Dobby/Client.pm +++ b/lib/Dobby/Client.pm @@ -50,7 +50,9 @@ sub http ($self) { }; } -async sub json_get ($self, $path) { +async sub json_get ($self, $path, $arg=undef) { + my $undef_if_404 = $arg && $arg->{undef_if_404}; + my $res = await $self->http->do_request( method => 'GET', uri => $self->api_base . $path, @@ -60,6 +62,10 @@ async sub json_get ($self, $path) { ); unless ($res->is_success) { + if ($undef_if_404 && $res->code == 404) { + return undef; + } + die "error getting $path at DigitalOcean: " . $res->as_string; } From c6dc4d950f2b046031615c0f7c42eedea447fbda Mon Sep 17 00:00:00 2001 From: Ricardo Signes Date: Thu, 8 May 2025 12:17:45 -0400 Subject: [PATCH 4/5] Client: add methods to get droplets by id or name --- lib/Dobby/Client.pm | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/lib/Dobby/Client.pm b/lib/Dobby/Client.pm index 86fc11f..5be51ae 100644 --- a/lib/Dobby/Client.pm +++ b/lib/Dobby/Client.pm @@ -253,6 +253,7 @@ async sub _do_action_status_f ($self, $action_url) { async sub _get_droplets ($self, $arg = {}) { my $path = '/droplets?per_page=200'; $path .= "&tag_name=$arg->{tag}" if $arg->{tag}; + $path .= "&name=$arg->{name}" if $arg->{name}; my $droplets_data = await $self->json_get($path); @@ -278,6 +279,25 @@ async sub get_droplets_with_tag ($self, $tag) { await $self->_get_droplets({ tag => $tag }); } +async sub get_droplet_by_id ($self, $id) { + $id =~ /\A[0-9]+\z/ + || Carp::croak("bogus id given to get_droplet_by_id; should be a string of digits"); + + my $path = "/droplets/$id"; + + my $droplet = await $self->json_get($path, { undef_if_404 => 1 }); + + return $droplet; +} + +async sub get_droplets_by_name ($self, $name) { + length $name + || Carp::croak("get_droplet_by_name without a name passed in"); + + my @droplets = await $self->_get_droplets({ name => $name }); + return @droplets; +} + async sub add_droplet_to_project ($self, $droplet_id, $project_id) { my $path = "/projects/$project_id/resources"; From 7e264434d1b3d968496dce33e9994bf5e267d273 Mon Sep 17 00:00:00 2001 From: Ricardo Signes Date: Thu, 8 May 2025 12:17:55 -0400 Subject: [PATCH 5/5] destroy: add new ways of finding droplets --- lib/Dobby/Boxmate/App/Command/destroy.pm | 54 +++++++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/lib/Dobby/Boxmate/App/Command/destroy.pm b/lib/Dobby/Boxmate/App/Command/destroy.pm index 2278819..14c808b 100644 --- a/lib/Dobby/Boxmate/App/Command/destroy.pm +++ b/lib/Dobby/Boxmate/App/Command/destroy.pm @@ -8,6 +8,21 @@ use utf8; sub abstract { 'destroy a box' } +sub opt_spec { + return ( + [ finder => hidden => { + default => 'by_prefix', + one_of => [ + [ 'by-prefix' => 'find box by prefix of default domain' ], + [ 'by-id|I' => 'find box by Droplet id' ], + [ 'by-name|N' => 'find box by Droplet name' ], + ], + } ], + [], + [ 'ip=s', 'only destroy if the public IP is this' ], + ); +} + sub usage_desc { '%c destroy %o BOXPREFIX', } @@ -18,9 +33,44 @@ sub validate_args ($self, $opt, $args) { sub execute ($self, $opt, $args) { my $boxman = $self->boxman; - my $droplet = $self->droplet_from_prefix($args->[0]); + my $locator = $args->[0]; + + my $method = "_find_" . $opt->finder; + + my @droplets = $self->$method($opt, $locator); + + if ($opt->ip) { + @droplets = grep { + $opt->ip eq $self->boxman->_ip_address_for_droplet($_) # XXX _method + } @droplets; + } + + unless (@droplets) { + die "I couldn't find the box you want to destroy.\n"; + } + + if (@droplets > 1) { + $self->print_droplet_list(\@droplets, undef); + say ""; + + die "More than one box matched your criteria.\n"; + } + + $boxman->destroy_droplet($droplets[0], { force => 1 })->get; +} + +sub _find_by_prefix ($self, $opt, $locator) { + return $self->maybe_droplet_from_prefix($locator); +} + +sub _find_by_id ($self, $opt, $locator) { + $opt->ip || die "You can't destroy a box by id without --ip for safety.\n"; + return $self->boxman->dobby->get_droplet_by_id($locator)->get; +} - $boxman->destroy_droplet($droplet, { force => 1 })->get; +sub _find_by_name ($self, $opt, $locator) { + my @droplets = $self->boxman->dobby->get_droplets_by_name($locator)->get; + return @droplets; } 1;