diff --git a/completions/bash/multipass b/completions/bash/multipass index 8cb158bccf..753b904296 100644 --- a/completions/bash/multipass +++ b/completions/bash/multipass @@ -230,7 +230,7 @@ _multipass_complete() prev_opts=false multipass_cmds="authenticate transfer delete exec find help info launch list mount networks \ purge recover restart restore shell snapshot start stop suspend umount version get set \ - alias aliases unalias" + clone alias aliases unalias" if [[ "${multipass_cmds}" =~ " ${cmd} " || "${multipass_cmds}" =~ ^${cmd} || "${multipass_cmds}" =~ \ ${cmd}$ ]]; then @@ -283,6 +283,9 @@ _multipass_complete() "restore") _add_nonrepeating_args "--destructive" ;; + "clone") + _add_nonrepeating_args "--name" + ;; "get") _add_nonrepeating_args "--raw --keys" ;; @@ -369,6 +372,9 @@ _multipass_complete() "snapshot") _multipass_instances "Stopped" ;; + "clone") + _multipass_instances "Stopped" + ;; "restore") _multipass_restorable_snapshots ;; diff --git a/include/multipass/cloud_init_iso.h b/include/multipass/cloud_init_iso.h index b3c0d3d8b5..0f7b2e1fcc 100644 --- a/include/multipass/cloud_init_iso.h +++ b/include/multipass/cloud_init_iso.h @@ -75,6 +75,11 @@ class CloudInitFileOps : public Singleton const std::vector& extra_interfaces, const std::string& new_instance_id, const std::filesystem::path& cloud_init_path) const; + + virtual void update_cloned_cloud_init_unique_identifiers(const std::string& default_mac_addr, + const std::vector& extra_interfaces, + const std::string& new_hostname, + const std::filesystem::path& cloud_init_path) const; virtual void add_extra_interface_to_cloud_init(const std::string& default_mac_addr, const NetworkInterface& extra_interfaces, const std::filesystem::path& cloud_init_path) const; diff --git a/include/multipass/constants.h b/include/multipass/constants.h index 45d8e301e3..879364a24d 100644 --- a/include/multipass/constants.h +++ b/include/multipass/constants.h @@ -61,6 +61,8 @@ constexpr auto mounts_key = "local.privileged-mounts"; // idem constexpr auto winterm_key = "client.apps.windows-terminal.profiles"; // idem constexpr auto mirror_key = "local.image.mirror"; // idem; this defines the mirror of simple streams +constexpr auto cloud_init_file_name = "cloud-init-config.iso"; + [[maybe_unused]] // hands off clang-format constexpr auto key_examples = {petenv_key, driver_key, mounts_key}; diff --git a/include/multipass/exceptions/clone_exceptions.h b/include/multipass/exceptions/clone_exceptions.h new file mode 100644 index 0000000000..1e876340c9 --- /dev/null +++ b/include/multipass/exceptions/clone_exceptions.h @@ -0,0 +1,32 @@ +/* + * 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_CLONE_EXCEPTIONS_H +#define MULTIPASS_CLONE_EXCEPTIONS_H + +#include + +namespace multipass +{ +class CloneInvalidNameException : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; +} // namespace multipass + +#endif // MULTIPASS_CLONE_EXCEPTIONS_H diff --git a/include/multipass/file_ops.h b/include/multipass/file_ops.h index 04f7e83d7d..203028fc9c 100644 --- a/include/multipass/file_ops.h +++ b/include/multipass/file_ops.h @@ -104,6 +104,7 @@ class FileOps : public Singleton std::ios_base::openmode mode = std::ios_base::out) const; virtual std::unique_ptr open_read(const fs::path& path, std::ios_base::openmode mode = std::ios_base::in) const; + virtual void copy(const fs::path& src, const fs::path& dist, fs::copy_options copy_options) const; virtual bool exists(const fs::path& path, std::error_code& err) const; virtual bool is_directory(const fs::path& path, std::error_code& err) const; virtual bool create_directory(const fs::path& path, std::error_code& err) const; diff --git a/include/multipass/json_utils.h b/include/multipass/json_utils.h index a37a4fec61..e151ab55b2 100644 --- a/include/multipass/json_utils.h +++ b/include/multipass/json_utils.h @@ -34,6 +34,7 @@ namespace multipass { +struct VMSpecs; class JsonUtils : public Singleton { public: @@ -41,6 +42,14 @@ class JsonUtils : public Singleton virtual void write_json(const QJsonObject& root, QString file_name) const; // transactional; creates parent dirs virtual std::string json_to_string(const QJsonObject& root) const; + virtual QJsonValue update_cloud_init_instance_id(const QJsonValue& cloud_init_instance_id_value, + const std::string& src_vm_name, + const std::string& dest_vm_name) const; + virtual QJsonValue update_unique_identifiers_of_metadata(const QJsonValue& metadata_value, + const multipass::VMSpecs& src_specs, + const multipass::VMSpecs& dest_specs, + const std::string& src_vm_name, + const std::string& dest_vm_name) const; virtual QJsonArray extra_interfaces_to_json_array(const std::vector& extra_interfaces) const; virtual std::optional> read_extra_interfaces(const QJsonObject& record) const; }; diff --git a/include/multipass/virtual_machine.h b/include/multipass/virtual_machine.h index 57e5b302b1..bf3f8c7058 100644 --- a/include/multipass/virtual_machine.h +++ b/include/multipass/virtual_machine.h @@ -120,6 +120,7 @@ class VirtualMachine : private DisabledCopyMove virtual void load_snapshots() = 0; virtual std::vector get_childrens_names(const Snapshot* parent) const = 0; virtual int get_snapshot_count() const = 0; + virtual void remove_snapshots_from_image() const = 0; QDir instance_directory() const; diff --git a/include/multipass/virtual_machine_factory.h b/include/multipass/virtual_machine_factory.h index 96ff4b52cf..a1871baba6 100644 --- a/include/multipass/virtual_machine_factory.h +++ b/include/multipass/virtual_machine_factory.h @@ -49,6 +49,13 @@ class VirtualMachineFactory : private DisabledCopyMove virtual VirtualMachine::UPtr create_virtual_machine(const VirtualMachineDescription& desc, const SSHKeyProvider& key_provider, VMStatusMonitor& monitor) = 0; + virtual VirtualMachine::UPtr create_vm_and_clone_instance_dir_data(const VMSpecs& src_vm_spec, + const VMSpecs& dest_vm_spec, + const std::string& source_name, + const std::string& destination_name, + const VMImage& dest_vm_image, + const SSHKeyProvider& key_provider, + VMStatusMonitor& monitor) = 0; /** Removes any resources associated with a VM of the given name. * @@ -73,6 +80,7 @@ class VirtualMachineFactory : private DisabledCopyMove virtual std::vector networks() const = 0; virtual void require_snapshots_support() const = 0; virtual void require_suspend_support() const = 0; + virtual void require_clone_support() const = 0; protected: VirtualMachineFactory() = default; diff --git a/include/multipass/vm_image_vault.h b/include/multipass/vm_image_vault.h index 1f14581842..d458880b61 100644 --- a/include/multipass/vm_image_vault.h +++ b/include/multipass/vm_image_vault.h @@ -93,6 +93,7 @@ class VMImageVault : private DisabledCopyMove virtual void update_images(const FetchType& fetch_type, const PrepareAction& prepare, const ProgressMonitor& monitor) = 0; virtual MemorySize minimum_image_size_for(const std::string& id) = 0; + virtual void clone(const std::string& source_instance_name, const std::string& destination_instance_name) = 0; virtual VMImageHost* image_host_for(const std::string& remote_name) const = 0; virtual std::vector> all_info_for(const Query& query) const = 0; diff --git a/include/multipass/vm_specs.h b/include/multipass/vm_specs.h index 48989fd213..155715a39a 100644 --- a/include/multipass/vm_specs.h +++ b/include/multipass/vm_specs.h @@ -44,6 +44,7 @@ struct VMSpecs std::unordered_map mounts; bool deleted; QJsonObject metadata; + int clone_count = 0; // tracks the number of cloned vm from this source vm (regardless of deletes) }; inline bool operator==(const VMSpecs& a, const VMSpecs& b) @@ -57,16 +58,18 @@ inline bool operator==(const VMSpecs& a, const VMSpecs& b) a.state, a.mounts, a.deleted, - a.metadata) == std::tie(b.num_cores, - b.mem_size, - b.disk_space, - b.default_mac_address, - b.extra_interfaces, - b.ssh_username, - b.state, - b.mounts, - b.deleted, - b.metadata); + a.metadata, + a.clone_count) == std::tie(b.num_cores, + b.mem_size, + b.disk_space, + b.default_mac_address, + b.extra_interfaces, + b.ssh_username, + b.state, + b.mounts, + b.deleted, + b.metadata, + a.clone_count); } inline bool operator!=(const VMSpecs& a, const VMSpecs& b) // TODO drop in C++20 diff --git a/include/multipass/yaml_node_utils.h b/include/multipass/yaml_node_utils.h index 17bd0bdc7f..cd19ffd5f5 100644 --- a/include/multipass/yaml_node_utils.h +++ b/include/multipass/yaml_node_utils.h @@ -48,6 +48,10 @@ YAML::Node make_cloud_init_network_config(const std::string& default_mac_addr, YAML::Node add_extra_interface_to_network_config(const std::string& default_mac_addr, const NetworkInterface& extra_interface, const std::string& network_config_file_content); +// the make_cloud_init_network_config and add_extra_interface_to_network_config functions are adapted to generate the +// new format network-config file (having default interface present and having dhcp-identifier: mac on every network +// interface). At the same time, it also needs to take care of the pre-existed file, meaning that the generated file +// from the old file should have the new format. } // namespace utils } // namespace multipass #endif // MULTIPASS_YAML_NODE_UTILS_H diff --git a/src/client/cli/client.cpp b/src/client/cli/client.cpp index 50f5402da9..47b17e2653 100644 --- a/src/client/cli/client.cpp +++ b/src/client/cli/client.cpp @@ -19,6 +19,7 @@ #include "cmd/alias.h" #include "cmd/aliases.h" #include "cmd/authenticate.h" +#include "cmd/clone.h" #include "cmd/delete.h" #include "cmd/exec.h" #include "cmd/find.h" @@ -108,6 +109,7 @@ mp::Client::Client(ClientConfig& config) add_command(aliases); add_command(); add_command(); + add_command(); sort_commands(); diff --git a/src/client/cli/cmd/CMakeLists.txt b/src/client/cli/cmd/CMakeLists.txt index d5606f6153..a46c93d5c5 100644 --- a/src/client/cli/cmd/CMakeLists.txt +++ b/src/client/cli/cmd/CMakeLists.txt @@ -17,6 +17,7 @@ add_library(commands STATIC aliases.cpp animated_spinner.cpp authenticate.cpp + clone.cpp common_cli.cpp create_alias.cpp delete.cpp diff --git a/src/client/cli/cmd/clone.cpp b/src/client/cli/cmd/clone.cpp new file mode 100644 index 0000000000..eaddea0ed5 --- /dev/null +++ b/src/client/cli/cmd/clone.cpp @@ -0,0 +1,109 @@ +/* + * 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 "clone.h" + +#include "animated_spinner.h" +#include "common_cli.h" + +#include + +namespace mp = multipass; +namespace cmd = multipass::cmd; + +mp::ReturnCode cmd::Clone::run(ArgParser* parser) +{ + const auto parscode = parse_args(parser); + if (parscode != ParseCode::Ok) + { + return parser->returnCodeFrom(parscode); + } + + AnimatedSpinner spinner{cout}; + auto action_on_success = [this, &spinner](CloneReply& reply) -> ReturnCode { + spinner.stop(); + cout << reply.reply_message(); + + return ReturnCode::Ok; + }; + + auto action_on_failure = [this, &spinner](grpc::Status& status, CloneReply& reply) -> ReturnCode { + spinner.stop(); + return standard_failure_handler_for(name(), cerr, status, reply.reply_message()); + }; + + spinner.start("Cloning " + rpc_request.source_name()); + return dispatch(&RpcMethod::clone, rpc_request, action_on_success, action_on_failure); +} + +std::string cmd::Clone::name() const +{ + return "clone"; +} + +QString cmd::Clone::short_help() const +{ + return QStringLiteral("Clone an instance"); +} + +QString cmd::Clone::description() const +{ + return QStringLiteral( + "Create an independent copy of an existing instance. The instance must be stopped before you proceed."); +} + +mp::ParseCode cmd::Clone::parse_args(ArgParser* parser) +{ + parser->addPositionalArgument("source_name", "The name of the source instance to be cloned", ""); + + const QCommandLineOption destination_name_option{ + {"n", "name"}, + "Specify a custom name for the cloned instance. The name must follow the same validity rules as instance names " + "(see \"help launch\"). Default: \"-cloneN\", where N is the Nth cloned instance.", + "destination-name"}; + + parser->addOption(destination_name_option); + + const auto status = parser->commandParse(this); + if (status != ParseCode::Ok) + { + return status; + } + + const auto number_of_positional_arguments = parser->positionalArguments().count(); + if (number_of_positional_arguments < 1) + { + cerr << "Please provide the name of the source instance.\n"; + return ParseCode::CommandLineError; + } + + if (number_of_positional_arguments > 1) + { + cerr << "Too many arguments.\n"; + return ParseCode::CommandLineError; + } + + const auto source_name = parser->positionalArguments()[0]; + rpc_request.set_source_name(source_name.toStdString()); + rpc_request.set_verbosity_level(parser->verbosityLevel()); + if (parser->isSet(destination_name_option)) + { + rpc_request.set_destination_name(parser->value(destination_name_option).toStdString()); + } + + return ParseCode::Ok; +} diff --git a/src/client/cli/cmd/clone.h b/src/client/cli/cmd/clone.h new file mode 100644 index 0000000000..d3f55e6a60 --- /dev/null +++ b/src/client/cli/cmd/clone.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_CLONE_H +#define MULTIPASS_CLONE_H + +#include + +namespace multipass +{ +namespace cmd +{ +class Clone final : 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: + ParseCode parse_args(ArgParser* parser); + + CloneRequest rpc_request; +}; +} // namespace cmd +} // namespace multipass +#endif // MULTIPASS_CLONE_H diff --git a/src/daemon/daemon.cpp b/src/daemon/daemon.cpp index 23102b75ff..09987d0556 100644 --- a/src/daemon/daemon.cpp +++ b/src/daemon/daemon.cpp @@ -22,8 +22,10 @@ #include "snapshot_settings_handler.h" #include +#include #include #include +#include #include #include #include @@ -252,6 +254,7 @@ std::unordered_map load_db(const mp::Path& data_path, auto state = record["state"].toInt(); auto deleted = record["deleted"].toBool(); auto metadata = record["metadata"].toObject(); + auto clone_count = record["clone_count"].toInt(); if (!num_cores && !deleted && ssh_username.empty() && metadata.isEmpty() && !mp::MemorySize{mem_size}.in_bytes() && !mp::MemorySize{disk_space}.in_bytes()) @@ -288,7 +291,8 @@ std::unordered_map load_db(const mp::Path& data_path, static_cast(state), mounts, deleted, - metadata}; + metadata, + clone_count}; } return reconstructed_records; } @@ -318,10 +322,16 @@ QJsonObject vm_spec_to_json(const mp::VMSpecs& specs) } json.insert("mounts", json_mounts); + json.insert("clone_count", specs.clone_count); return json; } +std::string generate_next_clone_name(int clone_count, const std::string& source_name) +{ + return fmt::format("{}-clone{}", source_name, clone_count + 1); +} + auto fetch_image_for(const std::string& name, mp::VirtualMachineFactory& factory, mp::VMImageVault& vault) { auto stub_prepare = [](const mp::VMImage&) -> mp::VMImage { return {}; }; @@ -550,6 +560,7 @@ auto connect_rpc(mp::DaemonRpc& rpc, mp::Daemon& daemon) QObject::connect(&rpc, &mp::DaemonRpc::on_find, &daemon, &mp::Daemon::find); QObject::connect(&rpc, &mp::DaemonRpc::on_info, &daemon, &mp::Daemon::info); QObject::connect(&rpc, &mp::DaemonRpc::on_list, &daemon, &mp::Daemon::list); + QObject::connect(&rpc, &mp::DaemonRpc::on_clone, &daemon, &mp::Daemon::clone); QObject::connect(&rpc, &mp::DaemonRpc::on_networks, &daemon, &mp::Daemon::networks); QObject::connect(&rpc, &mp::DaemonRpc::on_mount, &daemon, &mp::Daemon::mount); QObject::connect(&rpc, &mp::DaemonRpc::on_recover, &daemon, &mp::Daemon::recover); @@ -1339,7 +1350,7 @@ mp::Daemon::Daemon(std::unique_ptr the_config) } const auto instance_dir = mp::utils::base_dir(vm_image.image_path); - const auto cloud_init_iso = instance_dir.filePath("cloud-init-config.iso"); + const auto cloud_init_iso = instance_dir.filePath(cloud_init_file_name); mp::VirtualMachineDescription vm_desc{spec.num_cores, spec.mem_size, spec.disk_space, @@ -1395,6 +1406,7 @@ mp::Daemon::Daemon(std::unique_ptr the_config) { mpl::log(mpl::Level::warning, category, fmt::format("Removing invalid instance: {}", bad_spec)); vm_instance_specs.erase(bad_spec); + config->vault->remove(bad_spec); } if (!invalid_specs.empty()) @@ -2680,6 +2692,84 @@ catch (const std::exception& e) status_promise->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what(), "")); } +void mp::Daemon::clone(const CloneRequest* request, + grpc::ServerReaderWriterInterface* server, + std::promise* status_promise) +try +{ + config->factory->require_clone_support(); + mpl::ClientLogger logger{mpl::level_from(request->verbosity_level()), + *config->logger, + server}; + + const auto& source_name = request->source_name(); + const auto [instance_trail, status] = find_instance_and_react(operative_instances, + deleted_instances, + source_name, + require_operative_instances_reaction); + if (status.ok()) + { + assert(instance_trail.index() == 0); + const auto source_vm_ptr = std::get<0>(instance_trail)->second; + assert(source_vm_ptr); + const VirtualMachine::State source_vm_state = source_vm_ptr->current_state(); + if (source_vm_state != VirtualMachine::State::stopped && source_vm_state != VirtualMachine::State::off) + { + return status_promise->set_value( + grpc::Status{grpc::FAILED_PRECONDITION, + "Please stop instance " + source_name + " before you clone it."}); + } + + const std::string destination_name = generate_destination_instance_name_for_clone(*request); + auto rollback_clean_up_all_resource_of_dest_instance = + sg::make_scope_guard([this, destination_name]() noexcept -> void { + release_resources(destination_name); + preparing_instances.erase((destination_name)); + }); + + // signal that the new instance is being cooked up + preparing_instances.insert(destination_name); + auto& src_spec = vm_instance_specs[source_name]; + auto dest_spec = clone_spec(src_spec, source_name, destination_name); + + config->vault->clone(source_name, destination_name); + + const mp::VMImage dest_vm_image = fetch_image_for(destination_name, *config->factory, *config->vault); + + // QemuVirtualMachine constructor depends on vm_instance_specs[destination_name], so the appending of the + // dest_spec has to be done before the function create_vm_and_clone_instance_dir_data + vm_instance_specs.emplace(destination_name, dest_spec); + operative_instances[destination_name] = + config->factory->create_vm_and_clone_instance_dir_data(src_spec, + dest_spec, + source_name, + destination_name, + dest_vm_image, + *config->ssh_key_provider, + *this); + ++src_spec.clone_count; + // preparing instance is done + preparing_instances.erase(destination_name); + persist_instances(); + init_mounts(destination_name); + + CloneReply rpc_response; + rpc_response.set_reply_message(fmt::format("Cloned from {} to {}.\n", source_name, destination_name)); + server->Write(rpc_response); + rollback_clean_up_all_resource_of_dest_instance.dismiss(); + } + status_promise->set_value(status); +} +catch (const mp::CloneInvalidNameException& e) +{ + // all CloneInvalidNameException throws in generate_destination_instance_name_for_clone + status_promise->set_value(grpc::Status(grpc::StatusCode::INVALID_ARGUMENT, e.what())); +} +catch (const std::exception& e) +{ + status_promise->set_value(grpc::Status(grpc::StatusCode::INTERNAL, e.what())); +} + void mp::Daemon::on_shutdown() { } @@ -3550,6 +3640,78 @@ void mp::Daemon::populate_instance_info(VirtualMachine& vm, vm_specs.num_cores != 1); } +bool mp::Daemon::is_instance_name_already_used(const std::string& instance_name) +{ + return operative_instances.find(instance_name) != operative_instances.end() || + deleted_instances.find(instance_name) != deleted_instances.end() || + delayed_shutdown_instances.find(instance_name) != delayed_shutdown_instances.end() || + preparing_instances.find(instance_name) != preparing_instances.end(); +} + +std::string mp::Daemon::generate_destination_instance_name_for_clone(const CloneRequest& request) +{ + if (request.has_destination_name()) + { + if (!mp::utils::valid_hostname(request.destination_name())) + { + throw mp::CloneInvalidNameException("Invalid destination instance name: " + request.destination_name()); + } + + if (is_instance_name_already_used(request.destination_name())) + { + throw mp::CloneInvalidNameException(request.destination_name() + + " already exists, please choose a new name."); + } + + return request.destination_name(); + } + else + { + const std::string& source_name = request.source_name(); + const std::string destination_name = + generate_next_clone_name(vm_instance_specs[source_name].clone_count, source_name); + + if (is_instance_name_already_used(destination_name)) + { + throw mp::CloneInvalidNameException("auto-generated name " + destination_name + + " already exists, please specify a new name manually."); + } + + return destination_name; + } +}; + +mp::VMSpecs mp::Daemon::clone_spec(const VMSpecs& src_vm_spec, + const std::string& src_name, + const std::string& dest_name) +{ + mp::VMSpecs dest_vm_spec{src_vm_spec}; + dest_vm_spec.clone_count = 0; + + // update default mac addr and extra_interface mac addr + dest_vm_spec.default_mac_address = generate_unused_mac_address(allocated_mac_addrs); + for (auto& extra_interface : dest_vm_spec.extra_interfaces) + { + if (!extra_interface.mac_address.empty()) + { + extra_interface.mac_address = generate_unused_mac_address(allocated_mac_addrs); + } + } + + // non qemu snapshot files do not have metadata + if (!dest_vm_spec.metadata.isEmpty()) + { + dest_vm_spec.metadata = MP_JSONUTILS + .update_unique_identifiers_of_metadata(QJsonValue{dest_vm_spec.metadata}, + src_vm_spec, + dest_vm_spec, + src_name, + dest_name) + .toObject(); + } + return dest_vm_spec; +} + bool mp::Daemon::is_bridged(const std::string& instance_name) const { return is_bridged_impl(vm_instance_specs.at(instance_name), diff --git a/src/daemon/daemon.h b/src/daemon/daemon.h index 6556052f63..b6f79f8b61 100644 --- a/src/daemon/daemon.h +++ b/src/daemon/daemon.h @@ -140,6 +140,9 @@ public slots: virtual void authenticate(const AuthenticateRequest* request, grpc::ServerReaderWriterInterface* server, std::promise* status_promise); + virtual void clone(const CloneRequest* request, + grpc::ServerReaderWriterInterface* server, + std::promise* status_promise); virtual void snapshot(const SnapshotRequest* request, grpc::ServerReaderWriterInterface* server, @@ -204,6 +207,10 @@ public slots: void populate_instance_info(VirtualMachine& vm, InfoReply& response, bool runtime_info, bool deleted, bool& have_mounts); + bool is_instance_name_already_used(const std::string& instance_name); + std::string generate_destination_instance_name_for_clone(const CloneRequest& request); + VMSpecs clone_spec(const VMSpecs& src_vm_spec, const std::string& src_name, const std::string& dest_name); + std::unique_ptr config; protected: diff --git a/src/daemon/daemon_rpc.cpp b/src/daemon/daemon_rpc.cpp index f11369e83b..dcc630f5f1 100644 --- a/src/daemon/daemon_rpc.cpp +++ b/src/daemon/daemon_rpc.cpp @@ -200,6 +200,19 @@ grpc::Status mp::DaemonRpc::list(grpc::ServerContext* context, grpc::ServerReade std::bind(&DaemonRpc::on_list, this, &request, server, std::placeholders::_1), client_cert_from(context)); } +grpc::Status mp::DaemonRpc::clone(grpc::ServerContext* context, + grpc::ServerReaderWriter* server) +{ + CloneRequest request; + server->Read(&request); + + auto adapted_on_clone = [this, &request, server](auto&& arg) -> void { + this->on_clone(&request, server, std::forward(arg)); + }; + + return verify_client_and_dispatch_operation(adapted_on_clone, client_cert_from(context)); +} + grpc::Status mp::DaemonRpc::networks(grpc::ServerContext* context, grpc::ServerReaderWriter* server) { diff --git a/src/daemon/daemon_rpc.h b/src/daemon/daemon_rpc.h index ad1dd5ed1d..49f97187f0 100644 --- a/src/daemon/daemon_rpc.h +++ b/src/daemon/daemon_rpc.h @@ -69,6 +69,9 @@ class DaemonRpc : public QObject, public multipass::Rpc::Service, private Disabl std::promise* status_promise); void on_list(const ListRequest* request, grpc::ServerReaderWriter* server, std::promise* status_promise); + void on_clone(const CloneRequest* request, + grpc::ServerReaderWriter* server, + std::promise* status_promise); void on_networks(const NetworksRequest* request, grpc::ServerReaderWriter* server, std::promise* status_promise); void on_mount(const MountRequest* request, grpc::ServerReaderWriter* server, @@ -126,6 +129,8 @@ class DaemonRpc : public QObject, public multipass::Rpc::Service, private Disabl grpc::Status find(grpc::ServerContext* context, grpc::ServerReaderWriter* server) override; grpc::Status info(grpc::ServerContext* context, grpc::ServerReaderWriter* server) override; grpc::Status list(grpc::ServerContext* context, grpc::ServerReaderWriter* server) override; + grpc::Status clone(grpc::ServerContext* context, + grpc::ServerReaderWriter* server) override; grpc::Status networks(grpc::ServerContext* context, grpc::ServerReaderWriter* server) override; grpc::Status mount(grpc::ServerContext* context, diff --git a/src/daemon/default_vm_image_vault.cpp b/src/daemon/default_vm_image_vault.cpp index d65ae9ee37..42f1d00915 100644 --- a/src/daemon/default_vm_image_vault.cpp +++ b/src/daemon/default_vm_image_vault.cpp @@ -582,6 +582,30 @@ mp::MemorySize mp::DefaultVMImageVault::minimum_image_size_for(const std::string throw std::runtime_error(fmt::format("Cannot determine minimum image size for id \'{}\'", id)); } +void mp::DefaultVMImageVault::clone(const std::string& source_instance_name, + const std::string& destination_instance_name) +{ + const auto source_iter = instance_image_records.find(source_instance_name); + + if (source_iter == instance_image_records.end()) + { + throw std::runtime_error(source_instance_name + " does not exist in the image records"); + } + + if (instance_image_records.find(destination_instance_name) != instance_image_records.end()) + { + throw std::runtime_error(destination_instance_name + " already exists in the image records"); + } + + auto& dest_vault_record = instance_image_records[destination_instance_name] = + instance_image_records[source_instance_name]; + + // string replacement is "instances/"->"instances/" instead of + // ""->"", because the second one might match other substrings of the metadata. + dest_vault_record.image.image_path.replace("instances/" + QString{source_instance_name.c_str()}, + "instances/" + QString{destination_instance_name.c_str()}); + persist_instance_records(); +} mp::VMImage mp::DefaultVMImageVault::download_and_prepare_source_image( const VMImageInfo& info, std::optional& existing_source_image, const QDir& image_dir, diff --git a/src/daemon/default_vm_image_vault.h b/src/daemon/default_vm_image_vault.h index 2719f0420f..37db7c653d 100644 --- a/src/daemon/default_vm_image_vault.h +++ b/src/daemon/default_vm_image_vault.h @@ -65,6 +65,7 @@ class DefaultVMImageVault final : public BaseVMImageVault void update_images(const FetchType& fetch_type, const PrepareAction& prepare, const ProgressMonitor& monitor) override; MemorySize minimum_image_size_for(const std::string& id) override; + void clone(const std::string& source_instance_name, const std::string& destination_instance_name) override; private: VMImage image_instance_from(const VMImage& prepared_image, const Path& dest_dir); diff --git a/src/iso/cloud_init_iso.cpp b/src/iso/cloud_init_iso.cpp index 5ec407a013..ebe64ab83d 100644 --- a/src/iso/cloud_init_iso.cpp +++ b/src/iso/cloud_init_iso.cpp @@ -714,16 +714,29 @@ void mp::CloudInitFileOps::update_cloud_init_with_new_extra_interfaces_and_new_i meta_data_file_content = mpu::emit_cloud_config(mpu::make_cloud_init_meta_config_with_id_tweak(meta_data_file_content, new_instance_id)); - if (extra_interfaces.empty()) - { - iso_file.erase("network-config"); - } - else - { - // overwrite the whole network-config file content - iso_file["network-config"] = - mpu::emit_cloud_config(mpu::make_cloud_init_network_config(default_mac_addr, extra_interfaces)); - } + // overwrite the whole network-config file content + iso_file["network-config"] = + mpu::emit_cloud_config(mpu::make_cloud_init_network_config(default_mac_addr, extra_interfaces)); + iso_file.write_to(QString::fromStdString(cloud_init_path.string())); +} + +void mp::CloudInitFileOps::update_cloned_cloud_init_unique_identifiers( + const std::string& default_mac_addr, + const std::vector& extra_interfaces, + const std::string& new_hostname, + const std::filesystem::path& cloud_init_path) const +{ + CloudInitIso iso_file; + iso_file.read_from(cloud_init_path); + + std::string& meta_data_file_content = iso_file.at("meta-data"); + meta_data_file_content = + mpu::emit_cloud_config(mpu::make_cloud_init_meta_config(new_hostname, meta_data_file_content)); + + std::string& network_config_file_content = iso_file["network-config"]; + network_config_file_content = mpu::emit_cloud_config( + mpu::make_cloud_init_network_config(default_mac_addr, extra_interfaces, network_config_file_content)); + iso_file.write_to(QString::fromStdString(cloud_init_path.string())); } diff --git a/src/platform/backends/lxd/lxd_vm_image_vault.h b/src/platform/backends/lxd/lxd_vm_image_vault.h index 40f4f4e9f8..381280bc9c 100644 --- a/src/platform/backends/lxd/lxd_vm_image_vault.h +++ b/src/platform/backends/lxd/lxd_vm_image_vault.h @@ -19,6 +19,7 @@ #define MULTIPASS_LXD_VM_IMAGE_VAULT_H #include +#include #include #include @@ -52,6 +53,10 @@ class LXDVMImageVault final : public BaseVMImageVault void update_images(const FetchType& fetch_type, const PrepareAction& prepare, const ProgressMonitor& monitor) override; MemorySize minimum_image_size_for(const std::string& id) override; + void clone(const std::string& source_instance_name, const std::string& destination_instance_name) override + { + throw NotImplementedOnThisBackendException("clone"); + } private: void lxd_download_image(const VMImageInfo& info, const Query& query, const ProgressMonitor& monitor, diff --git a/src/platform/backends/qemu/qemu_virtual_machine.cpp b/src/platform/backends/qemu/qemu_virtual_machine.cpp index a2d0bfb69f..4aa0f01632 100644 --- a/src/platform/backends/qemu/qemu_virtual_machine.cpp +++ b/src/platform/backends/qemu/qemu_virtual_machine.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -210,6 +211,33 @@ void convert_to_qcow2_v3_if_necessary(const mp::Path& image_path, const std::str mpl::log(mpl::Level::error, vm_name, e.what()); } } + +QStringList extract_snapshot_tags(const QByteArray& snapshot_list_output_stream) +{ + QStringList lines = QString{snapshot_list_output_stream}.split('\n'); + QStringList snapshot_tags; + + // Snapshot list: + // ID TAG VM SIZE DATE VM CLOCK ICOUNT + // 2 @s2 0 B 2024-06-11 23:22:59 00:00:00.000 0 + // 3 @s3 0 B 2024-06-12 12:30:37 00:00:00.000 0 + + // The first two lines are headers + for (int i = 2; i < lines.size(); ++i) + { + // Qt::SkipEmptyParts improve the robustness of the code, it can keep the result correct in the case of leading + // and trailing spaces. + QStringList entries = lines[i].split(QRegularExpression{R"(\s+)"}, Qt::SkipEmptyParts); + + if (entries.count() >= 2) + { + snapshot_tags.append(entries[1]); + } + } + + return snapshot_tags; +} + } // namespace mp::QemuVirtualMachine::QemuVirtualMachine(const VirtualMachineDescription& desc, @@ -347,7 +375,7 @@ void mp::QemuVirtualMachine::shutdown(ShutdownPolicy shutdown_policy) if (has_suspend_snapshot) { mpl::log(mpl::Level::info, vm_name, "Deleting suspend image"); - mp::backend::delete_instance_suspend_image(desc.image.image_path, suspend_tag); + mp::backend::delete_snapshot_from_image(desc.image.image_path, suspend_tag); } state = State::off; @@ -743,6 +771,16 @@ mp::MountHandler::UPtr mp::QemuVirtualMachine::make_native_mount_handler(const s return std::make_unique(this, &key_provider, target, mount); } +void mp::QemuVirtualMachine::remove_snapshots_from_image() const +{ + const QStringList snapshot_tag_list = extract_snapshot_tags(backend::snapshot_list_output(desc.image.image_path)); + + for (const auto& snapshot_tag : snapshot_tag_list) + { + backend::delete_snapshot_from_image(desc.image.image_path, snapshot_tag); + } +} + mp::QemuVirtualMachine::MountArgs& mp::QemuVirtualMachine::modifiable_mount_args() { return mount_args; diff --git a/src/platform/backends/qemu/qemu_virtual_machine.h b/src/platform/backends/qemu/qemu_virtual_machine.h index 5610f5c010..c674e4dfc4 100644 --- a/src/platform/backends/qemu/qemu_virtual_machine.h +++ b/src/platform/backends/qemu/qemu_virtual_machine.h @@ -69,7 +69,7 @@ class QemuVirtualMachine : public QObject, public BaseVirtualMachine const NetworkInterface& extra_interface) override; virtual MountArgs& modifiable_mount_args(); std::unique_ptr make_native_mount_handler(const std::string& target, const VMMount& mount) override; - + void remove_snapshots_from_image() const override; signals: void on_delete_memory_snapshot(); void on_reset_network(); diff --git a/src/platform/backends/qemu/qemu_virtual_machine_factory.cpp b/src/platform/backends/qemu/qemu_virtual_machine_factory.cpp index 65ad872be8..57a01664ec 100644 --- a/src/platform/backends/qemu/qemu_virtual_machine_factory.cpp +++ b/src/platform/backends/qemu/qemu_virtual_machine_factory.cpp @@ -18,18 +18,22 @@ #include "qemu_virtual_machine_factory.h" #include "qemu_virtual_machine.h" +#include +#include #include #include #include #include #include - +#include +#include #include #include namespace mp = multipass; namespace mpl = multipass::logging; +namespace mpu = multipass::utils; namespace { @@ -59,6 +63,53 @@ mp::VirtualMachine::UPtr mp::QemuVirtualMachineFactory::create_virtual_machine(c get_instance_directory(desc.vm_name)); } +mp::VirtualMachine::UPtr mp::QemuVirtualMachineFactory::create_vm_and_clone_instance_dir_data( + const VMSpecs& /*src_vm_spec*/, + const VMSpecs& dest_vm_spec, + const std::string& source_name, + const std::string& destination_name, + const VMImage& dest_vm_image, + const SSHKeyProvider& key_provider, + VMStatusMonitor& monitor) +{ + const std::filesystem::path source_instance_data_directory{get_instance_directory(source_name).toStdString()}; + const std::filesystem::path dest_instance_data_directory{get_instance_directory(destination_name).toStdString()}; + + copy_instance_dir_with_essential_files(source_instance_data_directory, dest_instance_data_directory); + + const fs::path cloud_init_config_iso_file_path = dest_instance_data_directory / cloud_init_file_name; + + MP_CLOUD_INIT_FILE_OPS.update_cloned_cloud_init_unique_identifiers(dest_vm_spec.default_mac_address, + dest_vm_spec.extra_interfaces, + destination_name, + cloud_init_config_iso_file_path); + + // start to construct VirtualMachineDescription + mp::VirtualMachineDescription dest_vm_desc{dest_vm_spec.num_cores, + dest_vm_spec.mem_size, + dest_vm_spec.disk_space, + destination_name, + dest_vm_spec.default_mac_address, + dest_vm_spec.extra_interfaces, + dest_vm_spec.ssh_username, + dest_vm_image, + cloud_init_config_iso_file_path.string().c_str(), + {}, + {}, + {}, + {}}; + + mp::VirtualMachine::UPtr cloned_instance = + std::make_unique(dest_vm_desc, + qemu_platform.get(), + monitor, + key_provider, + get_instance_directory(dest_vm_desc.vm_name)); + cloned_instance->remove_snapshots_from_image(); + + return cloned_instance; +} + void mp::QemuVirtualMachineFactory::remove_resources_for_impl(const std::string& name) { qemu_platform->remove_resources_for(name); diff --git a/src/platform/backends/qemu/qemu_virtual_machine_factory.h b/src/platform/backends/qemu/qemu_virtual_machine_factory.h index 6d9e0634bf..a6995dbde9 100644 --- a/src/platform/backends/qemu/qemu_virtual_machine_factory.h +++ b/src/platform/backends/qemu/qemu_virtual_machine_factory.h @@ -36,6 +36,14 @@ class QemuVirtualMachineFactory final : public BaseVirtualMachineFactory VirtualMachine::UPtr create_virtual_machine(const VirtualMachineDescription& desc, const SSHKeyProvider& key_provider, VMStatusMonitor& monitor) override; + VirtualMachine::UPtr create_vm_and_clone_instance_dir_data(const VMSpecs& src_vm_spec, + const VMSpecs& dest_vm_spec, + const std::string& source_name, + const std::string& destination_name, + const VMImage& dest_vm_image, + const SSHKeyProvider& key_provider, + VMStatusMonitor& monitor) override; + VMImage prepare_source_image(const VMImage& source_image) override; void prepare_instance_image(const VMImage& instance_image, const VirtualMachineDescription& desc) override; void hypervisor_health_check() override; @@ -44,6 +52,9 @@ class QemuVirtualMachineFactory final : public BaseVirtualMachineFactory std::vector networks() const override; void require_snapshots_support() const override; void prepare_networking(std::vector& extra_interfaces) override; + void require_clone_support() const override + { + } protected: void remove_resources_for_impl(const std::string& name) override; diff --git a/src/platform/backends/shared/base_snapshot.cpp b/src/platform/backends/shared/base_snapshot.cpp index c066aafb5a..e2e5f957ef 100644 --- a/src/platform/backends/shared/base_snapshot.cpp +++ b/src/platform/backends/shared/base_snapshot.cpp @@ -19,12 +19,12 @@ #include "multipass/virtual_machine.h" #include +#include #include #include #include #include #include - #include #include @@ -193,9 +193,9 @@ mp::BaseSnapshot::BaseSnapshot(const QJsonObject& json, VirtualMachine& vm, cons json["comment"].toString().toStdString(), // comment choose_cloud_init_instance_id(json, std::filesystem::path{vm.instance_directory().absolutePath().toStdString()} / - "cloud-init-config.iso"), // instance id from cloud init - find_parent(json, vm), // parent - json["index"].toInt(), // index + cloud_init_file_name), // instance id from cloud init + find_parent(json, vm), // parent + json["index"].toInt(), // index QDateTime::fromString(json["creation_timestamp"].toString(), Qt::ISODateWithMs), // creation_timestamp json["num_cores"].toInt(), // num_cores MemorySize{json["mem_size"].toString().toStdString()}, // mem_size diff --git a/src/platform/backends/shared/base_virtual_machine.cpp b/src/platform/backends/shared/base_virtual_machine.cpp index dd90064387..049793643e 100644 --- a/src/platform/backends/shared/base_virtual_machine.cpp +++ b/src/platform/backends/shared/base_virtual_machine.cpp @@ -18,6 +18,7 @@ #include "base_virtual_machine.h" #include +#include #include #include #include @@ -32,7 +33,6 @@ #include #include #include - #include #include @@ -161,7 +161,7 @@ void mp::BaseVirtualMachine::apply_extra_interfaces_and_instance_id_to_cloud_ini const std::string& new_instance_id) const { const std::filesystem::path cloud_init_config_iso_file_path = - std::filesystem::path{instance_dir.absolutePath().toStdString()} / "cloud-init-config.iso"; + std::filesystem::path{instance_dir.absolutePath().toStdString()} / cloud_init_file_name; MP_CLOUD_INIT_FILE_OPS.update_cloud_init_with_new_extra_interfaces_and_new_id(default_mac_addr, extra_interfaces, @@ -173,7 +173,7 @@ void mp::BaseVirtualMachine::add_extra_interface_to_instance_cloud_init(const st const NetworkInterface& extra_interface) const { const std::filesystem::path cloud_init_config_iso_file_path = - std::filesystem::path{instance_dir.absolutePath().toStdString()} / "cloud-init-config.iso"; + std::filesystem::path{instance_dir.absolutePath().toStdString()} / cloud_init_file_name; MP_CLOUD_INIT_FILE_OPS.add_extra_interface_to_cloud_init(default_mac_addr, extra_interface, @@ -183,7 +183,7 @@ void mp::BaseVirtualMachine::add_extra_interface_to_instance_cloud_init(const st std::string mp::BaseVirtualMachine::get_instance_id_from_the_cloud_init() const { const std::filesystem::path cloud_init_config_iso_file_path = - std::filesystem::path{instance_dir.absolutePath().toStdString()} / "cloud-init-config.iso"; + std::filesystem::path{instance_dir.absolutePath().toStdString()} / cloud_init_file_name; return MP_CLOUD_INIT_FILE_OPS.get_instance_id_from_cloud_init(cloud_init_config_iso_file_path); } @@ -651,9 +651,9 @@ void mp::BaseVirtualMachine::log_latest_snapshot(LockT lock) const void mp::BaseVirtualMachine::load_snapshot(const QString& filename) { - auto snapshot = make_specific_snapshot(filename); + const auto snapshot = make_specific_snapshot(filename); const auto& name = snapshot->get_name(); - auto [it, success] = snapshots.try_emplace(name, snapshot); + const auto [_, success] = snapshots.try_emplace(name, snapshot); if (!success) { diff --git a/src/platform/backends/shared/base_virtual_machine.h b/src/platform/backends/shared/base_virtual_machine.h index cd387b62eb..0175613b21 100644 --- a/src/platform/backends/shared/base_virtual_machine.h +++ b/src/platform/backends/shared/base_virtual_machine.h @@ -63,7 +63,7 @@ class BaseVirtualMachine : public VirtualMachine std::unique_ptr make_native_mount_handler(const std::string& target, const VMMount& mount) override { throw NotImplementedOnThisBackendException("native mounts"); - }; + } SnapshotVista view_snapshots() const override; int get_num_snapshots() const override; @@ -84,6 +84,10 @@ class BaseVirtualMachine : public VirtualMachine void load_snapshots() override; std::vector get_childrens_names(const Snapshot* parent) const override; int get_snapshot_count() const override; + void remove_snapshots_from_image() const override + { + throw NotImplementedOnThisBackendException("snapshots"); + } protected: virtual void require_snapshots_support() const; @@ -156,7 +160,7 @@ class BaseVirtualMachine : public VirtualMachine std::optional ssh_session = std::nullopt; SnapshotMap snapshots; std::shared_ptr head_snapshot = nullptr; - int snapshot_count = 0; // tracks the number of snapshots ever taken (regardless or deletes) + int snapshot_count = 0; // tracks the number of snapshots ever taken (regardless of deletes) mutable std::recursive_mutex snapshot_mutex; }; diff --git a/src/platform/backends/shared/base_virtual_machine_factory.cpp b/src/platform/backends/shared/base_virtual_machine_factory.cpp index ee5d906d07..075ae6d4b2 100644 --- a/src/platform/backends/shared/base_virtual_machine_factory.cpp +++ b/src/platform/backends/shared/base_virtual_machine_factory.cpp @@ -19,6 +19,7 @@ #include "multipass/platform.h" #include +#include #include #include #include @@ -34,7 +35,7 @@ mp::BaseVirtualMachineFactory::BaseVirtualMachineFactory(const Path& instances_d void mp::BaseVirtualMachineFactory::configure(VirtualMachineDescription& vm_desc) { auto instance_dir{mpu::base_dir(vm_desc.image.image_path)}; - const auto cloud_init_iso = instance_dir.filePath("cloud-init-config.iso"); + const auto cloud_init_iso = instance_dir.filePath(cloud_init_file_name); if (!QFile::exists(cloud_init_iso)) { @@ -81,3 +82,28 @@ void mp::BaseVirtualMachineFactory::prepare_interface(NetworkInterface& net, } } } + +void mp::copy_instance_dir_with_essential_files(const fs::path& source_instance_dir_path, + const fs::path& dest_instance_dir_path) +{ + if (fs::exists(source_instance_dir_path) && fs::is_directory(source_instance_dir_path)) + { + for (const auto& entry : fs::directory_iterator(source_instance_dir_path)) + { + fs::create_directory(dest_instance_dir_path); + + // 1. Only cloud-init-config.iso file and .img file are needed for qemu + // 2. Only cloud-init-config.iso file is needed for virutalbox because the rest files are taken care of by + // clonevm command + // 3. Only cloud-init-config.iso file is needed for hyperv because the rest files are taken + // care of by export and import command + // By the way, snapshot related files are excluded in all three backends, so as a result we can have an + // inclusion file list below which works for all three backends + if (entry.path().extension().string() == ".iso" || entry.path().extension().string() == ".img") + { + const fs::path dest_file_path = dest_instance_dir_path / entry.path().filename(); + fs::copy(entry.path(), dest_file_path, fs::copy_options::update_existing); + } + } + } +} diff --git a/src/platform/backends/shared/base_virtual_machine_factory.h b/src/platform/backends/shared/base_virtual_machine_factory.h index ee04d90bfb..65c10b7f41 100644 --- a/src/platform/backends/shared/base_virtual_machine_factory.h +++ b/src/platform/backends/shared/base_virtual_machine_factory.h @@ -35,6 +35,16 @@ class BaseVirtualMachineFactory : public VirtualMachineFactory { public: explicit BaseVirtualMachineFactory(const Path& instances_dir); + VirtualMachine::UPtr create_vm_and_clone_instance_dir_data(const VMSpecs& src_vm_spec, + const VMSpecs& dest_vm_spec, + const std::string& source_name, + const std::string& destination_name, + const VMImage& dest_vm_image, + const SSHKeyProvider& key_provider, + VMStatusMonitor& monitor) override + { + throw NotImplementedOnThisBackendException("clone"); + } void remove_resources_for(const std::string& name) final; @@ -74,6 +84,11 @@ class BaseVirtualMachineFactory : public VirtualMachineFactory void require_suspend_support() const override; + void require_clone_support() const override + { + throw NotImplementedOnThisBackendException{"clone"}; + } + protected: static const Path instances_subdir; @@ -90,6 +105,10 @@ class BaseVirtualMachineFactory : public VirtualMachineFactory private: Path instances_dir; }; + +namespace fs = std::filesystem; +void copy_instance_dir_with_essential_files(const fs::path& source_instance_dir_path, + const fs::path& dest_instance_dir_path); } // namespace multipass inline void multipass::BaseVirtualMachineFactory::remove_resources_for(const std::string& name) diff --git a/src/platform/backends/shared/qemu_img_utils/qemu_img_utils.cpp b/src/platform/backends/shared/qemu_img_utils/qemu_img_utils.cpp index 58d343effc..929fd73607 100644 --- a/src/platform/backends/shared/qemu_img_utils/qemu_img_utils.cpp +++ b/src/platform/backends/shared/qemu_img_utils/qemu_img_utils.cpp @@ -103,9 +103,17 @@ bool mp::backend::instance_image_has_snapshot(const mp::Path& image_path, QStrin return QString{process->read_all_standard_output()}.contains(regex); } -void mp::backend::delete_instance_suspend_image(const Path& image_path, const QString& suspend_tag) +QByteArray mp::backend::snapshot_list_output(const Path& image_path) +{ + auto qemuimg_info_process = checked_exec_qemu_img( + std::make_unique(QStringList{"snapshot", "-l", image_path}, image_path), + "Cannot list snapshots from the image"); + return qemuimg_info_process->read_all_standard_output(); +} + +void mp::backend::delete_snapshot_from_image(const Path& image_path, const QString& snapshot_tag) { checked_exec_qemu_img( - std::make_unique(QStringList{"snapshot", "-d", suspend_tag, image_path}, image_path), - "Failed to delete suspend image"); + std::make_unique(QStringList{"snapshot", "-d", snapshot_tag, image_path}, image_path), + "Cannot delete snapshot from the image"); } diff --git a/src/platform/backends/shared/qemu_img_utils/qemu_img_utils.h b/src/platform/backends/shared/qemu_img_utils/qemu_img_utils.h index c10b70c187..70abcbb5a6 100644 --- a/src/platform/backends/shared/qemu_img_utils/qemu_img_utils.h +++ b/src/platform/backends/shared/qemu_img_utils/qemu_img_utils.h @@ -43,8 +43,8 @@ void resize_instance_image(const MemorySize& disk_space, const multipass::Path& Path convert_to_qcow_if_necessary(const Path& image_path); void amend_to_qcow2_v3(const Path& image_path); bool instance_image_has_snapshot(const Path& image_path, QString snapshot_tag); -void delete_instance_suspend_image(const Path& image_path, const QString& suspend_tag); - +QByteArray snapshot_list_output(const Path& image_path); +void delete_snapshot_from_image(const Path& image_path, const QString& snapshot_tag); } // namespace backend } // namespace multipass #endif // MULTIPASS_QEMU_IMG_UTILS_H diff --git a/src/rpc/multipass.proto b/src/rpc/multipass.proto index e6666ad820..3e577641ed 100644 --- a/src/rpc/multipass.proto +++ b/src/rpc/multipass.proto @@ -43,6 +43,7 @@ service Rpc { rpc authenticate (stream AuthenticateRequest) returns (stream AuthenticateReply); rpc snapshot (stream SnapshotRequest) returns (stream SnapshotReply); rpc restore (stream RestoreRequest) returns (stream RestoreReply); + rpc clone (stream CloneRequest) returns (stream CloneReply); } message LaunchRequest { @@ -527,3 +528,14 @@ message RestoreReply { string reply_message = 2; bool confirm_destructive = 3; } + +message CloneRequest { + string source_name = 1; + optional string destination_name = 2; + int32 verbosity_level = 3; +} + +message CloneReply { + string reply_message = 1; + string log_line = 2; +} diff --git a/src/utils/file_ops.cpp b/src/utils/file_ops.cpp index 08c20d4f2a..752cf96f82 100644 --- a/src/utils/file_ops.cpp +++ b/src/utils/file_ops.cpp @@ -222,6 +222,11 @@ std::unique_ptr mp::FileOps::open_read(const fs::path& path, std:: return std::make_unique(path, mode); } +void mp::FileOps::copy(const fs::path& src, const fs::path& dist, fs::copy_options copy_options) const +{ + fs::copy(src, dist, copy_options); +} + bool mp::FileOps::exists(const fs::path& path, std::error_code& err) const { return fs::exists(path, err); diff --git a/src/utils/json_utils.cpp b/src/utils/json_utils.cpp index 98ce955e6e..84ca937239 100644 --- a/src/utils/json_utils.cpp +++ b/src/utils/json_utils.cpp @@ -21,7 +21,9 @@ #include #include #include +#include +#include #include #include @@ -59,6 +61,51 @@ std::string mp::JsonUtils::json_to_string(const QJsonObject& root) const return QJsonDocument(root).toJson().toStdString(); } +QJsonValue mp::JsonUtils::update_cloud_init_instance_id(const QJsonValue& cloud_init_instance_id_value, + const std::string& src_vm_name, + const std::string& dest_vm_name) const +{ + std::string cloud_init_instance_id_str = cloud_init_instance_id_value.toString().toStdString(); + assert(cloud_init_instance_id_str.size() >= src_vm_name.size()); + + return QJsonValue{QString::fromStdString(cloud_init_instance_id_str.replace(0, src_vm_name.size(), dest_vm_name))}; +} + +QJsonValue mp::JsonUtils::update_unique_identifiers_of_metadata(const QJsonValue& metadata_value, + const multipass::VMSpecs& src_specs, + const multipass::VMSpecs& dest_specs, + const std::string& src_vm_name, + const std::string& dest_vm_name) const +{ + assert(src_specs.extra_interfaces.size() == dest_specs.extra_interfaces.size()); + + QJsonObject result_metadata_object = metadata_value.toObject(); + QJsonValueRef arguments = result_metadata_object["arguments"]; + QJsonArray json_array = arguments.toArray(); + for (QJsonValueRef item : json_array) + { + QString str = item.toString(); + + str.replace(src_specs.default_mac_address.c_str(), dest_specs.default_mac_address.c_str()); + for (size_t i = 0; i < src_specs.extra_interfaces.size(); ++i) + { + const std::string& src_extra_interface_mac_addr = src_specs.extra_interfaces[i].mac_address; + if (!src_extra_interface_mac_addr.empty()) + { + const std::string& dest_extra_interface_mac_addr = dest_specs.extra_interfaces[i].mac_address; + str.replace(src_extra_interface_mac_addr.c_str(), dest_extra_interface_mac_addr.c_str()); + } + } + // string replacement is "instances/"->"instances/" instead of + // ""->"", because the second one might match other substrings of the metadata. + str.replace("instances/" + QString{src_vm_name.c_str()}, "instances/" + QString{dest_vm_name.c_str()}); + item = str; + } + arguments = json_array; + + return QJsonValue{result_metadata_object}; +} + QJsonArray mp::JsonUtils::extra_interfaces_to_json_array( const std::vector& extra_interfaces) const { diff --git a/src/utils/yaml_node_utils.cpp b/src/utils/yaml_node_utils.cpp index 3555f7219d..67c9b6fde7 100644 --- a/src/utils/yaml_node_utils.cpp +++ b/src/utils/yaml_node_utils.cpp @@ -19,6 +19,8 @@ #include #include +#include + namespace mp = multipass; namespace @@ -29,6 +31,7 @@ YAML::Node create_extra_interface_node(const std::string& extra_interface_name, YAML::Node extra_interface_data{}; extra_interface_data["match"]["macaddress"] = extra_interface_mac_address; extra_interface_data["dhcp4"] = true; + extra_interface_data["dhcp-identifier"] = "mac"; // We make the default gateway associated with the first interface. extra_interface_data["dhcp4-overrides"]["route-metric"] = 200; // Make the interface optional, which means that networkd will not wait for the device to be configured. @@ -36,6 +39,18 @@ YAML::Node create_extra_interface_node(const std::string& extra_interface_name, return extra_interface_data; }; + +YAML::Node create_default_interface_node(const std::string& default_interface_mac_address) +{ + YAML::Node default_interface_data{}; + + default_interface_data["match"]["macaddress"] = default_interface_mac_address; + default_interface_data["dhcp4"] = true; + default_interface_data["dhcp-identifier"] = "mac"; + + return default_interface_data; +}; + } // namespace std::string mp::utils::emit_yaml(const YAML::Node& node) @@ -59,7 +74,21 @@ YAML::Node mp::utils::make_cloud_init_meta_config(const std::string& name, const { YAML::Node meta_data = file_content.empty() ? YAML::Node{} : YAML::Load(file_content); - meta_data["instance-id"] = name; + if (!file_content.empty()) + { + const std::string old_hostname = meta_data["local-hostname"].as(); + std::string old_instance_id = meta_data["instance-id"].as(); + + // The assumption here is that the instance_id is the hostname optionally appended _e sequence + assert(old_instance_id.size() >= old_hostname.size()); + // replace the old host name with the new host name + meta_data["instance-id"] = old_instance_id.replace(0, old_hostname.size(), name); + } + else + { + meta_data["instance-id"] = name; + } + meta_data["local-hostname"] = name; meta_data["cloud-name"] = "multipass"; @@ -89,24 +118,15 @@ YAML::Node mp::utils::make_cloud_init_network_config(const std::string& default_ { YAML::Node network_data = file_content.empty() ? YAML::Node{} : YAML::Load(file_content); - // Generate the cloud-init file only if there is at least one extra interface needing auto configuration. - if (std::find_if(extra_interfaces.begin(), extra_interfaces.end(), [](const auto& iface) { - return iface.auto_mode; - }) != extra_interfaces.end()) - { - network_data["version"] = "2"; + network_data["version"] = "2"; + network_data["ethernets"]["default"] = create_default_interface_node(default_mac_addr); - std::string name = "default"; - network_data["ethernets"][name]["match"]["macaddress"] = default_mac_addr; - network_data["ethernets"][name]["dhcp4"] = true; - - for (size_t i = 0; i < extra_interfaces.size(); ++i) + for (size_t i = 0; i < extra_interfaces.size(); ++i) + { + if (extra_interfaces[i].auto_mode) { - if (extra_interfaces[i].auto_mode) - { - name = "extra" + std::to_string(i); - network_data["ethernets"][name] = create_extra_interface_node(name, extra_interfaces[i].mac_address); - } + const std::string name = "extra" + std::to_string(i); + network_data["ethernets"][name] = create_extra_interface_node(name, extra_interfaces[i].mac_address); } } @@ -127,9 +147,7 @@ YAML::Node mp::utils::add_extra_interface_to_network_config(const std::string& d YAML::Node network_data{}; network_data["version"] = "2"; - const std::string default_network_name = "default"; - network_data["ethernets"][default_network_name]["match"]["macaddress"] = default_mac_addr; - network_data["ethernets"][default_network_name]["dhcp4"] = true; + network_data["ethernets"]["default"] = create_default_interface_node(default_mac_addr); const std::string extra_interface_name = "extra0"; network_data["ethernets"][extra_interface_name] = diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7a12d1621d..b201a931ae 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -68,6 +68,7 @@ add_executable(multipass_tests test_custom_image_host.cpp test_daemon.cpp test_daemon_authenticate.cpp + test_daemon_clone.cpp test_daemon_find.cpp test_daemon_launch.cpp test_daemon_mount.cpp diff --git a/tests/daemon_test_fixture.cpp b/tests/daemon_test_fixture.cpp index 3f1e7aeaff..af8d6f148b 100644 --- a/tests/daemon_test_fixture.cpp +++ b/tests/daemon_test_fixture.cpp @@ -680,3 +680,17 @@ template grpc::Status mpt::DaemonTestFixture::call_daemon_slot( std::promise*), const mp::RestoreRequest&, StrictMock>&&); +template grpc::Status mpt::DaemonTestFixture::call_daemon_slot( + mp::Daemon&, + void (mp::Daemon::*)(const mp::CloneRequest*, + grpc::ServerReaderWriterInterface*, + std::promise*), + const mp::CloneRequest&, + NiceMock>&); +template grpc::Status mpt::DaemonTestFixture::call_daemon_slot( + mp::Daemon&, + void (mp::Daemon::*)(const mp::CloneRequest*, + grpc::ServerReaderWriterInterface*, + std::promise*), + const mp::CloneRequest&, + NiceMock>&&); diff --git a/tests/lxd/test_lxd_image_vault.cpp b/tests/lxd/test_lxd_image_vault.cpp index cea8b0ac05..16c5d0b983 100644 --- a/tests/lxd/test_lxd_image_vault.cpp +++ b/tests/lxd/test_lxd_image_vault.cpp @@ -1257,3 +1257,17 @@ TEST_F(LXDImageVault, updateImagesThrowsOnMissingImage) EXPECT_THROW(image_vault.update_images(mp::FetchType::ImageOnly, stub_prepare, stub_monitor), mp::ImageNotFoundException); } + +TEST_F(LXDImageVault, lxdImageVaultCloneThrow) +{ + mp::LXDVMImageVault image_vault{hosts, + &stub_url_downloader, + mock_network_access_manager.get(), + base_url, + cache_dir.path(), + mp::days{0}}; + + MP_EXPECT_THROW_THAT(image_vault.clone("dummy_src_image_name", "dummy_dest_image_name"), + mp::NotImplementedOnThisBackendException, + mpt::match_what(StrEq("The clone feature is not implemented on this backend."))); +} diff --git a/tests/mock_client_rpc.h b/tests/mock_client_rpc.h index 35da28d0b3..7dc97a4319 100644 --- a/tests/mock_client_rpc.h +++ b/tests/mock_client_rpc.h @@ -212,6 +212,18 @@ class MockRpcStub : public multipass::Rpc::StubInterface PrepareAsyncrestoreRaw, (grpc::ClientContext * context, grpc::CompletionQueue* cq), (override)); + MOCK_METHOD((grpc::ClientReaderWriterInterface*), + cloneRaw, + (grpc::ClientContext * context), + (override)); + MOCK_METHOD((grpc::ClientAsyncReaderWriterInterface*), + AsynccloneRaw, + (grpc::ClientContext * context, grpc::CompletionQueue* cq, void* tag), + (override)); + MOCK_METHOD((grpc::ClientAsyncReaderWriterInterface*), + PrepareAsynccloneRaw, + (grpc::ClientContext * context, grpc::CompletionQueue* cq), + (override)); }; } // namespace multipass::test diff --git a/tests/mock_cloud_init_file_ops.h b/tests/mock_cloud_init_file_ops.h index bb9edaf727..82c5b1aa65 100644 --- a/tests/mock_cloud_init_file_ops.h +++ b/tests/mock_cloud_init_file_ops.h @@ -34,6 +34,11 @@ class MockCloudInitFileOps : public CloudInitFileOps update_cloud_init_with_new_extra_interfaces_and_new_id, (const std::string&, const std::vector&, const std::string&, const std::filesystem::path&), (const, override)); + MOCK_METHOD( + void, + update_cloned_cloud_init_unique_identifiers, + (const std::string&, const std::vector&, const std::string&, const std::filesystem::path&), + (const, override)); MOCK_METHOD(void, add_extra_interface_to_cloud_init, (const std::string&, const NetworkInterface&, const std::filesystem::path&), diff --git a/tests/mock_daemon.h b/tests/mock_daemon.h index d2dc92432f..16f77d0ad5 100644 --- a/tests/mock_daemon.h +++ b/tests/mock_daemon.h @@ -129,6 +129,12 @@ struct MockDaemon : public Daemon (grpc::ServerReaderWriterInterface*), std::promise*), (override)); + MOCK_METHOD(void, + clone, + (const CloneRequest*, + (grpc::ServerReaderWriterInterface*), + std::promise*), + (override)); template void set_promise_value(const Request*, grpc::ServerReaderWriterInterface*, diff --git a/tests/mock_virtual_machine.h b/tests/mock_virtual_machine.h index c9e5ccad64..ad2f53fa41 100644 --- a/tests/mock_virtual_machine.h +++ b/tests/mock_virtual_machine.h @@ -101,6 +101,7 @@ struct MockVirtualMachineT : public T MOCK_METHOD(void, load_snapshots, (), (override)); MOCK_METHOD(std::vector, get_childrens_names, (const Snapshot*), (const, override)); MOCK_METHOD(int, get_snapshot_count, (), (const, override)); + MOCK_METHOD(void, remove_snapshots_from_image, (), (const override)); std::unique_ptr tmp_dir; }; diff --git a/tests/mock_virtual_machine_factory.h b/tests/mock_virtual_machine_factory.h index 0a3d3315f4..04cfaad236 100644 --- a/tests/mock_virtual_machine_factory.h +++ b/tests/mock_virtual_machine_factory.h @@ -35,6 +35,16 @@ struct MockVirtualMachineFactory : public VirtualMachineFactory create_virtual_machine, (const VirtualMachineDescription&, const SSHKeyProvider&, VMStatusMonitor&), (override)); + MOCK_METHOD(VirtualMachine::UPtr, + create_vm_and_clone_instance_dir_data, + (const VMSpecs&, + const VMSpecs&, + const std::string&, + const std::string&, + const VMImage&, + const SSHKeyProvider&, + VMStatusMonitor&), + (override)); MOCK_METHOD(void, remove_resources_for, (const std::string&), (override)); MOCK_METHOD(FetchType, fetch_type, (), (override)); @@ -51,6 +61,7 @@ struct MockVirtualMachineFactory : public VirtualMachineFactory MOCK_METHOD(std::vector, networks, (), (const, override)); MOCK_METHOD(void, require_snapshots_support, (), (const, override)); MOCK_METHOD(void, require_suspend_support, (), (const, override)); + MOCK_METHOD(void, require_clone_support, (), (const, override)); // originally protected: MOCK_METHOD(std::string, create_bridge_with, (const NetworkInterfaceInfo&), (override)); diff --git a/tests/mock_vm_image_vault.h b/tests/mock_vm_image_vault.h index 0c85d83de5..9f17883fcf 100644 --- a/tests/mock_vm_image_vault.h +++ b/tests/mock_vm_image_vault.h @@ -58,6 +58,7 @@ class MockVMImageVault : public VMImageVault MOCK_METHOD(void, prune_expired_images, (), (override)); MOCK_METHOD(void, update_images, (const FetchType&, const PrepareAction&, const ProgressMonitor&), (override)); MOCK_METHOD(MemorySize, minimum_image_size_for, (const std::string&), (override)); + MOCK_METHOD(void, clone, (const std::string&, const std::string&), (override)); MOCK_METHOD(VMImageHost*, image_host_for, (const std::string&), (const, override)); MOCK_METHOD((std::vector>), all_info_for, (const Query&), (const, override)); diff --git a/tests/qemu/test_qemu_backend.cpp b/tests/qemu/test_qemu_backend.cpp index 1421e63bf2..ca4064c242 100644 --- a/tests/qemu/test_qemu_backend.cpp +++ b/tests/qemu/test_qemu_backend.cpp @@ -1170,6 +1170,68 @@ TEST_F(QemuBackend, createBridgeWithChecksWithQemuPlatform) EXPECT_NO_THROW(backend.prepare_networking(extra_interfaces)); } +TEST_F(QemuBackend, removeAllSnapshotsFromTheImage) +{ + EXPECT_CALL(*mock_qemu_platform_factory, make_qemu_platform(_)).WillOnce([this](auto...) { + return std::move(mock_qemu_platform); + }); + + // The sole reason to register this callback is to make the extract_snapshot_tags function get a non-empty snapshot + // list input, so we can cover the for loops + mpt::MockProcessFactory::Callback snapshot_list_callback = [](mpt::MockProcess* process) { + if (process->program().contains("qemu-img") && process->arguments().contains("snapshot") && + process->arguments().contains("-l")) + { + constexpr auto snapshot_list_output_stream = + R"(Snapshot list: + ID TAG VM SIZE DATE VM CLOCK ICOUNT + 2 @s2 0 B 2024-06-11 23:22:59 00:00:00.000 0 + 3 @s3 0 B 2024-06-12 12:30:37 00:00:00.000 0)"; + + EXPECT_CALL(*process, read_all_standard_output()).WillOnce(Return(QByteArray{snapshot_list_output_stream})); + } + }; + + process_factory->register_callback(snapshot_list_callback); + mpt::StubVMStatusMonitor stub_monitor; + mp::QemuVirtualMachineFactory backend{data_dir.path()}; + + const auto machine = backend.create_virtual_machine(default_description, key_provider, stub_monitor); + EXPECT_NO_THROW(machine->remove_snapshots_from_image()); + + const std::vector processes = process_factory->process_list(); + + EXPECT_GE(processes.size(), 2); + const auto lastProcessInfo = processes.back(); + const auto last2ndProcessInfo = processes[processes.size() - 2]; + + EXPECT_TRUE(lastProcessInfo.command == "qemu-img" && lastProcessInfo.arguments.contains("-d") && + lastProcessInfo.arguments.contains("@s3")); + EXPECT_TRUE(last2ndProcessInfo.command == "qemu-img" && last2ndProcessInfo.arguments.contains("-d") && + last2ndProcessInfo.arguments.contains("@s2")); +} + +TEST_F(QemuBackend, createVmAndCloneInstanceDirData) +{ + EXPECT_CALL(*mock_qemu_platform_factory, make_qemu_platform(_)).WillOnce([this](auto...) { + return std::move(mock_qemu_platform); + }); + + mpt::StubVMStatusMonitor stub_monitor; + mp::QemuVirtualMachineFactory backend{data_dir.path()}; + const mpt::MockCloudInitFileOps::GuardedMock mock_cloud_init_file_ops_injection = + mpt::MockCloudInitFileOps::inject(); + EXPECT_CALL(*mock_cloud_init_file_ops_injection.first, update_cloned_cloud_init_unique_identifiers(_, _, _, _)) + .Times(1); + EXPECT_TRUE(backend.create_vm_and_clone_instance_dir_data({}, + {}, + "dummy_src_name", + "dummy_dest_name", + {}, + key_provider, + stub_monitor)); +} + TEST(QemuPlatform, base_qemu_platform_returns_expected_values) { mpt::MockQemuPlatform qemu_platform; diff --git a/tests/stub_virtual_machine.h b/tests/stub_virtual_machine.h index f74d716eab..ae993d810e 100644 --- a/tests/stub_virtual_machine.h +++ b/tests/stub_virtual_machine.h @@ -194,6 +194,10 @@ struct StubVirtualMachine final : public multipass::VirtualMachine return 0; } + void remove_snapshots_from_image() const override + { + } + StubSnapshot snapshot; std::unique_ptr tmp_dir; }; diff --git a/tests/stub_virtual_machine_factory.h b/tests/stub_virtual_machine_factory.h index a692fc8fbd..80e8fe9610 100644 --- a/tests/stub_virtual_machine_factory.h +++ b/tests/stub_virtual_machine_factory.h @@ -46,6 +46,17 @@ struct StubVirtualMachineFactory : public multipass::BaseVirtualMachineFactory return std::make_unique(); } + multipass::VirtualMachine::UPtr create_vm_and_clone_instance_dir_data(const VMSpecs& src_vm_spec, + const VMSpecs& dest_vm_spec, + const std::string& source_name, + const std::string& destination_name, + const VMImage& dest_vm_image, + const SSHKeyProvider& key_provider, + VMStatusMonitor& monitor) override + { + return std::make_unique(); + } + void remove_resources_for_impl(const std::string& name) override { } diff --git a/tests/stub_vm_image_vault.h b/tests/stub_vm_image_vault.h index 71f5ccfbf2..2e363decc6 100644 --- a/tests/stub_vm_image_vault.h +++ b/tests/stub_vm_image_vault.h @@ -64,6 +64,10 @@ struct StubVMImageVault final : public multipass::VMImageVault return {}; } + void clone(const std::string& source_instance_name, const std::string& destination_instance_name) override + { + } + TempFile dummy_image; }; } diff --git a/tests/test_base_virtual_machine_factory.cpp b/tests/test_base_virtual_machine_factory.cpp index 02e773887b..6dd4d2dfe0 100644 --- a/tests/test_base_virtual_machine_factory.cpp +++ b/tests/test_base_virtual_machine_factory.cpp @@ -51,6 +51,16 @@ struct MockBaseFactory : mp::BaseVirtualMachineFactory create_virtual_machine, (const mp::VirtualMachineDescription&, const mp::SSHKeyProvider&, mp::VMStatusMonitor&), (override)); + MOCK_METHOD(mp::VirtualMachine::UPtr, + create_vm_and_clone_instance_dir_data, + (const mp::VMSpecs&, + const mp::VMSpecs&, + const std::string&, + const std::string&, + const mp::VMImage&, + const mp::SSHKeyProvider&, + mp::VMStatusMonitor&), + (override)); MOCK_METHOD(mp::VMImage, prepare_source_image, (const mp::VMImage&), (override)); MOCK_METHOD(void, prepare_instance_image, (const mp::VMImage&, const mp::VirtualMachineDescription&), (override)); MOCK_METHOD(void, hypervisor_health_check, (), (override)); diff --git a/tests/test_cli_client.cpp b/tests/test_cli_client.cpp index 3dbd9fe4ee..42c19ee8e5 100644 --- a/tests/test_cli_client.cpp +++ b/tests/test_cli_client.cpp @@ -146,6 +146,10 @@ struct MockDaemonRpc : public mp::DaemonRpc (grpc::ServerContext * context, (grpc::ServerReaderWriter * server)), (override)); + MOCK_METHOD(grpc::Status, + clone, + (grpc::ServerContext * context, (grpc::ServerReaderWriter * server)), + (override)); }; struct Client : public Test @@ -3199,6 +3203,48 @@ TEST_F(Client, help_cmd_launch_same_launch_cmd_help) EXPECT_THAT(help_cmd_launch.str(), Eq(launch_cmd_help.str())); } +// clone cli tests + +TEST_F(Client, cloneCmdWithTooManyArgsFails) +{ + EXPECT_EQ(send_command({"clone", "vm1", "vm2"}), mp::ReturnCode::CommandLineError); +} + +TEST_F(Client, cloneCmdWithTooLessArgsFails) +{ + EXPECT_EQ(send_command({"clone"}), mp::ReturnCode::CommandLineError); +} + +TEST_F(Client, cloneCmdInvalidOptionFails) +{ + EXPECT_EQ(send_command({"clone", "--invalid_option"}), mp::ReturnCode::CommandLineError); +} + +TEST_F(Client, cloneCmdHelpOk) +{ + EXPECT_EQ(send_command({"clone", "--help"}), mp::ReturnCode::Ok); +} + +TEST_F(Client, cloneCmdWithSrcVMNameOnly) +{ + EXPECT_CALL(mock_daemon, clone).Times(1); + EXPECT_EQ(send_command({"clone", "vm1"}), mp::ReturnCode::Ok); +} + +TEST_F(Client, cloneCmdWithDestName) +{ + EXPECT_CALL(mock_daemon, clone).Times(2); + EXPECT_EQ(send_command({"clone", "vm1", "-n", "vm2"}), mp::ReturnCode::Ok); + EXPECT_EQ(send_command({"clone", "vm1", "--name", "vm2"}), mp::ReturnCode::Ok); +} + +TEST_F(Client, cloneCmdFailedFromDaemon) +{ + const grpc::Status clone_failure{grpc::StatusCode::FAILED_PRECONDITION, "dummy_msg"}; + EXPECT_CALL(mock_daemon, clone).Times(1).WillOnce(Return(clone_failure)); + EXPECT_EQ(send_command({"clone", "vm1"}), mp::ReturnCode::CommandFail); +} + // snapshot cli tests TEST_F(Client, snapshotCmdHelpOk) { diff --git a/tests/test_cloud_init_iso.cpp b/tests/test_cloud_init_iso.cpp index 2318df8edc..6fc567e5bd 100644 --- a/tests/test_cloud_init_iso.cpp +++ b/tests/test_cloud_init_iso.cpp @@ -28,7 +28,7 @@ using namespace testing; namespace { -constexpr std::string_view meta_data_content = R"(#cloud-config +constexpr auto* meta_data_content = R"(#cloud-config instance-id: vm1 local-hostname: vm1 cloud-name: multipass)"; @@ -329,9 +329,9 @@ TEST_F(CloudInitIso, reads_iso_file_with_random_string_data) TEST_F(CloudInitIso, reads_iso_file_with_mocked_real_file_data) { - constexpr std::string_view user_data_content = R"(#cloud-config + constexpr auto* user_data_content = R"(#cloud-config {})"; - constexpr std::string_view vendor_data_content = R"(#cloud-config + constexpr auto* vendor_data_content = R"(#cloud-config growpart: mode: auto devices: [/] @@ -351,10 +351,10 @@ timezone: Europe/Amsterdam )"; mp::CloudInitIso original_iso; - original_iso.add_file("meta-data", std::string(meta_data_content)); - original_iso.add_file("vendor_data_content", std::string(vendor_data_content)); - original_iso.add_file("user-data", std::string(user_data_content)); - original_iso.add_file("network-data", "some random network-data"); + original_iso.add_file("meta-data", meta_data_content); + original_iso.add_file("vendor_data_content", vendor_data_content); + original_iso.add_file("user-data", user_data_content); + original_iso.add_file("network-config", "some random network-data"); original_iso.write_to(iso_path); mp::CloudInitIso new_iso; @@ -366,8 +366,8 @@ TEST_F(CloudInitIso, updateCloudInitWithNewNonEmptyExtraInterfaces) { mp::CloudInitIso original_iso; - original_iso.add_file("meta-data", std::string(meta_data_content)); - original_iso.add_file("network-data", ""); + original_iso.add_file("meta-data", meta_data_content); + original_iso.add_file("network-config", "dummy_data"); original_iso.write_to(iso_path); const std::string default_mac_addr = "52:54:00:56:78:90"; @@ -378,22 +378,24 @@ TEST_F(CloudInitIso, updateCloudInitWithNewNonEmptyExtraInterfaces) "vm2", iso_path.toStdString())); - constexpr std::string_view expected_modified_meta_data_content = R"(#cloud-config + constexpr auto* expected_modified_meta_data_content = R"(#cloud-config instance-id: vm2 local-hostname: vm1 cloud-name: multipass )"; - constexpr std::string_view expected_generated_network_config_data_content = R"(#cloud-config + constexpr auto* expected_generated_network_config_data_content = R"(#cloud-config version: 2 ethernets: default: match: macaddress: "52:54:00:56:78:90" dhcp4: true + dhcp-identifier: mac extra0: match: macaddress: "52:54:00:56:78:91" dhcp4: true + dhcp-identifier: mac dhcp4-overrides: route-metric: 200 optional: true @@ -408,8 +410,8 @@ version: 2 TEST_F(CloudInitIso, updateCloudInitWithNewEmptyExtraInterfaces) { mp::CloudInitIso original_iso; - original_iso.add_file("meta-data", std::string(meta_data_content)); - original_iso.add_file("network-data", ""); + original_iso.add_file("meta-data", meta_data_content); + original_iso.add_file("network-config", "dummy_data"); original_iso.write_to(iso_path); const std::string& default_mac_addr = "52:54:00:56:78:90"; @@ -421,13 +423,77 @@ TEST_F(CloudInitIso, updateCloudInitWithNewEmptyExtraInterfaces) iso_path.toStdString())); mp::CloudInitIso new_iso; new_iso.read_from(iso_path.toStdString()); - EXPECT_FALSE(new_iso.contains("network-config")); + EXPECT_TRUE(new_iso.contains("network-config")); +} + +TEST_F(CloudInitIso, updateCloneCloudInitSrcFileWithExtraInterfaces) +{ + constexpr auto* src_meta_data_content = R"(#cloud-config +instance-id: vm1_e_e +local-hostname: vm1 +cloud-name: multipass)"; + constexpr auto* src_network_config_data_content = R"(#cloud-config +version: 2 +ethernets: + default: + match: + macaddress: "00:00:00:00:00:00" + dhcp4: true + dhcp-identifier: mac + extra0: + match: + macaddress: "00:00:00:00:00:01" + dhcp4: true + dhcp-identifier: mac + dhcp4-overrides: + route-metric: 200 + optional: true +)"; + + mp::CloudInitIso original_iso; + original_iso.add_file("meta-data", src_meta_data_content); + original_iso.add_file("network-config", src_network_config_data_content); + original_iso.write_to(iso_path); + + const std::string default_mac_addr = "52:54:00:56:78:90"; + const std::vector extra_interfaces = {{"id", "52:54:00:56:78:91", true}}; + EXPECT_NO_THROW(MP_CLOUD_INIT_FILE_OPS.update_cloned_cloud_init_unique_identifiers(default_mac_addr, + extra_interfaces, + "vm1-clone1", + iso_path.toStdString())); + + constexpr auto* expected_modified_meta_data_content = R"(#cloud-config +instance-id: vm1-clone1_e_e +local-hostname: vm1-clone1 +cloud-name: multipass +)"; + constexpr auto* expected_generated_network_config_data_content = R"(#cloud-config +version: 2 +ethernets: + default: + match: + macaddress: "52:54:00:56:78:90" + dhcp4: true + dhcp-identifier: mac + extra0: + match: + macaddress: "52:54:00:56:78:91" + dhcp4: true + dhcp-identifier: mac + dhcp4-overrides: + route-metric: 200 + optional: true +)"; + mp::CloudInitIso new_iso; + new_iso.read_from(iso_path.toStdString()); + EXPECT_EQ(new_iso.at("meta-data"), expected_modified_meta_data_content); + EXPECT_EQ(new_iso.at("network-config"), expected_generated_network_config_data_content); } TEST_F(CloudInitIso, addExtraInterfaceToCloudInit) { mp::CloudInitIso original_iso; - original_iso.add_file("meta-data", std::string(meta_data_content)); + original_iso.add_file("meta-data", meta_data_content); original_iso.write_to(iso_path); const mp::NetworkInterface dummy_extra_interface{}; @@ -438,7 +504,7 @@ TEST_F(CloudInitIso, addExtraInterfaceToCloudInit) TEST_F(CloudInitIso, getInstanceIdFromCloudInit) { mp::CloudInitIso original_iso; - original_iso.add_file("meta-data", std::string(meta_data_content)); + original_iso.add_file("meta-data", meta_data_content); original_iso.write_to(iso_path); EXPECT_EQ(MP_CLOUD_INIT_FILE_OPS.get_instance_id_from_cloud_init(iso_path.toStdString()), "vm1"); diff --git a/tests/test_daemon.cpp b/tests/test_daemon.cpp index 4a7155cf1c..b11f7deb21 100644 --- a/tests/test_daemon.cpp +++ b/tests/test_daemon.cpp @@ -195,7 +195,8 @@ TEST_F(Daemon, receives_commands_and_calls_corresponding_slot) .WillOnce(Invoke(&daemon, &mpt::MockDaemon::set_promise_value)); EXPECT_CALL(daemon, restore) .WillOnce(Invoke(&daemon, &mpt::MockDaemon::set_promise_value)); - + EXPECT_CALL(daemon, clone) + .WillOnce(Invoke(&daemon, &mpt::MockDaemon::set_promise_value)); EXPECT_CALL(mock_settings, get(Eq("foo"))).WillRepeatedly(Return("bar")); send_commands({{"test_keys"}, @@ -219,7 +220,8 @@ TEST_F(Daemon, receives_commands_and_calls_corresponding_slot) {"find", "something"}, {"mount", ".", "target"}, {"umount", "instance"}, - {"networks"}}); + {"networks"}, + {"clone", "foo"}}); } TEST_F(Daemon, provides_version) @@ -404,7 +406,7 @@ struct LaunchWithBridges { }; -struct LaunchWithNoNetworkCloudInit : public Daemon, public WithParamInterface> +struct LaunchWithNoExtraNetworkCloudInit : public Daemon, public WithParamInterface> { }; @@ -1109,7 +1111,7 @@ TEST_P(DaemonCreateLaunchTestSuite, blueprint_not_found_passes_expected_data) send_command({GetParam()}); } -TEST_P(LaunchWithNoNetworkCloudInit, no_network_cloud_init) +TEST_P(LaunchWithNoExtraNetworkCloudInit, no_extra_network_cloud_init) { mpt::MockVirtualMachineFactory* mock_factory = use_a_mock_vm_factory(); mp::Daemon daemon{config_builder.build()}; @@ -1117,9 +1119,10 @@ TEST_P(LaunchWithNoNetworkCloudInit, no_network_cloud_init) const auto launch_args = GetParam(); EXPECT_CALL(*mock_factory, prepare_instance_image(_, _)) - .WillOnce(Invoke([](const multipass::VMImage&, const mp::VirtualMachineDescription& desc) { - EXPECT_TRUE(desc.network_data_config.IsNull()); - })); + .WillOnce([](const multipass::VMImage&, const mp::VirtualMachineDescription& desc) { + EXPECT_FALSE(desc.network_data_config["ethernets"]["default"].IsNull()); + EXPECT_FALSE(desc.network_data_config["ethernets"]["extra0"].IsDefined()); + }); send_command(launch_args); } @@ -1134,15 +1137,22 @@ std::vector make_args(const std::vector& args) return all_args; } -INSTANTIATE_TEST_SUITE_P( - Daemon, LaunchWithNoNetworkCloudInit, - Values(make_args({}), make_args({"xenial"}), make_args({"xenial", "--network", "name=eth0,mode=manual"}), - make_args({"groovy"}), make_args({"groovy", "--network", "name=eth0,mode=manual"}), - make_args({"--network", "name=eth0,mode=manual"}), make_args({"devel"}), - make_args({"hirsute", "--network", "name=eth0,mode=manual"}), make_args({"daily:21.04"}), - make_args({"daily:21.04", "--network", "name=eth0,mode=manual"}), - make_args({"appliance:openhab", "--network", "name=eth0,mode=manual"}), make_args({"appliance:nextcloud"}), - make_args({"snapcraft:core18", "--network", "name=eth0,mode=manual"}), make_args({"snapcraft:core20"}))); +INSTANTIATE_TEST_SUITE_P(Daemon, + LaunchWithNoExtraNetworkCloudInit, + Values(make_args({}), + make_args({"xenial"}), + make_args({"xenial", "--network", "name=eth0,mode=manual"}), + make_args({"groovy"}), + make_args({"groovy", "--network", "name=eth0,mode=manual"}), + make_args({"--network", "name=eth0,mode=manual"}), + make_args({"devel"}), + make_args({"hirsute", "--network", "name=eth0,mode=manual"}), + make_args({"daily:21.04"}), + make_args({"daily:21.04", "--network", "name=eth0,mode=manual"}), + make_args({"appliance:openhab", "--network", "name=eth0,mode=manual"}), + make_args({"appliance:nextcloud"}), + make_args({"snapcraft:core18", "--network", "name=eth0,mode=manual"}), + make_args({"snapcraft:core20"}))); TEST_P(LaunchWithBridges, creates_network_cloud_init_iso) { diff --git a/tests/test_daemon_clone.cpp b/tests/test_daemon_clone.cpp new file mode 100644 index 0000000000..51e0b16679 --- /dev/null +++ b/tests/test_daemon_clone.cpp @@ -0,0 +1,164 @@ +/* + * 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 "common.h" +#include "daemon_test_fixture.h" +#include "mock_platform.h" +#include "mock_server_reader_writer.h" +#include "mock_virtual_machine.h" +#include "mock_vm_image_vault.h" + +#include + +namespace mp = multipass; +namespace mpt = multipass::test; +using namespace testing; + +struct TestDaemonClone : public mpt::DaemonTestFixture +{ + void SetUp() override + { + config_builder.vault = std::make_unique>(); + } + + auto build_daemon_with_mock_instance() + { + auto instance_unique_ptr = std::make_unique>(mock_src_instance_name); + auto* instance_raw_ptr = instance_unique_ptr.get(); + + EXPECT_CALL(mock_factory, create_virtual_machine).WillOnce(Return(std::move(instance_unique_ptr))); + + const auto [temp_dir, _] = plant_instance_json(fake_json_contents(mac_addr, extra_interfaces)); + config_builder.data_directory = temp_dir->path(); + auto daemon = std::make_unique(config_builder.build()); + + return std::pair{std::move(daemon), instance_raw_ptr}; + } + + const std::string mock_src_instance_name{"real-zebraphant"}; + const std::string mac_addr{"52:54:00:73:76:28"}; + std::vector extra_interfaces; + + const mpt::MockVirtualMachineFactory& mock_factory = *use_a_mock_vm_factory(); + + const mpt::MockPlatform::GuardedMock attr{mpt::MockPlatform::inject()}; + const mpt::MockPlatform& mock_platform = *attr.first; +}; + +TEST_F(TestDaemonClone, missingOnSrcInstance) +{ + const std::string src_instance_name = "non_exist_instance"; + mp::CloneRequest request{}; + request.set_source_name(src_instance_name); + + mp::Daemon daemon{config_builder.build()}; + const auto status = call_daemon_slot(daemon, + &mp::Daemon::clone, + request, + NiceMock>{}); + + EXPECT_EQ(status.error_code(), grpc::StatusCode::NOT_FOUND); + EXPECT_EQ(status.error_message(), fmt::format("instance \"{}\" does not exist", src_instance_name)); +} + +TEST_F(TestDaemonClone, invalidDestVmName) +{ + const auto [daemon, instance] = build_daemon_with_mock_instance(); + + constexpr std::string_view dest_instance_name = "5invalid_vm_name"; + mp::CloneRequest request{}; + request.set_source_name(mock_src_instance_name); + request.set_destination_name(std::string{dest_instance_name}); + + const auto status = call_daemon_slot(*daemon, + &mp::Daemon::clone, + request, + NiceMock>{}); + + EXPECT_EQ(status.error_code(), grpc::StatusCode::INVALID_ARGUMENT); + EXPECT_THAT(status.error_message(), HasSubstr("Invalid destination instance name")); +} + +TEST_F(TestDaemonClone, alreadyExistDestVmName) +{ + const auto [daemon, instance] = build_daemon_with_mock_instance(); + + mp::CloneRequest request{}; + request.set_source_name(mock_src_instance_name); + request.set_destination_name(mock_src_instance_name); + + const auto status = call_daemon_slot(*daemon, + &mp::Daemon::clone, + request, + NiceMock>{}); + + EXPECT_EQ(status.error_code(), grpc::StatusCode::INVALID_ARGUMENT); + EXPECT_THAT(status.error_message(), HasSubstr("already exists, please choose a new name.")); +} + +TEST_F(TestDaemonClone, successfulCloneGenerateDestNameOkStatus) +{ + // add this line to cover the update_unique_identifiers_of_metadata all branches + extra_interfaces.emplace_back(mp::NetworkInterface{"eth1", "52:54:00:00:00:00", true}); + const auto [daemon, instance] = build_daemon_with_mock_instance(); + EXPECT_CALL(*instance, current_state).WillOnce(Return(mp::VirtualMachine::State::stopped)); + + mp::CloneRequest request{}; + request.set_source_name(mock_src_instance_name); + + const auto status = call_daemon_slot(*daemon, + &mp::Daemon::clone, + request, + NiceMock>{}); + + EXPECT_EQ(status.error_code(), grpc::StatusCode::OK); +} + +TEST_F(TestDaemonClone, successfulCloneSpecifyDestNameOkStatus) +{ + const auto [daemon, instance] = build_daemon_with_mock_instance(); + EXPECT_CALL(*instance, current_state).WillOnce(Return(mp::VirtualMachine::State::stopped)); + + mp::CloneRequest request{}; + request.set_source_name(mock_src_instance_name); + request.set_destination_name("valid-dest-instance-name"); + + const auto status = call_daemon_slot(*daemon, + &mp::Daemon::clone, + request, + NiceMock>{}); + + EXPECT_EQ(status.error_code(), grpc::StatusCode::OK); +} + +TEST_F(TestDaemonClone, failsOnCloneOnNonStoppedInstance) +{ + const auto [daemon, instance] = build_daemon_with_mock_instance(); + EXPECT_CALL(*instance, current_state).WillOnce(Return(mp::VirtualMachine::State::running)); + + mp::CloneRequest request{}; + request.set_source_name(mock_src_instance_name); + + const auto status = call_daemon_slot(*daemon, + &mp::Daemon::clone, + request, + NiceMock>{}); + + EXPECT_EQ(status.error_code(), grpc::StatusCode::FAILED_PRECONDITION); + EXPECT_EQ(status.error_message(), + fmt::format("Please stop instance {} before you clone it.", mock_src_instance_name)); +} diff --git a/tests/test_file_ops.cpp b/tests/test_file_ops.cpp index bb5596b6ae..f8a54caac2 100644 --- a/tests/test_file_ops.cpp +++ b/tests/test_file_ops.cpp @@ -64,6 +64,16 @@ TEST_F(FileOps, exists) EXPECT_FALSE(err); } +TEST_F(FileOps, copy) +{ + const fs::path src_dir = temp_dir / "sub_src_dir"; + const fs::path dest_dir = temp_dir / "sub_dest_dir"; + MP_FILEOPS.create_directory(src_dir, err); + + EXPECT_NO_THROW(MP_FILEOPS.copy(src_dir, dest_dir, std::filesystem::copy_options::recursive)); + EXPECT_TRUE(MP_FILEOPS.exists(dest_dir, err)); +} + TEST_F(FileOps, is_directory) { EXPECT_TRUE(MP_FILEOPS.is_directory(temp_dir, err)); diff --git a/tests/test_image_vault.cpp b/tests/test_image_vault.cpp index 73ecc923cd..29f3a31861 100644 --- a/tests/test_image_vault.cpp +++ b/tests/test_image_vault.cpp @@ -207,6 +207,55 @@ TEST_F(ImageVault, returned_image_contains_instance_name) EXPECT_TRUE(vm_image.image_path.contains(QString::fromStdString(instance_name))); } +TEST_F(ImageVault, imageCloneSuccess) +{ + mp::DefaultVMImageVault vault{hosts, &url_downloader, cache_dir.path(), data_dir.path(), mp::days{0}}; + vault.fetch_image(mp::FetchType::ImageOnly, + default_query, + stub_prepare, + stub_monitor, + false, + std::nullopt, + instance_dir); + + const std::string dest_name = instance_name + "clone"; + EXPECT_NO_THROW(vault.clone(instance_name, dest_name)); + EXPECT_TRUE(vault.has_record_for(dest_name)); +} + +TEST_F(ImageVault, imageCloneFailOnNonExistSrcImage) +{ + mp::DefaultVMImageVault vault{hosts, &url_downloader, cache_dir.path(), data_dir.path(), mp::days{0}}; + + EXPECT_THROW(vault.clone("non_exist_src_image_name", "dummy_dest_name"), std::runtime_error); +} + +TEST_F(ImageVault, imageCloneFailOnAlreadyExistDestImage) +{ + mp::DefaultVMImageVault vault{hosts, &url_downloader, cache_dir.path(), data_dir.path(), mp::days{0}}; + vault.fetch_image(mp::FetchType::ImageOnly, + default_query, + stub_prepare, + stub_monitor, + false, + std::nullopt, + instance_dir); + + const std::string dest_name = "valley-pied-piper-clone"; + const mp::Query second_query{dest_name, "xenial", false, "", mp::Query::Type::Alias}; + + vault.fetch_image(mp::FetchType::ImageOnly, + second_query, + stub_prepare, + stub_monitor, + false, + std::nullopt, + save_dir.filePath(QString::fromStdString(second_query.name))); + + // valley-pied-piper-clone is already added, so it will throw + EXPECT_THROW(vault.clone(instance_name, dest_name), std::runtime_error); +} + TEST_F(ImageVault, calls_prepare) { mp::DefaultVMImageVault vault{hosts, &url_downloader, cache_dir.path(), data_dir.path(), mp::days{0}}; diff --git a/tests/test_json_utils.cpp b/tests/test_json_utils.cpp index 61359701c5..e7d7ef3d0a 100644 --- a/tests/test_json_utils.cpp +++ b/tests/test_json_utils.cpp @@ -146,4 +146,10 @@ TEST_F(TestJsonUtils, throws_on_wrong_mac) std::runtime_error, mpt::match_what(StrEq("Invalid MAC address 52:54:00:00:00:0x"))); } + +TEST_F(TestJsonUtils, updateCloudInitInstanceIdSucceed) +{ + EXPECT_EQ(MP_JSONUTILS.update_cloud_init_instance_id(QJsonValue{"vm1_e_e_e"}, "vm1", "vm2"), + QJsonValue{"vm2_e_e_e"}); +} } // namespace diff --git a/tests/test_yaml_node_utils.cpp b/tests/test_yaml_node_utils.cpp index a94cee691b..ab28a34d28 100644 --- a/tests/test_yaml_node_utils.cpp +++ b/tests/test_yaml_node_utils.cpp @@ -34,11 +34,11 @@ TEST(UtilsTests, makeCloudInitMetaConfig) TEST(UtilsTests, makeCloudInitMetaConfigWithYAMLStr) { constexpr std::string_view meta_data_content = R"(#cloud-config -instance-id: vm2 +instance-id: vm2_e_e local-hostname: vm2 cloud-name: multipass)"; const YAML::Node meta_data_node = mpu::make_cloud_init_meta_config("vm1", std::string{meta_data_content}); - EXPECT_EQ(meta_data_node["instance-id"].as(), "vm1"); + EXPECT_EQ(meta_data_node["instance-id"].as(), "vm1_e_e"); EXPECT_EQ(meta_data_node["local-hostname"].as(), "vm1"); EXPECT_EQ(meta_data_node["cloud-name"].as(), "multipass"); } @@ -52,10 +52,12 @@ version: 2 match: macaddress: "52:54:00:51:84:0c" dhcp4: true + dhcp-identifier: mac extra0: match: macaddress: "52:54:00:d8:12:9b" dhcp4: true + dhcp-identifier: mac dhcp4-overrides: route-metric: 200 optional: true)"; @@ -67,10 +69,12 @@ version: 2 match: macaddress: "52:54:00:51:84:0c" dhcp4: true + dhcp-identifier: mac extra0: match: macaddress: "52:54:00:d8:12:9b" dhcp4: true + dhcp-identifier: mac dhcp4-overrides: route-metric: 200 optional: true @@ -78,6 +82,7 @@ version: 2 match: macaddress: "52:54:00:d8:12:9c" dhcp4: true + dhcp-identifier: mac dhcp4-overrides: route-metric: 200 optional: true @@ -103,10 +108,12 @@ version: 2 match: macaddress: "52:54:00:56:78:90" dhcp4: true + dhcp-identifier: mac extra0: match: macaddress: "52:54:00:d8:12:9c" dhcp4: true + dhcp-identifier: mac dhcp4-overrides: route-metric: 200 optional: true