diff --git a/lib/Synergy/Reactor/InABox.pm b/lib/Synergy/Reactor/InABox.pm index d351491c..2c54d7bf 100644 --- a/lib/Synergy/Reactor/InABox.pm +++ b/lib/Synergy/Reactor/InABox.pm @@ -18,7 +18,8 @@ use Process::Status; use Synergy::CommandPost; use Synergy::Logger '$Logger'; use Synergy::Util qw(bool_from_text reformat_help); -use String::Switches qw(parse_switches); +use Synergy::SwitchBox; +use String::Switches; use JSON::MaybeXS; use Future::Utils qw(repeat); use Text::Template; @@ -114,20 +115,24 @@ command box => { help => reformat_help(<<~"EOH"), box is a tool for managing cloud-based fminabox instances - All subcommands can take /version and /tag can be used to target a specific box. If not provided, defaults will be used. + All subcommands can take /version and /tag can be used to target a specific + box. If not provided, defaults will be used. - • status: show some info about your boxes, including IP address, fminabox build it was built from, and its current power status + • status: show some info about your boxes, including IP address, fminabox + build it was built from, and its current power status • create: create a new box • destroy: destroy a box. if its powered on, you have to shut it down first • shutdown: gracefully shut down and power off your box - • poweroff: forcibly shut down and power off your box (like pulling the power) + • poweroff: forcibly shut down and power off your box (like pulling the + power) • poweron: start up your box • vpn: get an OpenVPN config file to connect to your box The following preferences exist: • version: which version to create by default - • datacentre: which datacentre to create boxes in (if unset, chooses one near you) + • datacentre: which datacentre to create boxes in (if unset, chooses one + near you) • setup-by-default: if true, run your setup on the box when it's ready EOH #' # <-- idiotic thing to help Vim synhi cope with <<~, sorry -- rjbs, 2023-10-20 @@ -140,23 +145,11 @@ command box => { my $handler = $cmd ? $command_handler{$cmd} : undef; unless ($handler) { - return await $event->error_reply("usage: box [status|create|destroy|shutdown|poweroff|poweron|vpn]"); - } - - my ($switches, $error) = parse_switches($args); - return await $event->error_reply("couldn't parse switches: $error") if $error; - - my %switches = map { my ($k, @rest) = @$_; $k => \@rest } @$switches; - - # This should be simplified into a more generic "validate and normalize - # switches" call. -- rjbs, 2023-10-20 - for my $k (qw( version tag size )) { - next unless $switches{$k}; - $switches{$k} = $switches{$k}[0]; + return await $event->error_reply(q{I didn't understand that use of "box". Check out "help box".}); } eval { - await $handler->($self, $event, \%switches); + await $handler->($self, $event, $args); }; if (my $error = $@) { @@ -171,6 +164,38 @@ command box => { return; }; +sub _parse_switches ($self, $args) { + my ($switches, $error) = String::Switches::parse_switches($args); + Synergy::X->throw_public("couldn't parse switches: $error") if $error; + + state $switchbox = Synergy::SwitchBox->new({ + schema => { + version => { type => 'str' }, + tag => { type => 'str' }, + size => { type => 'str' }, + force => { type => 'bool' }, + + setup => { type => 'str', multi => 1 }, + nosetup => { type => 'bool' }, + }, + }); + + my $set = eval { $switchbox->handle_switches($switches); }; + + return $set if $set; + + my $error = $@; + if ($error isa 'Synergy::SwitchBox::Error') { + Synergy::X->throw_public({ + ident => "bad-switches", + message => "Your switches were no good: \n" + . join(qq{}, map {; "* $_\n" } $error->as_sentences) + }); + } + + die $error; +} + sub _determine_version_and_tag ($self, $event, $switches) { # this convoluted mess is about figuring out: # - the version, by request or from prefs or implied @@ -179,7 +204,9 @@ sub _determine_version_and_tag ($self, $event, $switches) { my $default_version = $self->get_user_preference($event->from_user, 'version') // $self->default_box_version; - my ($version, $tag) = $switches->@{qw(version tag)}; + my $version = $switches->version; + my $tag = $switches->tag; + my $is_default_box = !($version || $tag); $version //= $default_version; $tag //= $version; @@ -189,7 +216,10 @@ sub _determine_version_and_tag ($self, $event, $switches) { return ($version, $tag, $is_default_box); } -async sub handle_status ($self, $event, $switches) { +async sub handle_status ($self, $event, $args) { + return await $event->reply_error(q{"box status" doesn't take any arguments.}) + if length $args; + my $droplets = await $self->_get_droplets_for($event->from_user); if (@$droplets) { @@ -202,20 +232,21 @@ async sub handle_status ($self, $event, $switches) { return await $event->reply("You don't seem to have any boxes."); } -async sub handle_create ($self, $event, $switches) { +async sub handle_create ($self, $event, $args) { + my $switches = $self->_parse_switches($args); my ($version, $tag, $is_default_box) = $self->_determine_version_and_tag($event, $switches); # XXX call /v2/sizes API to validate # https://developers.digitalocean.com/documentation/changelog/api-v2/new-size-slugs-for-droplet-plan-changes/ - my $size = $switches->{size} // $self->default_box_size; + my $size = $switches->size // $self->default_box_size; my $user = $event->from_user; - if ($switches->{setup} && $switches->{nosetup}) { + if ($switches->has_setup && $switches->nosetup) { Synergy::X->throw_public("Passing /setup and /nosetup together is too weird for me to handle."); } - my $should_run_setup = $switches->{setup} ? 1 - : $switches->{nosetup} ? 0 + my $should_run_setup = $switches->has_setup ? 1 + : $switches->nosetup ? 0 : $self->get_user_preference($user, 'setup-by-default'); my $maybe_droplet = await $self->_get_droplet_for($user, $tag); @@ -316,7 +347,7 @@ async sub handle_create ($self, $event, $switches) { $event, $droplet, $key_file, - $switches->{setup}, + [ $switches->setup ], ); } @@ -426,7 +457,8 @@ async sub _setup_droplet ($self, $event, $droplet, $key_file, $args = []) { return await $event->reply("Something went wrong setting up your box, sorry!"); } -async sub handle_destroy ($self, $event, $switches) { +async sub handle_destroy ($self, $event, $args) { + my $switches = $self->_parse_switches($args); my ($version, $tag) = $self->_determine_version_and_tag($event, $switches); my $droplet = await $self->_get_droplet_for($event->from_user, $tag); @@ -437,7 +469,7 @@ async sub handle_destroy ($self, $event, $switches) { ); } - if ($droplet->{status} eq 'active' && !$switches->{force}) { + if ($droplet->{status} eq 'active' && !$switches->force) { Synergy::X->throw_public( "That box is powered on. Shut it down first, or use /force to destroy it anyway." ); @@ -526,25 +558,29 @@ async sub _handle_power ($self, $event, $action, $tag = undef) { return; } -sub handle_shutdown ($self, $event, $switches) { +sub handle_shutdown ($self, $event, $args) { + my $switches = $self->_parse_switches($args); my ($version, $tag) = $self->_determine_version_and_tag($event, $switches); return $self->_handle_power($event, 'shutdown', $tag); } -sub handle_poweroff ($self, $event, $switches) { +sub handle_poweroff ($self, $event, $args) { + my $switches = $self->_parse_switches($args); my ($version, $tag) = $self->_determine_version_and_tag($event, $switches); $self->_handle_power($event, 'off', $tag); } -sub handle_poweron ($self, $event, $switches) { +sub handle_poweron ($self, $event, $args) { + my $switches = $self->_parse_switches($args); my ($version, $tag) = $self->_determine_version_and_tag($event, $switches); return $self->_handle_power($event, 'on', $tag); } -sub handle_vpn ($self, $event, $switches) { +sub handle_vpn ($self, $event, $args) { + my $switches = $self->_parse_switches($args); my ($version, $tag) = $self->_determine_version_and_tag($event, $switches); my $template = Text::Template->new( diff --git a/lib/Synergy/SwitchBox.pm b/lib/Synergy/SwitchBox.pm new file mode 100644 index 00000000..01f4861e --- /dev/null +++ b/lib/Synergy/SwitchBox.pm @@ -0,0 +1,305 @@ +use v5.32.0; +use warnings; + +package Synergy::SwitchBox; +use Moose; + +use experimental qw(signatures); + +# name - str +# aliases - str[] +# type - str, int, num, bool, enum +# join - 0/1 +# multi - 0/1 +# default? + +# my $switcher = Switcher->new({ +# version => { type => 'Str' }, +# tag => { type => 'Str' } +# }); +has schema => ( + is => 'ro', + required => 1, +); + +has switchset_class => ( + is => 'ro', + lazy => 1, + builder => '_build_switchset_class', +); + +# create a class with methods for the properties +sub _build_switchset_class ($self) { + state $counter = 0; + + # It'd be nice if we could include the name of the package where this object + # was being constructed, but I'm not sure it's quite trivial to get that. + # (Look at StackTrace::Auto and weep.) -- rjbs, 2023-10-21 + my $package = join q{::}, __PACKAGE__, "_SwitchSet", ++$counter; + + my $meta = Moose::Meta::Class->initialize($package); + $meta->superclasses('Synergy::SwitchBox::Set'); + + my $schema = $self->schema; + ATTR: for my $name (sort keys %$schema) { + if ($schema->{$name}{multi}) { + $meta->add_attribute($name, + isa => 'ArrayRef[Value]', + traits => [ 'Array' ], + handles => { $name => 'elements' }, + default => sub { [] }, + ); + + # You might think "this is absurd, this should be a predicate method on + # the $name attribute, but we have a conundrum: + # * We want $obj->attr to return () if no "attr" was given, and this + # requires that attr is [], because it returns @{ $attr_slot }. + # * We want to know that no value was present. + # + # Since we need to have a default [] value for the "elements" delegation + # to work, we'll store "was explicit" in another attribute. Since we + # know we're the only one making these objects, we can get away with it. + # Gross, though, I know! -- rjbs, 2023-10-22 + $meta->add_attribute("has_$name", is => 'ro'); + + next ATTR; + } + + $meta->add_attribute($name, + is => 'ro', + isa => 'Value', + predicate => "has_$name", + ); + } + + return $meta->name; +} + +# get check with Moose::Util::TypeConstraints::find_type_constraint +# call: my $error = $TC->validate( $value ) +# or : my $is_ok = $TC->check( $value ) +sub _check_and_coerce_values ($self, $schema, $values) { + state %Type = ( + str => Moose::Util::TypeConstraints::find_type_constraint('Str'), + num => Moose::Util::TypeConstraints::find_type_constraint('Num'), + int => Moose::Util::TypeConstraints::find_type_constraint('Int'), + bool => 1, # special + ); + + my $type_name = $schema->{type}; + return unless $type_name; + + confess("unknown type $type_name") unless exists $Type{$type_name}; + + my @invalid; + + if ($type_name eq 'bool') { + state %Truthy = map {; $_ => 1 } qw( on true yes 1 ); + state %Falsy = map {; $_ => 1 } qw( off false noyes 0 ); + + my @new_values; + VALUE: for my $value (@$values) { + if ($Truthy{$value}) { push @new_values, 1; next VALUE } + if ($Falsy{$value}) { push @new_values, 0; next VALUE } + + push @invalid, $value; + } + + @$values = @new_values unless @invalid; + } else { + @invalid = grep {; ! $Type{$type_name}->check($_) } @$values; + } + + return unless @invalid; + + # We have @invalid so we could include it here, but we'd want some way to + # merge failures at the end. Rather than think about it now, punt... + # -- rjbs, 2023-10-22 + return { wanted => $type_name } +} + +sub handle_switches ($self, $switches) { + my %switch; + my %predicate; + + my %error; + # return SwitchBox::Set if good + # throw Switchbox::Error if bad + SWITCH: for my $switch (@$switches) { + unless ($switch && @$switch) { + $error{empty_switch} = 1; # Woah. + next SWITCH; + } + + my ($name, @args) = @$switch; + + unless (defined $name) { + $error{undef_name} = 1; # Yow. + next SWITCH; + } + + unless (exists $self->{schema}{$name}) { + $error{unknown}{$name} = 1; + next SWITCH; + } + + my $schema = $self->{schema}{$name}; + + if ($schema->{join} && @args > 1) { + @args = join q{ }, @args; + } + + if (@args == 0) { + if ($schema->{type} eq 'bool') { + @args = 1; + } elsif ($schema->{multi} && $schema->{zero_ok}) { + # ... just let it become set ... + } else { + $error{switch}{$name}{novalue} = 1; + next SWITCH; + } + } + + # This may alter @args in place! -- rjbs, 2023-10-22 + my $value_error = $self->_check_and_coerce_values($schema, \@args); + + if ($value_error) { + $error{switch}{$name}{value} = $value_error; + next SWITCH; + } + + if ($schema->{multi}) { + $switch{$name} //= []; + push $switch{$name}->@*, @args; + $predicate{"has_$name"} = 1; + next SWITCH; + } + + if ( + (@args > 1) + || + (exists $switch{$name}) + ) { + $error{switch}{$name}{multi} = 1; + next SWITCH; + } + + $switch{$name} = $args[0]; + } + + if (%error) { + Synergy::SwitchBox::Error->throw({ errors => \%error }); + } + + # Maybe later: barf, now or much earlier, if there's a multi-value switch S + # and also any switch named has_S. Honestly, though... -- rjbs, 2023-10-22 + return $self->switchset_class->new({ + %switch, + %predicate, + }); +} + +package Synergy::SwitchBox::Error { + use Moose; + with 'Throwable'; + + has _errors => ( + is => 'ro', + isa => 'HashRef', + required => 1, + init_arg => 'errors', + ); + + sub as_structs ($self) { + my @structs; + my %errors = $self->_errors->%*; + + for my $switch (keys $errors{switch}->%*) { + for my $type (keys $errors{switch}{$switch}->%*) { + push @structs, { switch => $switch, type => $type }; + } + } + + for my $switch (keys $errors{unknown}->%*) { + push @structs, { switch => $switch, type => 'unknown' }; + } + + # These are super weird but let's not just drop them on the floor. + push @structs, { type => 'undef_name' } if $errors{undef_name}; + push @structs, { type => 'empty_switch' } if $errors{empty_switch}; + + return @structs; + } + + sub as_sentences ($self) { + my sub andlist ($set) { + my @words = sort keys %$set; + $words[-1] = "and $words[-1]" if @words > 2; + join q{, }, @words; + } + + my @structs = $self->as_structs; + return unless @structs; + + my %switch_value; + my %switch_multi; + my %switch_novalue; + my %switch_unknown; + my %switch_misc; + my %other; + + STRUCT: for my $struct (@structs) { + if (defined(my $switch = $struct->{switch})) { + if ($struct->{type} eq 'value') { $switch_value{$switch} = 1 } + elsif ($struct->{type} eq 'multi') { $switch_multi{$switch} = 1 } + elsif ($struct->{type} eq 'novalue') { $switch_novalue{$switch} = 1 } + elsif ($struct->{type} eq 'unknown') { $switch_unknown{$switch} = 1 } + else { $switch_misc{$switch} = 1 } + + next STRUCT; + } + + if ($struct->{undef_name}) { $other{undef_name} = 1 } + elsif ($struct->{empty_switch}) { $other{empty_switch} = 1 } + else { $other{misc} = 1 } + } + + my @sentences; + if (keys %switch_value) { + push @sentences, + "These switches had invalid values: " . andlist(\%switch_value) . "."; + } + + if (keys %switch_novalue) { + push @sentences, + "These switches need to be given a value, but weren't: " . andlist(\%switch_novalue) . "."; + } + + if (keys %switch_multi) { + push @sentences, + "These switches can only be given once, but were given multiple times: " . andlist(\%switch_multi) . "."; + } + + if (keys %switch_unknown) { + push @sentences, + "There was something inexplicably wrong with these switches: " . andlist(\%switch_unknown) . "."; + } + + if (%other) { + push @sentences, "Some weird thing happened that probably shouldn't."; + } + + return @sentences; + } + + no Moose; +} + +package Synergy::SwitchBox::Set { + # This package exists only so that the generated packages can subclass it. + use Moose; + + no Moose; +} + +1; diff --git a/t/lib/Test/SwitchBox.pm b/t/lib/Test/SwitchBox.pm new file mode 100644 index 00000000..25827c5c --- /dev/null +++ b/t/lib/Test/SwitchBox.pm @@ -0,0 +1,101 @@ +use v5.32.0; +use warnings; + +package Test::SwitchBox; +use Moose; +extends 'Synergy::SwitchBox'; + +use experimental qw(isa signatures); + +use String::Switches (); +use Test::Deep ':v1'; +use Test::More; + +sub switches_ok ($self, $str, $want, $desc) { + my ($switches, $error) = String::Switches::parse_switches($str); + + confess("input string did not parse") unless $switches; + + local $Test::Builder::Level = $Test::Builder::Level + 1; + + my $set; + + subtest "switches_ok: $desc" => sub { + $set = eval { + $self->handle_switches($switches); + }; + + if ($@ and $@ isa 'Synergy::SwitchBox::Error') { + my $error = $@; + fail("SwitchBox rejected the input"); + diag(explain([ $error->as_structs ])); + return; + } + + isa_ok($set, 'Synergy::SwitchBox::Set', 'result of handle_switches'); + + my @keys = keys %$want; + my %single = map {; ref $want->{$_} ? () : ($_ => $want->{$_}) } @keys; + my %multi = map {; ref $want->{$_} ? ($_ => $want->{$_}) : () } @keys; + + confess('switches_ok with an empty $want is nonsensical') + unless %single || %multi; + + cmp_deeply( + $set, + all( + (%single ? methods(%single) : ()), + (%multi ? listmethods(%multi) : ()), + ), + "methods on SwitchBox::Set act as expected", + ); + }; + + return $set; +} + +sub errors_ok ($self, $str, $want, $desc) { + my ($switches, $error) = String::Switches::parse_switches($str); + + confess("input string did not parse") unless $switches; + + local $Test::Builder::Level = $Test::Builder::Level + 1; + + subtest "errors_ok: $desc" => sub { + eval { + $self->handle_switches($switches); + }; + + my $error = $@; + + return fail("handle_switches did not die") + unless $error; + + isa_ok($error, 'Synergy::SwitchBox::Error', 'result of handle_switches'); + + if ($want->{structs}) { + my @structs = $error->as_structs; + + cmp_deeply( + \@structs, + bag($want->{structs}->@*), + "got the expected error structs", + ); + } + + diag "S: $_" for $error->as_sentences; + + if ($want->{sentences}) { + my @sentences = $error->as_sentences; + + cmp_deeply( + \@sentences, + bag($want->{sentences}->@*), + "got the expected error sentences", + ); + } + }; +} + +no Moose; +1; diff --git a/t/switchbox.t b/t/switchbox.t new file mode 100644 index 00000000..68275626 --- /dev/null +++ b/t/switchbox.t @@ -0,0 +1,143 @@ +use v5.32.0; +use warnings; + +use experimental qw(signatures); + +use Test::More; +use Test::Deep ':v1'; + +use Synergy::SwitchBox; +use String::Switches qw(parse_switches); + +use lib 't/lib'; +use Test::SwitchBox; + +{ + my $box = Test::SwitchBox->new({ + schema => { + name => { type => 'str', join => 1 }, + age => { type => 'num' }, + cat => { type => 'str', multi => 1 }, + cool => { type => 'bool' }, + }, + }); + + $box->switches_ok( + "/name Aiden Baker /age 48 /cat Fido /cat Mew", + { + name => 'Aiden Baker', + age => 48, + cat => [ 'Fido', 'Mew' ], + }, + "basic test", + ); + + $box->switches_ok( + "/name Fran", + { + name => 'Fran', + age => undef, + cat => [], + }, + "behavior with switches not given", + ); + + $box->errors_ok( + "/name Dana Cassidy /age unknown /unexpected inquisition", + { + structs => [ + { switch => 'age', type => 'value' }, + { switch => 'unexpected', type => 'unknown' }, + ], + }, + "simple example with errors", + ); + + $box->errors_ok( + "/age 17 /age unknown /age 19", + { + structs => [ + { switch => 'age', type => 'value' }, + { switch => 'age', type => 'multi' }, + ], + sentences => [ + "These switches had invalid values: age.", + "These switches can only be given once, but were given multiple times: age.", + ], + }, + "simple example with type errors, /age x3", + ); + + # It would probably be better to provide both "value" and "multi", but we short + # circuit once one switch tuple has a value error and this is probably good + # enough. -- rjbs, 2023-10-22 + $box->errors_ok( + "/age 17 unknown 19", + { + structs => [ + { switch => 'age', type => 'value' }, + ], + }, + "simple example with type errors, /age with 3 values", + ); + + $box->errors_ok( + "/age /cat", + { + structs => [ + { switch => 'age', type => 'novalue' }, + { switch => 'cat', type => 'novalue' }, + ], + }, + "non-bool switch with no value", + ); + + $box->switches_ok( + "/cool", + { + cool => 1, + }, + "bool switch with no value", + ); + + for my $true (qw( 1 true yes on )) { + $box->switches_ok( + "/cool $true", + { + cool => 1, + }, + "bool switch with truthy value ($true)", + ); + } + + $box->switches_ok( + "/cool", + { + cool => 1, + }, + "bool switch with no value", + ); +} + +subtest "zero_ok tests" => sub { + my $box = Test::SwitchBox->new({ + schema => { + zeroplus => { type => 'str', multi => 1, zero_ok => 1 }, + maybenil => { type => 'str', multi => 1, zero_ok => 1 }, + }, + }); + + my $set = $box->switches_ok( + "/zeroplus", + { + zeroplus => [ ], + maybenil => [ ], + }, + "multi-value switch that is zero-valued", + ); + + ok( $set->has_zeroplus, "->has_zeroplus is true (it's empty)"); + ok(! $set->has_maybenil, "->has_maybenil is false (it's absent)"); +}; + +done_testing;