diff --git a/deps/rabbit/src/rabbit_db_vhost.erl b/deps/rabbit/src/rabbit_db_vhost.erl index 1b2a1d6f3c5e..9c925fcb0255 100644 --- a/deps/rabbit/src/rabbit_db_vhost.erl +++ b/deps/rabbit/src/rabbit_db_vhost.erl @@ -23,6 +23,8 @@ count_all/0, list/0, update/2, + enable_protection_from_deletion/1, + disable_protection_from_deletion/1, with_fun_in_mnesia_tx/2, with_fun_in_khepri_tx/2, delete/1, @@ -224,6 +226,36 @@ set_tags_in_khepri(VHostName, Tags) -> UpdateFun = fun(VHost) -> do_set_tags(VHost, Tags) end, update_in_khepri(VHostName, UpdateFun). +%% ------------------------------------------------------------------- +%% Deletion protection +%% ------------------------------------------------------------------- + +-spec enable_protection_from_deletion(VHostName) -> Ret when + VHostName :: vhost:name(), + Ret :: {ok, VHost} | + {error, {no_such_vhost, VHostName}} | + rabbit_khepri:timeout_error(), + VHost :: vhost:vhost(). +enable_protection_from_deletion(VHostName) -> + MetadataPatch = #{ + protected_from_deletion => true + }, + rabbit_log:info("Enabling deletion protection for virtual host '~ts'", [VHostName]), + merge_metadata(VHostName, MetadataPatch). + +-spec disable_protection_from_deletion(VHostName) -> Ret when + VHostName :: vhost:name(), + Ret :: {ok, VHost} | + {error, {no_such_vhost, VHostName}} | + rabbit_khepri:timeout_error(), + VHost :: vhost:vhost(). +disable_protection_from_deletion(VHostName) -> + MetadataPatch = #{ + protected_from_deletion => false + }, + rabbit_log:info("Disabling deletion protection for virtual host '~ts'", [VHostName]), + merge_metadata(VHostName, MetadataPatch). + %% ------------------------------------------------------------------- %% exists(). %% ------------------------------------------------------------------- diff --git a/deps/rabbit/src/rabbit_definitions.erl b/deps/rabbit/src/rabbit_definitions.erl index 1a707702ec39..0f69b3ddf424 100644 --- a/deps/rabbit/src/rabbit_definitions.erl +++ b/deps/rabbit/src/rabbit_definitions.erl @@ -786,8 +786,17 @@ add_vhost(VHost, ActingUser) -> case rabbit_vhost:put_vhost(Name, Description, Tags, DefaultQueueType, IsTracingEnabled, ActingUser) of ok -> ok; - {error, _} = Err -> - throw(Err) + {error, _} = Err1 -> + throw(Err1) + end, + + %% The newly created virtual host won't have all the metadata keys. Rather than + %% changing the functions above, simply update the metadata as a separate step. + case rabbit_vhost:update_metadata(Name, Metadata, ActingUser) of + ok -> + ok; + {error, _} = Err2 -> + throw(Err2) end. add_permission(Permission, ActingUser) -> diff --git a/deps/rabbit/src/rabbit_vhost.erl b/deps/rabbit/src/rabbit_vhost.erl index 26c25187da26..ce53154d7e08 100644 --- a/deps/rabbit/src/rabbit_vhost.erl +++ b/deps/rabbit/src/rabbit_vhost.erl @@ -11,11 +11,11 @@ -include("vhost.hrl"). -export([recover/0, recover/1, read_config/1]). --export([add/2, add/3, add/4, delete/2, exists/1, assert/1, +-export([add/2, add/3, add/4, delete/2, delete_ignoring_protection/2, exists/1, assert/1, set_limits/2, vhost_cluster_state/1, is_running_on_all_nodes/1, await_running_on_all_nodes/2, list/0, count/0, list_names/0, all/0, all_tagged_with/1]). -export([parse_tags/1, update_tags/3]). --export([update_metadata/3]). +-export([update_metadata/3, enable_protection_from_deletion/1, disable_protection_from_deletion/1]). -export([lookup/1, default_name/0]). -export([info/1, info/2, info_all/0, info_all/1, info_all/2, info_all/3]). -export([dir/1, msg_store_dir_path/1, msg_store_dir_wildcard/0, msg_store_dir_base/0, config_file_path/1, ensure_config_file/1]). @@ -253,20 +253,37 @@ declare_default_exchanges(VHostName, ActingUser) -> end, DefaultExchanges). -spec update_metadata(vhost:name(), vhost:metadata(), rabbit_types:username()) -> rabbit_types:ok_or_error(any()). +update_metadata(Name, undefined, _ActingUser) -> + case rabbit_db_vhost:exists(Name) of + true -> + ok; + false -> + {error, {no_such_vhost, Name}} + end; +update_metadata(Name, Metadata0, _ActingUser) when is_map(Metadata0) andalso map_size(Metadata0) =:= 0 -> + case rabbit_db_vhost:exists(Name) of + true -> + ok; + false -> + {error, {no_such_vhost, Name}} + end; update_metadata(Name, Metadata0, ActingUser) -> - Metadata = maps:with([description, tags, default_queue_type], Metadata0), + KnownKeys = [description, tags, default_queue_type, protected_from_deletion], + Metadata = maps:with(KnownKeys, Metadata0), case rabbit_db_vhost:merge_metadata(Name, Metadata) of {ok, VHost} -> Description = vhost:get_description(VHost), Tags = vhost:get_tags(VHost), DefaultQueueType = vhost:get_default_queue_type(VHost), + IsProtected = vhost:is_protected_from_deletion(VHost), rabbit_event:notify( vhost_updated, info(VHost) ++ [{user_who_performed_action, ActingUser}, {description, Description}, {tags, Tags}, - {default_queue_type, DefaultQueueType}]), + {default_queue_type, DefaultQueueType}, + {deletion_protection, IsProtected}]), ok; {error, _} = Error -> Error @@ -278,45 +295,61 @@ update(Name, Description, Tags, DefaultQueueType, ActingUser) -> update_metadata(Name, Metadata, ActingUser). -spec delete(vhost:name(), rabbit_types:username()) -> rabbit_types:ok_or_error(any()). -delete(VHost, ActingUser) -> +delete(Name, ActingUser) -> + case rabbit_db_vhost:get(Name) of + %% preserve the original behavior for backwards compatibility + undefined -> delete_ignoring_protection(Name, ActingUser); + VHost -> + case vhost:is_protected_from_deletion(VHost) of + true -> + Msg = "Refusing to delete virtual host '~ts' because it is protected from deletion", + rabbit_log:debug(Msg, [Name]), + {error, protected_from_deletion}; + false -> + delete_ignoring_protection(Name, ActingUser) + end + end. + +-spec delete_ignoring_protection(vhost:name(), rabbit_types:username()) -> rabbit_types:ok_or_error(any()). +delete_ignoring_protection(Name, ActingUser) -> %% FIXME: We are forced to delete the queues and exchanges outside %% the TX below. Queue deletion involves sending messages to the queue %% process, which in turn results in further database actions and %% eventually the termination of that process. Exchange deletion causes %% notifications which must be sent outside the TX - rabbit_log:info("Deleting vhost '~ts'", [VHost]), + rabbit_log:info("Deleting vhost '~ts'", [Name]), %% TODO: This code does a lot of "list resources, walk through the list to %% delete each resource". This feature should be provided by each called %% modules, like `rabbit_amqqueue:delete_all_for_vhost(VHost)'. These new %% calls would be responsible for the atomicity, not this code. %% Clear the permissions first to prohibit new incoming connections when deleting a vhost - rabbit_log:info("Clearing permissions in vhost '~ts' because it's being deleted", [VHost]), - ok = rabbit_auth_backend_internal:clear_all_permissions_for_vhost(VHost, ActingUser), - rabbit_log:info("Deleting queues in vhost '~ts' because it's being deleted", [VHost]), + rabbit_log:info("Clearing permissions in vhost '~ts' because it's being deleted", [Name]), + ok = rabbit_auth_backend_internal:clear_all_permissions_for_vhost(Name, ActingUser), + rabbit_log:info("Deleting queues in vhost '~ts' because it's being deleted", [Name]), QDelFun = fun (Q) -> rabbit_amqqueue:delete(Q, false, false, ActingUser) end, [begin - Name = amqqueue:get_name(Q), - assert_benign(rabbit_amqqueue:with(Name, QDelFun), ActingUser) - end || Q <- rabbit_amqqueue:list(VHost)], - rabbit_log:info("Deleting exchanges in vhost '~ts' because it's being deleted", [VHost]), - ok = rabbit_exchange:delete_all(VHost, ActingUser), - rabbit_log:info("Clearing policies and runtime parameters in vhost '~ts' because it's being deleted", [VHost]), - _ = rabbit_runtime_parameters:clear_vhost(VHost, ActingUser), - rabbit_log:debug("Removing vhost '~ts' from the metadata storage because it's being deleted", [VHost]), - Ret = case rabbit_db_vhost:delete(VHost) of + QName = amqqueue:get_name(Q), + assert_benign(rabbit_amqqueue:with(QName, QDelFun), ActingUser) + end || Q <- rabbit_amqqueue:list(Name)], + rabbit_log:info("Deleting exchanges in vhost '~ts' because it's being deleted", [Name]), + ok = rabbit_exchange:delete_all(Name, ActingUser), + rabbit_log:info("Clearing policies and runtime parameters in vhost '~ts' because it's being deleted", [Name]), + _ = rabbit_runtime_parameters:clear_vhost(Name, ActingUser), + rabbit_log:debug("Removing vhost '~ts' from the metadata storage because it's being deleted", [Name]), + Ret = case rabbit_db_vhost:delete(Name) of true -> ok = rabbit_event:notify( vhost_deleted, - [{name, VHost}, + [{name, Name}, {user_who_performed_action, ActingUser}]); false -> - {error, {no_such_vhost, VHost}}; + {error, {no_such_vhost, Name}}; {error, _} = Err -> Err end, %% After vhost was deleted from the database, we try to stop vhost %% supervisors on all the nodes. - rabbit_vhost_sup_sup:delete_on_all_nodes(VHost), + rabbit_vhost_sup_sup:delete_on_all_nodes(Name), Ret. -spec put_vhost(vhost:name(), @@ -530,6 +563,14 @@ lookup(VHostName) -> VHost -> VHost end. +-spec enable_protection_from_deletion(vhost:name()) -> vhost:vhost() | rabbit_types:ok_or_error(any()). +enable_protection_from_deletion(VHostName) -> + rabbit_db_vhost:enable_protection_from_deletion(VHostName). + +-spec disable_protection_from_deletion(vhost:name()) -> vhost:vhost() | rabbit_types:ok_or_error(any()). +disable_protection_from_deletion(VHostName) -> + rabbit_db_vhost:disable_protection_from_deletion(VHostName). + -spec assert(vhost:name()) -> 'ok'. assert(VHostName) -> case exists(VHostName) of @@ -624,6 +665,7 @@ i(cluster_state, VHost) -> vhost_cluster_state(vhost:get_name(VHost)); i(description, VHost) -> vhost:get_description(VHost); i(tags, VHost) -> vhost:get_tags(VHost); i(default_queue_type, VHost) -> rabbit_queue_type:short_alias_of(default_queue_type(vhost:get_name(VHost))); +i(protected_from_deletion, VHost) -> vhost:is_protected_from_deletion(VHost); i(metadata, VHost) -> DQT = rabbit_queue_type:short_alias_of(default_queue_type(vhost:get_name(VHost))), case vhost:get_metadata(VHost) of diff --git a/deps/rabbit/src/vhost.erl b/deps/rabbit/src/vhost.erl index f28607f7cdfa..a16116a3a99e 100644 --- a/deps/rabbit/src/vhost.erl +++ b/deps/rabbit/src/vhost.erl @@ -30,6 +30,11 @@ set_limits/2, set_metadata/2, merge_metadata/2, + + is_protected_from_deletion/1, + enable_protection_from_deletion/1, + disable_protection_from_deletion/1, + new_metadata/3, is_tagged_with/2, @@ -89,6 +94,8 @@ vhost_pattern/0, vhost_v2_pattern/0]). +-define(DELETION_PROTECTION_KEY, protected_from_deletion). + -spec new(name(), limits()) -> vhost(). new(Name, Limits) -> #vhost{virtual_host = Name, limits = Limits}. @@ -123,6 +130,7 @@ info_keys() -> description, tags, default_queue_type, + protected_from_deletion, metadata, tracing, cluster_state]. @@ -187,6 +195,23 @@ metadata_merger(default_queue_type, _, NewVHostDefaultQueueType) -> metadata_merger(_, _, NewMetadataValue) -> NewMetadataValue. +-spec is_protected_from_deletion(vhost()) -> boolean(). +is_protected_from_deletion(VHost) -> + case get_metadata(VHost) of + Map when map_size(Map) =:= 0 -> false; + #{?DELETION_PROTECTION_KEY := true} -> true; + #{?DELETION_PROTECTION_KEY := false} -> false; + _ -> false + end. + +-spec enable_protection_from_deletion(vhost()) -> vhost(). +enable_protection_from_deletion(VHost) -> + merge_metadata(VHost, #{?DELETION_PROTECTION_KEY => true}). + +-spec disable_protection_from_deletion(vhost()) -> vhost(). +disable_protection_from_deletion(VHost) -> + merge_metadata(VHost, #{?DELETION_PROTECTION_KEY => false}). + -spec new_metadata(binary(), [atom()], rabbit_queue_type:queue_type() | 'undefined') -> metadata(). new_metadata(Description, Tags, undefined) -> #{description => Description, diff --git a/deps/rabbit/test/definition_import_SUITE.erl b/deps/rabbit/test/definition_import_SUITE.erl index 0b3a554a384b..4d5758d0c47f 100644 --- a/deps/rabbit/test/definition_import_SUITE.erl +++ b/deps/rabbit/test/definition_import_SUITE.erl @@ -53,7 +53,8 @@ groups() -> import_case18, import_case19, import_case20, - import_case21 + import_case21, + import_case22 ]}, {boot_time_import_using_classic_source, [], [ @@ -273,7 +274,7 @@ import_case16(Config) -> Val when is_tuple(Val) -> ?assertEqual(<<"A case16 description">>, vhost:get_description(VHostRec)), ?assertEqual(<<"quorum">>, vhost:get_default_queue_type(VHostRec)), - ?assertEqual([multi_dc_replication,ab,cde], vhost:get_tags(VHostRec)) + ?assertEqual([<<"multi_dc_replication">>,<<"ab">>,<<"cde">>], vhost:get_tags(VHostRec)) end, ok. @@ -315,6 +316,25 @@ import_case20(Config) -> import_case21(Config) -> import_invalid_file_case(Config, "failing_case21"). +import_case22(Config) -> + import_file_case(Config, "case22"), + Name = <<"protected">>, + VirtualHostIsImported = + fun () -> + case vhost_lookup(Config, Name) of + {error, not_found} -> false; + _ -> true + end + end, + rabbit_ct_helpers:await_condition(VirtualHostIsImported, 20000), + VHost = vhost_lookup(Config, Name), + ?assertEqual(true, vhost:is_protected_from_deletion(VHost)), + + VHost2 = vhost_lookup(Config, <<"non-protected">>), + ?assertEqual(false, vhost:is_protected_from_deletion(VHost2)), + + ok. + export_import_round_trip_case1(Config) -> case rabbit_ct_helpers:is_mixed_versions() of false -> diff --git a/deps/rabbit/test/definition_import_SUITE_data/case22.json b/deps/rabbit/test/definition_import_SUITE_data/case22.json new file mode 100644 index 000000000000..a6eae42874c2 --- /dev/null +++ b/deps/rabbit/test/definition_import_SUITE_data/case22.json @@ -0,0 +1,66 @@ +{ + "bindings": [], + "exchanges": [], + "global_parameters": [ + { + "name": "cluster_name", + "value": "rabbitmq@localhost" + } + ], + "parameters": [], + "permissions": [ + { + "configure": ".*", + "read": ".*", + "user": "guest", + "vhost": "/", + "write": ".*" + } + ], + "policies": [], + "queues": [], + "rabbit_version": "4.0.5", + "rabbitmq_version": "4.0.5", + "topic_permissions": [], + "users": [ + { + "hashing_algorithm": "rabbit_password_hashing_sha256", + "limits": {"max-connections" : 2}, + "name": "limited_guest", + "password_hash": "wS4AT3B4Z5RpWlFn1FA30osf2C75D7WA3gem591ACDZ6saO6", + "tags": [ + "administrator" + ] + } + ], + "vhosts": [ + { + "limits": [], + "name": "non-protected", + "description": "", + "metadata": { + "description": "", + "tags": [], + "default_queue_type": "classic" + }, + "tags": [], + "default_queue_type": "classic" + }, + { + "name": "protected", + "description": "protected, DQT = quorum", + "metadata": { + "description": "DQT = quorum", + "tags": [], + "default_queue_type": "quorum", + "protected_from_deletion": true + }, + "tags": [], + "default_queue_type": "quorum" + }, + { + "limits": [], + "name": "tagged" + } + ] +} diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/cluster_status_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/cluster_status_command.ex index 70bc8f3de5bc..c273a621662d 100644 --- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/cluster_status_command.ex +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/cluster_status_command.ex @@ -52,6 +52,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.ClusterStatusCommand do status0 -> tags = cluster_tags(node_name, timeout) status = status0 ++ [{:cluster_tags, tags}] + case :rabbit_misc.rpc_call(node_name, :rabbit_nodes, :list_running, []) do {:badrpc, _} = err -> err @@ -136,10 +137,10 @@ defmodule RabbitMQ.CLI.Ctl.Commands.ClusterStatusCommand do [ "\n#{bright("Cluster Tags")}\n" ] ++ - case m[:cluster_tags] do - [] -> ["(none)"] - tags -> cluster_tag_lines(tags) - end + case m[:cluster_tags] do + [] -> ["(none)"] + tags -> cluster_tag_lines(tags) + end disk_nodes_section = [ @@ -397,11 +398,12 @@ defmodule RabbitMQ.CLI.Ctl.Commands.ClusterStatusCommand do defp cluster_tags(node, timeout) do case :rabbit_misc.rpc_call( - node, - :rabbit_runtime_parameters, - :value_global, - [:cluster_tags], - timeout) do + node, + :rabbit_runtime_parameters, + :value_global, + [:cluster_tags], + timeout + ) do :not_found -> [] tags -> tags end diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decode_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decode_command.ex index da124ae55564..1c835393ed78 100644 --- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decode_command.ex +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decode_command.ex @@ -87,6 +87,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DecodeCommand do catch _, _ -> IO.inspect(__STACKTRACE__) + {:error, "Failed to decrypt the value. Things to check: is the passphrase correct? Are the cipher and hash algorithms the same as those used for encryption?"} end @@ -111,6 +112,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DecodeCommand do catch _, _ -> IO.inspect(__STACKTRACE__) + {:error, "Failed to decrypt the value. Things to check: is the passphrase correct? Are the cipher and hash algorithms the same as those used for encryption?"} end diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decrypt_conf_value_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decrypt_conf_value_command.ex index 6ac5958a96a1..d6cb628f6935 100644 --- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decrypt_conf_value_command.ex +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/decrypt_conf_value_command.ex @@ -91,6 +91,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DecryptConfValueCommand do catch _, _ -> IO.inspect(__STACKTRACE__) + {:error, "Failed to decrypt the value. Things to check: is the passphrase correct? Are the cipher and hash algorithms the same as those used for encryption?"} end @@ -118,6 +119,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DecryptConfValueCommand do catch _, _ -> IO.inspect(__STACKTRACE__) + {:error, "Failed to decrypt the value. Things to check: is the passphrase correct? Are the cipher and hash algorithms the same as those used for encryption?"} end @@ -130,7 +132,8 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DecryptConfValueCommand do end def usage, - do: "decrypt_conf_value value passphrase [--cipher ] [--hash ] [--iterations ]" + do: + "decrypt_conf_value value passphrase [--cipher ] [--hash ] [--iterations ]" def usage_additional() do [ @@ -166,6 +169,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DecryptConfValueCommand do {:encrypted, untagged_val} end + defp tag_input_value_with_encrypted(value) do {:encrypted, value} end diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/delete_vhost_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/delete_vhost_command.ex index 02f741b62d0c..d799cf9fcf1e 100644 --- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/delete_vhost_command.ex +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/delete_vhost_command.ex @@ -5,10 +5,12 @@ ## Copyright (c) 2007-2023 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. defmodule RabbitMQ.CLI.Ctl.Commands.DeleteVhostCommand do - alias RabbitMQ.CLI.Core.{DocGuide, Helpers} + alias RabbitMQ.CLI.Core.{DocGuide, Helpers, ExitCodes} @behaviour RabbitMQ.CLI.CommandBehaviour + @protected_from_deletion_err "Cannot delete this virtual host: it is protected from deletion. To lift the protection, inspect and update its metadata" + use RabbitMQ.CLI.Core.MergesNoDefaults use RabbitMQ.CLI.Core.AcceptsOnePositionalArgument use RabbitMQ.CLI.Core.RequiresRabbitAppRunning @@ -17,6 +19,15 @@ defmodule RabbitMQ.CLI.Ctl.Commands.DeleteVhostCommand do :rabbit_misc.rpc_call(node_name, :rabbit_vhost, :delete, [arg, Helpers.cli_acting_user()]) end + def output({:error, :protected_from_deletion}, %{formatter: "json", node: node_name}) do + {:error, + %{"result" => "error", "node" => node_name, "reason" => @protected_from_deletion_err}} + end + + def output({:error, :protected_from_deletion}, _opts) do + {:error, ExitCodes.exit_dataerr(), @protected_from_deletion_err} + end + use RabbitMQ.CLI.DefaultOutput def usage, do: "delete_vhost " diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/disable_vhost_deletion_protection_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/disable_vhost_deletion_protection_command.ex new file mode 100644 index 000000000000..4a6e444310d8 --- /dev/null +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/disable_vhost_deletion_protection_command.ex @@ -0,0 +1,58 @@ +## This Source Code Form is subject to the terms of the Mozilla Public +## License, v. 2.0. If a copy of the MPL was not distributed with this +## file, You can obtain one at https://mozilla.org/MPL/2.0/. +## +## Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. + +defmodule RabbitMQ.CLI.Ctl.Commands.DisableVhostDeletionProtectionCommand do + alias RabbitMQ.CLI.Core.{DocGuide, Helpers} + + @behaviour RabbitMQ.CLI.CommandBehaviour + + @metadata_key :protected_from_deletion + + def switches(), do: [] + def aliases(), do: [] + + def merge_defaults(args, opts) do + {args, opts} + end + + use RabbitMQ.CLI.Core.RequiresRabbitAppRunning + use RabbitMQ.CLI.Core.AcceptsOnePositionalArgument + + def run([vhost], %{node: node_name}) do + metadata_patch = %{ + @metadata_key => false + } + :rabbit_misc.rpc_call(node_name, :rabbit_vhost, :update_metadata, [ + vhost, + metadata_patch, + Helpers.cli_acting_user() + ]) + end + + use RabbitMQ.CLI.DefaultOutput + + def usage, + do: + "disable_vhost_deletion_protection " + + def usage_additional() do + [ + ["", "Virtual host name"] + ] + end + + def usage_doc_guides() do + [ + DocGuide.virtual_hosts() + ] + end + + def help_section(), do: :virtual_hosts + + def description(), do: "Removes deletion protection from a virtual host (so that it can be deleted)" + + def banner([vhost], _), do: "Removing deletion protection from virtual host \"#{vhost}\" by updating its metadata..." +end diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_feature_flag_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_feature_flag_command.ex index c974cc3e1cd5..c4bc05c35ba3 100644 --- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_feature_flag_command.ex +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_feature_flag_command.ex @@ -10,20 +10,25 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EnableFeatureFlagCommand do def switches(), do: [experimental: :boolean, opt_in: :boolean] def aliases(), do: [e: :experimental, o: :opt_in] - def merge_defaults(args, opts), do: { args, Map.merge(%{experimental: false, opt_in: false}, opts) } + def merge_defaults(args, opts), + do: {args, Map.merge(%{experimental: false, opt_in: false}, opts)} def validate([], _opts), do: {:validation_failure, :not_enough_args} - def validate([_ | _] = args, _opts) when length(args) > 1, do: {:validation_failure, :too_many_args} + + def validate([_ | _] = args, _opts) when length(args) > 1, + do: {:validation_failure, :too_many_args} def validate([""], _opts), - do: {:validation_failure, {:bad_argument, "feature flag (or group) name cannot be an empty string"}} + do: + {:validation_failure, + {:bad_argument, "feature flag (or group) name cannot be an empty string"}} def validate([_], _opts), do: :ok use RabbitMQ.CLI.Core.RequiresRabbitAppRunning def run(["all"], %{node: node_name, opt_in: opt_in, experimental: experimental}) do - has_opted_in = (opt_in || experimental) + has_opted_in = opt_in || experimental enable_all(node_name, has_opted_in) end @@ -39,9 +44,8 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EnableFeatureFlagCommand do enable_all(node_name, false) end - def run([feature_flag], %{node: node_name, opt_in: opt_in, experimental: experimental}) do - has_opted_in = (opt_in || experimental) + has_opted_in = opt_in || experimental enable_one(node_name, feature_flag, has_opted_in) end @@ -57,10 +61,9 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EnableFeatureFlagCommand do enable_one(node_name, feature_flag, false) end - def output({:error, :unsupported}, %{node: node_name}) do {:error, RabbitMQ.CLI.Core.ExitCodes.exit_usage(), - "This feature flag is not supported by node #{node_name}"} + "This feature flag is not supported by node #{node_name}"} end use RabbitMQ.CLI.DefaultOutput @@ -96,8 +99,11 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EnableFeatureFlagCommand do defp enable_all(node_name, has_opted_in) do case has_opted_in do true -> - msg = "`--opt-in` (aliased as `--experimental`) flag is not allowed when enabling all feature flags.\nUse --opt-in with a specific feature flag name if to enable an opt-in flag" + msg = + "`--opt-in` (aliased as `--experimental`) flag is not allowed when enabling all feature flags.\nUse --opt-in with a specific feature flag name if to enable an opt-in flag" + {:error, RabbitMQ.CLI.Core.ExitCodes.exit_usage(), msg} + _ -> case :rabbit_misc.rpc_call(node_name, :rabbit_feature_flags, :enable_all, []) do {:badrpc, _} = err -> err @@ -107,20 +113,26 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EnableFeatureFlagCommand do end defp enable_one(node_name, feature_flag, has_opted_in) do - case {has_opted_in, :rabbit_misc.rpc_call(node_name, :rabbit_feature_flags, :get_stability, [ - String.to_atom(feature_flag) - ])} do - {_, {:badrpc, _} = err} -> err - {false, :experimental} -> - msg = "Feature flag #{feature_flag} requires the user to explicitly opt-in.\nUse --opt-in with a specific feature flag name if to enable an opt-in flag" - {:error, RabbitMQ.CLI.Core.ExitCodes.exit_usage(), msg} - _ -> - case :rabbit_misc.rpc_call(node_name, :rabbit_feature_flags, :enable, [ + case {has_opted_in, + :rabbit_misc.rpc_call(node_name, :rabbit_feature_flags, :get_stability, [ String.to_atom(feature_flag) - ]) do - {:badrpc, _} = err -> err - other -> other - end + ])} do + {_, {:badrpc, _} = err} -> + err + + {false, :experimental} -> + msg = + "Feature flag #{feature_flag} requires the user to explicitly opt-in.\nUse --opt-in with a specific feature flag name if to enable an opt-in flag" + + {:error, RabbitMQ.CLI.Core.ExitCodes.exit_usage(), msg} + + _ -> + case :rabbit_misc.rpc_call(node_name, :rabbit_feature_flags, :enable, [ + String.to_atom(feature_flag) + ]) do + {:badrpc, _} = err -> err + other -> other + end end end end diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_vhost_deletion_protection_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_vhost_deletion_protection_command.ex new file mode 100644 index 000000000000..008219bfb58e --- /dev/null +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/enable_vhost_deletion_protection_command.ex @@ -0,0 +1,58 @@ +## This Source Code Form is subject to the terms of the Mozilla Public +## License, v. 2.0. If a copy of the MPL was not distributed with this +## file, You can obtain one at https://mozilla.org/MPL/2.0/. +## +## Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. + +defmodule RabbitMQ.CLI.Ctl.Commands.EnableVhostDeletionProtectionCommand do + alias RabbitMQ.CLI.Core.{DocGuide, Helpers} + + @behaviour RabbitMQ.CLI.CommandBehaviour + + @metadata_key :protected_from_deletion + + def switches(), do: [] + def aliases(), do: [] + + def merge_defaults(args, opts) do + {args, opts} + end + + use RabbitMQ.CLI.Core.RequiresRabbitAppRunning + use RabbitMQ.CLI.Core.AcceptsOnePositionalArgument + + def run([vhost], %{node: node_name}) do + metadata_patch = %{ + @metadata_key => true + } + :rabbit_misc.rpc_call(node_name, :rabbit_vhost, :update_metadata, [ + vhost, + metadata_patch, + Helpers.cli_acting_user() + ]) + end + + use RabbitMQ.CLI.DefaultOutput + + def usage, + do: + "enable_vhost_deletion_protection " + + def usage_additional() do + [ + ["", "Virtual host name"] + ] + end + + def usage_doc_guides() do + [ + DocGuide.virtual_hosts() + ] + end + + def help_section(), do: :virtual_hosts + + def description(), do: "Protects a virtual host from deletion (until the protection is removed)" + + def banner([vhost], _), do: "Protecting virtual host \"#{vhost}\" from removal by updating its metadata..." +end diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encode_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encode_command.ex index 8eb43e688c91..2dcc0c031b71 100644 --- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encode_command.ex +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encode_command.ex @@ -149,7 +149,8 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EncodeCommand do def help_section(), do: :configuration - def description(), do: "Encrypts a sensitive configuration value to be used in the advanced.config file" + def description(), + do: "Encrypts a sensitive configuration value to be used in the advanced.config file" # # Implementation diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encrypt_conf_value_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encrypt_conf_value_command.ex index 914ad7debeb2..5932111ffc29 100644 --- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encrypt_conf_value_command.ex +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/encrypt_conf_value_command.ex @@ -125,7 +125,8 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EncryptConfValueCommand do end def usage, - do: "encrypt_conf_value value passphrase [--cipher ] [--hash ] [--iterations ]" + do: + "encrypt_conf_value value passphrase [--cipher ] [--hash ] [--iterations ]" def usage_additional() do [ @@ -145,7 +146,8 @@ defmodule RabbitMQ.CLI.Ctl.Commands.EncryptConfValueCommand do def help_section(), do: :configuration - def description(), do: "Encrypts a sensitive configuration value to be used in the advanced.config file" + def description(), + do: "Encrypts a sensitive configuration value to be used in the advanced.config file" # # Implementation diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/list_vhosts_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/list_vhosts_command.ex index 6ad98132c3e3..078bd1bc7cba 100644 --- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/list_vhosts_command.ex +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/list_vhosts_command.ex @@ -11,7 +11,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.ListVhostsCommand do @behaviour RabbitMQ.CLI.CommandBehaviour use RabbitMQ.CLI.DefaultOutput - @info_keys ~w(name description tags default_queue_type tracing cluster_state)a + @info_keys ~w(name description tags default_queue_type protected_from_deletion tracing cluster_state metadata)a def info_keys(), do: @info_keys diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/status_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/status_command.ex index 4452b4e1faad..1b90cbe42269 100644 --- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/status_command.ex +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/status_command.ex @@ -69,6 +69,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.StatusCommand do def output(result, %{node: node_name, unit: unit}) when is_list(result) do m = result_map(result) + product_name_section = case m do %{:product_name => product_name} when product_name != "" -> @@ -142,14 +143,15 @@ defmodule RabbitMQ.CLI.Ctl.Commands.StatusCommand do end IO.inspect(m[:tags]) + tags_section = [ "\n#{bright("Tags")}\n" ] ++ case m[:tags] do nil -> ["(none)"] - [] -> ["(none)"] - xs -> tag_lines(xs) + [] -> ["(none)"] + xs -> tag_lines(xs) end breakdown = compute_relative_values(m[:memory]) diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/update_vhost_metadata_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/update_vhost_metadata_command.ex index c01a4904bb4d..4e4700d41e75 100644 --- a/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/update_vhost_metadata_command.ex +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/ctl/commands/update_vhost_metadata_command.ex @@ -9,9 +9,9 @@ defmodule RabbitMQ.CLI.Ctl.Commands.UpdateVhostMetadataCommand do @behaviour RabbitMQ.CLI.CommandBehaviour - @metadata_keys [:description, :tags, :default_queue_type] + @metadata_keys [:description, :tags, :default_queue_type, :protected_from_deletion] - def switches(), do: [description: :string, tags: :string, default_queue_type: :string] + def switches(), do: [description: :string, tags: :string, default_queue_type: :string, protected_from_deletion: :boolean] def aliases(), do: [d: :description] def merge_defaults(args, opts) do @@ -86,7 +86,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.UpdateVhostMetadataCommand do def usage, do: - "update_vhost_metadata [--description ] [--tags \",,<...>\"] [--default-queue-type ]" + "update_vhost_metadata [--description=] [--tags=\",,<...>\"] [--default-queue-type=] [--protected-from-deletion=]" def usage_additional() do [ @@ -96,7 +96,8 @@ defmodule RabbitMQ.CLI.Ctl.Commands.UpdateVhostMetadataCommand do [ "--default-queue-type ", "Queue type to use if no type is explicitly provided by the client" - ] + ], + ["--protected-from-deletion", "When set to true, will make it impossible to delete a virtual host until the protection is removed"] ] end @@ -108,7 +109,7 @@ defmodule RabbitMQ.CLI.Ctl.Commands.UpdateVhostMetadataCommand do def help_section(), do: :virtual_hosts - def description(), do: "Updates metadata (tags, description, default queue type) a virtual host" + def description(), do: "Updates metadata (tags, description, default queue type, protection from deletion) a virtual host" def banner([vhost], _), do: "Updating metadata of vhost \"#{vhost}\" ..." end diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/check_if_any_deprecated_features_are_used_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/check_if_any_deprecated_features_are_used_command.ex index 66e2ef3beab9..264c308dab0e 100644 --- a/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/check_if_any_deprecated_features_are_used_command.ex +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/check_if_any_deprecated_features_are_used_command.ex @@ -15,25 +15,29 @@ defmodule RabbitMQ.CLI.Diagnostics.Commands.CheckIfAnyDeprecatedFeaturesAreUsedC use RabbitMQ.CLI.Core.RequiresRabbitAppRunning def run([], %{node: node_name, timeout: timeout}) do - deprecated_features_list = :rabbit_misc.rpc_call( - node_name, - :rabbit_deprecated_features, - :list, - [:used], - timeout - ) + deprecated_features_list = + :rabbit_misc.rpc_call( + node_name, + :rabbit_deprecated_features, + :list, + [:used], + timeout + ) # health checks return true if they pass case deprecated_features_list do {:badrpc, _} = err -> err + {:error, _} = err -> err + _ -> names = Enum.sort(Map.keys(deprecated_features_list)) + case names do [] -> true - _ -> {false, names} + _ -> {false, names} end end end diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/metadata_store_status_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/metadata_store_status_command.ex index 6eb3242bfbcd..510a722f2da3 100644 --- a/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/metadata_store_status_command.ex +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/diagnostics/commands/metadata_store_status_command.ex @@ -17,8 +17,9 @@ defmodule RabbitMQ.CLI.Diagnostics.Commands.MetadataStoreStatusCommand do case :rabbit_misc.rpc_call(node_name, :rabbit_feature_flags, :is_enabled, [:khepri_db]) do true -> :rabbit_misc.rpc_call(node_name, :rabbit_khepri, :status, []) + false -> - [[{<<"Metadata Store">>, "mnesia"}]] + [[{<<"Metadata Store">>, "mnesia"}]] end end diff --git a/deps/rabbitmq_cli/lib/rabbitmq/cli/queues/commands/check_if_node_is_mirror_sync_critical_command.ex b/deps/rabbitmq_cli/lib/rabbitmq/cli/queues/commands/check_if_node_is_mirror_sync_critical_command.ex index 3b9d66f311e2..ae4781615a3d 100644 --- a/deps/rabbitmq_cli/lib/rabbitmq/cli/queues/commands/check_if_node_is_mirror_sync_critical_command.ex +++ b/deps/rabbitmq_cli/lib/rabbitmq/cli/queues/commands/check_if_node_is_mirror_sync_critical_command.ex @@ -49,5 +49,4 @@ defmodule RabbitMQ.CLI.Queues.Commands.CheckIfNodeIsMirrorSyncCriticalCommand do def banner([], _) do "This command is DEPRECATED and is a no-op. It will be removed in a future version." end - end diff --git a/deps/rabbitmq_cli/test/core/json_stream_test.exs b/deps/rabbitmq_cli/test/core/json_stream_test.exs index 0d736fb8af61..84be37aeff54 100644 --- a/deps/rabbitmq_cli/test/core/json_stream_test.exs +++ b/deps/rabbitmq_cli/test/core/json_stream_test.exs @@ -11,9 +11,9 @@ defmodule JsonStreamTest do test "format_output map with atom keys is converted to JSON object" do assert @formatter.format_output(%{a: :apple, b: :beer}, %{}) == - "{\"a\":\"apple\",\"b\":\"beer\"}" - or @formatter.format_output(%{a: :apple, b: :beer}, %{}) == - "{\"b\":\"beer\",\"a\":\"apple\"}" + "{\"a\":\"apple\",\"b\":\"beer\"}" or + @formatter.format_output(%{a: :apple, b: :beer}, %{}) == + "{\"b\":\"beer\",\"a\":\"apple\"}" end test "format_output map with binary keys is converted to JSON object" do diff --git a/deps/rabbitmq_cli/test/ctl/disable_vhost_deletion_protection_command_test.exs b/deps/rabbitmq_cli/test/ctl/disable_vhost_deletion_protection_command_test.exs new file mode 100644 index 000000000000..f48c8e5f3251 --- /dev/null +++ b/deps/rabbitmq_cli/test/ctl/disable_vhost_deletion_protection_command_test.exs @@ -0,0 +1,71 @@ +## This Source Code Form is subject to the terms of the Mozilla Public +## License, v. 2.0. If a copy of the MPL was not distributed with this +## file, You can obtain one at https://mozilla.org/MPL/2.0/. +## +## Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. + +defmodule DisableVhostDeletionProtectionCommandTest do + use ExUnit.Case, async: false + import TestHelper + + @command RabbitMQ.CLI.Ctl.Commands.DisableVhostDeletionProtectionCommand + @inverse_command RabbitMQ.CLI.Ctl.Commands.EnableVhostDeletionProtectionCommand + @vhost "disable-vhost-deletion-protection" + + setup_all do + RabbitMQ.CLI.Core.Distribution.start() + {:ok, opts: %{node: get_rabbit_hostname()}} + end + + setup context do + on_exit(context, fn -> delete_vhost(context[:vhost]) end) + :ok + end + + test "validate: no arguments fails validation" do + assert @command.validate([], %{}) == {:validation_failure, :not_enough_args} + end + + test "validate: too many arguments fails validation" do + assert @command.validate(["test", "extra"], %{}) == {:validation_failure, :too_many_args} + end + + test "validate: virtual host name without options fails validation" do + assert @command.validate(["a-vhost"], %{}) == :ok + end + + test "run: enabling deletion protection succeeds", context do + _ = @command.run([@vhost], context[:opts]) + delete_vhost(@vhost) + add_vhost(@vhost) + + assert @inverse_command.run([@vhost], context[:opts]) == :ok + vh = find_vhost(@vhost) + assert vh[:protected_from_deletion] + + assert @command.run([@vhost], context[:opts]) == :ok + vh = find_vhost(@vhost) + assert !vh[:protected_from_deletion] + + delete_vhost(@vhost) + end + + test "run: attempt to use a non-existent virtual host fails", context do + vh = "a-non-existent-3882-vhost" + + assert match?( + {:error, {:no_such_vhost, _}}, + @command.run([vh], Map.merge(context[:opts], %{})) + ) + end + + test "run: attempt to use an unreachable node returns a nodedown" do + opts = %{node: :jake@thedog, timeout: 200, description: "does not matter"} + assert match?({:badrpc, _}, @command.run(["na"], opts)) + end + + test "banner", context do + assert @command.banner([@vhost], context[:opts]) =~ + ~r/Removing deletion protection/ + end +end diff --git a/deps/rabbitmq_cli/test/ctl/enable_feature_flag_test.exs b/deps/rabbitmq_cli/test/ctl/enable_feature_flag_test.exs index 635eaa07800b..76167e0557c2 100644 --- a/deps/rabbitmq_cli/test/ctl/enable_feature_flag_test.exs +++ b/deps/rabbitmq_cli/test/ctl/enable_feature_flag_test.exs @@ -79,20 +79,24 @@ defmodule EnableFeatureFlagCommandTest do test "run: enabling an experimental flag requires '--opt-in'", context do experimental_flag = Atom.to_string(context[:experimental_flag]) + assert match?( {:error, @usage_exit_code, _}, @command.run([experimental_flag], context[:opts]) ) + opts = Map.put(context[:opts], :opt_in, true) assert @command.run([experimental_flag], opts) == :ok end test "run: enabling an experimental flag accepts '--experimental'", context do experimental_flag = Atom.to_string(context[:experimental_flag]) + assert match?( {:error, @usage_exit_code, _}, @command.run([experimental_flag], context[:opts]) ) + opts = Map.put(context[:opts], :experimental, true) assert @command.run([experimental_flag], opts) == :ok end diff --git a/deps/rabbitmq_cli/test/ctl/enable_vhost_deletion_protection_command_test.exs b/deps/rabbitmq_cli/test/ctl/enable_vhost_deletion_protection_command_test.exs new file mode 100644 index 000000000000..7fdb5d5debd1 --- /dev/null +++ b/deps/rabbitmq_cli/test/ctl/enable_vhost_deletion_protection_command_test.exs @@ -0,0 +1,69 @@ +## This Source Code Form is subject to the terms of the Mozilla Public +## License, v. 2.0. If a copy of the MPL was not distributed with this +## file, You can obtain one at https://mozilla.org/MPL/2.0/. +## +## Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. + +defmodule EnableVhostDeletionProtectionCommandTest do + use ExUnit.Case, async: false + import TestHelper + + @command RabbitMQ.CLI.Ctl.Commands.EnableVhostDeletionProtectionCommand + @inverse_command RabbitMQ.CLI.Ctl.Commands.DisableVhostDeletionProtectionCommand + @vhost "enable-vhost-deletion-protection" + + setup_all do + RabbitMQ.CLI.Core.Distribution.start() + {:ok, opts: %{node: get_rabbit_hostname()}} + end + + setup context do + on_exit(context, fn -> delete_vhost(context[:vhost]) end) + :ok + end + + test "validate: no arguments fails validation" do + assert @command.validate([], %{}) == {:validation_failure, :not_enough_args} + end + + test "validate: too many arguments fails validation" do + assert @command.validate(["test", "extra"], %{}) == {:validation_failure, :too_many_args} + end + + test "validate: virtual host name without options fails validation" do + assert @command.validate(["a-vhost"], %{}) == :ok + end + + test "run: enabling deletion protection succeeds", context do + add_vhost(@vhost) + + assert @command.run([@vhost], context[:opts]) == :ok + vh = find_vhost(@vhost) + assert vh[:protected_from_deletion] + + assert @inverse_command.run([@vhost], context[:opts]) == :ok + vh = find_vhost(@vhost) + assert !vh[:protected_from_deletion] + + delete_vhost(@vhost) + end + + test "run: attempt to use a non-existent virtual host fails", context do + vh = "a-non-existent-3882-vhost" + + assert match?( + {:error, {:no_such_vhost, _}}, + @command.run([vh], Map.merge(context[:opts], %{})) + ) + end + + test "run: attempt to use an unreachable node returns a nodedown" do + opts = %{node: :jake@thedog, timeout: 200, description: "does not matter"} + assert match?({:badrpc, _}, @command.run(["na"], opts)) + end + + test "banner", context do + assert @command.banner([@vhost], context[:opts]) =~ + ~r/Protecting virtual host/ + end +end diff --git a/deps/rabbitmq_cli/test/ctl/force_boot_command_test.exs b/deps/rabbitmq_cli/test/ctl/force_boot_command_test.exs index 5ac152323d13..4701f489dd9a 100644 --- a/deps/rabbitmq_cli/test/ctl/force_boot_command_test.exs +++ b/deps/rabbitmq_cli/test/ctl/force_boot_command_test.exs @@ -38,6 +38,7 @@ defmodule ForceBootCommandTest do test "run: sets a force boot marker file on target node", context do node = get_rabbit_hostname() + case :rabbit_misc.rpc_call(node, :rabbit_khepri, :is_enabled, []) do true -> :ok diff --git a/deps/rabbitmq_cli/test/ctl/force_reset_command_test.exs b/deps/rabbitmq_cli/test/ctl/force_reset_command_test.exs index 0285a70abb0c..8783f70d423b 100644 --- a/deps/rabbitmq_cli/test/ctl/force_reset_command_test.exs +++ b/deps/rabbitmq_cli/test/ctl/force_reset_command_test.exs @@ -46,6 +46,7 @@ defmodule ForceResetCommandTest do assert vhost_exists?("some_vhost") node = get_rabbit_hostname() ret = @command.run([], context[:opts]) + case :rabbit_misc.rpc_call(node, :rabbit_khepri, :is_enabled, []) do true -> assert match?({:error, :rabbitmq_unexpectedly_running}, ret) @@ -53,6 +54,7 @@ defmodule ForceResetCommandTest do false -> assert match?({:error, :mnesia_unexpectedly_running}, ret) end + assert vhost_exists?("some_vhost") end diff --git a/deps/rabbitmq_cli/test/ctl/reset_command_test.exs b/deps/rabbitmq_cli/test/ctl/reset_command_test.exs index 85452b4cb841..cc538854908c 100644 --- a/deps/rabbitmq_cli/test/ctl/reset_command_test.exs +++ b/deps/rabbitmq_cli/test/ctl/reset_command_test.exs @@ -46,6 +46,7 @@ defmodule ResetCommandTest do assert vhost_exists?("some_vhost") node = get_rabbit_hostname() ret = @command.run([], context[:opts]) + case :rabbit_misc.rpc_call(node, :rabbit_khepri, :is_enabled, []) do true -> assert match?({:error, :rabbitmq_unexpectedly_running}, ret) @@ -53,6 +54,7 @@ defmodule ResetCommandTest do false -> assert match?({:error, :mnesia_unexpectedly_running}, ret) end + assert vhost_exists?("some_vhost") end diff --git a/deps/rabbitmq_cli/test/ctl/set_disk_free_limit_command_test.exs b/deps/rabbitmq_cli/test/ctl/set_disk_free_limit_command_test.exs index a98de49f092e..aac5bb9d64d1 100644 --- a/deps/rabbitmq_cli/test/ctl/set_disk_free_limit_command_test.exs +++ b/deps/rabbitmq_cli/test/ctl/set_disk_free_limit_command_test.exs @@ -26,12 +26,13 @@ defmodule SetDiskFreeLimitCommandTest do # silences warnings context[:tag] on_exit([], fn -> set_disk_free_limit(@default_limit) end) + :rabbit_misc.rpc_call( - get_rabbit_hostname(), - :rabbit_disk_monitor, - :set_enabled, - [:true] - ) + get_rabbit_hostname(), + :rabbit_disk_monitor, + :set_enabled, + [true] + ) {:ok, opts: %{node: get_rabbit_hostname()}} end @@ -110,11 +111,12 @@ defmodule SetDiskFreeLimitCommandTest do test "run: a valid integer input returns an ok and sets the disk free limit", context do set_disk_free_limit(@default_limit) assert @command.run([context[:limit]], context[:opts]) == :ok + await_condition( - fn -> - status()[:disk_free_limit] === context[:limit] - end, - 30000 + fn -> + status()[:disk_free_limit] === context[:limit] + end, + 30000 ) set_disk_free_limit(@default_limit) @@ -125,11 +127,12 @@ defmodule SetDiskFreeLimitCommandTest do context do set_disk_free_limit(@default_limit) assert @command.run([context[:limit]], context[:opts]) == :ok + await_condition( - fn -> - status()[:disk_free_limit] === round(context[:limit]) - end, - 30000 + fn -> + status()[:disk_free_limit] === round(context[:limit]) + end, + 30000 ) set_disk_free_limit(@default_limit) @@ -140,11 +143,12 @@ defmodule SetDiskFreeLimitCommandTest do context do set_disk_free_limit(@default_limit) assert @command.run([context[:limit]], context[:opts]) == :ok + await_condition( - fn -> - status()[:disk_free_limit] === context[:limit] |> Float.floor() |> round - end, - 30000 + fn -> + status()[:disk_free_limit] === context[:limit] |> Float.floor() |> round + end, + 30000 ) set_disk_free_limit(@default_limit) @@ -154,11 +158,12 @@ defmodule SetDiskFreeLimitCommandTest do test "run: an integer string input returns an ok and sets the disk free limit", context do set_disk_free_limit(@default_limit) assert @command.run([context[:limit]], context[:opts]) == :ok + await_condition( - fn -> - status()[:disk_free_limit] === String.to_integer(context[:limit]) - end, - 30000 + fn -> + status()[:disk_free_limit] === String.to_integer(context[:limit]) + end, + 30000 ) set_disk_free_limit(@default_limit) @@ -167,11 +172,12 @@ defmodule SetDiskFreeLimitCommandTest do @tag limit: "2MB" test "run: an valid unit string input returns an ok and changes the limit", context do assert @command.run([context[:limit]], context[:opts]) == :ok + await_condition( - fn -> - status()[:disk_free_limit] === 2_000_000 - end, - 30000 + fn -> + status()[:disk_free_limit] === 2_000_000 + end, + 30000 ) set_disk_free_limit(@default_limit) diff --git a/deps/rabbitmq_cli/test/ctl/update_vhost_metadata_command_test.exs b/deps/rabbitmq_cli/test/ctl/update_vhost_metadata_command_test.exs index dd44f709bb04..cad5076e094c 100644 --- a/deps/rabbitmq_cli/test/ctl/update_vhost_metadata_command_test.exs +++ b/deps/rabbitmq_cli/test/ctl/update_vhost_metadata_command_test.exs @@ -2,7 +2,7 @@ ## License, v. 2.0. If a copy of the MPL was not distributed with this ## file, You can obtain one at https://mozilla.org/MPL/2.0/. ## -## Copyright (c) 2007-2023 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. +## Copyright (c) 2007-2024 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. defmodule UpdateVhostMetadataCommandTest do use ExUnit.Case, async: false @@ -81,18 +81,32 @@ defmodule UpdateVhostMetadataCommandTest do assert vh[:tags] == [:a1, :b2, :c3] end - test "run: attempt to use a non-existent virtual host fails", context do - vh = "a-non-existent-3882-vhost" + test "run: enabling deletion protection succeeds", context do + add_vhost(@vhost) - assert match?( - {:error, {:no_such_vhost, _}}, - @command.run([vh], Map.merge(context[:opts], %{description: "irrelevant"})) - ) + opts = + Map.merge(context[:opts], %{ + description: "Protected from deletion", + protected_from_deletion: true + }) + + assert @command.run([@vhost], opts) == :ok + vh = find_vhost(@vhost) + assert vh[:protected_from_deletion] end - test "run: attempt to use an unreachable node returns a nodedown" do - opts = %{node: :jake@thedog, timeout: 200, description: "does not matter"} - assert match?({:badrpc, _}, @command.run(["na"], opts)) + test "run: disabling deletion protection succeeds", context do + add_vhost(@vhost) + + opts = + Map.merge(context[:opts], %{ + description: "Protected from deletion", + protected_from_deletion: false + }) + + assert @command.run([@vhost], opts) == :ok + vh = find_vhost(@vhost) + assert !vh[:protected_from_deletion] end test "run: vhost tags are coerced to a list", context do @@ -104,6 +118,20 @@ defmodule UpdateVhostMetadataCommandTest do assert vh[:tags] == [:my_tag] end + test "run: attempt to use a non-existent virtual host fails", context do + vh = "a-non-existent-3882-vhost" + + assert match?( + {:error, {:no_such_vhost, _}}, + @command.run([vh], Map.merge(context[:opts], %{description: "irrelevant"})) + ) + end + + test "run: attempt to use an unreachable node returns a nodedown" do + opts = %{node: :jake@thedog, timeout: 200, description: "does not matter"} + assert match?({:badrpc, _}, @command.run(["na"], opts)) + end + test "banner", context do assert @command.banner([@vhost], context[:opts]) =~ ~r/Updating metadata of vhost/ diff --git a/deps/rabbitmq_cli/test/diagnostics/schema_info_command_test.exs b/deps/rabbitmq_cli/test/diagnostics/schema_info_command_test.exs index 2178bf6f9dfd..b91f1e296eb8 100644 --- a/deps/rabbitmq_cli/test/diagnostics/schema_info_command_test.exs +++ b/deps/rabbitmq_cli/test/diagnostics/schema_info_command_test.exs @@ -64,6 +64,7 @@ defmodule SchemaInfoCommandTest do test "run: can filter info keys", context do node = context[:opts][:node] + case :rabbit_misc.rpc_call(node, :rabbit_khepri, :is_enabled, []) do true -> :ok @@ -72,9 +73,9 @@ defmodule SchemaInfoCommandTest do wanted_keys = ~w(name access_mode) assert match?( - [[name: _, access_mode: _] | _], - run_command_to_list(@command, [wanted_keys, context[:opts]]) - ) + [[name: _, access_mode: _] | _], + run_command_to_list(@command, [wanted_keys, context[:opts]]) + ) end end diff --git a/deps/rabbitmq_ct_helpers/src/rabbit_ct_broker_helpers.erl b/deps/rabbitmq_ct_helpers/src/rabbit_ct_broker_helpers.erl index 6137fcf2fcbf..d68881ec1246 100644 --- a/deps/rabbitmq_ct_helpers/src/rabbit_ct_broker_helpers.erl +++ b/deps/rabbitmq_ct_helpers/src/rabbit_ct_broker_helpers.erl @@ -112,6 +112,8 @@ add_vhost/3, add_vhost/4, update_vhost_metadata/3, + enable_vhost_protection_from_deletion/2, + disable_vhost_protection_from_deletion/2, delete_vhost/2, delete_vhost/3, delete_vhost/4, @@ -1606,6 +1608,18 @@ add_vhost(Config, Node, VHost, Username) -> update_vhost_metadata(Config, VHost, Meta) -> catch rpc(Config, 0, rabbit_vhost, update_metadata, [VHost, Meta, <<"acting-user">>]). +enable_vhost_protection_from_deletion(Config, VHost) -> + MetadataPatch = #{ + protected_from_deletion => true + }, + update_vhost_metadata(Config, VHost, MetadataPatch). + +disable_vhost_protection_from_deletion(Config, VHost) -> + MetadataPatch = #{ + protected_from_deletion => false + }, + update_vhost_metadata(Config, VHost, MetadataPatch). + delete_vhost(Config, VHost) -> delete_vhost(Config, 0, VHost). diff --git a/deps/rabbitmq_ct_helpers/src/rabbit_mgmt_test_util.erl b/deps/rabbitmq_ct_helpers/src/rabbit_mgmt_test_util.erl index 2d454d5e85aa..c1fc1437fcd9 100644 --- a/deps/rabbitmq_ct_helpers/src/rabbit_mgmt_test_util.erl +++ b/deps/rabbitmq_ct_helpers/src/rabbit_mgmt_test_util.erl @@ -68,6 +68,10 @@ http_post(Config, Path, List, CodeExp) -> http_post(Config, Path, List, User, Pass, CodeExp) -> http_post_raw(Config, Path, format_for_upload(List), User, Pass, CodeExp). +http_post_json(Config, Path, Body, Assertion) -> + http_upload_raw(Config, post, Path, Body, "guest", "guest", + Assertion, [{"content-type", "application/json"}]). + http_post_accept_json(Config, Path, List, CodeExp) -> http_post_accept_json(Config, Path, List, "guest", "guest", CodeExp). diff --git a/deps/rabbitmq_management/priv/www/api/index.html b/deps/rabbitmq_management/priv/www/api/index.html index 14bcaeb36a22..d7e4a4a5214d 100644 --- a/deps/rabbitmq_management/priv/www/api/index.html +++ b/deps/rabbitmq_management/priv/www/api/index.html @@ -723,6 +723,14 @@

Reference

/api/vhosts/name/topic-permissions A list of all topic permissions for a given virtual host. + + + + X + X + /api/vhosts/name/deletion/protection + Enables (when used with POST) or disabled (with DELETE) deletion protection for the virtual host. + diff --git a/deps/rabbitmq_management/priv/www/js/tmpl/vhost.ejs b/deps/rabbitmq_management/priv/www/js/tmpl/vhost.ejs index df943c4e76e9..232fa1e5017b 100644 --- a/deps/rabbitmq_management/priv/www/js/tmpl/vhost.ejs +++ b/deps/rabbitmq_management/priv/www/js/tmpl/vhost.ejs @@ -26,6 +26,10 @@ Default queue type: <%= vhost.default_queue_type == "undefined" ? "<not set>" :vhost.default_queue_type %> + + Deletion protection: + <%= vhost.protected_from_deletion ? "enabled" :"disabled" %> + State: diff --git a/deps/rabbitmq_management/src/rabbit_mgmt_dispatcher.erl b/deps/rabbitmq_management/src/rabbit_mgmt_dispatcher.erl index ad6ef0414ae9..dd9d61ba5905 100644 --- a/deps/rabbitmq_management/src/rabbit_mgmt_dispatcher.erl +++ b/deps/rabbitmq_management/src/rabbit_mgmt_dispatcher.erl @@ -161,6 +161,7 @@ dispatcher() -> {"/bindings/:vhost/e/:source/:dtype/:destination/:props", rabbit_mgmt_wm_binding, []}, {"/vhosts", rabbit_mgmt_wm_vhosts, []}, {"/vhosts/:vhost", rabbit_mgmt_wm_vhost, []}, + {"/vhosts/:vhost/deletion/protection", rabbit_mgmt_wm_vhost_deletion_protection, []}, {"/vhosts/:vhost/start/:node", rabbit_mgmt_wm_vhost_restart, []}, {"/vhosts/:vhost/permissions", rabbit_mgmt_wm_permissions_vhost, []}, {"/vhosts/:vhost/topic-permissions", rabbit_mgmt_wm_topic_permissions_vhost, []}, diff --git a/deps/rabbitmq_management/src/rabbit_mgmt_util.erl b/deps/rabbitmq_management/src/rabbit_mgmt_util.erl index 5e737bcaf2ea..557ac0433835 100644 --- a/deps/rabbitmq_management/src/rabbit_mgmt_util.erl +++ b/deps/rabbitmq_management/src/rabbit_mgmt_util.erl @@ -19,7 +19,7 @@ is_authorized_global_parameters/2]). -export([user/1]). -export([bad_request/3, service_unavailable/3, bad_request_exception/4, - internal_server_error/3, internal_server_error/4, + internal_server_error/3, internal_server_error/4, precondition_failed/3, id/2, parse_bool/1, parse_int/1, redirect_to_home/3]). -export([with_decode/4, not_found/3]). -export([with_channel/4, with_channel/5]). @@ -675,6 +675,9 @@ not_found(Reason, ReqData, Context) -> method_not_allowed(Reason, ReqData, Context) -> halt_response(405, method_not_allowed, Reason, ReqData, Context). +precondition_failed(Reason, ReqData, Context) -> + halt_response(412, precondition_failed, Reason, ReqData, Context). + internal_server_error(Reason, ReqData, Context) -> internal_server_error(internal_server_error, Reason, ReqData, Context). diff --git a/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost.erl b/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost.erl index 3a2b98499c48..cec419c96af2 100644 --- a/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost.erl +++ b/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost.erl @@ -95,15 +95,20 @@ delete_resource(ReqData, Context = #context{user = #user{username = Username}}) case rabbit_vhost:delete(VHost, Username) of ok -> {true, ReqData, Context}; + {error, protected_from_deletion} -> + Msg = "Refusing to delete virtual host '~ts' because it is protected from deletion", + Reason = iolist_to_binary(io_lib:format(Msg, [VHost])), + rabbit_log:error(Msg, [VHost]), + rabbit_mgmt_util:precondition_failed(Reason, ReqData, Context); {error, timeout} -> rabbit_mgmt_util:internal_server_error( timeout, - "Timed out waiting for the vhost to be deleted", + "Timed out waiting for the virtual host to be deleted", ReqData, Context); {error, E} -> Reason = iolist_to_binary( io_lib:format( - "Error occurred while deleting vhost: ~tp", + "Error occurred while deleting virtual host '~tp'", [E])), rabbit_mgmt_util:internal_server_error( Reason, ReqData, Context) diff --git a/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost_deletion_protection.erl b/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost_deletion_protection.erl new file mode 100644 index 000000000000..c391f671ef72 --- /dev/null +++ b/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhost_deletion_protection.erl @@ -0,0 +1,88 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. +%% + +-module(rabbit_mgmt_wm_vhost_deletion_protection). + +-export([init/2, resource_exists/2, + content_types_accepted/2, + is_authorized/2, allowed_methods/2, accept_content/2, + delete_resource/2, id/1]). +-export([variances/2]). + +-import(rabbit_misc, [pget/2]). + +-include_lib("rabbitmq_management_agent/include/rabbit_mgmt_records.hrl"). +-include_lib("rabbit_common/include/rabbit.hrl"). + +-dialyzer({nowarn_function, accept_content/2}). + +%%-------------------------------------------------------------------- + +init(Req, _State) -> + {cowboy_rest, rabbit_mgmt_headers:set_common_permission_headers(Req, ?MODULE), #context{}}. + +variances(Req, Context) -> + {[<<"accept-encoding">>, <<"origin">>], Req, Context}. + +content_types_accepted(ReqData, Context) -> + {[{'*', accept_content}], ReqData, Context}. + +allowed_methods(ReqData, Context) -> + {[<<"GET">>, <<"POST">>, <<"DELETE">>, <<"OPTIONS">>], ReqData, Context}. + +resource_exists(ReqData, Context) -> + {rabbit_db_vhost:exists(id(ReqData)), ReqData, Context}. + +accept_content(ReqData, Context) -> + Name = id(ReqData), + case rabbit_db_vhost:enable_protection_from_deletion(Name) of + {ok, _NewRecord} -> + {true, ReqData, Context}; + {error, {no_such_vhost, _}} -> + Msg = "Cannot enable deletion protection of virtual host '~ts' because it does not exist", + Reason = iolist_to_binary(io_lib:format(Msg, [Name])), + rabbit_log:error(Msg, [Name]), + rabbit_mgmt_util:not_found( + Reason, ReqData, Context); + {error, E} -> + Msg = "Cannot enable deletion protection of virtual host '~ts': ~tp", + Reason = iolist_to_binary(io_lib:format(Msg, [Name, E])), + rabbit_log:error(Msg, [Name]), + rabbit_mgmt_util:internal_server_error( + Reason, ReqData, Context) + end. + +delete_resource(ReqData, Context) -> + Name = id(ReqData), + case rabbit_db_vhost:disable_protection_from_deletion(Name) of + {ok, _NewRecord} -> + {true, ReqData, Context}; + {error, {no_such_vhost, _}} -> + Msg = "Cannot disable deletion protection of virtual host '~ts' because it does not exist", + Reason = iolist_to_binary(io_lib:format(Msg, [Name])), + rabbit_log:error(Msg, [Name]), + rabbit_mgmt_util:not_found( + Reason, ReqData, Context); + {error, E} -> + Msg = "Cannot disable deletion protection of virtual host '~ts': ~tp", + Reason = iolist_to_binary(io_lib:format(Msg, [Name, E])), + rabbit_log:error(Msg, [Name]), + rabbit_mgmt_util:internal_server_error( + Reason, ReqData, Context) + end. + +is_authorized(ReqData, Context) -> + rabbit_mgmt_util:is_authorized_admin(ReqData, Context). + +%%-------------------------------------------------------------------- + +id(ReqData) -> + case rabbit_mgmt_util:id(vhost, ReqData) of + [Value] -> Value; + Value -> Value + end. + diff --git a/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhosts.erl b/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhosts.erl index 945a04e8d67c..f33e3ea3f543 100644 --- a/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhosts.erl +++ b/deps/rabbitmq_management/src/rabbit_mgmt_wm_vhosts.erl @@ -69,5 +69,5 @@ augmented(ReqData, #context{user = User}) -> basic() -> Maps = lists:map( fun maps:from_list/1, - rabbit_vhost:info_all([name, description, tags, default_queue_type, metadata])), + rabbit_vhost:info_all([name, description, tags, default_queue_type, metadata, protected_from_deletion])), rabbit_queue_type:vhosts_with_dqt(Maps). diff --git a/deps/rabbitmq_management/test/clustering_prop_SUITE.erl b/deps/rabbitmq_management/test/clustering_prop_SUITE.erl index 56f9d5e80b66..2c335b6fd8b3 100644 --- a/deps/rabbitmq_management/test/clustering_prop_SUITE.erl +++ b/deps/rabbitmq_management/test/clustering_prop_SUITE.erl @@ -282,7 +282,7 @@ dump_table(Config, Table) -> Data0 = rabbit_ct_broker_helpers:rpc(Config, 1, ets, tab2list, [Table]), ct:pal(?LOW_IMPORTANCE, "Node 1: Dump of table ~tp:~n~tp~n", [Table, Data0]). -retry_for(Fun, 0) -> +retry_for(_Fun, 0) -> false; retry_for(Fun, Retries) -> case Fun() of diff --git a/deps/rabbitmq_management/test/rabbit_mgmt_http_SUITE.erl b/deps/rabbitmq_management/test/rabbit_mgmt_http_SUITE.erl index 00bac6dd2299..8926e0f9fd2e 100644 --- a/deps/rabbitmq_management/test/rabbit_mgmt_http_SUITE.erl +++ b/deps/rabbitmq_management/test/rabbit_mgmt_http_SUITE.erl @@ -27,6 +27,7 @@ http_get_no_decode/5, http_put/4, http_put/6, http_post/4, http_post/6, + http_post_json/4, http_upload_raw/8, http_delete/3, http_delete/4, http_delete/5, http_put_raw/4, http_post_accept_json/4, @@ -3463,7 +3464,7 @@ check_cors_all_endpoints(Config) -> Endpoints = get_all_http_endpoints(), [begin - ct:pal("Options for ~tp~n", [EP]), + ct:pal("Verifying CORS for module ~tp using an OPTIONS request~n", [EP]), {ok, {{_, 200, _}, _, _}} = req(Config, options, EP, [{"origin", "https://rabbitmq.com"}]) end || EP <- Endpoints]. @@ -4208,10 +4209,6 @@ publish(Ch) -> publish(Ch) end. -http_post_json(Config, Path, Body, Assertion) -> - http_upload_raw(Config, post, Path, Body, "guest", "guest", - Assertion, [{"content-type", "application/json"}]). - %% @doc encode fields and file for HTTP post multipart/form-data. %% @reference Inspired by Python implementation. format_multipart_filedata(Boundary, Files) -> diff --git a/deps/rabbitmq_management/test/rabbit_mgmt_http_health_checks_SUITE.erl b/deps/rabbitmq_management/test/rabbit_mgmt_http_health_checks_SUITE.erl index 4e12d7696cda..e891cf032b17 100644 --- a/deps/rabbitmq_management/test/rabbit_mgmt_http_health_checks_SUITE.erl +++ b/deps/rabbitmq_management/test/rabbit_mgmt_http_health_checks_SUITE.erl @@ -47,7 +47,7 @@ all_tests() -> [ ]. %% ------------------------------------------------------------------- -%% Testsuite setup/teardown. +%% Test suite setup/teardown %% ------------------------------------------------------------------- init_per_group(Group, Config0) -> @@ -90,7 +90,7 @@ end_per_testcase(Testcase, Config) -> rabbit_ct_helpers:testcase_finished(Config, Testcase). %% ------------------------------------------------------------------- -%% Testcases. +%% Test cases %% ------------------------------------------------------------------- health_checks_test(Config) -> diff --git a/deps/rabbitmq_management/test/rabbit_mgmt_http_vhost_deletion_protection_SUITE.erl b/deps/rabbitmq_management/test/rabbit_mgmt_http_vhost_deletion_protection_SUITE.erl new file mode 100644 index 000000000000..0aa04bddb188 --- /dev/null +++ b/deps/rabbitmq_management/test/rabbit_mgmt_http_vhost_deletion_protection_SUITE.erl @@ -0,0 +1,170 @@ +%% This Source Code Form is subject to the terms of the Mozilla Public +%% License, v. 2.0. If a copy of the MPL was not distributed with this +%% file, You can obtain one at https://mozilla.org/MPL/2.0/. +%% +%% Copyright (c) 2007-2025 Broadcom. All Rights Reserved. The term “Broadcom” refers to Broadcom Inc. and/or its subsidiaries. All rights reserved. +%% + +-module(rabbit_mgmt_http_vhost_deletion_protection_SUITE). + +-include("rabbit_mgmt.hrl"). +-include_lib("amqp_client/include/amqp_client.hrl"). +-include_lib("eunit/include/eunit.hrl"). +-include_lib("rabbitmq_ct_helpers/include/rabbit_mgmt_test.hrl"). + +-import(rabbit_mgmt_test_util, [http_get/3, http_delete/3, http_post_json/4]). + +-compile(nowarn_export_all). +-compile(export_all). + +all() -> + [ + {group, cluster_size_3}, + {group, single_node} + ]. + +groups() -> + [ + {cluster_size_3, [], all_tests()}, + {single_node, [], all_tests()} + ]. + +all_tests() -> [ + protected_virtual_host_cannot_be_deleted, + virtual_host_can_be_deleted_after_protection_removal, + protected_virtual_host_is_marked_as_such_in_definition_export + ]. + +%% ------------------------------------------------------------------- +%% Testsuite setup/teardown. +%% ------------------------------------------------------------------- + +init_per_group(Group, Config0) -> + rabbit_ct_helpers:log_environment(), + inets:start(), + ClusterSize = case Group of + cluster_size_3 -> 3; + cluster_size_5 -> 5; + single_node -> 1 + end, + NodeConf = [{rmq_nodename_suffix, Group}, + {rmq_nodes_count, ClusterSize}, + {tcp_ports_base}], + Config1 = rabbit_ct_helpers:set_config(Config0, NodeConf), + rabbit_ct_helpers:run_setup_steps( + Config1, + rabbit_ct_broker_helpers:setup_steps() ++ + rabbit_ct_client_helpers:setup_steps()). + +end_per_group(_, Config) -> + inets:stop(), + Teardown0 = rabbit_ct_client_helpers:teardown_steps(), + Teardown1 = rabbit_ct_broker_helpers:teardown_steps(), + Steps = Teardown0 ++ Teardown1, + rabbit_ct_helpers:run_teardown_steps(Config, Steps). + +init_per_testcase(Testcase, Config) -> + case rabbit_ct_helpers:is_mixed_versions() of + true -> + {skip, "not mixed versions compatible"}; + _ -> + rabbit_ct_helpers:testcase_started(Config, Testcase) + end. + +end_per_testcase(Testcase, Config) -> + rabbit_ct_helpers:testcase_finished(Config, Testcase). + +%% ------------------------------------------------------------------- +%% Test cases +%% ------------------------------------------------------------------- + +-define(DELETION_PROTECTION_KEY, protected_from_deletion). + +protected_virtual_host_cannot_be_deleted(Config) -> + VH = rabbit_data_coercion:to_binary(?FUNCTION_NAME), + + MetaWhenLocked = #{ + ?DELETION_PROTECTION_KEY => true, + tags => [VH, "locked"] + }, + + MetaWhenUnlocked = #{ + ?DELETION_PROTECTION_KEY => false, + tags => [VH] + }, + + %% extra care needs to be taken to a delete a potentially protected virtual host + rabbit_ct_broker_helpers:update_vhost_metadata(Config, VH, MetaWhenUnlocked), + rabbit_ct_broker_helpers:delete_vhost(Config, VH), + rabbit_ct_broker_helpers:add_vhost(Config, VH), + rabbit_ct_broker_helpers:set_full_permissions(Config, VH), + + %% protect the virtual host from deletion + rabbit_ct_broker_helpers:update_vhost_metadata(Config, VH, MetaWhenLocked), + + %% deletion fails with 412 Precondition Failed + Path = io_lib:format("/vhosts/~ts", [VH]), + http_delete(Config, Path, 412), + + rabbit_ct_broker_helpers:update_vhost_metadata(Config, VH, MetaWhenUnlocked), + http_delete(Config, Path, {group, '2xx'}), + + passed. + +virtual_host_can_be_deleted_after_protection_removal(Config) -> + VH = rabbit_data_coercion:to_binary(?FUNCTION_NAME), + + rabbit_ct_broker_helpers:disable_vhost_protection_from_deletion(Config, VH), + rabbit_ct_broker_helpers:delete_vhost(Config, VH), + rabbit_ct_broker_helpers:add_vhost(Config, VH), + rabbit_ct_broker_helpers:set_full_permissions(Config, VH), + + rabbit_ct_broker_helpers:enable_vhost_protection_from_deletion(Config, VH), + + %% deletion fails with 412 Precondition Failed + Path = io_lib:format("/vhosts/~ts", [VH]), + http_delete(Config, Path, 412), + + %% lift the protection + rabbit_ct_broker_helpers:disable_vhost_protection_from_deletion(Config, VH), + %% deletion succeeds + http_delete(Config, Path, {group, '2xx'}), + %% subsequent deletion responds with a 404 Not Found + http_delete(Config, Path, ?NOT_FOUND), + + passed. + +protected_virtual_host_is_marked_as_such_in_definition_export(Config) -> + Name = rabbit_data_coercion:to_binary(?FUNCTION_NAME), + + %% extra care needs to be taken to a delete a potentially protected virtual host + rabbit_ct_broker_helpers:disable_vhost_protection_from_deletion(Config, Name), + rabbit_ct_broker_helpers:delete_vhost(Config, Name), + + rabbit_ct_broker_helpers:add_vhost(Config, Name), + rabbit_ct_broker_helpers:set_full_permissions(Config, Name), + + %% protect the virtual host from deletion + rabbit_ct_broker_helpers:enable_vhost_protection_from_deletion(Config, Name), + + %% Get the definitions + Definitions = http_get(Config, "/definitions", ?OK), + ct:pal("Exported definitions:~n~tp~tn", [Definitions]), + + %% Check if vhost definition is correct + VHosts = maps:get(vhosts, Definitions), + {value, VHost} = lists:search(fun(VHost) -> + maps:get(name, VHost) =:= Name + end, VHosts), + + Metadata = maps:get(metadata, VHost), + ?assertEqual(Name, maps:get(name, VHost)), + ?assertEqual(Metadata, #{ + protected_from_deletion => true, + default_queue_type => <<"classic">> + }), + + rabbit_ct_broker_helpers:disable_vhost_protection_from_deletion(Config, Name), + rabbit_ct_broker_helpers:delete_vhost(Config, Name), + + passed. \ No newline at end of file