diff --git a/.ameba.yml b/.ameba.yml index 9319b70..ee790b8 100644 --- a/.ameba.yml +++ b/.ameba.yml @@ -1,9 +1,9 @@ # This configuration file was generated by `ameba --gen-config` -# on 2023-04-27 15:07:08 UTC using Ameba version 1.4.2. +# on 2023-07-17 18:42:36 UTC using Ameba version 1.4.3. # The point is for the user to remove these configuration records # one by one as the reported problems are removed from the code base. -# Problems found: 14 +# Problems found: 23 # Run `ameba --only Layout/TrailingWhitespace` for details Layout/TrailingWhitespace: Description: Disallows trailing whitespace @@ -13,10 +13,11 @@ Layout/TrailingWhitespace: - spec/cb/role_spec.cr - spec/cb/cluster_list_spec.cr - spec/cb/team_spec.cr + - spec/cb/config_param_spec.cr Enabled: true Severity: Convention -# Problems found: 10 +# Problems found: 11 # Run `ameba --only Style/QueryBoolMethods` for details Style/QueryBoolMethods: Description: Reports boolean properties without the `?` suffix @@ -26,6 +27,7 @@ Style/QueryBoolMethods: - src/cb/cluster_list.cr - src/cb/cluster_upgrade.cr - src/cb/detach.cr + - src/cb/config_param.cr - src/cb/cluster_destroy.cr - src/cb/network.cr - src/cb/program.cr diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a03938..8019315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- `cb config-param` command to manage supported cluster configuration + parameters. Supports `get`, `list-supported`, `reset` and `set`. ## [3.3.3] - 2023-05-18 ### Added diff --git a/spec/cb/completion_spec.cr b/spec/cb/completion_spec.cr index 6a7dc98..a93a35b 100644 --- a/spec/cb/completion_spec.cr +++ b/spec/cb/completion_spec.cr @@ -261,6 +261,68 @@ Spectator.describe CB::Completion do expect(result).to_not have_option "-v" end + it "config-param" do + result = parse("cb config-param ") + expect(result).to have_option "get" + expect(result).to have_option "list-supported" + expect(result).to have_option "set" + expect(result).to have_option "reset" + + # cb config-param get + result = parse("cb config-param get ") + expect(result).to have_option "--cluster" + expect(result).to have_option "--format" + + result = parse("cb config-param get --cluster ") + expect(result).to eq expected_cluster_suggestion + + result = parse("cb config-param get --cluster abc ") + expect(result).to have_option "--format" + + result = parse("cb config-param get --format ") + expect(result).to eq ["json", "table"] + + # cb config-param list-supported + result = parse("cb config-param list-supported ") + expect(result).to have_option "--format" + + result = parse("cb config-param list-supported --format ") + expect(result).to eq ["json", "table"] + + # cb config-param reset + result = parse("cb config-param reset ") + expect(result).to have_option "--allow-restart" + expect(result).to have_option "--cluster" + expect(result).to have_option "--format" + + result = parse("cb config-param reset --cluster ") + expect(result).to eq expected_cluster_suggestion + + result = parse("cb config-param reset --cluster abc ") + expect(result).to have_option "--format" + + result = parse("cb config-param reset --allow-restart ") + expect(result).to eq ["false", "true"] + + result = parse("cb config-param reset --format ") + expect(result).to eq ["json", "table"] + + # cb config-param set + result = parse("cb config-param set ") + expect(result).to have_option "--allow-restart" + expect(result).to have_option "--cluster" + expect(result).to have_option "--format" + + result = parse("cb config-param set --cluster ") + expect(result).to eq expected_cluster_suggestion + + result = parse("cb config-param set --cluster abc ") + expect(result).to have_option "--allow-restart" + + result = parse("cb config-param set --allow-restart ") + expect(result).to eq ["false", "true"] + end + it "destroy" do result = parse("cb destroy ") expect(result).to eq expected_cluster_suggestion diff --git a/spec/cb/config_param_spec.cr b/spec/cb/config_param_spec.cr new file mode 100644 index 0000000..f624cd7 --- /dev/null +++ b/spec/cb/config_param_spec.cr @@ -0,0 +1,254 @@ +require "../spec_helper" + +Spectator.describe ConfigurationParameterGet do + subject(action) { described_class.new client: client, output: IO::Memory.new } + + let(client) { Client.new TEST_TOKEN } + let(cluster) { Factory.cluster } + + mock_client + + describe "#validate" do + it "validates that required arguments are present" do + expect(&.validate).to raise_error Program::Error, /Missing required argument/ + + action.cluster_id = cluster.id + + expect(&.validate).to be_true + end + end + + describe "#call" do + before_each { + action.cluster_id = cluster.id + } + + it "gets by name" do + action.args = ["postgres:max_connections"] + + expect(client).to receive(:get_configuration_parameter).and_return(Factory.configuration_parameter) + + action.call + + expected = <<-EXPECTED + Component Name Value + postgres max_connections 100 \n + EXPECTED + + expect(&.output.to_s).to eq expected + end + + it "lists no parameters when none are set" do + expect(client).to receive(:list_configuration_parameters).and_return([] of CB::Model::ConfigurationParameter) + + action.call + + expected = <<-EXPECTED + Component Name Value \n + EXPECTED + + expect(&.output.to_s).to eq expected + end + + it "outputs default format" do + expect(client).to receive(:list_configuration_parameters).and_return([Factory.configuration_parameter, Factory.configuration_parameter]) + + action.call + + expected = <<-EXPECTED + Component Name Value + postgres max_connections 100 + postgres max_connections 100 \n + EXPECTED + + expect(&.output.to_s).to eq expected + end + + it "outputs json format" do + action.format = Format::JSON + + expect(client).to receive(:list_configuration_parameters).and_return([Factory.configuration_parameter]) + + action.call + + expected = <<-EXPECTED + { + "parameters": [ + { + "component": "postgres", + "name": "postgres:max_connections", + "parameter_name": "max_connections", + "requires_restart": false, + "value": "100" + } + ] + }\n + EXPECTED + + expect(&.output.to_s).to eq expected + end + end +end + +Spectator.describe ConfigurationParameterListSupported do + subject(action) { described_class.new client: client, output: IO::Memory.new } + + let(client) { Client.new TEST_TOKEN } + let(cluster) { Factory.cluster } + + mock_client + + describe "#call" do + before_each { + expect(client).to receive(:list_supported_configuration_parameters).and_return [ + Factory.configuration_parameter(value: nil), + Factory.configuration_parameter( + component: "pgbouncer", + name: "pgbouncer:default_pool_size", + parameter_name: "default_pool_size", + value: nil + ), + ] + } + + it "outputs all" do + action.call + + expected = <<-EXPECTED + Component Name Requires Restart + postgres max_connections no + pgbouncer default_pool_size no \n + EXPECTED + + expect(&.output.to_s).to eq expected + end + + it "outputs specific component" do + action.args = ["postgres"] + action.call + + expected = <<-EXPECTED + Component Name Requires Restart + postgres max_connections no \n + EXPECTED + + expect(&.output.to_s).to eq expected + end + end +end + +Spectator.describe ConfigurationParameterSet do + subject(action) { described_class.new client: client, output: IO::Memory.new } + + let(client) { Client.new TEST_TOKEN } + let(cluster) { Factory.cluster } + + mock_client + + describe "#call" do + before_each { + action.args = ["postgres:max_connections=100"] + action.cluster_id = cluster.id + } + + it "outputs default" do + expect(client).to receive(:update_configuration_parameters).and_return [Factory.configuration_parameter] + + action.call + + expected = <<-EXPECTED + Component Name Value + postgres max_connections 100 \n + EXPECTED + + expect(&.output.to_s).to eq expected + end + + it "outputs json format" do + action.format = Format::JSON + + expect(client).to receive(:update_configuration_parameters).and_return([Factory.configuration_parameter]) + + action.call + + expected = <<-EXPECTED + { + "parameters": [ + { + "component": "postgres", + "name": "postgres:max_connections", + "parameter_name": "max_connections", + "requires_restart": false, + "value": "100" + } + ] + }\n + EXPECTED + + expect(&.output.to_s).to eq expected + end + end +end + +Spectator.describe ConfigurationParameterReset do + subject(action) { described_class.new client: client, output: IO::Memory.new } + + let(client) { Client.new TEST_TOKEN } + let(cluster) { Factory.cluster } + + mock_client + + describe "#call" do + before_each { + action.cluster_id = cluster.id + + expect(client).to receive(:update_configuration_parameters).and_return [Factory.configuration_parameter] + } + + it "resets parameters" do + action.args = ["postgres:max_connections"] + + action.call + + expected = <<-EXPECTED + Component Name Value + postgres max_connections 100 \n + EXPECTED + + expect(&.output.to_s).to eq expected + end + + it "outputs default" do + action.call + + expected = <<-EXPECTED + Component Name Value + postgres max_connections 100 \n + EXPECTED + + expect(&.output.to_s).to eq expected + end + + it "outputs json format" do + action.format = Format::JSON + + action.call + + expected = <<-EXPECTED + { + "parameters": [ + { + "component": "postgres", + "name": "postgres:max_connections", + "parameter_name": "max_connections", + "requires_restart": false, + "value": "100" + } + ] + }\n + EXPECTED + + expect(&.output.to_s).to eq expected + end + end +end diff --git a/spec/cb/login_spec.cr b/spec/cb/login_spec.cr index 477c47c..706aa34 100644 --- a/spec/cb/login_spec.cr +++ b/spec/cb/login_spec.cr @@ -21,6 +21,7 @@ Spectator.describe CB::Login do let(client) { mock(Client) } before_each { + ENV["CB_API_KEY"] = nil action.client = client action.open_browser = ->(_url : String) { true } action.store_credentials = ->(_account : String, _secret : String) { true } @@ -43,5 +44,10 @@ Spectator.describe CB::Login do result = action.call expect(result).to_not be_empty end + + it "raises error if CB_API_KEY is set" do + ENV["CB_API_KEY"] = "cbkey_secret" + expect(&.call).to raise_error(CB::Program::Error) + end end end diff --git a/spec/cb/open_spec.cr b/spec/cb/open_spec.cr index 68e2706..31e6e87 100644 --- a/spec/cb/open_spec.cr +++ b/spec/cb/open_spec.cr @@ -16,6 +16,8 @@ Spectator.describe CB::Open do end it "creates a session and executes open" do + ENV["CB_API_KEY"] = nil + open_args : Array(String)? = nil action.open = ->(args : Array(String), _env : Process::Env) do @@ -36,5 +38,10 @@ Spectator.describe CB::Open do expected_login_url = "https://#{client_host}/sessions/#{session_id}/actions/login?one_time_token=#{session_one_time_token}" expect(open_args).to eq([expected_login_url]) end + + it "raises error if CB_API_KEY set" do + ENV["CB_API_KEY"] = "cbkey_secret" + expect(&.call).to raise_error(CB::Program::Error) + end end end diff --git a/spec/factory.cr b/spec/factory.cr index 52ea9cd..597b5c3 100644 --- a/spec/factory.cr +++ b/spec/factory.cr @@ -183,6 +183,18 @@ module Factory CB::Model::Role.new **params end + def configuration_parameter(**params) + params = { + component: "postgres", + name: "postgres:max_connections", + parameter_name: "max_connections", + requires_restart: false, + value: "100", + }.merge(params) + + CB::Model::ConfigurationParameter.new **params + end + def role_user(**params) params = { account_email: "user@example.com", diff --git a/src/cb/completion.cr b/src/cb/completion.cr index be60cf7..40b392b 100644 --- a/src/cb/completion.cr +++ b/src/cb/completion.cr @@ -46,6 +46,8 @@ class CB::Completion case args.first when "info", "rename", "logs", "suspend", "resume" single_cluster_suggestion + when "config-param" + config_param when "create" create when "destroy" @@ -124,6 +126,7 @@ class CB::Completion "logs\tView live cluster logs", "suspend\tTemporarily turn off a cluster", "resume\tTurn on a suspended cluster", + "config-param\tManage configuration parameters", ] if @client options @@ -385,6 +388,85 @@ class CB::Completion suggest end + def config_param + case @args[1] + when "get" + config_param_get + when "list-supported" + config_param_list_supported + when "reset" + config_param_reset + when "set" + config_param_set + else + [ + "get\tdisplay configuration parameters", + "list-supported\tdisplay supported configuration parameters", + "reset\treset configuration parameters to the default value", + "set\tset configuration parameters", + ] + end + end + + def config_param_get + cluster = find_arg_value "--cluster" + + if last_arg?("--cluster") + return cluster.nil? ? cluster_suggestions : [] of String + end + + if last_arg?("--format") + return ["json", "table"] + end + + suggest = [] of String + suggest << "--cluster\tcluster id" unless has_full_flag? :cluster + suggest << "--format\toutput format" unless has_full_flag? :format + suggest + end + + def config_param_list_supported + return ["json", "table"] if last_arg?("--format") + + suggest = [] of String + suggest << "--format\toutput format" unless has_full_flag? :format + suggest + end + + def config_param_reset + return ["false", "true"] if last_arg?("--allow-restart") + + if last_arg?("--cluster") + cluster = find_arg_value "--cluster" + return cluster.nil? ? cluster_suggestions : [] of String + end + + return ["json", "table"] if last_arg?("--format") + + suggest = [] of String + suggest << "--allow-restart\tallow restart" unless has_full_flag? :allow_restart + suggest << "--cluster\tcluster id" unless has_full_flag? :cluster + suggest << "--format\toutput format" unless has_full_flag? :format + suggest + end + + def config_param_set + return ["false", "true"] if last_arg?("--allow-restart") + + if last_arg?("--cluster") + cluster = find_arg_value "--cluster" + return cluster.nil? ? cluster_suggestions : [] of String + end + + return ["json", "table"] if last_arg?("--format") + + suggest = [] of String + suggest << "--allow-restart\tallow restart" unless has_full_flag? :allow_restart + suggest << "--cluster\tcluster id" unless has_full_flag? :cluster + suggest << "--format\toutput format" unless has_full_flag? :format + suggest + end + def maintenance case @args[1] when "info" @@ -1081,6 +1163,7 @@ class CB::Completion # only return the long version, but search for long and short def find_full_flags full = Set(Symbol).new + full << :allow_restart if has_full_flag? "--allow-restart" full << :ha if has_full_flag? "--ha" full << :plan if has_full_flag? "--plan" full << :name if has_full_flag? "--name", "-n" diff --git a/src/cb/config_param.cr b/src/cb/config_param.cr new file mode 100644 index 0000000..93f3f70 --- /dev/null +++ b/src/cb/config_param.cr @@ -0,0 +1,165 @@ +require "./action" + +module CB + # API action for configuration parameters. + # + # All configuration parameter actions must inherit this action. + abstract class ConfigurationParameterAction < APIAction + # The cluster ID. + cluster_identifier_setter cluster_id + + # The output format. The default format is table format. + format_setter format + + # Flag to indicate whether the output should include a header. This only + # has an effect when the output format is a table. + property no_header : Bool = false + + # List of configuration parameter argments for the action. + property args : Array(String) = [] of String + + def validate + check_required_args do |missing| + missing << "cluster" if @cluster_id.empty? + end + end + + def run + validate + end + + protected def display(parameters : Array(Model::ConfigurationParameter)) + case @format + when Format::Default + output_default(parameters) + when Format::JSON + output_json(parameters) + end + end + + protected def output_default(parameters : Array(Model::ConfigurationParameter)) + table = Table::TableBuilder.new(border: :none) do + columns do + add "Component" + add "Name" + add "Value" + end + + header unless no_header + + rows parameters.map { |p| [p.component, p.parameter_name, p.value_str] } + end + + output << table.render << '\n' + end + + protected def output_json(parameters : Array(Model::ConfigurationParameter)) + output << { + "parameters": parameters, + }.to_pretty_json << '\n' + end + end + + # Action for getting configuration parameters. + class ConfigurationParameterGet < ConfigurationParameterAction + def validate + raise Error.new "Too many arguments provided. Ensure that only one configuration parameter is given or none." unless @args.size <= 1 + super + end + + def run + validate + + if @args.empty? + parameters = client.list_configuration_parameters(cluster_id[:cluster]) + else + name = @args.try &.first + parameters = [client.get_configuration_parameter(cluster_id[:cluster], name)] + end + + display(parameters) + end + end + + class ConfigurationParameterListSupported < ConfigurationParameterAction + def run + parameters = client.list_supported_configuration_parameters + parameters = parameters.select { |p| @args.includes? p.component.to_s } unless args.empty? + + case @format + when Format::Default, Format::Table + table = Table::TableBuilder.new(border: :none) do + columns do + add "Component" + add "Name" + add "Requires Restart" + end + + header unless no_header + + rows parameters.map { |p| [p.component, p.parameter_name, p.requires_restart ? "yes" : "no"] } + end + + output << table.render << '\n' + when Format::JSON + output << { + "parameters": parameters.map do |p| + { + "component": p.component, + "name": p.name, + "parameter_name": p.parameter_name, + "require_restart": p.requires_restart, + } + end, + }.to_pretty_json + else + raise Error.new("Format '#{@format}' is not supported for this command.") + end + end + end + + # Action for setting configuration parameters. + class ConfigurationParameterSet < ConfigurationParameterAction + bool_setter allow_restart + + def run + super + + updated_parameters = @args.map do |arg| + parts = arg.split('=') + {"name" => parts[0], "value" => parts[1]} + rescue IndexError + raise Error.new("Invalid argument: #{arg}). Make sure that it has the following format :=.") + end + + begin + parameters = client.update_configuration_parameters( + cluster_id[:cluster], + parameters: updated_parameters, + allow_restart: @allow_restart + ) + rescue e : CB::Client::Error + raise Error.new(e.message) + end + + display(parameters) + end + end + + # Action for resetting configuration parameters. + class ConfigurationParameterReset < ConfigurationParameterAction + bool_setter allow_restart + + def run + super + + parameters = client.update_configuration_parameters( + cluster_id[:cluster], + parameters: @args.map { |arg| {name: arg, "value": nil} }, + allow_restart: @allow_restart, + ) + + display(parameters) + end + end +end diff --git a/src/cb/format.cr b/src/cb/format.cr index 1572e14..c1e53b0 100644 --- a/src/cb/format.cr +++ b/src/cb/format.cr @@ -6,5 +6,9 @@ module CB List Table Tree + + def to_s(io : IO) + io << self.to_s.downcase + end end end diff --git a/src/cli.cr b/src/cli.cr index fb92ba8..efa83af 100755 --- a/src/cli.cr +++ b/src/cli.cr @@ -171,6 +171,94 @@ op = OptionParser.new do |parser| EXAMPLES end + # + # Custom Configuration Parameter Management + # + + parser.on("config-param", "Manage configuration parameters") do + parser.banner = "cb config-param " + + # TODO (abrightwell): would be really cool to implement this kind of + # support. + # + # parser.on("edit", "interactively edit config values") do + # parser.on("--cluster ID", "Choose cluster") + # end + + parser.on("get", "display one or all configuration parameters for a cluster") do + parser.banner = "cb config-param get <--cluster> [NAME]" + + get = set_action ConfigurationParameterGet + parser.on("--cluster ID", "Choose cluster") { |arg| get.cluster_id = arg } + parser.on("--format FORMAT", "Choose output format (default: table)") { |arg| get.format = arg } + parser.on("--no-header", "Do not display table header") { get.no_header = true } + + parser.unknown_args { |args| get.args = args } + + parser.examples = <<-EXAMPLES + Get a single configuration parameter: + $ cb config-params get --cluster postgres:max_connections + + Get all configuration parameters: + $ cb config-params get --cluster + EXAMPLES + end + + parser.on("list-supported", "display supported configuration parameters") do + parser.banner = "cb config-param list-supported" + + supported = set_action ConfigurationParameterListSupported + parser.on("--format FORMAT", "Choose output format (default: table)") { |arg| supported.format = arg } + parser.on("--no-header", "Do not display table header") { supported.no_header = true } + + parser.unknown_args { |args| supported.args = args } + + parser.examples = <<-EXAMPLES + List all supported configuration parameters: + $ cb config-param list-supported + + List all supported configuration parameters by component: + $ cb config-param list-supported postgres + EXAMPLES + end + + parser.on("set", "set one or more configuration parameter values") do + parser.banner = "cb config-param set <--cluster> [...]" + + set = set_action ConfigurationParameterSet + parser.on("--cluster ID", "Choose cluster") { |arg| set.cluster_id = arg } + parser.on("--allow-restart ", "Allow restarting the cluster (default: false).") { |arg| set.allow_restart = arg } + + parser.unknown_args { |args| set.args = args } + + parser.examples = <<-EXAMPLES + Set a single configuration parameter: + $ cb config-params set --cluster postgres:max_connections=100 + + Set multiple configuration parameters: + $ cb config-params set --cluster postgres:max_connections=100 postgres:hot_standby=off + EXAMPLES + end + + parser.on("reset", "reset one or more configuration parameter values to default") do + parser.banner = "cb config-params reset <--cluster> " + + reset = set_action ConfigurationParameterReset + parser.on("--cluster ID", "Choose cluster") { |arg| reset.cluster_id = arg } + parser.on("--allow-restart ", "Allow restarting the cluster (default: false).") { |arg| reset.allow_restart = arg } + + parser.unknown_args { |args| reset.args = args } + + parser.examples = <<-EXAMPLES + Reset a single configuration parameter: + $ cb config-param reset --cluster postgres:max_connections + + Reset multiple configuration parameters: + $ cb config-param reset --cluster postgres:max_connections postgres:hot_standby + EXAMPLES + end + end + # Cluster Upgrade parser.on("upgrade", "Manage a cluster upgrades") do parser.banner = "cb upgrade " diff --git a/src/client/config_param.cr b/src/client/config_param.cr new file mode 100644 index 0000000..0d6012f --- /dev/null +++ b/src/client/config_param.cr @@ -0,0 +1,29 @@ +require "./client" + +module CB + class Client + # Get configuration parameter value + def get_configuration_parameter(id : Identifier, name : String) + resp = get "clusters/#{id}/configuration-parameters/#{name}" + Model::ConfigurationParameter.from_json resp.body + end + + # List configuration parameter values. + def list_configuration_parameters(id : Identifier) + resp = get "clusters/#{id}/configuration-parameters" + Array(Model::ConfigurationParameter).from_json resp.body, root: "parameters" + end + + # List support configuration parameters + def list_supported_configuration_parameters + resp = get "configuration-parameters" + Array(Model::ConfigurationParameter).from_json resp.body, root: "parameters" + end + + # Update configuration parameter values. + def update_configuration_parameters(id, parameters, allow_restart : Bool = false) + resp = put "clusters/#{id}/configuration-parameters", {"parameters": parameters, "allow_restart": allow_restart} + Array(Model::ConfigurationParameter).from_json resp.body, root: "parameters" + end + end +end diff --git a/src/client/error.cr b/src/client/error.cr index b305420..74e977d 100644 --- a/src/client/error.cr +++ b/src/client/error.cr @@ -23,6 +23,12 @@ module CB end end + def message + JSON.parse(resp.body).as_h["message"].to_s + rescue JSON::ParseException + resp.body unless resp.body == "" + end + def bad_request? resp.status == HTTP::Status::BAD_REQUEST end diff --git a/src/ext/option_parser.cr b/src/ext/option_parser.cr index 60617c7..3917052 100644 --- a/src/ext/option_parser.cr +++ b/src/ext/option_parser.cr @@ -42,7 +42,7 @@ class OptionParser io << '\n' end - unless examples.nil? + if examples io << '\n' << "Examples".colorize.bold << ":\n" io << examples << '\n' end diff --git a/src/models/config_param.cr b/src/models/config_param.cr new file mode 100644 index 0000000..3297f41 --- /dev/null +++ b/src/models/config_param.cr @@ -0,0 +1,17 @@ +module CB::Model + jrecord ConfigurationParameter, + component : String? = nil, + name : String = "", + parameter_name : String? = nil, + requires_restart : Bool = false, + value : String? = nil do + @[JSON::Field(key: "parameter_name", emit_null: false)] + def to_s(io : IO) + io << name.colorize.t_name << '=' << value + end + + def value_str + @value ? @value : "default" + end + end +end