diff --git a/lib/command/base.rb b/lib/command/base.rb index 38dedff1..a7c788d6 100644 --- a/lib/command/base.rb +++ b/lib/command/base.rb @@ -454,6 +454,19 @@ def self.add_app_identity_option(required: false) } } end + + def self.dir_option(required: false) + { + name: :dir, + params: { + banner: "DIR", + desc: "Output directory", + type: :string, + required: required + } + } + end + # rubocop:enable Metrics/MethodLength def self.all_options diff --git a/lib/command/terraform/generate.rb b/lib/command/terraform/generate.rb index 1ee8be21..932f84aa 100644 --- a/lib/command/terraform/generate.rb +++ b/lib/command/terraform/generate.rb @@ -5,26 +5,89 @@ module Terraform class Generate < Base SUBCOMMAND_NAME = "terraform" NAME = "generate" + OPTIONS = [ + app_option, + dir_option + ].freeze DESCRIPTION = "Generates terraform configuration files" LONG_DESCRIPTION = <<~DESC - Generates terraform configuration files based on `controlplane.yml` and `templates/` config DESC WITH_INFO_HEADER = false - VALIDATIONS = [].freeze def call - File.write(terraform_dir.join("providers.tf"), cpln_provider.to_tf) + generate_common_configs + generate_app_configs end private - def cpln_provider - TerraformConfig::RequiredProvider.new("cpln", source: "controlplane-com/cpln", version: "~> 1.0") + def generate_common_configs + cpln_provider = TerraformConfig::RequiredProvider.new( + "cpln", + source: "controlplane-com/cpln", + version: "~> 1.0" + ) + + File.write(terraform_dir.join("providers.tf"), cpln_provider.to_tf) + end + + def generate_app_configs + Array(config.app || config.apps.keys).each do |app| + config.instance_variable_set(:@app, app) + generate_app_config + end + end + + def generate_app_config + terraform_app_dir = recreate_terraform_app_dir + + templates.each do |template| + generator = TerraformConfig::Generator.new(config: config, template: template) + + # TODO: Delete line below after all template kinds are supported + next unless %w[gvc identity].include?(template["kind"]) + + File.write(terraform_app_dir.join(generator.filename), generator.tf_config.to_tf, mode: "a+") + end + end + + def recreate_terraform_app_dir + full_path = terraform_dir.join(config.app) + + unless File.expand_path(full_path).include?(Cpflow.root_path.to_s) + Shell.abort("Directory to save terraform configuration files cannot be outside of current directory") + end + + FileUtils.rm_rf(full_path) + FileUtils.mkdir_p(full_path) + + full_path + end + + def templates + parser = TemplateParser.new(self) + template_files = Dir["#{parser.template_dir}/*.yml"] + + if template_files.empty? + Shell.warn("No templates found in #{parser.template_dir}") + return [] + end + + parser.parse(template_files) + rescue StandardError => e + Shell.warn("Error parsing templates: #{e.message}") + [] end def terraform_dir - @terraform_dir ||= Cpflow.root_path.join("terraform").tap do |path| - FileUtils.mkdir_p(path) + @terraform_dir ||= begin + full_path = config.options.fetch(:dir, Cpflow.root_path.join("terraform")) + Pathname.new(full_path).tap do |path| + FileUtils.mkdir_p(path) + rescue StandardError => e + Shell.abort("Invalid directory: #{e.message}") + end end end end diff --git a/lib/core/terraform_config/dsl.rb b/lib/core/terraform_config/dsl.rb index 68b9baac..3f231628 100644 --- a/lib/core/terraform_config/dsl.rb +++ b/lib/core/terraform_config/dsl.rb @@ -4,6 +4,8 @@ module TerraformConfig module Dsl extend Forwardable + REFERENCE_PATTERN = /^(var|locals|cpln_\w+)\./.freeze + def_delegators :current_context, :put, :output def block(name, *labels) @@ -44,7 +46,7 @@ def tf_value(value) end def expression?(value) - value.start_with?("var.") || value.start_with?("locals.") + value.match?(REFERENCE_PATTERN) end def block_declaration(name, labels) @@ -62,7 +64,7 @@ def initialize end def put(content, indent: 0) - @output += content.indent(indent) + @output += content.to_s.indent(indent) end end diff --git a/lib/core/terraform_config/generator.rb b/lib/core/terraform_config/generator.rb new file mode 100644 index 00000000..cc861f1e --- /dev/null +++ b/lib/core/terraform_config/generator.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module TerraformConfig + class Generator + attr_reader :config, :template + + def initialize(config:, template:) + @config = config + @template = template + end + + def filename + case template["kind"] + when "gvc" + "gvc.tf" + when "identity" + "identities.tf" + else + raise "Unsupported template kind - #{template['kind']}" + end + end + + def tf_config + case template["kind"] + when "gvc" + gvc_config + when "identity" + identity_config + else + raise "Unsupported template kind - #{template['kind']}" + end + end + + private + + def gvc_config # rubocop:disable Metrics/MethodLength + pull_secrets = template.dig("spec", "pullSecretLinks")&.map do |secret_link| + secret_name = secret_link.split("/").last + "cpln_secret.#{secret_name}.name" + end + + load_balancer = template.dig("spec", "loadBalancer") + + TerraformConfig::Gvc.new( + name: template["name"], + description: template["description"], + tags: template["tags"], + domain: template.dig("spec", "domain"), + env: env, + pull_secrets: pull_secrets, + locations: locations, + load_balancer: load_balancer + ) + end + + def identity_config + TerraformConfig::Identity.new( + gvc: "cpln_gvc.#{config.app}.name", # GVC name matches application name + name: template["name"], + description: template["description"], + tags: template["tags"] + ) + end + + def env + template.dig("spec", "env").to_h { |env_var| [env_var["name"], env_var["value"]] } + end + + def locations + template.dig("spec", "staticPlacement", "locationLinks")&.map do |location_link| + location_link.split("/").last + end + end + end +end diff --git a/lib/core/terraform_config/gvc.rb b/lib/core/terraform_config/gvc.rb new file mode 100644 index 00000000..db14becd --- /dev/null +++ b/lib/core/terraform_config/gvc.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module TerraformConfig + class Gvc < Base + attr_reader :name, :description, :tags, :domain, :locations, :pull_secrets, :env, :load_balancer + + def initialize( # rubocop:disable Metrics/ParameterLists + name:, + description: nil, + tags: nil, + domain: nil, + locations: nil, + pull_secrets: nil, + env: nil, + load_balancer: nil + ) + super() + + @name = name + @description = description + @tags = tags + @domain = domain + @locations = locations + @pull_secrets = pull_secrets + @env = env + @load_balancer = load_balancer&.transform_keys { |k| k.to_s.underscore.to_sym } + end + + def to_tf + block :resource, :cpln_gvc, name do + argument :name, name + argument :description, description, optional: true + argument :tags, tags, optional: true + + argument :domain, domain, optional: true + argument :locations, locations, optional: true + argument :pull_secrets, pull_secrets, optional: true + argument :env, env, optional: true + + load_balancer_tf + end + end + + private + + def load_balancer_tf + return if load_balancer.nil? + + block :load_balancer do + argument :dedicated, load_balancer.fetch(:dedicated) + argument :trusted_proxies, load_balancer.fetch(:trusted_proxies, nil), optional: true + end + end + end +end diff --git a/lib/core/terraform_config/identity.rb b/lib/core/terraform_config/identity.rb new file mode 100644 index 00000000..a211ff46 --- /dev/null +++ b/lib/core/terraform_config/identity.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module TerraformConfig + class Identity < Base + attr_reader :gvc, :name, :description, :tags + + def initialize(gvc:, name:, description: nil, tags: nil) + super() + + @gvc = gvc + @name = name + @description = description + @tags = tags + end + + def to_tf + block :resource, :cpln_identity, name do + argument :gvc, gvc + + argument :name, name + argument :description, description, optional: true + + argument :tags, tags, optional: true + end + end + end +end diff --git a/lib/cpflow.rb b/lib/cpflow.rb index 5ac95cfb..4b352784 100644 --- a/lib/cpflow.rb +++ b/lib/cpflow.rb @@ -235,7 +235,7 @@ def self.klass_for(subcommand_name) long_desc(long_description) command_options.each do |option| - params = process_option_params(option[:params]) + params = Cpflow::Cli.process_option_params(option[:params]) method_option(option[:name], **params) end end diff --git a/lib/patches/string.rb b/lib/patches/string.rb index 53b12e99..15508cb3 100644 --- a/lib/patches/string.rb +++ b/lib/patches/string.rb @@ -17,5 +17,9 @@ def indent!(amount, indent_string = nil, indent_empty_lines = false) def unindent gsub(/^#{scan(/^[ \t]+(?=\S)/).min}/, "") end + + def underscore + gsub(/(.)([A-Z])/, '\1_\2').downcase + end end # rubocop:enable Style/OptionalBooleanParameter, Lint/UnderscorePrefixedVariableName diff --git a/spec/command/terraform/generate_spec.rb b/spec/command/terraform/generate_spec.rb index 5c163ef2..2ca2005f 100644 --- a/spec/command/terraform/generate_spec.rb +++ b/spec/command/terraform/generate_spec.rb @@ -9,22 +9,94 @@ TERRAFORM_CONFIG_DIR_PATH = GENERATOR_PLAYGROUND_PATH.join("terraform") describe Command::Terraform::Generate do - before do - FileUtils.rm_r(GENERATOR_PLAYGROUND_PATH) if Dir.exist?(GENERATOR_PLAYGROUND_PATH) - FileUtils.mkdir_p GENERATOR_PLAYGROUND_PATH + subject(:result) { run_cpflow_command(described_class::SUBCOMMAND_NAME, described_class::NAME, *options) } + + let!(:app) { dummy_test_app } + let(:options) { ["-a", app] } + before do + FileUtils.rm_rf(GENERATOR_PLAYGROUND_PATH) + FileUtils.mkdir_p(GENERATOR_PLAYGROUND_PATH) allow(Cpflow).to receive(:root_path).and_return(GENERATOR_PLAYGROUND_PATH) end after do - FileUtils.rm_r GENERATOR_PLAYGROUND_PATH + FileUtils.rm_rf GENERATOR_PLAYGROUND_PATH + end + + it "generates terraform config files", :aggregate_failures do + config_file_paths.each { |config_file_path| expect(config_file_path).not_to exist } + + expect(result[:status]).to eq(0) + + expect(config_file_paths).to all(exist) end - it "generates terraform config files" do - providers_config_file_path = TERRAFORM_CONFIG_DIR_PATH.join("providers.tf") + context "when templates folder is empty" do + let(:template_dir) { "non-existing-folder" } + + before do + allow_any_instance_of(TemplateParser).to receive(:template_dir).and_return(template_dir) # rubocop:disable RSpec/AnyInstance + end + + it "generates only common config files" do + config_file_paths.each { |config_file_path| expect(config_file_path).not_to exist } + + expect(result[:stderr]).to include("No templates found in #{template_dir}") + + expect(common_config_files).to all(exist) + app_config_files.each { |config_file_path| expect(config_file_path).not_to exist } + end + end + + context "when template parsing fails" do + before do + allow_any_instance_of(TemplateParser).to receive(:parse).and_raise("error") # rubocop:disable RSpec/AnyInstance + end + + it "generates only common config files" do + config_file_paths.each { |config_file_path| expect(config_file_path).not_to exist } + + expect(result[:stderr]).to include("Error parsing templates: error") + + expect(common_config_files).to all(exist) + app_config_files.each { |config_file_path| expect(config_file_path).not_to exist } + end + end + + context "when --dir option is outside of project dir" do + let(:options) { ["-a", app, "--dir", GEM_TEMP_PATH.join("path-outside-of-project").to_s] } + + it "aborts command execution" do + expect(result[:status]).to eq(ExitCode::ERROR_DEFAULT) + expect(result[:stderr]).to include( + "Directory to save terraform configuration files cannot be outside of current directory" + ) + end + end + + context "when terraform config directory creation fails" do + before do + allow(FileUtils).to receive(:mkdir_p).and_raise("error") + end + + it "aborts command execution" do + expect(result[:status]).to eq(ExitCode::ERROR_DEFAULT) + expect(result[:stderr]).to include("error") + end + end + + def config_file_paths + common_config_files + app_config_files + end + + def common_config_files + [TERRAFORM_CONFIG_DIR_PATH.join("providers.tf")] + end - expect(providers_config_file_path).not_to exist - run_cpflow_command(described_class::SUBCOMMAND_NAME, described_class::NAME) - expect(providers_config_file_path).to exist + def app_config_files + %w[gvc.tf identities.tf].map do |config_file_path| + TERRAFORM_CONFIG_DIR_PATH.join(app, config_file_path) + end end end diff --git a/spec/core/terraform_config/generator_spec.rb b/spec/core/terraform_config/generator_spec.rb new file mode 100644 index 00000000..9a57dc8c --- /dev/null +++ b/spec/core/terraform_config/generator_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe TerraformConfig::Generator do + let(:generator) { described_class.new(config: config, template: template) } + + let(:config) { instance_double(Config, org: "org-name", app: "app-name") } + + context "when template's kind is gvc" do + let(:template) do + { + "kind" => "gvc", + "name" => config.app, + "description" => "description", + "tags" => { "tag1" => "tag1_value", "tag2" => "tag2_value" }, + "spec" => { + "domain" => "app.example.com", + "env" => [ + { + "name" => "DATABASE_URL", + "value" => "postgres://the_user:the_password@postgres.#{config.app}.cpln.local:5432/#{config.app}" + }, + { + "name" => "RAILS_ENV", + "value" => "production" + }, + { + "name" => "RAILS_SERVE_STATIC_FILES", + "value" => "true" + } + ], + "staticPlacement" => { + "locationLinks" => ["/org/#{config.org}/location/aws-us-east-2"] + }, + "pullSecretLinks" => ["/org/#{config.org}/secret/some-secret"], + "loadBalancer" => { + "dedicated" => true, + "trustedProxies" => 1 + } + } + } + end + + it "generates correct terraform config and filename for it", :aggregate_failures do + tf_config = generator.tf_config + expect(tf_config).to be_an_instance_of(TerraformConfig::Gvc) + + expect(tf_config.name).to eq(config.app) + expect(tf_config.description).to eq("description") + expect(tf_config.tags).to eq("tag1" => "tag1_value", "tag2" => "tag2_value") + + expect(tf_config.domain).to eq("app.example.com") + expect(tf_config.locations).to eq(["aws-us-east-2"]) + expect(tf_config.pull_secrets).to eq(["cpln_secret.some-secret.name"]) + expect(tf_config.env).to eq( + { + "DATABASE_URL" => "postgres://the_user:the_password@postgres.#{config.app}.cpln.local:5432/#{config.app}", + "RAILS_ENV" => "production", + "RAILS_SERVE_STATIC_FILES" => "true" + } + ) + expect(tf_config.load_balancer).to eq({ dedicated: true, trusted_proxies: 1 }) + + tf_filename = generator.filename + expect(tf_filename).to eq("gvc.tf") + end + end + + context "when template's kind is identity" do + let(:template) do + { + "kind" => "identity", + "name" => "identity-name", + "description" => "description", + "tags" => { "tag1" => "tag1_value", "tag2" => "tag2_value" } + } + end + + it "generates correct terraform config and filename for it", :aggregate_failures do + tf_config = generator.tf_config + expect(tf_config).to be_an_instance_of(TerraformConfig::Identity) + + expect(tf_config.name).to eq("identity-name") + expect(tf_config.description).to eq("description") + expect(tf_config.tags).to eq("tag1" => "tag1_value", "tag2" => "tag2_value") + + tf_filename = generator.filename + expect(tf_filename).to eq("identities.tf") + end + end +end diff --git a/spec/core/terraform_config/gvc_spec.rb b/spec/core/terraform_config/gvc_spec.rb new file mode 100644 index 00000000..2050914d --- /dev/null +++ b/spec/core/terraform_config/gvc_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe TerraformConfig::Gvc do + let(:config) { described_class.new(**options) } + + describe "#to_tf" do + subject(:generated) { config.to_tf } + + context "with required and optional args" do + let(:options) do + { + name: "gvc-name", + description: "gvc description", + domain: "app.example.com", + env: { "var1" => "value", "var2" => 1 }, + tags: { "tag1" => "tag_value", "tag2" => true }, + locations: %w[aws-us-east-1 aws-us-east-2], + pull_secrets: ["cpln_secret.docker.name"], + load_balancer: { "dedicated" => true, "trusted_proxies" => 1 } + } + end + + it "generates correct config" do + expect(generated).to eq( + <<~EXPECTED + resource "cpln_gvc" "gvc-name" { + name = "gvc-name" + description = "gvc description" + tags = { + tag1 = "tag_value" + tag2 = true + } + domain = "app.example.com" + locations = ["aws-us-east-1", "aws-us-east-2"] + pull_secrets = ["cpln_secret.docker.name"] + env = { + var1 = "value" + var2 = 1 + } + load_balancer { + dedicated = true + trusted_proxies = 1 + } + } + EXPECTED + ) + end + end + end +end diff --git a/spec/core/terraform_config/identity_spec.rb b/spec/core/terraform_config/identity_spec.rb new file mode 100644 index 00000000..ca39eeac --- /dev/null +++ b/spec/core/terraform_config/identity_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe TerraformConfig::Identity do + let(:config) { described_class.new(**options) } + + describe "#to_tf" do + subject(:generated) { config.to_tf } + + let(:options) do + { + gvc: "cpln_gvc.some-gvc.name", + name: "identity-name", + description: "identity description", + tags: { "tag1" => "true", "tag2" => "false" } + } + end + + it "generates correct config" do + expect(generated).to eq( + <<~EXPECTED + resource "cpln_identity" "identity-name" { + gvc = cpln_gvc.some-gvc.name + name = "identity-name" + description = "identity description" + tags = { + tag1 = "true" + tag2 = "false" + } + } + EXPECTED + ) + end + end +end