From 0fe4eaac6b42a6a7f6e3157d7f73939040518656 Mon Sep 17 00:00:00 2001 From: tcondeixa Date: Mon, 29 Jun 2026 13:24:51 +0200 Subject: [PATCH] ebs_tags_from_labels to add tags to EBS Volumes --- .../crds/operatorconfigurations.yaml | 4 + manifests/configmap.yaml | 1 + manifests/operatorconfiguration.crd.yaml | 4 + ...gresql-operator-default-configuration.yaml | 3 + .../v1/operator_configuration_type.go | 21 +- .../v1/operatorconfiguration.crd.yaml | 4 + .../acid.zalan.do/v1/zz_generated.deepcopy.go | 7 +- pkg/cluster/volumes.go | 80 +++ pkg/controller/operator_config.go | 1 + pkg/util/config/config.go | 1 + pkg/util/filesystems/ext234.go | 2 +- pkg/util/teams/teams_test.go | 486 +++++++++--------- pkg/util/volumes/ebs.go | 39 +- pkg/util/volumes/ebs_test.go | 4 +- pkg/util/volumes/volumes.go | 2 + 15 files changed, 400 insertions(+), 259 deletions(-) diff --git a/charts/postgres-operator/crds/operatorconfigurations.yaml b/charts/postgres-operator/crds/operatorconfigurations.yaml index 5875b5808..a0f842585 100644 --- a/charts/postgres-operator/crds/operatorconfigurations.yaml +++ b/charts/postgres-operator/crds/operatorconfigurations.yaml @@ -63,6 +63,10 @@ spec: aws_region: default: eu-central-1 type: string + ebs_tags_inherit_labels: + items: + type: string + type: array enable_ebs_gp3_migration: type: boolean enable_ebs_gp3_migration_max_size: diff --git a/manifests/configmap.yaml b/manifests/configmap.yaml index 1c663c757..5806fe7b2 100644 --- a/manifests/configmap.yaml +++ b/manifests/configmap.yaml @@ -41,6 +41,7 @@ data: enable_cross_namespace_secret: "false" enable_finalizers: "false" enable_database_access: "true" + # ebs_tags_inherit_labels: "application,team" enable_ebs_gp3_migration: "false" enable_ebs_gp3_migration_max_size: "1000" enable_init_containers: "true" diff --git a/manifests/operatorconfiguration.crd.yaml b/manifests/operatorconfiguration.crd.yaml index 5f347f2ac..cd6422dd1 100644 --- a/manifests/operatorconfiguration.crd.yaml +++ b/manifests/operatorconfiguration.crd.yaml @@ -64,6 +64,10 @@ spec: aws_region: default: eu-central-1 type: string + ebs_tags_inherit_labels: + items: + type: string + type: array enable_ebs_gp3_migration: type: boolean enable_ebs_gp3_migration_max_size: diff --git a/manifests/postgresql-operator-default-configuration.yaml b/manifests/postgresql-operator-default-configuration.yaml index 88af48b66..3f8fa405c 100644 --- a/manifests/postgresql-operator-default-configuration.yaml +++ b/manifests/postgresql-operator-default-configuration.yaml @@ -167,6 +167,9 @@ configuration: # additional_secret_mount: "some-secret-name" # additional_secret_mount_path: "/some/dir" aws_region: eu-central-1 + # ebs_tags_inherit_labels: + # - application + # - team enable_ebs_gp3_migration: false # enable_ebs_gp3_migration_max_size: 1000 # gcp_credentials: "" diff --git a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go index 60793c45c..c202f6cf5 100644 --- a/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go +++ b/pkg/apis/acid.zalan.do/v1/operator_configuration_type.go @@ -247,16 +247,17 @@ type LoadBalancerConfiguration struct { type AWSGCPConfiguration struct { WALES3Bucket string `json:"wal_s3_bucket,omitempty"` // +kubebuilder:default=eu-central-1 - AWSRegion string `json:"aws_region,omitempty"` - WALGSBucket string `json:"wal_gs_bucket,omitempty"` - GCPCredentials string `json:"gcp_credentials,omitempty"` - WALAZStorageAccount string `json:"wal_az_storage_account,omitempty"` - LogS3Bucket string `json:"log_s3_bucket,omitempty"` - KubeIAMRole string `json:"kube_iam_role,omitempty"` - AdditionalSecretMount string `json:"additional_secret_mount,omitempty"` - AdditionalSecretMountPath string `json:"additional_secret_mount_path,omitempty"` - EnableEBSGp3Migration bool `json:"enable_ebs_gp3_migration,omitempty"` - EnableEBSGp3MigrationMaxSize int64 `json:"enable_ebs_gp3_migration_max_size,omitempty"` + AWSRegion string `json:"aws_region,omitempty"` + WALGSBucket string `json:"wal_gs_bucket,omitempty"` + GCPCredentials string `json:"gcp_credentials,omitempty"` + WALAZStorageAccount string `json:"wal_az_storage_account,omitempty"` + LogS3Bucket string `json:"log_s3_bucket,omitempty"` + KubeIAMRole string `json:"kube_iam_role,omitempty"` + AdditionalSecretMount string `json:"additional_secret_mount,omitempty"` + AdditionalSecretMountPath string `json:"additional_secret_mount_path,omitempty"` + EnableEBSGp3Migration bool `json:"enable_ebs_gp3_migration,omitempty"` + EnableEBSGp3MigrationMaxSize int64 `json:"enable_ebs_gp3_migration_max_size,omitempty"` + EBSTagsInheritLabels []string `json:"ebs_tags_inherit_labels,omitempty"` } // OperatorDebugConfiguration defines options for the debug mode diff --git a/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml b/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml index 5f347f2ac..cd6422dd1 100644 --- a/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml +++ b/pkg/apis/acid.zalan.do/v1/operatorconfiguration.crd.yaml @@ -64,6 +64,10 @@ spec: aws_region: default: eu-central-1 type: string + ebs_tags_inherit_labels: + items: + type: string + type: array enable_ebs_gp3_migration: type: boolean enable_ebs_gp3_migration_max_size: diff --git a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go index 7d18c2cf2..27c00b4c8 100644 --- a/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go +++ b/pkg/apis/acid.zalan.do/v1/zz_generated.deepcopy.go @@ -37,6 +37,11 @@ import ( // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AWSGCPConfiguration) DeepCopyInto(out *AWSGCPConfiguration) { *out = *in + if in.EBSTagsInheritLabels != nil { + in, out := &in.EBSTagsInheritLabels, &out.EBSTagsInheritLabels + *out = make([]string, len(*in)) + copy(*out, *in) + } return } @@ -470,7 +475,7 @@ func (in *OperatorConfigurationData) DeepCopyInto(out *OperatorConfigurationData out.PostgresPodResources = in.PostgresPodResources out.Timeouts = in.Timeouts in.LoadBalancer.DeepCopyInto(&out.LoadBalancer) - out.AWSGCP = in.AWSGCP + in.AWSGCP.DeepCopyInto(&out.AWSGCP) in.OperatorDebug.DeepCopyInto(&out.OperatorDebug) in.TeamsAPI.DeepCopyInto(&out.TeamsAPI) out.LoggingRESTAPI = in.LoggingRESTAPI diff --git a/pkg/cluster/volumes.go b/pkg/cluster/volumes.go index e32e558e6..3fd567b67 100644 --- a/pkg/cluster/volumes.go +++ b/pkg/cluster/volumes.go @@ -37,6 +37,9 @@ func (c *Cluster) syncVolumes() error { if err != nil { c.logger.Errorf("populating EBS meta data failed, skipping potential adjustments: %v", err) } else { + if err = c.tagEBSVolumes(); err != nil { + c.logger.Warningf("tagging EBS volumes failed: %v", err) + } err = c.syncUnderlyingEBSVolume() if err != nil { c.logger.Errorf("errors occurred during EBS volume adjustments: %v", err) @@ -56,6 +59,11 @@ func (c *Cluster) syncVolumes() error { // TODO: handle the case of the cluster that is downsized and enlarged again // (there will be a volume from the old pod for which we can't act before the // the statefulset modification is concluded) + if err = c.populateVolumeMetaData(); err != nil { + c.logger.Warningf("populating EBS meta data failed, skipping EBS volume tagging: %v", err) + } else if err = c.tagEBSVolumes(); err != nil { + c.logger.Warningf("tagging EBS volumes failed: %v", err) + } if err = c.syncEbsVolumes(); err != nil { err = fmt.Errorf("could not sync persistent volumes: %v", err) return err @@ -497,3 +505,75 @@ func (c *Cluster) executeEBSMigration() error { return nil } + +// tagEBSVolumes propagates labels (by name) from the PostgreSQL CR to EBS volumes as tags. +func (c *Cluster) tagEBSVolumes() error { + if c.VolumeResizer == nil { + return fmt.Errorf("no volume resizer set for EBS volume tagging") + } + + if len(c.OpConfig.EBSTagsInheritLabels) == 0 { + c.logger.Debugf("no label keys configured for EBS tag inheritance, skipping") + return nil + } + + if len(c.EBSVolumes) == 0 { + c.logger.Debugf("no EBS volumes found for tagging") + return nil + } + + desiredTags := make(map[string]string) + for _, labelKey := range c.OpConfig.EBSTagsInheritLabels { + labelValue, ok := c.ObjectMeta.Labels[labelKey] + if !ok || labelValue == "" { + c.logger.Debugf("label %q not found or empty on CR, skipping", labelKey) + continue + } + desiredTags[labelKey] = labelValue + } + + if len(desiredTags) == 0 { + c.logger.Debugf("no tags to apply from configured labels") + return nil + } + + volumesToTag := make([]string, 0, len(c.EBSVolumes)) + for volumeID, volumeProps := range c.EBSVolumes { + if c.tagsNeedUpdate(volumeProps.Tags, desiredTags) { + volumesToTag = append(volumesToTag, volumeID) + } + } + + if len(volumesToTag) == 0 { + c.logger.Debugf("all EBS volumes already have the desired tags") + return nil + } + + if !c.VolumeResizer.IsConnectedToProvider() { + if err := c.VolumeResizer.ConnectToProvider(); err != nil { + return fmt.Errorf("could not connect to volume provider for tagging: %v", err) + } + defer func() { + if err := c.VolumeResizer.DisconnectFromProvider(); err != nil { + c.logger.Errorf("disconnecting from volume provider failed: %v", err) + } + }() + } + + if err := c.VolumeResizer.TagVolumes(volumesToTag, desiredTags); err != nil { + return fmt.Errorf("could not tag EBS volumes: %v", err) + } + + c.logger.Infof("successfully tagged %d EBS volumes with labels: %v", len(volumesToTag), desiredTags) + return nil +} + +// tagsNeedUpdate returns true if any desired tag is missing or has a different value. +func (c *Cluster) tagsNeedUpdate(existingTags, desiredTags map[string]string) bool { + for key, desiredValue := range desiredTags { + if existingTags[key] != desiredValue { + return true + } + } + return false +} diff --git a/pkg/controller/operator_config.go b/pkg/controller/operator_config.go index 66fc7a731..af5e140cb 100644 --- a/pkg/controller/operator_config.go +++ b/pkg/controller/operator_config.go @@ -193,6 +193,7 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur result.AdditionalSecretMountPath = fromCRD.AWSGCP.AdditionalSecretMountPath result.EnableEBSGp3Migration = fromCRD.AWSGCP.EnableEBSGp3Migration result.EnableEBSGp3MigrationMaxSize = util.CoalesceInt64(fromCRD.AWSGCP.EnableEBSGp3MigrationMaxSize, 1000) + result.EBSTagsInheritLabels = fromCRD.AWSGCP.EBSTagsInheritLabels // logical backup config result.LogicalBackupSchedule = util.Coalesce(fromCRD.LogicalBackup.Schedule, "30 00 * * *") diff --git a/pkg/util/config/config.go b/pkg/util/config/config.go index 43fa37a33..81f2979f5 100644 --- a/pkg/util/config/config.go +++ b/pkg/util/config/config.go @@ -201,6 +201,7 @@ type Config struct { AdditionalSecretMountPath string `name:"additional_secret_mount_path"` EnableEBSGp3Migration bool `name:"enable_ebs_gp3_migration" default:"false"` EnableEBSGp3MigrationMaxSize int64 `name:"enable_ebs_gp3_migration_max_size" default:"1000"` + EBSTagsInheritLabels []string `name:"ebs_tags_inherit_labels" default:""` DebugLogging bool `name:"debug_logging" default:"true"` EnableDBAccess bool `name:"enable_database_access" default:"true"` EnableTeamsAPI bool `name:"enable_teams_api" default:"true"` diff --git a/pkg/util/filesystems/ext234.go b/pkg/util/filesystems/ext234.go index fcd4053fc..ed37d5910 100644 --- a/pkg/util/filesystems/ext234.go +++ b/pkg/util/filesystems/ext234.go @@ -17,7 +17,7 @@ const ( resize2fs = "resize2fs" ) -//Ext234Resize implements the FilesystemResizer interface for the ext4/3/2fs. +// Ext234Resize implements the FilesystemResizer interface for the ext4/3/2fs. type Ext234Resize struct { } diff --git a/pkg/util/teams/teams_test.go b/pkg/util/teams/teams_test.go index da9f497c1..ecf22a4e3 100644 --- a/pkg/util/teams/teams_test.go +++ b/pkg/util/teams/teams_test.go @@ -1,243 +1,243 @@ -package teams - -import ( - "fmt" - "net/http" - "net/http/httptest" - "reflect" - "testing" - - "github.com/sirupsen/logrus" -) - -var ( - logger = logrus.New().WithField("pkg", "teamsapi") - token = "ec45b1cfbe7100c6315d183a3eb6cec0M2U1LWJkMzEtZDgzNzNmZGQyNGM3IiwiYXV0aF90aW1lIjoxNDkzNzMwNzQ1LCJpc3MiOiJodHRwcz" - input = `{ - "dn": "cn=100100,ou=official,ou=foobar,dc=zalando,dc=net", - "id": "acid", - "id_name": "acid", - "team_id": "111222", - "type": "official", - "name": "Acid team name", - "mail": [ - "email1@example.com", - "email2@example.com" - ], - "alias": [ - "acid" - ], - "member": [ - "member1", - "member2", - "member3" - ], - "infrastructure-accounts": [ - { - "id": "1234512345", - "name": "acid", - "provider": "aws", - "type": "aws", - "description": "", - "owner": "acid", - "owner_dn": "cn=100100,ou=official,ou=foobar,dc=zalando,dc=net", - "disabled": false - }, - { - "id": "5432154321", - "name": "db", - "provider": "aws", - "type": "aws", - "description": "", - "owner": "acid", - "owner_dn": "cn=100100,ou=official,ou=foobar,dc=zalando,dc=net", - "disabled": false - } - ], - "cost_center": "00099999", - "delivery_lead": "member4", - "parent_team_id": "111221" - }` -) -var teamsAPItc = []struct { - in string - inCode int - inTeam string - out *Team - err error -}{ - { - input, - 200, - "acid", - &Team{ - Dn: "cn=100100,ou=official,ou=foobar,dc=zalando,dc=net", - ID: "acid", - TeamName: "acid", - TeamID: "111222", - Type: "official", - FullName: "Acid team name", - Aliases: []string{"acid"}, - Mails: []string{"email1@example.com", "email2@example.com"}, - Members: []string{"member1", "member2", "member3"}, - CostCenter: "00099999", - DeliveryLead: "member4", - ParentTeamID: "111221", - InfrastructureAccounts: []infrastructureAccount{ - { - ID: "1234512345", - Name: "acid", - Provider: "aws", - Type: "aws", - Description: "", - Owner: "acid", - OwnerDn: "cn=100100,ou=official,ou=foobar,dc=zalando,dc=net", - Disabled: false}, - { - ID: "5432154321", - Name: "db", - Provider: "aws", - Type: "aws", - Description: "", - Owner: "acid", - OwnerDn: "cn=100100,ou=official,ou=foobar,dc=zalando,dc=net", - Disabled: false}, - }, - }, - nil}, { - `{"error": "Access Token not valid"}`, - 401, - "acid", - nil, - fmt.Errorf(`team API query failed with status code 401 and message: '"Access Token not valid"'`), - }, - { - `{"status": "I'm a teapot'"}`, - 418, - "acid", - nil, - fmt.Errorf(`team API query failed with status code 418`), - }, - { - `{"status": "I'm a teapot`, - 418, - "acid", - nil, - fmt.Errorf(`team API query failed with status code 418 and malformed response: unexpected EOF`), - }, - { - `{"status": "I'm a teapot`, - 200, - "acid", - nil, - fmt.Errorf(`could not parse team API response: unexpected EOF`), - }, - { - input, - 404, - "banana", - nil, - fmt.Errorf(`team API query failed with status code 404`), - }, -} - -var requestsURLtc = []struct { - url string - err error -}{ - { - "coffee://localhost/", - fmt.Errorf(`Get "coffee://localhost/teams/acid": unsupported protocol scheme "coffee"`), - }, - { - "http://192.168.0.%31/", - fmt.Errorf(`parse "http://192.168.0.%%31/teams/acid": invalid URL escape "%%31"`), - }, -} - -func TestInfo(t *testing.T) { - for _, tc := range teamsAPItc { - func() { - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer "+token { - t.Errorf("authorization token is wrong or not provided") - } - w.WriteHeader(tc.inCode) - if _, err := fmt.Fprint(w, tc.in); err != nil { - t.Errorf("error writing teams api response %v", err) - } - })) - defer ts.Close() - api := NewTeamsAPI(ts.URL, logger) - - actual, statusCode, err := api.TeamInfo(tc.inTeam, token) - if err != nil && err.Error() != tc.err.Error() { - t.Errorf("expected error: %v, got: %v", tc.err, err) - return - } - - if !reflect.DeepEqual(actual, tc.out) { - t.Errorf("expected %#v, got: %#v", tc.out, actual) - } - - if statusCode != tc.inCode { - t.Errorf("expected %d, got: %d", tc.inCode, statusCode) - } - }() - } -} - -type mockHTTPClient struct { -} - -type mockBody struct { -} - -func (b *mockBody) Read(p []byte) (n int, err error) { - return 2, nil -} - -func (b *mockBody) Close() error { - return fmt.Errorf("close error") -} - -func (c *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { - resp := http.Response{ - Status: "200 OK", - StatusCode: 200, - ContentLength: 2, - Close: false, - Request: req, - } - resp.Body = &mockBody{} - - return &resp, nil -} - -func TestHttpClientClose(t *testing.T) { - ts := httptest.NewServer(nil) - - api := NewTeamsAPI(ts.URL, logger) - api.httpClient = &mockHTTPClient{} - - _, _, err := api.TeamInfo("acid", token) - expError := fmt.Errorf("error when closing response: close error") - if err.Error() != expError.Error() { - t.Errorf("expected error: %v, got: %v", expError, err) - } -} - -func TestRequest(t *testing.T) { - for _, tc := range requestsURLtc { - api := NewTeamsAPI(tc.url, logger) - resp, _, err := api.TeamInfo("acid", token) - if resp != nil { - t.Errorf("response expected to be nil") - continue - } - - if err.Error() != tc.err.Error() { - t.Errorf("expected error: %v, got: %v", tc.err, err) - } - } -} +package teams + +import ( + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/sirupsen/logrus" +) + +var ( + logger = logrus.New().WithField("pkg", "teamsapi") + token = "ec45b1cfbe7100c6315d183a3eb6cec0M2U1LWJkMzEtZDgzNzNmZGQyNGM3IiwiYXV0aF90aW1lIjoxNDkzNzMwNzQ1LCJpc3MiOiJodHRwcz" + input = `{ + "dn": "cn=100100,ou=official,ou=foobar,dc=zalando,dc=net", + "id": "acid", + "id_name": "acid", + "team_id": "111222", + "type": "official", + "name": "Acid team name", + "mail": [ + "email1@example.com", + "email2@example.com" + ], + "alias": [ + "acid" + ], + "member": [ + "member1", + "member2", + "member3" + ], + "infrastructure-accounts": [ + { + "id": "1234512345", + "name": "acid", + "provider": "aws", + "type": "aws", + "description": "", + "owner": "acid", + "owner_dn": "cn=100100,ou=official,ou=foobar,dc=zalando,dc=net", + "disabled": false + }, + { + "id": "5432154321", + "name": "db", + "provider": "aws", + "type": "aws", + "description": "", + "owner": "acid", + "owner_dn": "cn=100100,ou=official,ou=foobar,dc=zalando,dc=net", + "disabled": false + } + ], + "cost_center": "00099999", + "delivery_lead": "member4", + "parent_team_id": "111221" + }` +) +var teamsAPItc = []struct { + in string + inCode int + inTeam string + out *Team + err error +}{ + { + input, + 200, + "acid", + &Team{ + Dn: "cn=100100,ou=official,ou=foobar,dc=zalando,dc=net", + ID: "acid", + TeamName: "acid", + TeamID: "111222", + Type: "official", + FullName: "Acid team name", + Aliases: []string{"acid"}, + Mails: []string{"email1@example.com", "email2@example.com"}, + Members: []string{"member1", "member2", "member3"}, + CostCenter: "00099999", + DeliveryLead: "member4", + ParentTeamID: "111221", + InfrastructureAccounts: []infrastructureAccount{ + { + ID: "1234512345", + Name: "acid", + Provider: "aws", + Type: "aws", + Description: "", + Owner: "acid", + OwnerDn: "cn=100100,ou=official,ou=foobar,dc=zalando,dc=net", + Disabled: false}, + { + ID: "5432154321", + Name: "db", + Provider: "aws", + Type: "aws", + Description: "", + Owner: "acid", + OwnerDn: "cn=100100,ou=official,ou=foobar,dc=zalando,dc=net", + Disabled: false}, + }, + }, + nil}, { + `{"error": "Access Token not valid"}`, + 401, + "acid", + nil, + fmt.Errorf(`team API query failed with status code 401 and message: '"Access Token not valid"'`), + }, + { + `{"status": "I'm a teapot'"}`, + 418, + "acid", + nil, + fmt.Errorf(`team API query failed with status code 418`), + }, + { + `{"status": "I'm a teapot`, + 418, + "acid", + nil, + fmt.Errorf(`team API query failed with status code 418 and malformed response: unexpected EOF`), + }, + { + `{"status": "I'm a teapot`, + 200, + "acid", + nil, + fmt.Errorf(`could not parse team API response: unexpected EOF`), + }, + { + input, + 404, + "banana", + nil, + fmt.Errorf(`team API query failed with status code 404`), + }, +} + +var requestsURLtc = []struct { + url string + err error +}{ + { + "coffee://localhost/", + fmt.Errorf(`Get "coffee://localhost/teams/acid": unsupported protocol scheme "coffee"`), + }, + { + "http://192.168.0.%31/", + fmt.Errorf(`parse "http://192.168.0.%%31/teams/acid": invalid URL escape "%%31"`), + }, +} + +func TestInfo(t *testing.T) { + for _, tc := range teamsAPItc { + func() { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+token { + t.Errorf("authorization token is wrong or not provided") + } + w.WriteHeader(tc.inCode) + if _, err := fmt.Fprint(w, tc.in); err != nil { + t.Errorf("error writing teams api response %v", err) + } + })) + defer ts.Close() + api := NewTeamsAPI(ts.URL, logger) + + actual, statusCode, err := api.TeamInfo(tc.inTeam, token) + if err != nil && err.Error() != tc.err.Error() { + t.Errorf("expected error: %v, got: %v", tc.err, err) + return + } + + if !reflect.DeepEqual(actual, tc.out) { + t.Errorf("expected %#v, got: %#v", tc.out, actual) + } + + if statusCode != tc.inCode { + t.Errorf("expected %d, got: %d", tc.inCode, statusCode) + } + }() + } +} + +type mockHTTPClient struct { +} + +type mockBody struct { +} + +func (b *mockBody) Read(p []byte) (n int, err error) { + return 2, nil +} + +func (b *mockBody) Close() error { + return fmt.Errorf("close error") +} + +func (c *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { + resp := http.Response{ + Status: "200 OK", + StatusCode: 200, + ContentLength: 2, + Close: false, + Request: req, + } + resp.Body = &mockBody{} + + return &resp, nil +} + +func TestHttpClientClose(t *testing.T) { + ts := httptest.NewServer(nil) + + api := NewTeamsAPI(ts.URL, logger) + api.httpClient = &mockHTTPClient{} + + _, _, err := api.TeamInfo("acid", token) + expError := fmt.Errorf("error when closing response: close error") + if err.Error() != expError.Error() { + t.Errorf("expected error: %v, got: %v", expError, err) + } +} + +func TestRequest(t *testing.T) { + for _, tc := range requestsURLtc { + api := NewTeamsAPI(tc.url, logger) + resp, _, err := api.TeamInfo("acid", token) + if resp != nil { + t.Errorf("response expected to be nil") + continue + } + + if err.Error() != tc.err.Error() { + t.Errorf("expected error: %v, got: %v", tc.err, err) + } + } +} diff --git a/pkg/util/volumes/ebs.go b/pkg/util/volumes/ebs.go index bb7506d93..b799adffd 100644 --- a/pkg/util/volumes/ebs.go +++ b/pkg/util/volumes/ebs.go @@ -89,11 +89,18 @@ func (r *EBSVolumeResizer) DescribeVolumes(volumeIds []string) ([]VolumeProperti } for _, v := range volumeOutput.Volumes { + tags := make(map[string]string) + for _, tag := range v.Tags { + if tag.Key != nil && tag.Value != nil { + tags[*tag.Key] = *tag.Value + } + } + switch v.VolumeType { case "gp3": - p = append(p, VolumeProperties{VolumeID: *v.VolumeId, Size: int64(*v.Size), VolumeType: string(v.VolumeType), Iops: int64(*v.Iops), Throughput: int64(*v.Throughput)}) + p = append(p, VolumeProperties{VolumeID: *v.VolumeId, Size: int64(*v.Size), VolumeType: string(v.VolumeType), Iops: int64(*v.Iops), Throughput: int64(*v.Throughput), Tags: tags}) case "gp2": - p = append(p, VolumeProperties{VolumeID: *v.VolumeId, Size: int64(*v.Size), VolumeType: string(v.VolumeType)}) + p = append(p, VolumeProperties{VolumeID: *v.VolumeId, Size: int64(*v.Size), VolumeType: string(v.VolumeType), Tags: tags}) default: return nil, fmt.Errorf("discovered unexpected volume type %s %s", *v.VolumeId, v.VolumeType) } @@ -206,6 +213,34 @@ func (r *EBSVolumeResizer) ModifyVolume(volumeID string, newType *string, newSiz }) } +// TagVolumes tags the given EBS volumes with the provided tags. +func (r *EBSVolumeResizer) TagVolumes(volumeIds []string, tags map[string]string) error { + if !r.IsConnectedToProvider() { + if err := r.ConnectToProvider(); err != nil { + return err + } + } + + if len(volumeIds) == 0 { + return nil + } + + ec2Tags := make([]types.Tag, 0, len(tags)) + for key, value := range tags { + k, v := key, value + ec2Tags = append(ec2Tags, types.Tag{Key: &k, Value: &v}) + } + + _, err := r.connection.CreateTags(context.TODO(), &ec2.CreateTagsInput{ + Resources: volumeIds, + Tags: ec2Tags, + }) + if err != nil { + return fmt.Errorf("could not tag EBS volumes: %v", err) + } + return nil +} + // DisconnectFromProvider closes connection to the EC2 instance func (r *EBSVolumeResizer) DisconnectFromProvider() error { r.connection = nil diff --git a/pkg/util/volumes/ebs_test.go b/pkg/util/volumes/ebs_test.go index 6f722ff7b..073ce2197 100644 --- a/pkg/util/volumes/ebs_test.go +++ b/pkg/util/volumes/ebs_test.go @@ -2,9 +2,9 @@ package volumes import ( "fmt" - "testing" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "testing" ) func TestGetProviderVolumeID(t *testing.T) { @@ -88,7 +88,7 @@ func TestVolumeBelongsToProvider(t *testing.T) { name: "AWS EBS volume handle", pv: &v1.PersistentVolume{ ObjectMeta: metav1.ObjectMeta{ - Annotations: map[string]string { + Annotations: map[string]string{ "pv.kubernetes.io/provisioned-by": "kubernetes.io/aws-ebs", }, }, diff --git a/pkg/util/volumes/volumes.go b/pkg/util/volumes/volumes.go index 32f68c65e..51eac8a3b 100644 --- a/pkg/util/volumes/volumes.go +++ b/pkg/util/volumes/volumes.go @@ -11,6 +11,7 @@ type VolumeProperties struct { Size int64 Iops int64 Throughput int64 + Tags map[string]string } // VolumeResizer defines the set of methods used to implememnt provider-specific resizing of persistent volumes. @@ -24,4 +25,5 @@ type VolumeResizer interface { ModifyVolume(providerVolumeID string, newType *string, newSize *int64, iops *int64, throughput *int64) error DisconnectFromProvider() error DescribeVolumes(providerVolumesID []string) ([]VolumeProperties, error) + TagVolumes(providerVolumesID []string, tags map[string]string) error }