diff --git a/charts/postgres-operator/crds/operatorconfigurations.yaml b/charts/postgres-operator/crds/operatorconfigurations.yaml index 5875b5808..461351544 100644 --- a/charts/postgres-operator/crds/operatorconfigurations.yaml +++ b/charts/postgres-operator/crds/operatorconfigurations.yaml @@ -68,6 +68,10 @@ spec: enable_ebs_gp3_migration_max_size: format: int64 type: integer + ebs_volume_tags_from_annotations: + additionalProperties: + type: string + type: object gcp_credentials: type: string kube_iam_role: diff --git a/manifests/complete-postgres-manifest.yaml b/manifests/complete-postgres-manifest.yaml index 93797e0e1..7ceb97763 100644 --- a/manifests/complete-postgres-manifest.yaml +++ b/manifests/complete-postgres-manifest.yaml @@ -9,6 +9,8 @@ metadata: # "acid.zalan.do/controller": "second-operator" # "delete-date": "2020-08-31" # can only be deleted on that day if "delete-date "key is configured # "delete-clustername": "acid-test-cluster" # can only be deleted when name matches if "delete-clustername" key is configured +# "myorg.io/application": "my-app" +# "myorg.io/team": "my-team" spec: dockerImage: ghcr.io/zalando/spilo-18:4.1-p1 teamId: "acid" diff --git a/manifests/configmap.yaml b/manifests/configmap.yaml index 1c663c757..4ee556fb6 100644 --- a/manifests/configmap.yaml +++ b/manifests/configmap.yaml @@ -36,6 +36,7 @@ data: # delete_annotation_name_key: delete-clustername docker_image: ghcr.io/zalando/spilo-18:4.1-p1 # downscaler_annotations: "deployment-time,downscaler/*" + # ebs_volume_tags_from_annotations: "application:myorg.io/application,team:myorg.io/team" enable_admin_role_for_users: "true" enable_crd_registration: "true" enable_cross_namespace_secret: "false" diff --git a/manifests/operatorconfiguration.crd.yaml b/manifests/operatorconfiguration.crd.yaml index 5f347f2ac..b5aca56b1 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_volume_tags_from_annotations: + additionalProperties: + type: string + type: object 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..0d4ba41c1 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_volume_tags_from_annotations: + # application: "myorg.io/application" + # team: "myorg.io/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..21a30db37 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"` + EBSVolumeTagsFromAnnotations map[string]string `json:"ebs_volume_tags_from_annotations,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..b5aca56b1 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_volume_tags_from_annotations: + additionalProperties: + type: string + type: object 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..7d0a0e5d4 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,13 @@ 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.EBSVolumeTagsFromAnnotations != nil { + in, out := &in.EBSVolumeTagsFromAnnotations, &out.EBSVolumeTagsFromAnnotations + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } return } @@ -470,7 +477,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..cc68989d4 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,80 @@ func (c *Cluster) executeEBSMigration() error { return nil } + +// tagEBSVolumes tags EBS volumes based on the configured annotation-to-tag mappings +// Only tags volumes that don't already have the desired 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.EBSVolumeTagsFromAnnotations) == 0 { + c.logger.Debugf("no annotation-to-tag mappings configured, skipping EBS volume tagging") + return nil + } + + if len(c.EBSVolumes) == 0 { + c.logger.Debugf("no EBS volumes found for tagging") + return nil + } + + desiredTags := make(map[string]string) + for tagName, annotationKey := range c.OpConfig.EBSVolumeTagsFromAnnotations { + annotationValue, ok := c.ObjectMeta.Annotations[annotationKey] + if !ok || annotationValue == "" { + c.logger.Debugf("annotation %q not found or empty, skipping tag %q", annotationKey, tagName) + continue + } + desiredTags[tagName] = annotationValue + } + + if len(desiredTags) == 0 { + c.logger.Debugf("no tags to apply from configured annotations") + return nil + } + + // Filter volumes that need tagging + 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() { + err := c.VolumeResizer.ConnectToProvider() + if 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) + } + }() + } + + err := c.VolumeResizer.TagVolumes(volumesToTag, desiredTags) + if err != nil { + return fmt.Errorf("could not tag EBS volumes: %v", err) + } + + c.logger.Infof("successfully tagged %d EBS volumes with tags: %v", len(volumesToTag), desiredTags) + return nil +} + +// tagsNeedUpdate checks if the desired tags differ from existing tags +func (c *Cluster) tagsNeedUpdate(existingTags, desiredTags map[string]string) bool { + for key, desiredValue := range desiredTags { + existingValue, exists := existingTags[key] + if !exists || existingValue != desiredValue { + return true + } + } + return false +} diff --git a/pkg/controller/operator_config.go b/pkg/controller/operator_config.go index 66fc7a731..1bb71c8cc 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.EBSVolumeTagsFromAnnotations = fromCRD.AWSGCP.EBSVolumeTagsFromAnnotations // 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..bb9233e96 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"` + EBSVolumeTagsFromAnnotations map[string]string `name:"ebs_volume_tags_from_annotations" 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..744eeeaf6 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,37 @@ func (r *EBSVolumeResizer) ModifyVolume(volumeID string, newType *string, newSiz }) } +// TagVolumes tags the given EBS volumes with the provided tags. +// Callers are responsible for filtering out volumes that already have the desired tags. +func (r *EBSVolumeResizer) TagVolumes(volumeIds []string, tags map[string]string) error { + if !r.IsConnectedToProvider() { + err := r.ConnectToProvider() + if err != nil { + return err + } + } + + if len(volumeIds) == 0 { + return nil + } + + ec2Tags := make([]types.Tag, 0, len(tags)) + for key, value := range tags { + ec2Tags = append(ec2Tags, types.Tag{Key: &key, Value: &value}) + } + + input := &ec2.CreateTagsInput{ + Resources: volumeIds, + Tags: ec2Tags, + } + + _, err := r.connection.CreateTags(context.TODO(), input) + 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..9c5c3c26a 100644 --- a/pkg/util/volumes/ebs_test.go +++ b/pkg/util/volumes/ebs_test.go @@ -3,6 +3,7 @@ package volumes import ( "fmt" "testing" + v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -88,7 +89,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", }, }, @@ -121,3 +122,45 @@ func TestVolumeBelongsToProvider(t *testing.T) { }) } } + +func TestTagVolumes(t *testing.T) { + tests := []struct { + name string + volumes []string + tags map[string]string + // We're testing the interface, not the actual tagging + // since that requires a mock EC2 client + }{ + { + name: "Single volume with single tag", + volumes: []string{"vol-123456"}, + tags: map[string]string{ + "application": "my-app", + }, + }, + { + name: "Multiple volumes with multiple tags", + volumes: []string{"vol-123456", "vol-789012"}, + tags: map[string]string{ + "application": "my-app", + "environment": "production", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // This test verifies the interface exists and can be called + // The actual EC2 API calls are tested via integration tests + resizer := EBSVolumeResizer{} + + // Verify the method signature exists and handles disconnected state + err := resizer.TagVolumes(tt.volumes, tt.tags) + if err == nil || err.Error() != "could not establish AWS session: *" { + // We expect an error because we're not really connecting to AWS + // The important part is that the method exists and can be called + t.Logf("TagVolumes called successfully for %d volumes with %d tags", len(tt.volumes), len(tt.tags)) + } + }) + } +} 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 }