From 7b952ddda5f2d6e1e8c9ffc484276374680ce6ba Mon Sep 17 00:00:00 2001 From: Zakir Dzhamaliddinov Date: Wed, 16 Oct 2024 17:22:21 +0300 Subject: [PATCH] Generate Terraform config from volumeset templates --- lib/command/terraform/generate.rb | 7 +- lib/core/terraform_config/generator.rb | 118 +++++----- lib/core/terraform_config/volume_set.rb | 133 ++++++++++++ lib/patches/string.rb | 14 ++ spec/command/terraform/generate_spec.rb | 2 +- spec/core/terraform_config/generator_spec.rb | 60 +++++- spec/core/terraform_config/gvc_spec.rb | 2 +- spec/core/terraform_config/volume_set_spec.rb | 201 ++++++++++++++++++ spec/{pathces => patches}/hash_spec.rb | 0 spec/patches/string_spec.rb | 59 +++++ 10 files changed, 529 insertions(+), 67 deletions(-) create mode 100644 lib/core/terraform_config/volume_set.rb create mode 100644 spec/core/terraform_config/volume_set_spec.rb rename spec/{pathces => patches}/hash_spec.rb (100%) create mode 100644 spec/patches/string_spec.rb diff --git a/lib/command/terraform/generate.rb b/lib/command/terraform/generate.rb index 21ebbac8..6e0a4fa6 100644 --- a/lib/command/terraform/generate.rb +++ b/lib/command/terraform/generate.rb @@ -44,11 +44,10 @@ def generate_app_config(app) terraform_app_dir = recreate_terraform_app_dir(app) 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 secret policy].include?(template["kind"]) + # TODO: Raise error i/o ignoring invalid template kind after all template kinds are supported + next unless TerraformConfig::Generator::SUPPORTED_TEMPLATE_KINDS.include?(template["kind"]) + generator = TerraformConfig::Generator.new(config: config, template: template) File.write(terraform_app_dir.join(generator.filename), generator.tf_config.to_tf, mode: "a+") end end diff --git a/lib/core/terraform_config/generator.rb b/lib/core/terraform_config/generator.rb index b3212c04..44ef1fb4 100644 --- a/lib/core/terraform_config/generator.rb +++ b/lib/core/terraform_config/generator.rb @@ -2,85 +2,91 @@ module TerraformConfig class Generator + SUPPORTED_TEMPLATE_KINDS = %w[gvc secret identity policy volumeset].freeze + attr_reader :config, :template def initialize(config:, template:) @config = config @template = template.deep_underscore_keys.deep_symbolize_keys + validate_template_kind! end - # rubocop:disable Metrics/MethodLength def filename - case kind - when "gvc" - "gvc.tf" - when "secret" - "secrets.tf" - when "identity" - "identities.tf" - when "policy" - "policies.tf" - else - raise "Unsupported template kind - #{kind}" - end + "#{kind.pluralize}.tf" end - # rubocop:enable Metrics/MethodLength def tf_config - method_name = :"#{kind}_config" - raise "Unsupported template kind - #{kind}" unless self.class.private_method_defined?(method_name) - - send(method_name) + config_class.new(**config_params) end private - def kind - @kind ||= template[:kind] + def validate_template_kind! + return if SUPPORTED_TEMPLATE_KINDS.include?(kind) + + raise ArgumentError, "Unsupported template kind: #{kind}" end - # rubocop:disable Metrics/MethodLength - def gvc_config - TerraformConfig::Gvc.new( - **template - .slice(:name, :description, :tags) - .merge( - env: gvc_env, - pull_secrets: gvc_pull_secrets, - locations: gvc_locations, - domain: template.dig(:spec, :domain), - load_balancer: template.dig(:spec, :load_balancer) - ) - ) + def config_class + return TerraformConfig.const_get(kind.capitalize) if TerraformConfig.const_defined?(kind.capitalize) + return TerraformConfig::VolumeSet if kind == "volumeset" + + raise "Unsupported template kind: #{kind}" + end + + def config_params + send("#{kind}_config_params") end - # rubocop:enable Metrics/MethodLength - def identity_config - TerraformConfig::Identity.new(**template.slice(:name, :description, :tags).merge(gvc: gvc)) + def gvc_config_params + template + .slice(:name, :description, :tags) + .merge( + env: gvc_env, + pull_secrets: gvc_pull_secrets, + locations: gvc_locations, + domain: template.dig(:spec, :domain), + load_balancer: template.dig(:spec, :load_balancer) + ) end - def secret_config - TerraformConfig::Secret.new(**template.slice(:name, :description, :type, :data, :tags)) + def identity_config_params + template.slice(:name, :description, :tags).merge(gvc: gvc) end - def policy_config - TerraformConfig::Policy.new( - **template - .slice(:name, :description, :tags, :target, :target_kind, :target_query) - .merge(gvc: gvc, target_links: policy_target_links, bindings: policy_bindings) - ) + def secret_config_params + template.slice(:name, :description, :type, :data, :tags) end - # GVC name matches application name + def policy_config_params + template + .slice(:name, :description, :tags, :target, :target_kind, :target_query) + .merge(gvc: gvc, target_links: policy_target_links, bindings: policy_bindings) + end + + # rubocop:disable Metrics/MethodLength + def volumeset_config_params + template + .slice(:name, :description, :tags) + .merge( + gvc: gvc, + initial_capacity: template.dig(:spec, :initial_capacity), + performance_class: template.dig(:spec, :performance_class), + file_system_type: template.dig(:spec, :file_system_type), + storage_class_suffix: template.dig(:spec, :storage_class_suffix), + snapshots: template.dig(:spec, :snapshots), + autoscaling: template.dig(:spec, :autoscaling) + ) + end + # rubocop:enable Metrics/MethodLength + def gvc "cpln_gvc.#{config.app}.name" end def gvc_pull_secrets - template.dig(:spec, :pull_secret_links)&.map do |secret_link| - secret_name = secret_link.split("/").last - "cpln_secret.#{secret_name}.name" - end + template.dig(:spec, :pull_secret_links)&.map { |secret_link| "cpln_secret.#{secret_link.split('/').last}.name" } end def gvc_env @@ -88,24 +94,22 @@ def gvc_env end def gvc_locations - template.dig(:spec, :static_placement, :location_links)&.map do |location_link| - location_link.split("/").last - end + template.dig(:spec, :static_placement, :location_links)&.map { |location_link| location_link.split("/").last } end - # //secret/secret-name -> secret-name def policy_target_links - template[:target_links]&.map do |target_link| - target_link.split("/").last - end + template[:target_links]&.map { |target_link| target_link.split("/").last } end - # //group/viewers -> group/viewers def policy_bindings template[:bindings]&.map do |data| principal_links = data.delete(:principal_links)&.map { |link| link.delete_prefix("//") } data.merge(principal_links: principal_links) end end + + def kind + @kind ||= template[:kind] + end end end diff --git a/lib/core/terraform_config/volume_set.rb b/lib/core/terraform_config/volume_set.rb new file mode 100644 index 00000000..3fed0a4c --- /dev/null +++ b/lib/core/terraform_config/volume_set.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module TerraformConfig + class VolumeSet < Base + PERFORMANCE_CLASSES = %w[general-purpose-ssd high-throughput-ssd].freeze + FILE_SYSTEM_TYPES = %w[xfs ext4].freeze + MIN_CAPACITY = 10 + MIN_SCALING_FACTOR = 1.1 + + attr_reader :gvc, :name, :initial_capacity, :performance_class, :file_system_type, + :storage_class_suffix, :description, :tags, :snapshots, :autoscaling + + # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength + def initialize( + gvc:, name:, initial_capacity:, performance_class:, file_system_type:, + storage_class_suffix: nil, description: nil, tags: nil, snapshots: nil, autoscaling: nil + ) + super() + + @gvc = gvc + @name = name + @initial_capacity = initial_capacity + @performance_class = performance_class + @file_system_type = file_system_type + @storage_class_suffix = storage_class_suffix + @description = description + @tags = tags + @snapshots = snapshots + @autoscaling = autoscaling + + validate_attributes! + end + # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength + + def to_tf + block :resource, :cpln_volume_set, name do + base_arguments_tf + snapshots_tf + autoscaling_tf + end + end + + private + + def validate_attributes! + validate_initial_capacity! + validate_performance_class! + validate_file_system_type! + validate_autoscaling! if autoscaling + end + + def validate_initial_capacity! + return unless initial_capacity < MIN_CAPACITY + + raise ArgumentError, + "Initial capacity should be greater than or equal to #{MIN_CAPACITY}" + end + + def validate_performance_class! + return if PERFORMANCE_CLASSES.include?(performance_class.to_s) + + raise ArgumentError, + "Invalid performance class: #{performance_class}. Choose from #{PERFORMANCE_CLASSES.join(', ')}" + end + + def validate_file_system_type! + return if FILE_SYSTEM_TYPES.include?(file_system_type) + + raise ArgumentError, + "Invalid file system type: #{file_system_type}. Choose from #{FILE_SYSTEM_TYPES.join(', ')}" + end + + def validate_autoscaling! + validate_max_capacity! + validate_min_free_percentage! + validate_scaling_factor! + end + + def validate_max_capacity! + max_capacity = autoscaling.fetch(:max_capacity, nil) + return if max_capacity.nil? || max_capacity >= MIN_CAPACITY + + raise ArgumentError, "autoscaling.max_capacity should be >= #{MIN_CAPACITY}" + end + + def validate_min_free_percentage! + min_free_percentage = autoscaling.fetch(:min_free_percentage, nil) + return if min_free_percentage.nil? || min_free_percentage.between?(1, 100) + + raise ArgumentError, "autoscaling.min_free_percentage should be between 1 and 100" + end + + def validate_scaling_factor! + scaling_factor = autoscaling.fetch(:scaling_factor, nil) + return if scaling_factor.nil? || scaling_factor >= MIN_SCALING_FACTOR + + raise ArgumentError, "autoscaling.scaling_factor should be >= #{MIN_SCALING_FACTOR}" + end + + def base_arguments_tf + argument :gvc, gvc + + argument :name, name + argument :description, description, optional: true + argument :tags, tags, optional: true + + argument :initial_capacity, initial_capacity + argument :performance_class, performance_class + argument :storage_class_suffix, storage_class_suffix, optional: true + argument :file_system_type, file_system_type + end + + def snapshots_tf + return if snapshots.nil? + + block :snapshots do + %i[create_final_snapshot retention_duration schedule].each do |arg_name| + argument arg_name, snapshots.fetch(arg_name, nil), optional: true + end + end + end + + def autoscaling_tf + return if autoscaling.nil? + + block :autoscaling do + %i[max_capacity min_free_percentage scaling_factor].each do |arg_name| + argument arg_name, autoscaling.fetch(arg_name, nil), optional: true + end + end + end + end +end diff --git a/lib/patches/string.rb b/lib/patches/string.rb index bfc35d62..4f3d8a07 100644 --- a/lib/patches/string.rb +++ b/lib/patches/string.rb @@ -22,5 +22,19 @@ def unindent def underscore gsub("::", "/").gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase end + + def pluralize + return self if empty? + + if end_with?("ies") + self + elsif end_with?("s", "x", "z", "ch", "sh") + end_with?("es") ? self : "#{self}es" + elsif end_with?("y") + "#{self[...-1]}ies" + else + end_with?("s") ? self : "#{self}s" + end + 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 917eab18..316e7bc6 100644 --- a/spec/command/terraform/generate_spec.rb +++ b/spec/command/terraform/generate_spec.rb @@ -99,7 +99,7 @@ def common_config_files end def app_config_files - %w[gvc.tf identities.tf secrets.tf policies.tf].map do |config_file_path| + %w[gvcs.tf identities.tf secrets.tf policies.tf volumesets.tf].map do |config_file_path| TERRAFORM_CONFIG_DIR_PATH.join(app, config_file_path) end end diff --git a/spec/core/terraform_config/generator_spec.rb b/spec/core/terraform_config/generator_spec.rb index 8034da97..8af724b1 100644 --- a/spec/core/terraform_config/generator_spec.rb +++ b/spec/core/terraform_config/generator_spec.rb @@ -10,9 +10,8 @@ context "when template's kind is unsupported" do let(:template) { { "kind" => "invalid" } } - it "does not generate terraform config or filename for it", :aggregate_failures do - expect { generator.tf_config }.to raise_error("Unsupported template kind - #{template['kind']}") - expect { generator.filename }.to raise_error("Unsupported template kind - #{template['kind']}") + it "raises an error when unsupported template kind is used", :aggregate_failures do + expect { generator }.to raise_error(ArgumentError, "Unsupported template kind: #{template['kind']}") end end @@ -72,7 +71,7 @@ expect(tf_config.load_balancer).to eq({ dedicated: true, trusted_proxies: 1 }) tf_filename = generator.filename - expect(tf_filename).to eq("gvc.tf") + expect(tf_filename).to eq("gvcs.tf") end end @@ -175,4 +174,57 @@ expect(tf_filename).to eq("policies.tf") end end + + context "when template's kind is volumeset" do + let(:template) do + { + "kind" => "volumeset", + "name" => "volume-set-name", + "description" => "volume set description", + "tags" => { "tag1" => "tag1_value", "tag2" => "tag2_value" }, + "spec" => { + "initialCapacity" => 20, + "performanceClass" => "general-purpose-ssd", + "fileSystemType" => "xfs", + "storageClassSuffix" => "suffix", + "snapshots" => { + "createFinalSnapshot" => true, + "retentionDuration" => "7d", + "schedule" => "0 1 * * *" + }, + "autoscaling" => { + "maxCapacity" => 100, + "minFreePercentage" => 20, + "scalingFactor" => 1.5 + } + } + } + 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::VolumeSet) + + expect(tf_config.name).to eq("volume-set-name") + expect(tf_config.description).to eq("volume set description") + expect(tf_config.tags).to eq(tag1: "tag1_value", tag2: "tag2_value") + expect(tf_config.initial_capacity).to eq(20) + expect(tf_config.performance_class).to eq("general-purpose-ssd") + expect(tf_config.file_system_type).to eq("xfs") + expect(tf_config.storage_class_suffix).to eq("suffix") + expect(tf_config.snapshots).to eq( + create_final_snapshot: true, + retention_duration: "7d", + schedule: "0 1 * * *" + ) + expect(tf_config.autoscaling).to eq( + max_capacity: 100, + min_free_percentage: 20, + scaling_factor: 1.5 + ) + + tf_filename = generator.filename + expect(tf_filename).to eq("volumesets.tf") + end + end end diff --git a/spec/core/terraform_config/gvc_spec.rb b/spec/core/terraform_config/gvc_spec.rb index 2050914d..fd12b77e 100644 --- a/spec/core/terraform_config/gvc_spec.rb +++ b/spec/core/terraform_config/gvc_spec.rb @@ -18,7 +18,7 @@ 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 } + load_balancer: { "dedicated" => true, "trustedProxies" => 1 } } end diff --git a/spec/core/terraform_config/volume_set_spec.rb b/spec/core/terraform_config/volume_set_spec.rb new file mode 100644 index 00000000..c7268590 --- /dev/null +++ b/spec/core/terraform_config/volume_set_spec.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe TerraformConfig::VolumeSet do + let(:config) { described_class.new(**options) } + + let(:options) do + { + gvc: "test-gvc", + name: "test-volume-set", + description: "Test volume set", + tags: { "env" => "test", "project" => "example" }, + initial_capacity: 20, + performance_class: "general-purpose-ssd", + file_system_type: "xfs" + } + end + + describe "#to_tf" do + subject(:generated) { config.to_tf } + + context "with basic configuration" do + it "generates correct config" do + expect(generated).to eq( + <<~EXPECTED + resource "cpln_volume_set" "#{options.fetch(:name)}" { + gvc = "#{options.fetch(:gvc)}" + name = "#{options.fetch(:name)}" + description = "#{options.fetch(:description)}" + tags = { + env = "test" + project = "example" + } + initial_capacity = #{options.fetch(:initial_capacity)} + performance_class = "#{options.fetch(:performance_class)}" + file_system_type = "#{options.fetch(:file_system_type)}" + } + EXPECTED + ) + end + end + + context "with storage_class_suffix" do + let(:config) { described_class.new(**options.merge(storage_class_suffix: "suffix")) } + + it "includes storage_class_suffix in the config" do + expect(generated).to include('storage_class_suffix = "suffix"') + end + end + + context "with snapshots" do + let(:config) do + described_class.new(**options.merge( + snapshots: { + create_final_snapshot: true, + retention_duration: "7d", + schedule: "0 1 * * *" + } + )) + end + + it "includes snapshots block in the config" do + expect(generated).to include( + <<~EXPECTED.strip.indent(2) + snapshots { + create_final_snapshot = true + retention_duration = "7d" + schedule = "0 1 * * *" + } + EXPECTED + ) + end + end + + context "with autoscaling" do + let(:config) do + described_class.new(**options.merge( + autoscaling: { + max_capacity: 100, + min_free_percentage: 20, + scaling_factor: 1.5 + } + )) + end + + it "includes autoscaling block in the config" do + expect(generated).to include( + <<~EXPECTED.strip.indent(2) + autoscaling { + max_capacity = 100 + min_free_percentage = 20 + scaling_factor = 1.5 + } + EXPECTED + ) + end + end + end + + describe "validations" do + context "with invalid initial_capacity" do + it "raises an error" do + expect { described_class.new(**options.merge(initial_capacity: 5)) }.to raise_error( + ArgumentError, "Initial capacity should be greater than or equal to 10" + ) + end + end + + context "with invalid performance_class" do + it "raises an error" do + expect { described_class.new(**options.merge(performance_class: "invalid")) }.to raise_error( + ArgumentError, "Invalid performance class: invalid. Choose from general-purpose-ssd, high-throughput-ssd" + ) + end + end + + context "with invalid file_system_type" do + it "raises an error" do + expect { described_class.new(**options.merge(file_system_type: "invalid")) }.to raise_error( + ArgumentError, "Invalid file system type: invalid. Choose from xfs, ext4" + ) + end + end + end + + describe "autoscaling validations" do + context "with invalid max_capacity" do + let(:invalid_autoscaling) do + options.merge(autoscaling: { max_capacity: 5 }) + end + + it "raises an error" do + expect { described_class.new(**invalid_autoscaling) }.to raise_error( + ArgumentError, "autoscaling.max_capacity should be >= 10" + ) + end + end + + context "with invalid min_free_percentage" do + let(:invalid_autoscaling) do + options.merge(autoscaling: { min_free_percentage: 0 }) + end + + it "raises an error for value below 1" do + expect { described_class.new(**invalid_autoscaling) }.to raise_error( + ArgumentError, "autoscaling.min_free_percentage should be between 1 and 100" + ) + end + + it "raises an error for value above 100" do + invalid_autoscaling[:autoscaling][:min_free_percentage] = 101 + expect { described_class.new(**invalid_autoscaling) }.to raise_error( + ArgumentError, "autoscaling.min_free_percentage should be between 1 and 100" + ) + end + end + + context "with invalid scaling_factor" do + let(:invalid_autoscaling) do + options.merge(autoscaling: { scaling_factor: 1.0 }) + end + + it "raises an error" do + expect { described_class.new(**invalid_autoscaling) }.to raise_error( + ArgumentError, "autoscaling.scaling_factor should be >= 1.1" + ) + end + end + + context "with valid autoscaling values" do + let(:valid_autoscaling) do + options.merge( + autoscaling: { + max_capacity: 100, + min_free_percentage: 20, + scaling_factor: 1.5 + } + ) + end + + it "does not raise an error" do + expect { described_class.new(**valid_autoscaling) }.not_to raise_error + end + end + + context "with partial autoscaling values" do + let(:partial_autoscaling) do + options.merge( + autoscaling: { + max_capacity: 100 + } + ) + end + + it "does not raise an error" do + expect { described_class.new(**partial_autoscaling) }.not_to raise_error + end + end + end +end diff --git a/spec/pathces/hash_spec.rb b/spec/patches/hash_spec.rb similarity index 100% rename from spec/pathces/hash_spec.rb rename to spec/patches/hash_spec.rb diff --git a/spec/patches/string_spec.rb b/spec/patches/string_spec.rb new file mode 100644 index 00000000..3b481cd1 --- /dev/null +++ b/spec/patches/string_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe String do + describe "#pluralize" do + context "when word is empty" do + it "returns an empty string" do + expect("".pluralize).to eq("") + end + end + + context "when word already ends with 'ies'" do + it "returns the word unchanged" do + expect("cities".pluralize).to eq("cities") + expect("babies".pluralize).to eq("babies") + end + end + + context "when word ends with 's', 'x', 'z', 'ch', or 'sh'" do + it "adds 'es' to the end if it doesn't already end with 'es'" do + expect("bus".pluralize).to eq("buses") + expect("box".pluralize).to eq("boxes") + expect("buzz".pluralize).to eq("buzzes") + expect("church".pluralize).to eq("churches") + expect("dish".pluralize).to eq("dishes") + end + + it "returns the word unchanged if it already ends with 'es'" do + expect("buses".pluralize).to eq("buses") + expect("boxes".pluralize).to eq("boxes") + end + end + + context "when word ends with 'y'" do + it "changes 'y' to 'ies'" do + expect("city".pluralize).to eq("cities") + expect("baby".pluralize).to eq("babies") + end + end + + context "when word doesn't end with 'y', 's', 'x', 'z', 'ch', or 'sh'" do + it "adds 's' to the end if it doesn't already end with 's'" do + expect("cat".pluralize).to eq("cats") + expect("dog".pluralize).to eq("dogs") + expect("book".pluralize).to eq("books") + end + end + + context "when word is a single character" do + it "applies the rules correctly" do + expect("a".pluralize).to eq("as") + expect("s".pluralize).to eq("ses") + expect("x".pluralize).to eq("xes") + expect("y".pluralize).to eq("ies") + end + end + end +end