From 0c4d2a6269d3905a1209fb261549f86719add1f4 Mon Sep 17 00:00:00 2001 From: Zakir Dzhamaliddinov Date: Tue, 30 Jul 2024 13:19:40 +0300 Subject: [PATCH 1/2] Introduce terraform subcommand --- docs/commands.md | 8 ++++++ lib/command/base.rb | 21 ++++++++++++---- lib/command/base_sub_command.rb | 15 +++++++++++ lib/command/terraform/generate.rb | 25 ++++++++++++++++++ lib/cpflow.rb | 42 ++++++++++++++++++++++++------- spec/cpflow_spec.rb | 15 +++++++++++ spec/support/command_helpers.rb | 9 +++++++ 7 files changed, 121 insertions(+), 14 deletions(-) create mode 100644 lib/command/base_sub_command.rb create mode 100644 lib/command/terraform/generate.rb diff --git a/docs/commands.md b/docs/commands.md index 2077e1af..43e6fa61 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -444,6 +444,14 @@ cpflow run -a $APP_NAME --entrypoint /app/alternative-entrypoint.sh -- rails db: cpflow setup-app -a $APP_NAME ``` +### `generate` + +Generates terraform configuration files based on `controlplane.yml` and `templates/` config + +```sh +cpflow terraform generate +``` + ### `version` - Displays the current version of the CLI diff --git a/lib/command/base.rb b/lib/command/base.rb index b0c645e3..47d14aae 100644 --- a/lib/command/base.rb +++ b/lib/command/base.rb @@ -38,16 +38,27 @@ class Base # rubocop:disable Metrics/ClassLength WITH_INFO_HEADER = true # Which validations to run before the command VALIDATIONS = %w[config].freeze + SUBCOMMAND = nil def initialize(config) @config = config end - def self.all_commands - Dir["#{__dir__}/*.rb"].each_with_object({}) do |file, result| - filename = File.basename(file, ".rb") - classname = File.read(file).match(/^\s+class (\w+) < Base($| .*$)/)&.captures&.first - result[filename.to_sym] = Object.const_get("::Command::#{classname}") if classname + def self.all_commands # rubocop:disable Metrics/MethodLength + Dir["#{__dir__}/**/*.rb"].each_with_object({}) do |file, result| + content = File.read(file) + + classname = content.match(/^\s+class (\w+) < (?:.*Base)(?:$| .*$)/)&.captures&.first + next unless classname + + namespaces = content.scan(/^\s+module (\w+)/).flatten + full_classname = [*namespaces, classname].join("::").prepend("::") + + command_key = File.basename(file, ".rb") + prefix = namespaces[1..1].map(&:downcase).join("_") + command_key.prepend(prefix.concat("_")) unless prefix.empty? + + result[command_key.to_sym] = Object.const_get(full_classname) end end diff --git a/lib/command/base_sub_command.rb b/lib/command/base_sub_command.rb new file mode 100644 index 00000000..02133eb3 --- /dev/null +++ b/lib/command/base_sub_command.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Inspired by https://github.com/rails/thor/wiki/Subcommands +class BaseSubCommand < Thor + def self.banner(command, _namespace = nil, _subcommand = false) # rubocop:disable Style/OptionalBooleanParameter + "#{basename} #{subcommand_prefix} #{command.usage}" + end + + def self.subcommand_prefix + name + .gsub(/.*::/, "") + .gsub(/^[A-Z]/) { |match| match[0].downcase } + .gsub(/[A-Z]/) { |match| "-#{match[0].downcase}" } + end +end diff --git a/lib/command/terraform/generate.rb b/lib/command/terraform/generate.rb new file mode 100644 index 00000000..f4ba7442 --- /dev/null +++ b/lib/command/terraform/generate.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Command + module Terraform + class Generate < ::Command::Base + SUBCOMMAND = "terraform" + NAME = "generate" + DESCRIPTION = "Generates terraform configuration files" + LONG_DESCRIPTION = <<~DESC + Generates terraform configuration files based on `controlplane.yml` and `templates/` config + DESC + EXAMPLES = <<~EX + ```sh + cpflow terraform generate + ``` + EX + WITH_INFO_HEADER = false + VALIDATIONS = [].freeze + + def call + # TODO: Implement + end + end + end +end diff --git a/lib/cpflow.rb b/lib/cpflow.rb index be0f94f4..5325dd56 100644 --- a/lib/cpflow.rb +++ b/lib/cpflow.rb @@ -113,6 +113,10 @@ def self.check_cpflow_version # rubocop:disable Metrics/MethodLength def self.fix_help_option help_mappings = Thor::HELP_MAPPINGS + ["help"] matches = help_mappings & ARGV + + # Help option works correctly for subcommands + return if matches && (ARGV & subcommand_names).any? + matches.each do |match| ARGV.delete(match) ARGV.unshift(match) @@ -149,6 +153,10 @@ def self.all_base_commands ::Command::Base.all_commands.merge(deprecated_commands) end + def self.subcommand_names + ::Command::Base.all_commands.values.map { |command| command::SUBCOMMAND }.compact + end + def self.process_option_params(params) # Ensures that if no value is provided for a non-boolean option (e.g., `cpflow command --option`), # it defaults to an empty string instead of the option name (which is the default Thor behavior) @@ -157,6 +165,17 @@ def self.process_option_params(params) params end + def self.klass_for(subcommand) + klass_name = subcommand.to_s.split("-").collect(&:capitalize).join + return Cpflow.const_get(klass_name) if Cpflow.const_defined?(klass_name) + + Cpflow.const_set(klass_name, Class.new(BaseSubCommand)).tap do |subcommand_klass| + desc subcommand, "#{subcommand.capitalize} commands" + subcommand subcommand, subcommand_klass + end + end + private_class_method :klass_for + @commands_with_required_options = [] @commands_with_extra_options = [] @@ -181,6 +200,7 @@ def self.process_option_params(params) hide = command_class::HIDE || deprecated with_info_header = command_class::WITH_INFO_HEADER validations = command_class::VALIDATIONS + subcommand = command_class::SUBCOMMAND long_description += "\n#{examples}" if examples.length.positive? @@ -188,21 +208,25 @@ def self.process_option_params(params) # so we store it here to be able to use it raise_args_error = ->(*args) { handle_argument_error(commands[name_for_method], ArgumentError, *args) } - desc(usage, description, hide: hide) - long_desc(long_description) - - command_options.each do |option| - params = process_option_params(option[:params]) - method_option(option[:name], **params) - end - # We'll handle required options manually in `Config` required_options = command_options.select { |option| option[:params][:required] }.map { |option| option[:name] } @commands_with_required_options.push(name_for_method.to_sym) if required_options.any? @commands_with_extra_options.push(name_for_method.to_sym) if accepts_extra_options - define_method(name_for_method) do |*provided_args| # rubocop:disable Metrics/BlockLength, Metrics/MethodLength + klass = subcommand ? klass_for(subcommand) : self + + klass.class_eval do + desc(usage, description, hide: hide) + long_desc(long_description) + + command_options.each do |option| + params = process_option_params(option[:params]) + method_option(option[:name], **params) + end + end + + klass.define_method(name_for_method) do |*provided_args| # rubocop:disable Metrics/BlockLength, Metrics/MethodLength if deprecated normalized_old_name = ::Helpers.normalize_command_name(command_key) ::Shell.warn_deprecated("Command '#{normalized_old_name}' is deprecated, " \ diff --git a/spec/cpflow_spec.rb b/spec/cpflow_spec.rb index 10031ff3..de57206f 100644 --- a/spec/cpflow_spec.rb +++ b/spec/cpflow_spec.rb @@ -19,4 +19,19 @@ expect(result[:stderr]).to include("No value provided for option --#{option[:name].to_s.tr('_', '-')}") end end + + it "handles subcommands correctly" do + result = run_cpflow_command("help") + + expect(result[:status]).to eq(0) + + Cpflow::Cli.subcommand_names.each do |subcommand| + expect(result[:stdout]).to include("#{package_name} #{subcommand}") + + subcommand_result = run_cpflow_command(subcommand, "help") + + expect(subcommand_result[:status]).to eq(0) + expect(subcommand_result[:stdout]).to include("#{package_name} #{subcommand} help [COMMAND]") + end + end end diff --git a/spec/support/command_helpers.rb b/spec/support/command_helpers.rb index 6e275083..95afc82f 100644 --- a/spec/support/command_helpers.rb +++ b/spec/support/command_helpers.rb @@ -157,6 +157,9 @@ def create_app_if_not_exists(app, deploy: false, image_before_deploy_count: 0, i end def run_cpflow_command(*args, raise_errors: false) # rubocop:disable Metrics/MethodLength + program_name_before = $PROGRAM_NAME + $PROGRAM_NAME = package_name + LogHelpers.write_command_to_log(args.join(" ")) result = { @@ -182,6 +185,8 @@ def run_cpflow_command(*args, raise_errors: false) # rubocop:disable Metrics/Met raise result.to_json if result[:status].nonzero? && raise_errors result + ensure + $PROGRAM_NAME = program_name_before end def run_cpflow_command!(*args) @@ -257,4 +262,8 @@ def spec_directory File.dirname(current_directory) end + + def package_name + @package_name ||= Cpflow::Cli.instance_variable_get("@package_name") + end end From 7eaf5cac171c53d0dcae6e401d08d2e5b40e23aa Mon Sep 17 00:00:00 2001 From: Zakir Dzhamaliddinov Date: Thu, 1 Aug 2024 10:53:37 +0300 Subject: [PATCH 2/2] Review fixes --- docs/commands.md | 4 ++-- lib/command/base.rb | 5 +++-- lib/command/terraform/generate.rb | 11 +++-------- lib/cpflow.rb | 21 +++++++++++++-------- script/update_command_docs | 8 ++++++-- spec/cpflow_spec.rb | 11 +++++++---- spec/support/command_helpers.rb | 9 --------- 7 files changed, 34 insertions(+), 35 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 43e6fa61..14fa758a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -444,9 +444,9 @@ cpflow run -a $APP_NAME --entrypoint /app/alternative-entrypoint.sh -- rails db: cpflow setup-app -a $APP_NAME ``` -### `generate` +### `terraform generate` -Generates terraform configuration files based on `controlplane.yml` and `templates/` config +- Generates terraform configuration files based on `controlplane.yml` and `templates/` config ```sh cpflow terraform generate diff --git a/lib/command/base.rb b/lib/command/base.rb index 47d14aae..38dedff1 100644 --- a/lib/command/base.rb +++ b/lib/command/base.rb @@ -12,6 +12,8 @@ class Base # rubocop:disable Metrics/ClassLength VALIDATIONS_WITH_ADDITIONAL_OPTIONS = %w[templates].freeze ALL_VALIDATIONS = VALIDATIONS_WITHOUT_ADDITIONAL_OPTIONS + VALIDATIONS_WITH_ADDITIONAL_OPTIONS + # Used to call the command (`cpflow SUBCOMMAND_NAME NAME`) + SUBCOMMAND_NAME = nil # Used to call the command (`cpflow NAME`) # NAME = "" # Displayed when running `cpflow help` or `cpflow help NAME` (defaults to `NAME`) @@ -38,7 +40,6 @@ class Base # rubocop:disable Metrics/ClassLength WITH_INFO_HEADER = true # Which validations to run before the command VALIDATIONS = %w[config].freeze - SUBCOMMAND = nil def initialize(config) @config = config @@ -55,7 +56,7 @@ def self.all_commands # rubocop:disable Metrics/MethodLength full_classname = [*namespaces, classname].join("::").prepend("::") command_key = File.basename(file, ".rb") - prefix = namespaces[1..1].map(&:downcase).join("_") + prefix = namespaces[1..].map(&:downcase).join("_") command_key.prepend(prefix.concat("_")) unless prefix.empty? result[command_key.to_sym] = Object.const_get(full_classname) diff --git a/lib/command/terraform/generate.rb b/lib/command/terraform/generate.rb index f4ba7442..55141dd0 100644 --- a/lib/command/terraform/generate.rb +++ b/lib/command/terraform/generate.rb @@ -2,18 +2,13 @@ module Command module Terraform - class Generate < ::Command::Base - SUBCOMMAND = "terraform" + class Generate < Base + SUBCOMMAND_NAME = "terraform" NAME = "generate" DESCRIPTION = "Generates terraform configuration files" LONG_DESCRIPTION = <<~DESC - Generates terraform configuration files based on `controlplane.yml` and `templates/` config + - Generates terraform configuration files based on `controlplane.yml` and `templates/` config DESC - EXAMPLES = <<~EX - ```sh - cpflow terraform generate - ``` - EX WITH_INFO_HEADER = false VALIDATIONS = [].freeze diff --git a/lib/cpflow.rb b/lib/cpflow.rb index 5325dd56..e4123fb3 100644 --- a/lib/cpflow.rb +++ b/lib/cpflow.rb @@ -115,7 +115,7 @@ def self.fix_help_option matches = help_mappings & ARGV # Help option works correctly for subcommands - return if matches && (ARGV & subcommand_names).any? + return if matches && subcommand? matches.each do |match| ARGV.delete(match) @@ -123,6 +123,11 @@ def self.fix_help_option end end + def self.subcommand? + (subcommand_names & ARGV).any? + end + private_class_method :subcommand? + # Needed to silence deprecation warning def self.exit_on_failure? true @@ -154,7 +159,7 @@ def self.all_base_commands end def self.subcommand_names - ::Command::Base.all_commands.values.map { |command| command::SUBCOMMAND }.compact + Dir["#{__dir__}/command/*"].filter_map { |name| File.basename(name) if File.directory?(name) } end def self.process_option_params(params) @@ -165,13 +170,13 @@ def self.process_option_params(params) params end - def self.klass_for(subcommand) - klass_name = subcommand.to_s.split("-").collect(&:capitalize).join + def self.klass_for(subcommand_name) + klass_name = subcommand_name.to_s.split("-").map(&:capitalize).join return Cpflow.const_get(klass_name) if Cpflow.const_defined?(klass_name) Cpflow.const_set(klass_name, Class.new(BaseSubCommand)).tap do |subcommand_klass| - desc subcommand, "#{subcommand.capitalize} commands" - subcommand subcommand, subcommand_klass + desc(subcommand_name, "#{subcommand_name.capitalize} commands") + subcommand(subcommand_name, subcommand_klass) end end private_class_method :klass_for @@ -188,6 +193,7 @@ def self.klass_for(subcommand) deprecated = deprecated_commands[command_key] name = command_class::NAME + subcommand_name = command_class::SUBCOMMAND_NAME name_for_method = deprecated ? command_key : name.tr("-", "_") usage = command_class::USAGE.empty? ? name : command_class::USAGE requires_args = command_class::REQUIRES_ARGS @@ -200,7 +206,6 @@ def self.klass_for(subcommand) hide = command_class::HIDE || deprecated with_info_header = command_class::WITH_INFO_HEADER validations = command_class::VALIDATIONS - subcommand = command_class::SUBCOMMAND long_description += "\n#{examples}" if examples.length.positive? @@ -214,7 +219,7 @@ def self.klass_for(subcommand) @commands_with_extra_options.push(name_for_method.to_sym) if accepts_extra_options - klass = subcommand ? klass_for(subcommand) : self + klass = subcommand_name ? klass_for(subcommand_name) : self klass.class_eval do desc(usage, description, hide: hide) diff --git a/script/update_command_docs b/script/update_command_docs index 74e7dea2..8ab78c34 100755 --- a/script/update_command_docs +++ b/script/update_command_docs @@ -12,12 +12,16 @@ commands.keys.sort.each do |command_key| next if command_class::HIDE name = command_class::NAME - usage = command_class::USAGE.empty? ? name : command_class::USAGE + subcommand_name = command_class::SUBCOMMAND_NAME + + full_command = [subcommand_name, name].compact.join(" ") + + usage = command_class::USAGE.empty? ? full_command : command_class::USAGE options = command_class::OPTIONS long_description = command_class::LONG_DESCRIPTION examples = command_class::EXAMPLES - command_str = "### `#{name}`\n\n" + command_str = "### `#{full_command}`\n\n" command_str += "#{long_description.strip}\n\n" if examples.empty? diff --git a/spec/cpflow_spec.rb b/spec/cpflow_spec.rb index de57206f..761117d5 100644 --- a/spec/cpflow_spec.rb +++ b/spec/cpflow_spec.rb @@ -21,17 +21,20 @@ end it "handles subcommands correctly" do - result = run_cpflow_command("help") + result = run_cpflow_command("--help") expect(result[:status]).to eq(0) + # Temporary solution, will be fixed with https://github.com/rails/thor/issues/742 + basename = Cpflow::Cli.send(:basename) + Cpflow::Cli.subcommand_names.each do |subcommand| - expect(result[:stdout]).to include("#{package_name} #{subcommand}") + expect(result[:stdout]).to include("#{basename} #{subcommand}") - subcommand_result = run_cpflow_command(subcommand, "help") + subcommand_result = run_cpflow_command(subcommand, "--help") expect(subcommand_result[:status]).to eq(0) - expect(subcommand_result[:stdout]).to include("#{package_name} #{subcommand} help [COMMAND]") + expect(subcommand_result[:stdout]).to include("#{basename} #{subcommand} help [COMMAND]") end end end diff --git a/spec/support/command_helpers.rb b/spec/support/command_helpers.rb index 95afc82f..6e275083 100644 --- a/spec/support/command_helpers.rb +++ b/spec/support/command_helpers.rb @@ -157,9 +157,6 @@ def create_app_if_not_exists(app, deploy: false, image_before_deploy_count: 0, i end def run_cpflow_command(*args, raise_errors: false) # rubocop:disable Metrics/MethodLength - program_name_before = $PROGRAM_NAME - $PROGRAM_NAME = package_name - LogHelpers.write_command_to_log(args.join(" ")) result = { @@ -185,8 +182,6 @@ def run_cpflow_command(*args, raise_errors: false) # rubocop:disable Metrics/Met raise result.to_json if result[:status].nonzero? && raise_errors result - ensure - $PROGRAM_NAME = program_name_before end def run_cpflow_command!(*args) @@ -262,8 +257,4 @@ def spec_directory File.dirname(current_directory) end - - def package_name - @package_name ||= Cpflow::Cli.instance_variable_get("@package_name") - end end