diff --git a/src/client/cli/client.cpp b/src/client/cli/client.cpp index 8cb3bac23a..c9ebb353ff 100644 --- a/src/client/cli/client.cpp +++ b/src/client/cli/client.cpp @@ -21,6 +21,8 @@ #include "cmd/authenticate.h" #include "cmd/clone.h" #include "cmd/delete.h" +#include "cmd/disable_zones.h" +#include "cmd/enable_zones.h" #include "cmd/exec.h" #include "cmd/find.h" #include "cmd/get.h" @@ -87,6 +89,8 @@ mp::Client::Client(ClientConfig& config) add_command(); add_command(aliases); add_command(aliases); + add_command(); + add_command(); add_command(aliases); add_command(); add_command(); diff --git a/src/client/cli/cmd/CMakeLists.txt b/src/client/cli/cmd/CMakeLists.txt index 5661183706..d030c4a2d3 100644 --- a/src/client/cli/cmd/CMakeLists.txt +++ b/src/client/cli/cmd/CMakeLists.txt @@ -21,6 +21,8 @@ add_library(commands STATIC common_cli.cpp create_alias.cpp delete.cpp + disable_zones.cpp + enable_zones.cpp exec.cpp find.cpp get.cpp diff --git a/src/client/cli/cmd/disable_zones.cpp b/src/client/cli/cmd/disable_zones.cpp new file mode 100644 index 0000000000..831f6f239a --- /dev/null +++ b/src/client/cli/cmd/disable_zones.cpp @@ -0,0 +1,141 @@ +/* + * Copyright (C) Canonical, Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "disable_zones.h" + +#include "animated_spinner.h" +#include "common_callbacks.h" +#include "common_cli.h" +#include "multipass/cli/argparser.h" +#include "multipass/cli/client_common.h" + +#include + +namespace multipass::cmd +{ +ReturnCode DisableZones::run(ArgParser* parser) +{ + if (const auto ret = parse_args(parser); ret != ParseCode::Ok) + return parser->returnCodeFrom(ret); + + if (ask_for_confirmation) + { + if (!term->is_live()) + throw std::runtime_error{"Unable to query client for confirmation. Use '--force' to forcefully make " + "unavailable all instances in the specified zones."}; + + if (!confirm()) + return ReturnCode::CommandFail; + } + + AnimatedSpinner spinner{cout}; + const auto use_all_zones = request.zones().empty(); + const auto message = + use_all_zones ? "Disabling all zones" : fmt::format("Disabling {}", fmt::join(request.zones(), ", ")); + spinner.start(message); + + const auto on_success = [&](const ZonesStateReply&) { + spinner.stop(); + const auto output_message = use_all_zones ? "All zones disabled" + : fmt::format("Zone{} disabled: {}", + request.zones_size() == 1 ? "" : "s", + fmt::join(request.zones(), ", ")); + cout << output_message << std::endl; + return Ok; + }; + + const auto on_failure = [this, &spinner](const grpc::Status& status) { + spinner.stop(); + return standard_failure_handler_for(name(), cerr, status); + }; + + const auto streaming_callback = make_logging_spinner_callback(spinner, cerr); + + return dispatch(&RpcMethod::zones_state, request, on_success, on_failure, streaming_callback); +} + +std::string DisableZones::name() const +{ + return "disable-zones"; +} + +QString DisableZones::short_help() const +{ + return QStringLiteral("Make zones unavailable"); +} + +QString DisableZones::description() const +{ + return QStringLiteral("Makes the given availability zones unavailable. Instances therein are " + "forcefully switched off and remain unavailable until their zone is re-enabled " + "(simulating a loss of availability on a cloud provider)."); +} + +ParseCode DisableZones::parse_args(ArgParser* parser) +{ + parser->addPositionalArgument("zone", "Name of the zones to make unavailable", " [ ...]"); + + QCommandLineOption all_option(all_option_name, "Disable all zones"); + QCommandLineOption forceOption{"force", "Do not ask for confirmation"}; + parser->addOptions({all_option, forceOption}); + + if (const auto status = parser->commandParse(this); status != ParseCode::Ok) + return status; + + if (const auto status = check_for_name_and_all_option_conflict(parser, cerr); status != ParseCode::Ok) + return status; + + request.set_available(false); + request.set_verbosity_level(parser->verbosityLevel()); + + if (!parser->isSet(all_option_name)) + { + for (const auto& zone_name : parser->positionalArguments()) + request.add_zones(zone_name.toStdString()); + } + + ask_for_confirmation = !parser->isSet(forceOption); + + return ParseCode::Ok; +} + +bool DisableZones::confirm() const +{ + // joins zones by comma with an 'and' for the last one e.g. 'zone1, zone2 and zone3' + const auto format_zones = [this] { + if (request.zones().empty()) + return std::string("all zones"); + if (request.zones_size() == 1) + return request.zones(0); + + const auto last_zone = request.zones_size() - 1; + return fmt::format("{} and {}", + fmt::join(request.zones().begin(), request.zones().begin() + last_zone, ", "), + request.zones(last_zone)); + }; + const auto message = "This operation will forcefully stop the VMs in " + format_zones() + + ". Are you sure you want to continue? (Yes/no)"; + + const PlainPrompter prompter{term}; + auto answer = prompter.prompt(message); + while (!answer.empty() && !std::regex_match(answer, client::yes_answer) && + !std::regex_match(answer, client::no_answer)) + answer = prompter.prompt("Please answer (Yes/No)"); + + return answer.empty() || std::regex_match(answer, client::yes_answer); +} +} // namespace multipass::cmd diff --git a/src/client/cli/cmd/disable_zones.h b/src/client/cli/cmd/disable_zones.h new file mode 100644 index 0000000000..0b266dc431 --- /dev/null +++ b/src/client/cli/cmd/disable_zones.h @@ -0,0 +1,41 @@ +/* + * Copyright (C) Canonical, Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef MULTIPASS_DISABLE_ZONES_H +#define MULTIPASS_DISABLE_ZONES_H + +#include + +namespace multipass::cmd +{ +class DisableZones : public Command +{ +public: + using Command::Command; + ReturnCode run(ArgParser* parser) override; + std::string name() const override; + QString short_help() const override; + QString description() const override; + +private: + bool ask_for_confirmation = true; + ZonesStateRequest request{}; + ParseCode parse_args(ArgParser* parser); + bool confirm() const; +}; +} // namespace multipass::cmd +#endif // MULTIPASS_DISABLE_ZONES_H diff --git a/src/client/cli/cmd/enable_zones.cpp b/src/client/cli/cmd/enable_zones.cpp new file mode 100644 index 0000000000..667a739e22 --- /dev/null +++ b/src/client/cli/cmd/enable_zones.cpp @@ -0,0 +1,101 @@ +/* + * Copyright (C) Canonical, Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "enable_zones.h" + +#include "animated_spinner.h" +#include "common_callbacks.h" +#include "common_cli.h" +#include "multipass/cli/argparser.h" +#include "multipass/cli/client_common.h" + +#include + +namespace multipass::cmd +{ +ReturnCode EnableZones::run(ArgParser* parser) +{ + if (const auto ret = parse_args(parser); ret != ParseCode::Ok) + return parser->returnCodeFrom(ret); + + AnimatedSpinner spinner{cout}; + const auto use_all_zones = request.zones().empty(); + const auto message = + use_all_zones ? "Enabling all zones" : fmt::format("Enabling {}", fmt::join(request.zones(), ", ")); + spinner.start(message); + + const auto on_success = [&](const ZonesStateReply&) { + spinner.stop(); + const auto output_message = use_all_zones ? "All zones enabled" + : fmt::format("Zone{} enabled: {}", + request.zones_size() == 1 ? "" : "s", + fmt::join(request.zones(), ", ")); + cout << output_message << std::endl; + return Ok; + }; + + const auto on_failure = [this, &spinner](const grpc::Status& status) { + spinner.stop(); + return standard_failure_handler_for(name(), cerr, status); + }; + + const auto streaming_callback = make_logging_spinner_callback(spinner, cerr); + + return dispatch(&RpcMethod::zones_state, request, on_success, on_failure, streaming_callback); +} + +std::string EnableZones::name() const +{ + return "enable-zones"; +} + +QString EnableZones::short_help() const +{ + return QStringLiteral("Make zones available"); +} + +QString EnableZones::description() const +{ + return QStringLiteral("Makes the given availability zones available. Instances therein are started if they were " + "running when their zone was last disabled."); +} + +ParseCode EnableZones::parse_args(ArgParser* parser) +{ + parser->addPositionalArgument("zone", "Name of the zones to make available", " [ ...]"); + + QCommandLineOption all_option(all_option_name, "Enable all zones"); + parser->addOption(all_option); + + if (const auto status = parser->commandParse(this); status != ParseCode::Ok) + return status; + + if (const auto status = check_for_name_and_all_option_conflict(parser, cerr); status != ParseCode::Ok) + return status; + + request.set_available(true); + request.set_verbosity_level(parser->verbosityLevel()); + + if (!parser->isSet(all_option_name)) + { + for (const auto& zone_name : parser->positionalArguments()) + request.add_zones(zone_name.toStdString()); + } + + return ParseCode::Ok; +} +} // namespace multipass::cmd diff --git a/src/client/cli/cmd/enable_zones.h b/src/client/cli/cmd/enable_zones.h new file mode 100644 index 0000000000..330c45d2b5 --- /dev/null +++ b/src/client/cli/cmd/enable_zones.h @@ -0,0 +1,39 @@ +/* + * Copyright (C) Canonical, Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef MULTIPASS_ENABLE_ZONES_H +#define MULTIPASS_ENABLE_ZONES_H + +#include + +namespace multipass::cmd +{ +class EnableZones : public Command +{ +public: + using Command::Command; + ReturnCode run(ArgParser* parser) override; + std::string name() const override; + QString short_help() const override; + QString description() const override; + +private: + ZonesStateRequest request{}; + ParseCode parse_args(ArgParser* parser); +}; +} // namespace multipass::cmd +#endif // MULTIPASS_ENABLE_ZONES_H diff --git a/src/client/cli/cmd/launch.cpp b/src/client/cli/cmd/launch.cpp index c992378a38..ebc8bb2e3d 100644 --- a/src/client/cli/cmd/launch.cpp +++ b/src/client/cli/cmd/launch.cpp @@ -233,14 +233,25 @@ mp::ParseCode cmd::Launch::parse_args(mp::ArgParser* parser) "You can also use a shortcut of \"\" to mean \"name=\".", "spec"); QCommandLineOption bridgedOption("bridged", "Adds one `--network bridged` network."); + QCommandLineOption zoneOption("zone", "The zone in which to launch the instance.", "zone"); QCommandLineOption mountOption("mount", "Mount a local directory inside the instance. If is omitted, the " "mount point will be under /home/ubuntu/, where is " "the name of the directory.", "source>:addOptions({cpusOption, diskOption, memOption, memOptionDeprecated, nameOption, cloudInitOption, - networkOption, bridgedOption, mountOption}); + parser->addOptions({ + cpusOption, + diskOption, + memOption, + memOptionDeprecated, + nameOption, + cloudInitOption, + networkOption, + bridgedOption, + zoneOption, + mountOption, + }); mp::cmd::add_timeout(parser); @@ -426,8 +437,10 @@ mp::ParseCode cmd::Launch::parse_args(mp::ArgParser* parser) try { if (parser->isSet(networkOption)) + { for (const auto& net : parser->values(networkOption)) request.mutable_network_options()->Add(net_digest(net)); + } request.set_timeout(mp::cmd::parse_timeout(parser)); } @@ -449,6 +462,17 @@ mp::ReturnCode cmd::Launch::request_launch(const ArgParser* parser) spinner = std::make_unique( cout); // Creating just in time to work around canonical/multipass#2075 + if (parser->isSet("zone")) + { + auto zone = parser->value("zone").trimmed(); + if (zone.isEmpty()) + { + cerr << "Error: Empty zone specified with --zone option\n"; + return ReturnCode::CommandLineError; + } + request.set_zone(zone.toStdString()); + } + if (timer) timer->resume(); else if (parser->isSet("timeout")) @@ -507,7 +531,7 @@ mp::ReturnCode cmd::Launch::request_launch(const ArgParser* parser) } } - cout << "Launched: " << reply.vm_instance_name() << "\n"; + cout << "Launched: " << reply.vm_instance_name() << " in " << reply.zone() << "\n"; if (term->is_live() && update_available(reply.update_info())) { @@ -554,6 +578,14 @@ mp::ReturnCode cmd::Launch::request_launch(const ArgParser* parser) // LaunchError proto. error_details = "Invalid network options supplied"; } + else if (error == LaunchError::INVALID_ZONE) + { + error_details = fmt::format("Invalid zone name supplied: {}", request.zone()); + } + else if (error == LaunchError::ZONE_UNAVAILABLE) + { + error_details = fmt::format("Unavailable zone name supplied: {}", request.zone()); + } } return standard_failure_handler_for(name(), cerr, status, error_details); diff --git a/src/daemon/daemon.cpp b/src/daemon/daemon.cpp index 288ef2bc32..4abc1bb443 100644 --- a/src/daemon/daemon.cpp +++ b/src/daemon/daemon.cpp @@ -2918,9 +2918,20 @@ try // clang-format on { mpl::ClientLogger logger{mpl::level_from(request->verbosity_level()), *config->logger, server}; - for (const auto& zone_name : request->zones()) + auto& az_manager = *config->az_manager; + if (request->zones().empty()) { - config->az_manager->get_zone(zone_name).set_available(request->available()); + for (auto&& zone : az_manager.get_zones()) + { + az_manager.get_zone(zone.get().get_name()).set_available(request->available()); + } + } + else + { + for (const auto& zone_name : request->zones()) + { + az_manager.get_zone(zone_name).set_available(request->available()); + } } status_promise->set_value(grpc::Status{}); @@ -3021,7 +3032,8 @@ void mp::Daemon::release_resources(const std::string& instance) void mp::Daemon::create_vm(const CreateRequest* request, grpc::ServerReaderWriterInterface* server, - std::promise* status_promise, bool start) + std::promise* status_promise, + bool start) { typedef typename std::pair VMFullDescription; @@ -3029,7 +3041,8 @@ void mp::Daemon::create_vm(const CreateRequest* request, if (!checked_args.option_errors.error_codes().empty()) { - return status_promise->set_value(grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, "Invalid arguments supplied", + return status_promise->set_value(grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, + "Invalid arguments supplied", checked_args.option_errors.SerializeAsString())); } else if (auto& nets = checked_args.nets_need_bridging; !nets.empty() && !request->permission_to_bridge()) @@ -3094,7 +3107,8 @@ void mp::Daemon::create_vm(const CreateRequest* request, auto log_level = mpl::level_from(request->verbosity_level()); QObject::connect( - prepare_future_watcher, &QFutureWatcher::finished, + prepare_future_watcher, + &QFutureWatcher::finished, [this, server, status_promise, name, timeout, start, prepare_future_watcher, log_level] { mpl::ClientLogger logger{log_level, *config->logger, server}; @@ -3134,33 +3148,38 @@ void mp::Daemon::create_vm(const CreateRequest* request, operative_instances[name]->start(); - auto future_watcher = create_future_watcher([this, server, name, vm_aliases, vm_workspaces] { - LaunchReply reply; - reply.set_vm_instance_name(name); - config->update_prompt->populate_if_time_to_show(reply.mutable_update_info()); - - // Attach the aliases to be created by the CLI to the last message. - for (const auto& blueprint_alias : vm_aliases) - { - mpl::log(mpl::Level::debug, category, - fmt::format("Adding alias '{}' to RPC reply", blueprint_alias.first)); - auto alias = reply.add_aliases_to_be_created(); - alias->set_name(blueprint_alias.first); - alias->set_instance(blueprint_alias.second.instance); - alias->set_command(blueprint_alias.second.command); - alias->set_working_directory(blueprint_alias.second.working_directory); - } - - // Now attach the workspaces. - for (const auto& blueprint_workspace : vm_workspaces) - { - mpl::log(mpl::Level::debug, category, - fmt::format("Adding workspace '{}' to RPC reply", blueprint_workspace)); - reply.add_workspaces_to_be_created(blueprint_workspace); - } - - server->Write(reply); - }); + auto future_watcher = + create_future_watcher([this, server, name, vm_aliases, vm_workspaces, zone = vm_desc.zone] { + LaunchReply reply; + reply.set_vm_instance_name(name); + config->update_prompt->populate_if_time_to_show(reply.mutable_update_info()); + + reply.set_zone(zone); + + // Attach the aliases to be created by the CLI to the last message. + for (const auto& blueprint_alias : vm_aliases) + { + mpl::log(mpl::Level::debug, + category, + fmt::format("Adding alias '{}' to RPC reply", blueprint_alias.first)); + auto alias = reply.add_aliases_to_be_created(); + alias->set_name(blueprint_alias.first); + alias->set_instance(blueprint_alias.second.instance); + alias->set_command(blueprint_alias.second.command); + alias->set_working_directory(blueprint_alias.second.working_directory); + } + + // Now attach the workspaces. + for (const auto& blueprint_workspace : vm_workspaces) + { + mpl::log(mpl::Level::debug, + category, + fmt::format("Adding workspace '{}' to RPC reply", blueprint_workspace)); + reply.add_workspaces_to_be_created(blueprint_workspace); + } + + server->Write(reply); + }); future_watcher->setFuture( QtConcurrent::run(&Daemon::async_wait_for_ready_all, this, @@ -3198,7 +3217,7 @@ void mp::Daemon::create_vm(const CreateRequest* request, try { CreateReply reply; - reply.set_create_message("Creating " + name); + reply.set_create_message(fmt::format("Creating {} in {}", name, zone_name)); server->Write(reply); Query query; @@ -3788,8 +3807,9 @@ void mp::Daemon::populate_instance_info(VirtualMachine& vm, const auto& name = vm.vm_name; info->set_name(name); const auto zone = info->mutable_zone(); - zone->set_name(vm.get_zone().get_name()); - zone->set_available(vm.get_zone().is_available()); + const auto& az = vm.get_zone(); + zone->set_name(az.get_name()); + zone->set_available(az.is_available()); if (deleted) info->mutable_instance_status()->set_status(mp::InstanceStatus::DELETED); diff --git a/src/rpc/multipass.proto b/src/rpc/multipass.proto index a6a4b08074..06528985f8 100644 --- a/src/rpc/multipass.proto +++ b/src/rpc/multipass.proto @@ -128,6 +128,7 @@ message LaunchReply { repeated Alias aliases_to_be_created = 10; repeated string workspaces_to_be_created = 11; bool password_requested = 12; + string zone = 13; } message PurgeRequest { diff --git a/tests/mock_availability_zone.h b/tests/mock_availability_zone.h new file mode 100644 index 0000000000..0ef74d7517 --- /dev/null +++ b/tests/mock_availability_zone.h @@ -0,0 +1,44 @@ +/* + * Copyright (C) Canonical, Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef MULTIPASS_MOCK_AVAILABILITY_ZONE_H +#define MULTIPASS_MOCK_AVAILABILITY_ZONE_H + +#include +#include + +namespace multipass +{ +namespace test +{ + +namespace mp = multipass; + +struct MockAvailabilityZone : public mp::AvailabilityZone +{ + MOCK_METHOD(const std::string&, get_name, (), (const, override)); + MOCK_METHOD(const std::string&, get_subnet, (), (const, override)); + MOCK_METHOD(bool, is_available, (), (const, override)); + MOCK_METHOD(void, set_available, (bool), (override)); + MOCK_METHOD(void, add_vm, (mp::VirtualMachine&), (override)); + MOCK_METHOD(void, remove_vm, (mp::VirtualMachine&), (override)); +}; + +} // namespace test +} // namespace multipass + +#endif // MULTIPASS_MOCK_AVAILABILITY_ZONE_H diff --git a/tests/mock_availability_zone_manager.h b/tests/mock_availability_zone_manager.h new file mode 100644 index 0000000000..6ee5fd7fe3 --- /dev/null +++ b/tests/mock_availability_zone_manager.h @@ -0,0 +1,44 @@ +/* + * Copyright (C) Canonical, Ltd. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef MULTIPASS_MOCK_AVAILABILITY_ZONE_MANAGER_H +#define MULTIPASS_MOCK_AVAILABILITY_ZONE_MANAGER_H + +#include "mock_availability_zone.h" + +#include +#include + +namespace multipass +{ +namespace test +{ + +namespace mp = multipass; + +struct MockAvailabilityZoneManager : public mp::AvailabilityZoneManager +{ + MOCK_METHOD(mp::AvailabilityZone&, get_zone, (const std::string&), (override)); + MOCK_METHOD(std::vector>, get_zones, (), (override)); + MOCK_METHOD(std::string, get_automatic_zone_name, (), (override)); + MOCK_METHOD(std::string, get_default_zone_name, (), (const, override)); +}; + +} // namespace test +} // namespace multipass + +#endif // MULTIPASS_MOCK_AVAILABILITY_ZONE_MANAGER_H diff --git a/tests/test_cli_client.cpp b/tests/test_cli_client.cpp index e54bd23700..65de9f4be3 100644 --- a/tests/test_cli_client.cpp +++ b/tests/test_cli_client.cpp @@ -154,6 +154,11 @@ struct MockDaemonRpc : public mp::DaemonRpc zones, (grpc::ServerContext * context, (grpc::ServerReaderWriter * server)), (override)); + MOCK_METHOD(grpc::Status, + zones_state, + (grpc::ServerContext * context, + (grpc::ServerReaderWriter * server)), + (override)); }; struct Client : public Test @@ -1143,6 +1148,45 @@ TEST_F(Client, DISABLE_ON_MACOS(launch_cmd_custom_image_http_ok)) EXPECT_THAT(send_command({"launch", "http://foo"}), Eq(mp::ReturnCode::Ok)); } +TEST_F(Client, launch_cmd_with_zone_ok) +{ + EXPECT_CALL(mock_daemon, launch(_, _)); + EXPECT_THAT(send_command({"launch", "--zone", "zone1"}), Eq(mp::ReturnCode::Ok)); +} + +TEST_F(Client, launch_cmd_with_empty_zone_fails) +{ + EXPECT_THAT(send_command({"launch", "--zone", ""}), Eq(mp::ReturnCode::CommandLineError)); +} + +TEST_F(Client, launch_cmd_with_invalid_zone_fails) +{ + const auto request_matcher = Property(&mp::LaunchRequest::zone, StrEq("invalid_zone")); + mp::LaunchError launch_error; + launch_error.add_error_codes(mp::LaunchError::INVALID_ZONE); + const auto failure = grpc::Status{grpc::StatusCode::INVALID_ARGUMENT, "msg", launch_error.SerializeAsString()}; + EXPECT_CALL(mock_daemon, launch) + .WillOnce(WithArg<1>(check_request_and_return(request_matcher, failure))); + EXPECT_THAT(send_command({"launch", "--zone", "invalid_zone"}), Eq(mp::ReturnCode::CommandFail)); +} + +TEST_F(Client, launch_cmd_with_unavailable_zone_fails) +{ + const auto request_matcher = Property(&mp::LaunchRequest::zone, StrEq("unavailable_zone")); + mp::LaunchError launch_error; + launch_error.add_error_codes(mp::LaunchError::ZONE_UNAVAILABLE); + const auto failure = grpc::Status{grpc::StatusCode::INVALID_ARGUMENT, "msg", launch_error.SerializeAsString()}; + EXPECT_CALL(mock_daemon, launch) + .WillOnce(WithArg<1>(check_request_and_return(request_matcher, failure))); + EXPECT_THAT(send_command({"launch", "--zone", "unavailable_zone"}), Eq(mp::ReturnCode::CommandFail)); +} + +TEST_F(Client, launch_cmd_with_timer) +{ + EXPECT_CALL(mock_daemon, launch(_, _)); + EXPECT_THAT(send_command({"launch", "--timeout", "1"}), Eq(mp::ReturnCode::Ok)); +} + TEST_F(Client, launch_cmd_cloudinit_option_with_valid_file_is_ok) { QTemporaryFile tmpfile; // file is auto-deleted when this goes out of scope @@ -3933,6 +3977,137 @@ TEST_F(Client, zones_cmd_verbosity_forwarded) EXPECT_THAT(send_command({"zones", "-vv"}), Eq(mp::ReturnCode::Ok)); } +// enable_zones tests +TEST_F(Client, enable_zones_cmd_help_ok) +{ + EXPECT_THAT(send_command({"enable-zones", "-h"}), Eq(mp::ReturnCode::Ok)); +} + +TEST_F(Client, enable_zones_cmd_success) +{ + EXPECT_CALL(mock_daemon, zones_state(_, _)); + EXPECT_THAT(send_command({"enable-zones", "zone1", "zone2"}), Eq(mp::ReturnCode::Ok)); +} + +TEST_F(Client, enable_zones_cmd_no_zones_fails) +{ + EXPECT_CALL(mock_daemon, zones_state(_, _)).Times(0); + EXPECT_THAT(send_command({"enable-zones"}), Eq(mp::ReturnCode::CommandLineError)); +} + +TEST_F(Client, enable_zones_cmd_passes_proper_request) +{ + const auto request_matcher = AllOf(Property(&mp::ZonesStateRequest::available, true), + Property(&mp::ZonesStateRequest::zones, ElementsAre("zone1", "zone2"))); + + EXPECT_CALL(mock_daemon, zones_state) + .WillOnce( + WithArg<1>(check_request_and_return(request_matcher, ok))); + + EXPECT_THAT(send_command({"enable-zones", "zone1", "zone2"}), Eq(mp::ReturnCode::Ok)); +} + +TEST_F(Client, enable_zones_cmd_on_failure) +{ + const auto failure = grpc::Status{grpc::StatusCode::UNAVAILABLE, "msg"}; + EXPECT_CALL(mock_daemon, zones_state(_, _)).WillOnce(Return(failure)); + EXPECT_THAT(send_command({"enable-zones", "zone1"}), Eq(mp::ReturnCode::CommandFail)); +} + +// disable_zones tests +TEST_F(Client, disable_zones_cmd_help_ok) +{ + EXPECT_THAT(send_command({"disable-zones", "-h"}), Eq(mp::ReturnCode::Ok)); +} + +TEST_F(Client, disable_zones_cmd_success) +{ + EXPECT_CALL(mock_daemon, zones_state(_, _)); + EXPECT_THAT(send_command({"disable-zones", "--force", "zone1", "zone2"}), Eq(mp::ReturnCode::Ok)); +} + +TEST_F(Client, disable_zones_cmd_no_zones_fails) +{ + EXPECT_CALL(mock_daemon, zones_state(_, _)).Times(0); + EXPECT_THAT(send_command({"disable-zones"}), Eq(mp::ReturnCode::CommandLineError)); +} + +TEST_F(Client, disable_zones_cmd_passes_proper_request) +{ + const auto request_matcher = AllOf(Property(&mp::ZonesStateRequest::available, false), + Property(&mp::ZonesStateRequest::zones, ElementsAre("zone1", "zone2"))); + + EXPECT_CALL(mock_daemon, zones_state) + .WillOnce( + WithArg<1>(check_request_and_return(request_matcher, ok))); + + EXPECT_THAT(send_command({"disable-zones", "--force", "zone1", "zone2"}), Eq(mp::ReturnCode::Ok)); +} + +TEST_F(Client, disable_zones_cmd_with_force_option) +{ + EXPECT_CALL(mock_daemon, zones_state(_, _)); + EXPECT_THAT(send_command({"disable-zones", "--force", "zone1"}), Eq(mp::ReturnCode::Ok)); +} + +TEST_F(Client, disable_zones_cmd_confirm) +{ + std::stringstream cout, cerr; + std::istringstream cin; + mpt::MockTerminal term; + EXPECT_CALL(term, cout()).WillRepeatedly(ReturnRef(cout)); + EXPECT_CALL(term, cerr()).WillRepeatedly(ReturnRef(cerr)); + EXPECT_CALL(term, cin()).WillRepeatedly(ReturnRef(cin)); + EXPECT_CALL(term, cin_is_live()).WillRepeatedly(Return(true)); + EXPECT_CALL(term, cout_is_live()).WillRepeatedly(Return(true)); + EXPECT_CALL(mock_daemon, zones_state(_, _)); + + cin.str("yes\n"); + EXPECT_THAT(setup_client_and_run({"disable-zones", "zone1"}, term), Eq(mp::ReturnCode::Ok)); + + cin.str("no\n"); + EXPECT_THAT(setup_client_and_run({"disable-zones", "zone1"}, term), Eq(mp::ReturnCode::CommandFail)); +} + +TEST_F(Client, disable_zones_cmd_on_failure) +{ + const auto failure = grpc::Status{grpc::StatusCode::UNAVAILABLE, "msg"}; + EXPECT_CALL(mock_daemon, zones_state(_, _)).WillOnce(Return(failure)); + EXPECT_THAT(send_command({"disable-zones", "--force", "zone1"}), Eq(mp::ReturnCode::CommandFail)); +} +TEST_F(Client, disable_zones_cmd_not_live_term_fails) +{ + NiceMock term; + EXPECT_CALL(term, cin_is_live()).WillRepeatedly(Return(false)); + EXPECT_CALL(term, cout_is_live()).WillRepeatedly(Return(true)); + + EXPECT_THROW(setup_client_and_run({"disable-zones", "zone1"}, term), std::runtime_error); +} + +TEST_F(Client, disable_zones_cmd_confirm_multiple_zones) +{ + NiceMock term; + std::stringstream cin_stream; + cin_stream << "Yes\n"; + ON_CALL(term, cin()).WillByDefault(ReturnRef(cin_stream)); + ON_CALL(term, cin_is_live()).WillByDefault(Return(true)); + ON_CALL(term, cout_is_live()).WillByDefault(Return(true)); + + std::stringstream cout_stream; + ON_CALL(term, cout()).WillByDefault(ReturnRef(cout_stream)); + std::stringstream cerr_stream; + ON_CALL(term, cerr()).WillByDefault(ReturnRef(cerr_stream)); + + EXPECT_CALL(mock_daemon, + zones_state(An(), + An*>())) + .WillOnce(Return(grpc::Status::OK)); + EXPECT_THAT(setup_client_and_run({"disable-zones", "zone1", "zone2", "zone3"}, term), Eq(mp::ReturnCode::Ok)); + EXPECT_THAT(cout_stream.str(), + HasSubstr("This operation will forcefully stop the VMs in zone1, zone2 and zone3. Are you sure you " + "want to continue? (Yes/no)")); +} + TEST_F(ClientAlias, aliasRefusesCreateDuplicateAlias) { EXPECT_CALL(mock_daemon, info(_, _)).Times(AtMost(1)).WillRepeatedly(make_info_function()); diff --git a/tests/test_daemon.cpp b/tests/test_daemon.cpp index 61ff676a16..a5338c2b8c 100644 --- a/tests/test_daemon.cpp +++ b/tests/test_daemon.cpp @@ -21,6 +21,8 @@ #include "dummy_ssh_key_provider.h" #include "fake_alias_config.h" #include "json_test_utils.h" +#include "mock_availability_zone.h" +#include "mock_availability_zone_manager.h" #include "mock_cert_provider.h" #include "mock_daemon.h" #include "mock_environment_helpers.h" @@ -2694,3 +2696,38 @@ TEST_F(Daemon, sets_up_permission_inheritance) } } // namespace + +TEST_F(Daemon, zones_state_cmd_disable_all_zones) +{ + auto mock_az_manager = std::make_unique>(); + auto zone1 = std::make_unique>(); + auto zone2 = std::make_unique>(); + + const std::string zone1_name = "zone1"; + const std::string zone2_name = "zone2"; + + ON_CALL(*zone1, get_name()).WillByDefault(ReturnRef(zone1_name)); + ON_CALL(*zone1, is_available()).WillByDefault(Return(true)); + EXPECT_CALL(*zone1, set_available(false)).Times(1); + + ON_CALL(*zone2, get_name()).WillByDefault(ReturnRef(zone2_name)); + ON_CALL(*zone2, is_available()).WillByDefault(Return(true)); + EXPECT_CALL(*zone2, set_available(false)).Times(1); + + ON_CALL(*mock_az_manager, get_zones()) + .WillByDefault( + Return(std::vector>{*zone1.get(), *zone2.get()})); + ON_CALL(*mock_az_manager, get_zone("zone1")).WillByDefault(ReturnRef(*zone1.get())); + ON_CALL(*mock_az_manager, get_zone("zone2")).WillByDefault(ReturnRef(*zone2.get())); + + config_builder.az_manager = std::move(mock_az_manager); + mp::Daemon daemon{config_builder.build()}; + + mp::ZonesStateRequest request; + request.set_available(false); + std::promise status_promise; + StrictMock> mock_server; + + daemon.zones_state(&request, &mock_server, &status_promise); + EXPECT_TRUE(status_promise.get_future().get().ok()); +}