From f1c796b57929d3af9b8eaf1d7ddbf6596e0cc50f Mon Sep 17 00:00:00 2001 From: Olivier Bierlaire Date: Tue, 19 Sep 2023 13:23:55 +0200 Subject: [PATCH] [AWS] autoscaling group (#93) --- internal/plan/json_getters.go | 17 +-- internal/plan/mappings/aws/ec2_asg.yaml | 109 ++++++++++++++++++ internal/plan/mappings/aws/general.yaml | 3 +- internal/plan/resources.go | 4 +- internal/plan/test/resources_aws_asg_test.go | 80 +++++++++++++ internal/plan/test/resources_aws_test.go | 2 +- internal/utils/defaults.yaml | 1 + test/config/default_conf.yaml | 4 + test/terraform/aws_asg/asg_launch_template.tf | 33 ++++++ test/terraform/aws_asg/asg_with_lc.tf | 21 ++++ test/terraform/aws_asg/main.tf | 9 ++ test/terraform/aws_asg/network.tf | 43 +++++++ test/terraform/aws_asg/provider.tf | 4 + test/terraform/planRaw/plan.tfplan | Bin 3740 -> 3746 bytes 14 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 internal/plan/mappings/aws/ec2_asg.yaml create mode 100644 internal/plan/test/resources_aws_asg_test.go create mode 100644 test/terraform/aws_asg/asg_launch_template.tf create mode 100644 test/terraform/aws_asg/asg_with_lc.tf create mode 100644 test/terraform/aws_asg/main.tf create mode 100644 test/terraform/aws_asg/network.tf create mode 100644 test/terraform/aws_asg/provider.tf diff --git a/internal/plan/json_getters.go b/internal/plan/json_getters.go index cbc30c6..2fb1246 100644 --- a/internal/plan/json_getters.go +++ b/internal/plan/json_getters.go @@ -17,7 +17,7 @@ type tfContext struct { Resource map[string]interface{} // Json of the terraform plan resource Mapping *ResourceMapping // Mapping of the resource type ResourceAddress string // Address of the resource in tf plan - ParentContext *tfContext // Parent context + RootContext *tfContext // Root context Provider providers.Provider } @@ -60,7 +60,7 @@ func getSlice(key string, context *tfContext) ([]interface{}, error) { Resource: context.Resource, Mapping: &itemMapping, ResourceAddress: context.ResourceAddress + "." + key, - ParentContext: context, + RootContext: context.RootContext, Provider: context.Provider, } itemResults, err := getSliceItems(context) @@ -85,11 +85,10 @@ func getSliceItems(context tfContext) ([]interface{}, error) { for _, pathRaw := range paths { path := pathRaw if strings.Contains(pathRaw, "${") { - path, err = resolvePlaceholders(pathRaw, context.ParentContext) + path, err = resolvePlaceholders(pathRaw, &context) if err != nil { return nil, err } - fmt.Println(path) } jsonResults, err := getJSON(path, context.Resource) if err != nil { @@ -137,7 +136,7 @@ func getItem(context tfContext, itemMappingProperties *ResourceMapping, jsonResu Resource: jsonResultI, Mapping: itemMappingProperties, ResourceAddress: context.ResourceAddress, - ParentContext: context.ParentContext, + RootContext: context.RootContext, Provider: context.Provider, } property, err := getValue(key, &itemContext) @@ -209,6 +208,7 @@ func getValue(key string, context *tfContext) (*valueWithUnit, error) { path := pathRaw if strings.Contains(pathRaw, "${") { path, err = resolvePlaceholders(path, context) + if err != nil { return nil, errors.Wrapf(err, "Cannot resolve placeholders for %v", path) } @@ -326,6 +326,9 @@ func resolvePlaceholder(expression string, context *tfContext) (*string, error) thisProperty := strings.TrimPrefix(expression, "this") resource := context.Resource value, err := utils.GetJSON(thisProperty, resource) + if value == nil { + log.Debugf("No value found for %v", expression) + } if err != nil { return nil, errors.Wrapf(err, "Cannot get value for variable %s", expression) } @@ -403,7 +406,7 @@ func getVariable(name string, contextParam *tfContext) (interface{}, error) { context := contextParam variablesMappings := context.Mapping.Variables if variablesMappings == nil { - context = contextParam.ParentContext + context = contextParam.RootContext if context != nil { variablesMappings = context.Mapping.Variables } @@ -415,7 +418,7 @@ func getVariable(name string, contextParam *tfContext) (interface{}, error) { Resource: context.Resource, Mapping: variablesMappings, ResourceAddress: context.ResourceAddress + ".variables", - ParentContext: context.ParentContext, + RootContext: context.RootContext, Provider: context.Provider, } value, err := getValue(name, &variableContext) diff --git a/internal/plan/mappings/aws/ec2_asg.yaml b/internal/plan/mappings/aws/ec2_asg.yaml new file mode 100644 index 0000000..b0ea206 --- /dev/null +++ b/internal/plan/mappings/aws/ec2_asg.yaml @@ -0,0 +1,109 @@ +compute_resource: + aws_autoscaling_group: + paths: + - cbf::all_select("type"; "aws_autoscaling_group") + type: resource + variables: + properties: + provider_region: + - paths: + - '.configuration' + property: "region" + launch_configuration: + - paths: + - '.configuration.root_module.resources[] | select(.address == "${this.address}") | .expressions.launch_configuration?.references[]? | select(endswith(".id") or endswith(".name")) | gsub("\\.(id|name)$"; "")' + - '.configuration.root_module.resources[] | select(.address == "${this.address}") | .expressions.launch_template[]?.id?.references[]? | select(endswith(".id") or endswith(".name")) | gsub("\\.(id|name)$"; "")' + reference: + paths: + - cbf::all_select("address"; "${key}") + - cbf::all_select("address"; ("${key}" | split(".")[0:2] | join("."))) | .resources[] | select(.name", ("${key}" | split(".")[2])) + - .prior_state.values.root_module.resources[] | select(.address == "${key}") + return_path: true + ami: + - paths: + - '${launch_configuration}.values.image_id' + reference: + paths: + - cbf::all_select("type"; "aws_ami") | select(.values.image_id == "${key}") + - .prior_state.values.root_module.resources[] | select(.type == "aws_ami") | select(.values.image_id == "${key}") + return_path: true + properties: + name: + - paths: ".name" + address: + - paths: ".address" + type: + - paths: ".type" + vCPUs: + - paths: "${launch_configuration}.values.instance_type" + reference: + json_file: aws_instances + property: ".VCPU" + memory: + - paths: "${launch_configuration}.values.instance_type" + unit: mb + reference: + json_file: aws_instances + property: ".MemoryMb" + zone: + zone: + - paths: ".values.availability_zone" + region: + - paths: ".values.availability_zone" + regex: + pattern: '^(.+-\d+)[a-z]+' + group: 1 + - paths: ".configuration.provider_config.aws.expressions.region" + replication_factor: + - default: 1 + count: + - paths: + - '.values | if has("max_size") then (.min_size // 1) + ${config.provider.aws.avg_autoscaler_size_percent} * (.max_size - (.min_size? // 1)) else null end' + storage: + - type: list + item: + - paths: '${ami}.values.block_device_mappings[].ebs | select(length > 0)' + properties: + size: + - paths: ".volume_size" + default: 8 + unit: gb + type: + - paths: ".volume_type" + default: standard + reference: + general: disk_types + - paths: + - '${launch_configuration}.values.ebs_block_device[] | select(length > 0)' + - '${launch_configuration}.values.block_device_mappings[] | select(length > 0) | select(.virtual_name == null or (.virtual_name | startswith("ephemeral") | not)) | .ebs' + properties: + size: + - paths: ".volume_size" + unit: gb + - paths: ".snapshot_id" + reference: + paths: .prior_state.values.root_module.resources[] | select(.values.id == "${key}") | .values + property: ".volume_size" + - default: 8 + unit: gb + type: + - paths: ".volume_type" + default: standard + reference: + general: disk_types + - paths: + - '${launch_configuration}.values.ephemeral_block_device[] | select(length > 0)' + - '${launch_configuration}.values.block_device_mappings[] | select(length > 0) | select(.virtual_name != null and (.virtual_name | startswith("ephemeral")))' + properties: + size: + - paths: '${launch_configuration}.values.instance_type' + unit: gb + reference: + json_file: aws_instances + property: ".InstanceStorage.SizePerDiskGB" + type: + - paths: '${launch_configuration}.values.instance_type' + default: standard + reference: + json_file: aws_instances + property: ".InstanceStorage.Type" \ No newline at end of file diff --git a/internal/plan/mappings/aws/general.yaml b/internal/plan/mappings/aws/general.yaml index 64cba7c..cb8f127 100644 --- a/internal/plan/mappings/aws/general.yaml +++ b/internal/plan/mappings/aws/general.yaml @@ -14,4 +14,5 @@ general: aws_instances : "aws_instances.json" ignored_resources: - "aws_vpc" - - "aws_volume_attachment" \ No newline at end of file + - "aws_volume_attachment" + - "aws_launch_configuration" \ No newline at end of file diff --git a/internal/plan/resources.go b/internal/plan/resources.go index 2a23fb5..a2b575f 100644 --- a/internal/plan/resources.go +++ b/internal/plan/resources.go @@ -153,12 +153,14 @@ func GetComputeResource(resourceI interface{}, resourceMapping *ResourceMapping, if err != nil { return nil, nil } - context := &tfContext{ + contextObject := tfContext{ ResourceAddress: resourceAddress, Mapping: resourceMapping, Resource: resource, Provider: provider, } + contextObject.RootContext = &contextObject + context := &contextObject name, err := getString("name", context) if err != nil { return nil, errors.Wrapf(err, "Cannot get name for resource %v", resourceAddress) diff --git a/internal/plan/test/resources_aws_asg_test.go b/internal/plan/test/resources_aws_asg_test.go new file mode 100644 index 0000000..b27f86a --- /dev/null +++ b/internal/plan/test/resources_aws_asg_test.go @@ -0,0 +1,80 @@ +package plan_test + +import ( + "path" + "testing" + + "github.com/carboniferio/carbonifer/internal/plan" + "github.com/carboniferio/carbonifer/internal/providers" + "github.com/carboniferio/carbonifer/internal/resources" + "github.com/carboniferio/carbonifer/internal/terraform" + "github.com/carboniferio/carbonifer/internal/testutils" + "github.com/shopspring/decimal" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestGetResource_AWSASG(t *testing.T) { + + testutils.SkipWithCreds(t) + + // reset + terraform.ResetTerraformExec() + + wd := path.Join(testutils.RootDir, "test/terraform/aws_asg") + viper.Set("workdir", wd) + + wantResources := map[string]resources.Resource{ + "aws_autoscaling_group.asg_with_launchconfig": resources.ComputeResource{ + Identification: &resources.ResourceIdentification{ + Name: "asg_with_launchconfig", + Address: "aws_autoscaling_group.asg_with_launchconfig", + ResourceType: "aws_autoscaling_group", + Provider: providers.AWS, + Region: "eu-west-3", + Count: 6, + ReplicationFactor: 1, + }, + Specs: &resources.ComputeResourceSpecs{ + VCPUs: int32(4), + MemoryMb: int32(16384), + + HddStorage: decimal.Zero, + SsdStorage: decimal.NewFromInt(180), + }, + }, + "aws_autoscaling_group.asg_launch_template": resources.ComputeResource{ + Identification: &resources.ResourceIdentification{ + Name: "asg_launch_template", + Address: "aws_autoscaling_group.asg_launch_template", + ResourceType: "aws_autoscaling_group", + Provider: providers.AWS, + Region: "eu-west-3", + Count: 6, + ReplicationFactor: 1, + }, + Specs: &resources.ComputeResourceSpecs{ + VCPUs: int32(4), + MemoryMb: int32(16384), + + HddStorage: decimal.NewFromInt(300), + SsdStorage: decimal.NewFromInt(30 + 150), + }, + }, + } + tfPlan, err := terraform.TerraformPlan() + assert.NoError(t, err) + gotResources, err := plan.GetResources(tfPlan) + assert.NoError(t, err) + for _, got := range gotResources { + if got.GetIdentification().ResourceType == "aws_launch_configuration" { + // This should not exists, it should be ignored + assert.Fail(t, "aws_launch_configuration should be ignored") + } else if got.GetIdentification().ResourceType == "aws_autoscaling_group" { + assert.Equal(t, wantResources[got.GetAddress()], got) + } else { + // Anything else should be unsupported + assert.IsType(t, resources.UnsupportedResource{}, got) + } + } +} diff --git a/internal/plan/test/resources_aws_test.go b/internal/plan/test/resources_aws_test.go index a5a4deb..56a5d99 100644 --- a/internal/plan/test/resources_aws_test.go +++ b/internal/plan/test/resources_aws_test.go @@ -14,7 +14,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGetResource_DiskFromAMI(t *testing.T) { +func TestGetResource_EC2(t *testing.T) { testutils.SkipWithCreds(t) diff --git a/internal/utils/defaults.yaml b/internal/utils/defaults.yaml index 636268a..f9ffa06 100644 --- a/internal/utils/defaults.yaml +++ b/internal/utils/defaults.yaml @@ -15,5 +15,6 @@ provider: aws: avg_cpu_use: 0.5 avg_gpu_use: 0.5 + avg_autoscaler_size_percent: 0.5 log: level : "warn" diff --git a/test/config/default_conf.yaml b/test/config/default_conf.yaml index 4f1dfd8..95ad694 100644 --- a/test/config/default_conf.yaml +++ b/test/config/default_conf.yaml @@ -12,5 +12,9 @@ provider: avg_cpu_use: 0.5 avg_gpu_use: 0.5 avg_autoscaler_size_percent: 0.5 + aws: + avg_cpu_use: 0.5 + avg_gpu_use: 0.5 + avg_autoscaler_size_percent: 0.5 log: level : "warn" \ No newline at end of file diff --git a/test/terraform/aws_asg/asg_launch_template.tf b/test/terraform/aws_asg/asg_launch_template.tf new file mode 100644 index 0000000..ef811d4 --- /dev/null +++ b/test/terraform/aws_asg/asg_launch_template.tf @@ -0,0 +1,33 @@ +resource "aws_launch_template" "my_launch_template" { + name_prefix = "my_launch_template" + image_id = "${data.aws_ami.ubuntu.id}" + instance_type = "m5d.xlarge" + + block_device_mappings { + device_name = "/dev/sda1" + + ebs { + volume_size = 300 + volume_type = "standard" + delete_on_termination = true + } + } + + block_device_mappings { + device_name = "/dev/sdb" + virtual_name = "ephemeral0" + } + +} + +resource "aws_autoscaling_group" "asg_launch_template" { + availability_zones = ["eu-west-3a"] + desired_capacity = 4 + max_size = 2 + min_size = 10 + + launch_template { + id = aws_launch_template.my_launch_template.id + version = "$Latest" + } +} \ No newline at end of file diff --git a/test/terraform/aws_asg/asg_with_lc.tf b/test/terraform/aws_asg/asg_with_lc.tf new file mode 100644 index 0000000..676af1e --- /dev/null +++ b/test/terraform/aws_asg/asg_with_lc.tf @@ -0,0 +1,21 @@ + +resource "aws_autoscaling_group" "asg_with_launchconfig" { + name = "asg_with_launchconfig" + max_size = 10 + min_size = 2 + desired_capacity = 4 + vpc_zone_identifier = [aws_subnet.subnet_1.id, aws_subnet.subnet_2.id] + launch_configuration = aws_launch_configuration.asg_launch_config.name + +} + +resource "aws_launch_configuration" "asg_launch_config" { + name = "asg_launch_config" + image_id = data.aws_ami.ubuntu.id + instance_type = "m5d.xlarge" + + ephemeral_block_device { + device_name = "/dev/sdk" + virtual_name = "ephemeral0" + } +} \ No newline at end of file diff --git a/test/terraform/aws_asg/main.tf b/test/terraform/aws_asg/main.tf new file mode 100644 index 0000000..b1d1620 --- /dev/null +++ b/test/terraform/aws_asg/main.tf @@ -0,0 +1,9 @@ +data "aws_ami" "ubuntu" { + most_recent = true + + filter { + name = "block-device-mapping.volume-size" + values = [ "30" ] + } + } + \ No newline at end of file diff --git a/test/terraform/aws_asg/network.tf b/test/terraform/aws_asg/network.tf new file mode 100644 index 0000000..23106a3 --- /dev/null +++ b/test/terraform/aws_asg/network.tf @@ -0,0 +1,43 @@ +resource "aws_vpc" "my_vpc" { + cidr_block = "172.16.0.0/16" + + tags = { + Name = "tf-example" + } +} + +resource "aws_subnet" "subnet_1" { + vpc_id = aws_vpc.my_vpc.id + cidr_block = "172.16.10.0/24" + availability_zone = "us-west-2a" + tags = { + Name = "tf-example" + } +} + +resource "aws_network_interface" "network_interface_1" { + subnet_id = aws_subnet.subnet_1.id + private_ips = ["172.16.10.100"] + + tags = { + Name = "primary_network_interface" + } +} + +resource "aws_subnet" "subnet_2" { + vpc_id = aws_vpc.my_vpc.id + cidr_block = "172.16.20.0/24" + availability_zone = "us-west-2a" + tags = { + Name = "tf-example" + } +} + +resource "aws_network_interface" "network_interface_2" { + subnet_id = aws_subnet.subnet_2.id + private_ips = ["172.16.20.100"] + + tags = { + Name = "primary_network_interface" + } +} \ No newline at end of file diff --git a/test/terraform/aws_asg/provider.tf b/test/terraform/aws_asg/provider.tf new file mode 100644 index 0000000..5b8c3cb --- /dev/null +++ b/test/terraform/aws_asg/provider.tf @@ -0,0 +1,4 @@ +provider "aws" { + region = "eu-west-3" +} + diff --git a/test/terraform/planRaw/plan.tfplan b/test/terraform/planRaw/plan.tfplan index b57ab481118ac390c3970027eb247e9c7c347449..9fffff450754f84158ce60024748f320ef86e7be 100644 GIT binary patch delta 2597 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 delta 2584 zcmZvec{CK<8^=eOMMEQ{rZINCBD@my+A0&tC|Sm2nGJRqQoFf*!hp~mNXPPrR{1C9&Ybhfp=^j6*tQlQKSKg z^;DF?G1y}Sx7A%#O6UHVM^Lrw5~&mi8+^a(IJ9NYx9AVHqr)D}-*mUjPxW}7WpS1# zTn%OA4)qJk#oI=6B#*&WP?|;)@du#oS7W*9yV6@;T;H0m?sWJ>$GNliRdoMdt zZJbpQ_li@>QOX5S&4kC%9`#xtW~D);+)_+fMo0I=@W5oFdH(PKt8HP9Pe(|rq zDgT8V2<&yc0Wcp`CB!B0U-?-I6^oJy!s*lrc+O6emof+Xe%17dj zWz;(P_UJS64ck5jzJ0QQIJ&pVjr^-Sg}tng&9 zwLSkXG*!;-ajWEEP#HP#Pv7*nghI0y!4M3($NMDl0hI+Tpj87q#N z6FQA%3_VEsgZS-Q3A1gITP@rFFN{f}g;8Afgy&ix!ej-<14L}d(ma^>;m3!ycZNC~yt<@wys=B7|X zdyx7!$a{-a?)HlhrL2w&c5;2<(h#o8TCMmFLQ{!kml@tu(>@(fJ}P(`K08xM@4(8n zzNyx0QOlmmjZRuEZo!!AoS1!CC;VLcz93L6xq9t$Eq5CcG}#vwK-)VYmmDshK6+(4 zGraMB2-T#F!?5j{R2Cu?<@k>6U@bVV`k#tc7%cTKm6<-w;~I7aVEoO>v|WL!guvdll}5uIrR0-E_Vxnpm*NbS#E!RLenWaJ+^dR zuXbP!uEoMdl=zz~i<}>3#7f8nAFK$Yr$%18bdOjwX?|e&$qKqnswicg#Z}D{A&#(} zhjU^d#%gxho|arAEv$AO*#ZEpub)AK!0TTcGm*jo08s=0_*XB*0)81F00a>19u({n z?1r%s1_*36+2f>Yku93gD~pE4E&H$4f9%4F|7DgyDHIM6+-SuVX`sB&rj3#!eC@l? z0o?apDCGl-1imW;V6`8XH(<-}f;vR?hu{Q3eHR`=_I+>Y<~y%Zbznt@6xDwp6@7(ZiSX4(J{WR2hZT!*~qBciE|K7|#4BM8t*@h}xYI?t$_-&p`tFrW7R0wo z4D#)@^}&7T+aYbOOs0!cl!&?o=kh6POcK`DS?EDR?w9kTsFS(-0;$K)2JrO9fBh+# zVh`*7JVJ`LO#LD?^+u_&zU(FNVW*{oxm^cq^s{(SaK^#VE17u#3o6(h+bluF5MsH8 zDY>l=TF9|#8hUVt_G+l|il>A2o<-g4_GWp8uM)syu(f>A#ngQ`5n%VQ)6FHBB3ov0 z7ln+*qUCqwGo5kPf`#}AMWk)5xqw{p1$^;&2@iRPjyI~Xa&4?l;;Smv6Y=$5pR1BQ zGL&s6n5g=a0yUXz)Q-#fcI+3fL~+{I;=Wr7ab*H=UrB*TP8Z)M=V>Kt>QZvSql&cs zb2x;YQBnNXNr2g-J=I#zAd$wxCk3guyn8H(N-5cYAsGy#w(LiiDz=vW%V} zad|0;u15}hqg5K;W6^H|s2|YLveQJHHW0ijv-e#gt9R3r%%m4&<+0+;@{0aq6ooqAb>IiglqmsQY<(i|Kg#I zIy5)du8q_Ef7}8CC@;WBjsJ?urg|awZCE$g&VNPiq{xZ=cg(MMB=N5p62(UhvFAsg T&B6cx(8in+001(eo4fx3&lRI)