From 32e87903c58e52e81f0bbbde4b6ba430f2a68ffc Mon Sep 17 00:00:00 2001 From: Zakir Dzhamaliddinov Date: Fri, 30 Aug 2024 21:49:53 +1200 Subject: [PATCH] Generate Terraform config from gvc & identity templates --- docs/commands.md | 2 +- lib/command/terraform/generate.rb | 19 +++- lib/core/terraform_config/dsl.rb | 6 +- lib/core/terraform_config/generator.rb | 77 ++++++++++++++++ lib/core/terraform_config/gvc.rb | 57 ++++++++++++ lib/core/terraform_config/identity.rb | 27 ++++++ lib/cpflow.rb | 2 +- lib/patches/string.rb | 4 + spec/command/terraform/generate_spec.rb | 14 +-- spec/core/terraform_config/generator_spec.rb | 92 ++++++++++++++++++++ spec/core/terraform_config/gvc_spec.rb | 52 +++++++++++ spec/core/terraform_config/identity_spec.rb | 36 ++++++++ 12 files changed, 378 insertions(+), 10 deletions(-) create mode 100644 lib/core/terraform_config/generator.rb create mode 100644 lib/core/terraform_config/gvc.rb create mode 100644 lib/core/terraform_config/identity.rb create mode 100644 spec/core/terraform_config/generator_spec.rb create mode 100644 spec/core/terraform_config/gvc_spec.rb create mode 100644 spec/core/terraform_config/identity_spec.rb diff --git a/docs/commands.md b/docs/commands.md index 14fa758a..c6a1ac75 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -449,7 +449,7 @@ cpflow setup-app -a $APP_NAME - Generates terraform configuration files based on `controlplane.yml` and `templates/` config ```sh -cpflow terraform generate +cpflow terraform generate -a $APP_NAME ``` ### `version` diff --git a/lib/command/terraform/generate.rb b/lib/command/terraform/generate.rb index 1ee8be21..164d665f 100644 --- a/lib/command/terraform/generate.rb +++ b/lib/command/terraform/generate.rb @@ -5,15 +5,26 @@ module Terraform class Generate < Base SUBCOMMAND_NAME = "terraform" NAME = "generate" + OPTIONS = [ + app_option(required: true) + ].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) + + 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_dir.join(generator.filename), generator.tf_config.to_tf, mode: "a+") + end end private @@ -22,8 +33,14 @@ def cpln_provider TerraformConfig::RequiredProvider.new("cpln", source: "controlplane-com/cpln", version: "~> 1.0") end + def templates + parser = TemplateParser.new(self) + parser.parse(Dir["#{parser.template_dir}/*.yml"]) + end + def terraform_dir @terraform_dir ||= Cpflow.root_path.join("terraform").tap do |path| + FileUtils.rm_rf(path) FileUtils.mkdir_p(path) 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..84264959 --- /dev/null +++ b/lib/core/terraform_config/generator.rb @@ -0,0 +1,77 @@ +# 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 + + # rubocop:disable Metrics/MethodLength + def gvc_config + 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 + # rubocop:enable Metrics/MethodLength + + 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..401e2791 --- /dev/null +++ b/lib/core/terraform_config/gvc.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module TerraformConfig + class Gvc < Base + attr_reader :name, :description, :tags, :domain, :locations, :pull_secrets, :env, :load_balancer + + # rubocop:disable Metrics/ParameterLists + def initialize( + 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 + # rubocop:enable Metrics/ParameterLists + + 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..4c42dff9 100644 --- a/spec/command/terraform/generate_spec.rb +++ b/spec/command/terraform/generate_spec.rb @@ -9,6 +9,8 @@ TERRAFORM_CONFIG_DIR_PATH = GENERATOR_PLAYGROUND_PATH.join("terraform") describe Command::Terraform::Generate do + let!(:app) { dummy_test_app } + before do FileUtils.rm_r(GENERATOR_PLAYGROUND_PATH) if Dir.exist?(GENERATOR_PLAYGROUND_PATH) FileUtils.mkdir_p GENERATOR_PLAYGROUND_PATH @@ -20,11 +22,13 @@ FileUtils.rm_r GENERATOR_PLAYGROUND_PATH end - it "generates terraform config files" do - providers_config_file_path = TERRAFORM_CONFIG_DIR_PATH.join("providers.tf") + it "generates terraform config files", :aggregate_failures do + config_file_paths = %w[providers.tf gvc.tf identities.tf].map do |config_file_path| + TERRAFORM_CONFIG_DIR_PATH.join(config_file_path) + 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 + config_file_paths.each { |config_file_path| expect(config_file_path).not_to exist } + run_cpflow_command(described_class::SUBCOMMAND_NAME, described_class::NAME, "-a", app) + expect(config_file_paths).to all(exist) 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