From eba55e124f6ca21e79f9fedf5b4c4cdfdede044a Mon Sep 17 00:00:00 2001 From: tcondeixa Date: Wed, 24 Jun 2026 19:29:48 +0200 Subject: [PATCH 1/5] feat: tag EBS volumes from PostgreSQL CR annotations Adds a configurable mapping between PostgreSQL CR annotations and EBS volume tags. The operator reads the configured annotation keys from the CR metadata and applies them as tags on the associated EBS volumes during each sync cycle. Tags are compared against existing EBS tags (extracted from DescribeVolumes, which is already called for volume management) and CreateTags is only called when a tag is missing or has a different value, avoiding unnecessary AWS API calls on steady state. Configuration example in the operator ConfigMap/OperatorConfiguration: aws_or_gcp: ebs_volume_tags_from_annotations: application: zalando.org/owning-application team: zalando.org/team Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: tcondeixa --- ...gresql-operator-default-configuration.yaml | 3 + pkg/cluster/volumes.go | 85 +++++++++++++++++++ pkg/util/config/config.go | 1 + pkg/util/volumes/ebs.go | 42 ++++++++- pkg/util/volumes/ebs_test.go | 43 ++++++++++ pkg/util/volumes/volumes.go | 2 + 6 files changed, 174 insertions(+), 2 deletions(-) diff --git a/manifests/postgresql-operator-default-configuration.yaml b/manifests/postgresql-operator-default-configuration.yaml index 88af48b66..d720ec586 100644 --- a/manifests/postgresql-operator-default-configuration.yaml +++ b/manifests/postgresql-operator-default-configuration.yaml @@ -169,6 +169,9 @@ configuration: aws_region: eu-central-1 enable_ebs_gp3_migration: false # enable_ebs_gp3_migration_max_size: 1000 + # ebs_volume_tags_from_annotations: + # application: zalando.org/owning-application + # team: zalando.org/team # gcp_credentials: "" # kube_iam_role: "" # log_s3_bucket: "" 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/util/config/config.go b/pkg/util/config/config.go index 43fa37a33..148518235 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/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..a8ff24136 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" ) @@ -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 } From 9bf9085adf8b51136c45ae3238bd59bb2b46cb52 Mon Sep 17 00:00:00 2001 From: tcondeixa Date: Wed, 24 Jun 2026 22:44:03 +0200 Subject: [PATCH 2/5] comma separated key:value format for new config --- manifests/complete-postgres-manifest.yaml | 2 ++ manifests/configmap.yaml | 1 + manifests/postgresql-operator-default-configuration.yaml | 4 +--- 3 files changed, 4 insertions(+), 3 deletions(-) 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/postgresql-operator-default-configuration.yaml b/manifests/postgresql-operator-default-configuration.yaml index d720ec586..cac3915f6 100644 --- a/manifests/postgresql-operator-default-configuration.yaml +++ b/manifests/postgresql-operator-default-configuration.yaml @@ -167,11 +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 - # ebs_volume_tags_from_annotations: - # application: zalando.org/owning-application - # team: zalando.org/team # gcp_credentials: "" # kube_iam_role: "" # log_s3_bucket: "" From 1b37874b521b2334d32a98c5971b3ed21e296b2f Mon Sep 17 00:00:00 2001 From: tcondeixa Date: Wed, 24 Jun 2026 22:44:59 +0200 Subject: [PATCH 3/5] run make fmt --- pkg/util/config/config.go | 2 +- pkg/util/filesystems/ext234.go | 2 +- pkg/util/teams/teams_test.go | 486 ++++++++++++++++----------------- pkg/util/volumes/ebs_test.go | 12 +- 4 files changed, 251 insertions(+), 251 deletions(-) diff --git a/pkg/util/config/config.go b/pkg/util/config/config.go index 148518235..bb9233e96 100644 --- a/pkg/util/config/config.go +++ b/pkg/util/config/config.go @@ -201,7 +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:""` + 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_test.go b/pkg/util/volumes/ebs_test.go index a8ff24136..9c5c3c26a 100644 --- a/pkg/util/volumes/ebs_test.go +++ b/pkg/util/volumes/ebs_test.go @@ -89,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", }, }, @@ -125,21 +125,21 @@ func TestVolumeBelongsToProvider(t *testing.T) { func TestTagVolumes(t *testing.T) { tests := []struct { - name string - volumes []string - tags map[string]string + 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", + name: "Single volume with single tag", volumes: []string{"vol-123456"}, tags: map[string]string{ "application": "my-app", }, }, { - name: "Multiple volumes with multiple tags", + name: "Multiple volumes with multiple tags", volumes: []string{"vol-123456", "vol-789012"}, tags: map[string]string{ "application": "my-app", From 476453489ba6a54f2c08e514e445138b7a081bcc Mon Sep 17 00:00:00 2001 From: tcondeixa Date: Wed, 24 Jun 2026 23:15:42 +0200 Subject: [PATCH 4/5] add operator CRD config and generate new CRDs --- Makefile | 4 ++-- .../crds/operatorconfigurations.yaml | 4 ++++ hack/adjust_postgresql_crd.sh | 4 ++-- manifests/operatorconfiguration.crd.yaml | 4 ++++ ...gresql-operator-default-configuration.yaml | 4 +++- .../v1/operator_configuration_type.go | 21 ++++++++++--------- .../v1/operatorconfiguration.crd.yaml | 4 ++++ .../acid.zalan.do/v1/zz_generated.deepcopy.go | 9 +++++++- pkg/controller/operator_config.go | 1 + 9 files changed, 39 insertions(+), 16 deletions(-) diff --git a/Makefile b/Makefile index 3613c1044..d37ba74b5 100644 --- a/Makefile +++ b/Makefile @@ -69,8 +69,8 @@ $(GENERATED_CRDS): $(GENERATED) go tool controller-gen crd:crdVersions=v1,allowDangerousTypes=true paths=./pkg/apis/acid.zalan.do/... output:crd:dir=manifests @mv manifests/acid.zalan.do_postgresqls.yaml manifests/postgresql.crd.yaml @# hack to use lowercase kind and listKind - @sed -i -e 's/kind: Postgresql/kind: postgresql/' manifests/postgresql.crd.yaml - @sed -i -e 's/listKind: PostgresqlList/listKind: postgresqlList/' manifests/postgresql.crd.yaml + @sed -i.bak 's/kind: Postgresql/kind: postgresql/' manifests/postgresql.crd.yaml && rm manifests/postgresql.crd.yaml.bak + @sed -i.bak 's/listKind: PostgresqlList/listKind: postgresqlList/' manifests/postgresql.crd.yaml && rm manifests/postgresql.crd.yaml.bak @hack/adjust_postgresql_crd.sh @mv manifests/acid.zalan.do_operatorconfigurations.yaml manifests/operatorconfiguration.crd.yaml @mv manifests/acid.zalan.do_postgresteams.yaml manifests/postgresteam.crd.yaml 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/hack/adjust_postgresql_crd.sh b/hack/adjust_postgresql_crd.sh index d06b74a2d..ff111b621 100755 --- a/hack/adjust_postgresql_crd.sh +++ b/hack/adjust_postgresql_crd.sh @@ -13,12 +13,12 @@ file="${1:-"manifests/postgresql.crd.yaml"}" -sed -i '/^[[:space:]]*standby:$/{ +sed -i '' '/^[[:space:]]*standby:$/{ # Capture the indentation s/^\([[:space:]]*\)standby:$/\1standby:\n\1 anyOf:\n\1 - required:\n\1 - s3_wal_path\n\1 - required:\n\1 - gs_wal_path\n\1 - required:\n\1 - standby_host\n\1 not:\n\1 required:\n\1 - s3_wal_path\n\1 - gs_wal_path/ }' "$file" -sed -i '/^[[:space:]]*maintenanceWindows:$/{ +sed -i '' '/^[[:space:]]*maintenanceWindows:$/{ # Capture the indentation s/^\([[:space:]]*\)maintenanceWindows:$/\1maintenanceWindows:\n\1 items:\n\1 pattern: '\''^\\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\\d):([0-5]?\\d)|(2[0-3]|[01]?\\d):([0-5]?\\d))-((2[0-3]|[01]?\\d):([0-5]?\\d)|(2[0-3]|[01]?\\d):([0-5]?\\d))\\ *$'\''\n\1 type: string/ }' "$file" 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 cac3915f6..0d4ba41c1 100644 --- a/manifests/postgresql-operator-default-configuration.yaml +++ b/manifests/postgresql-operator-default-configuration.yaml @@ -167,7 +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" + # 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/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 * * *") From 9c14b19d7aa67b0d2ff441dd24e1f61b413b0975 Mon Sep 17 00:00:00 2001 From: tcondeixa Date: Wed, 24 Jun 2026 23:17:07 +0200 Subject: [PATCH 5/5] revert changes done locally to work on macos --- Makefile | 4 ++-- hack/adjust_postgresql_crd.sh | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index d37ba74b5..3613c1044 100644 --- a/Makefile +++ b/Makefile @@ -69,8 +69,8 @@ $(GENERATED_CRDS): $(GENERATED) go tool controller-gen crd:crdVersions=v1,allowDangerousTypes=true paths=./pkg/apis/acid.zalan.do/... output:crd:dir=manifests @mv manifests/acid.zalan.do_postgresqls.yaml manifests/postgresql.crd.yaml @# hack to use lowercase kind and listKind - @sed -i.bak 's/kind: Postgresql/kind: postgresql/' manifests/postgresql.crd.yaml && rm manifests/postgresql.crd.yaml.bak - @sed -i.bak 's/listKind: PostgresqlList/listKind: postgresqlList/' manifests/postgresql.crd.yaml && rm manifests/postgresql.crd.yaml.bak + @sed -i -e 's/kind: Postgresql/kind: postgresql/' manifests/postgresql.crd.yaml + @sed -i -e 's/listKind: PostgresqlList/listKind: postgresqlList/' manifests/postgresql.crd.yaml @hack/adjust_postgresql_crd.sh @mv manifests/acid.zalan.do_operatorconfigurations.yaml manifests/operatorconfiguration.crd.yaml @mv manifests/acid.zalan.do_postgresteams.yaml manifests/postgresteam.crd.yaml diff --git a/hack/adjust_postgresql_crd.sh b/hack/adjust_postgresql_crd.sh index ff111b621..d06b74a2d 100755 --- a/hack/adjust_postgresql_crd.sh +++ b/hack/adjust_postgresql_crd.sh @@ -13,12 +13,12 @@ file="${1:-"manifests/postgresql.crd.yaml"}" -sed -i '' '/^[[:space:]]*standby:$/{ +sed -i '/^[[:space:]]*standby:$/{ # Capture the indentation s/^\([[:space:]]*\)standby:$/\1standby:\n\1 anyOf:\n\1 - required:\n\1 - s3_wal_path\n\1 - required:\n\1 - gs_wal_path\n\1 - required:\n\1 - standby_host\n\1 not:\n\1 required:\n\1 - s3_wal_path\n\1 - gs_wal_path/ }' "$file" -sed -i '' '/^[[:space:]]*maintenanceWindows:$/{ +sed -i '/^[[:space:]]*maintenanceWindows:$/{ # Capture the indentation s/^\([[:space:]]*\)maintenanceWindows:$/\1maintenanceWindows:\n\1 items:\n\1 pattern: '\''^\\ *((Mon|Tue|Wed|Thu|Fri|Sat|Sun):(2[0-3]|[01]?\\d):([0-5]?\\d)|(2[0-3]|[01]?\\d):([0-5]?\\d))-((2[0-3]|[01]?\\d):([0-5]?\\d)|(2[0-3]|[01]?\\d):([0-5]?\\d))\\ *$'\''\n\1 type: string/ }' "$file"