Skip to content

Commit

Permalink
Introduce terraform subcommand
Browse files Browse the repository at this point in the history
  • Loading branch information
zzaakiirr committed Jul 30, 2024
1 parent 66e532b commit 0c4d2a6
Show file tree
Hide file tree
Showing 7 changed files with 121 additions and 14 deletions.
8 changes: 8 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 16 additions & 5 deletions lib/command/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions lib/command/base_sub_command.rb
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions lib/command/terraform/generate.rb
Original file line number Diff line number Diff line change
@@ -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
42 changes: 33 additions & 9 deletions lib/cpflow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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 = []

Expand All @@ -181,28 +200,33 @@ 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?

# `handle_argument_error` does not exist in the context below,
# 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, " \
Expand Down
15 changes: 15 additions & 0 deletions spec/cpflow_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions spec/support/command_helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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)
Expand Down Expand Up @@ -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

0 comments on commit 0c4d2a6

Please sign in to comment.