diff --git a/README_EN.md b/README_EN.md index 09eda4b7..830e7114 100644 --- a/README_EN.md +++ b/README_EN.md @@ -178,17 +178,48 @@ velero install \ #### Backup Storage Location Configuration Parameters +#### Backup Storage Location Configuration Parameters + | Parameter | Type | Description | Example | |:-----|:-----|:-----|:-----| | `region` | Required | The region where the OSS bucket is located | `cn-hangzhou` | | `network` | Optional | Network type. Options: `internal` (internal network), `accelerate` (accelerate domain). Default is public network | `internal` | | `endpoint` | Optional | Custom OSS endpoint | `https://oss-custom.example.com` | +| `accessKeyId` | Optional | Alibaba Cloud Access Key ID. Alternative to `spec.credential`; takes priority over env vars when combined with `accessKeySecret` | `LTAI5t...` | +| `accessKeySecret` | Optional | Alibaba Cloud Access Key Secret. Required when `accessKeyId` is set | `xxxxx` | +| `stsToken` | Optional | STS security token (only needed when using temporary credentials) | `xxxxx` | #### Volume Snapshot Location Configuration Parameters | Parameter | Type | Description | Example | |:-----|:-----|:-----|:-----| | `region` | Required | The region where ECS snapshots are located | `cn-hangzhou` | +| `accessKeyId` | Optional | Alibaba Cloud Access Key ID. Alternative to `spec.credential`; takes priority over env vars when combined with `accessKeySecret` | `LTAI5t...` | +| `accessKeySecret` | Optional | Alibaba Cloud Access Key Secret. Required when `accessKeyId` is set | `xxxxx` | +| `stsToken` | Optional | STS security token (only needed when using temporary credentials) | `xxxxx` | + +> **Credential patterns:** +> +> **Pattern 1 — Shared credential (recommended for most cases):** +> A single Kubernetes Secret is used for both BSL and VSL. Pass it to Velero via +> `--secret-file` at install time, or reference it with `spec.credential` on the BSL/VSL. +> Velero mounts the secret and injects `credentialsFile` into the plugin config automatically. +> +> **Pattern 2 — Per-location credentials via Kubernetes Secret (recommended when BSL and VSL need different credentials):** +> Set `spec.credential` on each BSL/VSL object to reference a separate Kubernetes Secret. +> Velero v1.10+ mounts each secret independently and injects `credentialsFile` per location. +> This is the same approach used by the AWS plugin. +> +> **Credential resolution priority order:** +> 1. `credentialsFile` config key (set automatically by Velero when `spec.credential` is used) — highest priority +> 2. `ALIBABA_CLOUD_CREDENTIALS_FILE` environment variable +> 3. `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET` environment variables +> 4. `ALIBABA_CLOUD_RAM_ROLE` environment variable (custom RAM role) +> 5. ECS instance RAM role (ACK environments only) +> +> The `accessKeyId`/`accessKeySecret` config keys in the tables above are an alternative +> when neither a Kubernetes Secret nor environment variables are available. They store +> credentials directly in the BSL/VSL config object. #### Other common Optional Parameters diff --git a/velero-plugin-alibabacloud/common.go b/velero-plugin-alibabacloud/common.go index 37ffa271..73eef756 100644 --- a/velero-plugin-alibabacloud/common.go +++ b/velero-plugin-alibabacloud/common.go @@ -25,6 +25,17 @@ const ( notOnECSConfigKey = "notOnECS" credFileConfigKey = "credentialsFile" + // Optional config keys for per-BSL/VSL credentials. + // Velero v1.10+ supports spec.credential on BSL/VSL objects, which references a + // Kubernetes Secret. Velero mounts the secret and injects credentialsFile into the + // plugin config. These keys are the alternative path for passing credentials directly + // via the BSL/VSL config map (lower precedence than credentialsFile/env vars from + // a mounted secret). When both accessKeyId and accessKeySecret are present, they + // take highest priority over all other credential sources for that location. + accessKeyIDConfigKey = "accessKeyId" + accessKeySecretConfigKey = "accessKeySecret" + stsTokenConfigKey = "stsToken" + networkTypeAccelerate = "accelerate" networkTypeInternal = "internal" @@ -45,6 +56,9 @@ var validConfigKeys = []string{ endpointConfigKey, notOnECSConfigKey, credFileConfigKey, + accessKeyIDConfigKey, + accessKeySecretConfigKey, + stsTokenConfigKey, } // loadCredentialFileFromEnv loads environment variables from a credentials file. @@ -174,59 +188,76 @@ func veleroForAck(config map[string]string) bool { } // getCredentials retrieves OSS credentials based on the environment and configuration. -// It supports multiple authentication methods with the following priority order: +// It supports two usage patterns: +// +// Pattern 1 — Shared credential (existing approach, recommended for most cases): +// - A single Kubernetes Secret is referenced by the Velero installation (e.g. --secret-file). +// - Both BSL and VSL use the same credential source. +// - Credential resolution order: credentialsFile config key → ALIBABA_CLOUD_CREDENTIALS_FILE +// env var → ALIBABA_CLOUD_ACCESS_KEY_ID/SECRET env vars → ALIBABA_CLOUD_RAM_ROLE env var +// → ECS instance RAM role (ACK only). +// +// Pattern 2 — Per-location credential via Kubernetes Secret (new approach): +// - Each BSL/VSL has its own spec.credential field referencing a separate Kubernetes Secret. +// - Velero v1.10+ mounts the secret and injects credentialsFile=/tmp/... into plugin config. +// - The plugin reads the file via loadCredentialFileFromEnv (step 2 below). +// - This allows BSL and VSL to use different credentials (e.g. different RAM users). +// +// Full credential resolution priority order within this function: // -// 1. AccessKey credentials (highest priority): -// - Load from file (if ALIBABA_CLOUD_CREDENTIALS_FILE is set) and/or environment variables -// - Environment variables: ALIBABA_CLOUD_ACCESS_KEY_ID, ALIBABA_CLOUD_ACCESS_KEY_SECRET -// - Optional: ALIBABA_CLOUD_ACCESS_STS_TOKEN -// - If both AccessKey ID and Secret are provided, they take precedence over RAM role +// 1. Per-location config keys (accessKeyId + accessKeySecret in BSL/VSL config map): +// - When both are present, they are used directly as the highest-priority fallback. +// - Note: the recommended per-location approach is Pattern 2 above (spec.credential). +// These config keys are an alternative when a Kubernetes Secret is not available. // -// 2. Custom RAM Role (via environment variable): -// - Environment variable: ALIBABA_CLOUD_RAM_ROLE -// - Allows specifying a custom RAM role name instead of using the ECS instance's default role -// - Works in both ACK and non-ACK environments -// - The function will use this role to obtain STS credentials via getSTSAK() +// 2. Credentials file (Pattern 2 — spec.credential path lands here): +// - Config key: credentialsFile (takes precedence over ALIBABA_CLOUD_CREDENTIALS_FILE env var) +// - File format: dotenv key=value pairs (ALIBABA_CLOUD_ACCESS_KEY_ID, etc.) // -// 3. ECS Instance RAM Role (ACK environment fallback): -// - For ACK environments: automatically detect the RAM role from ECS metadata -// - Only used if no AccessKey credentials and no custom RAM role are provided -// - Requires the ECS instance to have a RAM role attached +// 3. AccessKey credentials from environment variables: +// - ALIBABA_CLOUD_ACCESS_KEY_ID, ALIBABA_CLOUD_ACCESS_KEY_SECRET (required) +// - ALIBABA_CLOUD_ACCESS_STS_TOKEN (optional) // -// 4. Error (non-ACK environment without credentials): -// - For non-ACK environments: returns error if no AccessKey and no custom RAM role are provided +// 4. Custom RAM Role (via environment variable): +// - ALIBABA_CLOUD_RAM_ROLE — custom RAM role name // -// Parameters: -// - config: configuration map that may contain: -// - "credentialsFile": path to credentials file (takes precedence over ALIBABA_CLOUD_CREDENTIALS_FILE env var) -// - "notOnECS": if set to "true", indicates not running on ECS (affects RAM role detection) +// 5. ECS Instance RAM Role (ACK environment fallback): +// - Automatically detected from ECS metadata service // -// Returns: -// - ossCredentials: contains accessKeyID, accessKeySecret, stsToken, and ramRole -// - error: if credentials cannot be obtained +// 6. Error (non-ACK environment without any credentials) func getCredentials(config map[string]string) (*ossCredentials, error) { cred := &ossCredentials{} - // Step 1: Load credentials from file if specified (this may set env vars) + // Step 1: Per-location config keys (highest priority). + // If accessKeyId and accessKeySecret are both present in the BSL/VSL config map, + // use them directly — credentialsFile and env vars are not consulted for this location. + if config != nil && config[accessKeyIDConfigKey] != "" && config[accessKeySecretConfigKey] != "" { + cred.accessKeyID = config[accessKeyIDConfigKey] + cred.accessKeySecret = config[accessKeySecretConfigKey] + cred.stsToken = config[stsTokenConfigKey] // optional + return cred, nil + } + + // Step 2: Load credentials from file if specified (this may set env vars) if err := loadCredentialFileFromEnv(config); err != nil { return nil, err } - // Step 2: Get credentials from environment variables + // Step 3: Get credentials from environment variables // These may be set by loadCredentialFileFromEnv or directly by the user cred.accessKeyID = os.Getenv("ALIBABA_CLOUD_ACCESS_KEY_ID") cred.accessKeySecret = os.Getenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET") cred.stsToken = os.Getenv("ALIBABA_CLOUD_ACCESS_STS_TOKEN") // Token may be empty cred.ramRole = os.Getenv("ALIBABA_CLOUD_RAM_ROLE") // Custom RAM role name - // Step 3: If we have both accessKeyID and accessKeySecret, use them directly + // Step 4: If we have both accessKeyID and accessKeySecret, use them directly // AccessKey credentials take precedence over RAM role if len(cred.accessKeyID) != 0 && len(cred.accessKeySecret) != 0 { cred.ramRole = "" return cred, nil } - // Step 4: Handle RAM role authentication + // Step 5: Handle RAM role authentication // If no AccessKey credentials are available, try to use RAM role if !veleroForAck(config) && cred.ramRole == "" { // For non-ACK environment: if no AccessKey and no custom RAM role, return error @@ -244,7 +275,7 @@ func getCredentials(config map[string]string) (*ossCredentials, error) { cred.ramRole = ramRole } - // Step 5: Get STS credentials from the RAM role + // Step 6: Get STS credentials from the RAM role var err error cred.accessKeyID, cred.accessKeySecret, cred.stsToken, err = getSTSAK(cred.ramRole) if err != nil { diff --git a/velero-plugin-alibabacloud/common_test.go b/velero-plugin-alibabacloud/common_test.go index 2d27717d..dffa9bcd 100644 --- a/velero-plugin-alibabacloud/common_test.go +++ b/velero-plugin-alibabacloud/common_test.go @@ -257,7 +257,7 @@ ALIBABA_CLOUD_ACCESS_STS_TOKEN=config-file-token expectedError: "Failed to get sts token from ram role CustomVeleroRole", }, { - name: "success: custom RAM role takes precedence over AccessKey", + name: "success: AccessKey takes precedence over custom RAM role", config: nil, setupEnv: func(t *testing.T) map[string]string { t.Setenv("ALIBABA_CLOUD_CREDENTIALS_FILE", "") @@ -289,6 +289,9 @@ ALIBABA_CLOUD_ACCESS_STS_TOKEN=config-file-token require.NoError(t, err) t.Setenv("ALIBABA_CLOUD_CREDENTIALS_FILE", credFile) + t.Setenv("ALIBABA_CLOUD_ACCESS_KEY_ID", "") + t.Setenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "") + t.Setenv("ALIBABA_CLOUD_ACCESS_STS_TOKEN", "") // Cleanup after test t.Cleanup(func() { os.RemoveAll(tmpDir) @@ -299,6 +302,80 @@ ALIBABA_CLOUD_ACCESS_STS_TOKEN=config-file-token // but it verifies that the custom RAM role from file is used expectedError: "Failed to get sts token from ram role FileCustomRole", }, + { + name: "success: per-location accessKeyId+accessKeySecret in config (no env vars needed)", + config: map[string]string{ + "notOnECS": "true", + accessKeyIDConfigKey: "inline-ak", + accessKeySecretConfigKey: "inline-sk", + }, + setupEnv: func(t *testing.T) map[string]string { + t.Setenv("ALIBABA_CLOUD_ACCESS_KEY_ID", "") + t.Setenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "") + t.Setenv("ALIBABA_CLOUD_ACCESS_STS_TOKEN", "") + return nil + }, + validateCred: func(t *testing.T, cred *ossCredentials) { + assert.Equal(t, "inline-ak", cred.accessKeyID) + assert.Equal(t, "inline-sk", cred.accessKeySecret) + assert.Empty(t, cred.stsToken) + assert.Empty(t, cred.ramRole) + }, + }, + { + name: "success: per-location credentials with optional stsToken", + config: map[string]string{ + "notOnECS": "true", + accessKeyIDConfigKey: "inline-ak", + accessKeySecretConfigKey: "inline-sk", + stsTokenConfigKey: "inline-sts", + }, + setupEnv: func(t *testing.T) map[string]string { + t.Setenv("ALIBABA_CLOUD_ACCESS_KEY_ID", "") + t.Setenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "") + t.Setenv("ALIBABA_CLOUD_ACCESS_STS_TOKEN", "") + return nil + }, + validateCred: func(t *testing.T, cred *ossCredentials) { + assert.Equal(t, "inline-ak", cred.accessKeyID) + assert.Equal(t, "inline-sk", cred.accessKeySecret) + assert.Equal(t, "inline-sts", cred.stsToken) + assert.Empty(t, cred.ramRole) + }, + }, + { + name: "success: per-location credentials take priority over env vars", + config: map[string]string{ + accessKeyIDConfigKey: "inline-ak", + accessKeySecretConfigKey: "inline-sk", + }, + setupEnv: func(t *testing.T) map[string]string { + t.Setenv("ALIBABA_CLOUD_ACCESS_KEY_ID", "env-ak") + t.Setenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "env-sk") + t.Setenv("ALIBABA_CLOUD_ACCESS_STS_TOKEN", "") + return nil + }, + validateCred: func(t *testing.T, cred *ossCredentials) { + assert.Equal(t, "inline-ak", cred.accessKeyID, "per-location config must win over env vars") + assert.Equal(t, "inline-sk", cred.accessKeySecret) + assert.Empty(t, cred.ramRole) + }, + }, + { + name: "fallback: only accessKeyId without accessKeySecret falls through to env vars", + config: map[string]string{ + "notOnECS": "true", + accessKeyIDConfigKey: "inline-ak-only", + }, + setupEnv: func(t *testing.T) map[string]string { + t.Setenv("ALIBABA_CLOUD_ACCESS_KEY_ID", "") + t.Setenv("ALIBABA_CLOUD_ACCESS_KEY_SECRET", "") + t.Setenv("ALIBABA_CLOUD_ACCESS_STS_TOKEN", "") + t.Setenv("ALIBABA_CLOUD_RAM_ROLE", "") + return nil + }, + expectedError: "ALIBABA_CLOUD_ACCESS_KEY_ID or ALIBABA_CLOUD_ACCESS_KEY_SECRET environment variable is not set", + }, } for _, tc := range tests { diff --git a/velero-plugin-alibabacloud/volume_snapshotter.go b/velero-plugin-alibabacloud/volume_snapshotter.go index c607fbad..5c3b250c 100644 --- a/velero-plugin-alibabacloud/volume_snapshotter.go +++ b/velero-plugin-alibabacloud/volume_snapshotter.go @@ -17,6 +17,7 @@ import ( "context" "fmt" "os" + "regexp" "strings" openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" @@ -37,7 +38,11 @@ import ( const ( ackClusterNameKey = "ACK_CLUSTER_NAME" - originalVolumeAZTagKey = "alibabacloud.velero-plugin/orginal-volume-az" + originalVolumeAZTagKey = "alibabacloud.velero-plugin_orginal-volume-az" + // legacyVolumeAZTagKey is the old tag key value used before the slash was replaced with underscore. + // Snapshots taken with plugin versions prior to this fix carry this key. We check both during + // restore so that existing snapshots are not silently placed in the wrong availability zone. + legacyVolumeAZTagKey = "alibabacloud.velero-plugin/orginal-volume-az" ) // DiskPerformanceLevels maps performance levels to their max IOPS values @@ -49,6 +54,32 @@ var DiskPerformanceLevels = map[string]int64{ "PL3": 1000000, // Up to 1,000,000 random read/write IOPS } +// ecsTagKeyForbiddenPrefixes lists prefixes that Alibaba Cloud ECS rejects in tag keys. +var ecsTagKeyForbiddenPrefixes = []string{"aliyun", "acs:", "http://", "https://"} + +// ecsTagKeyInvalidChars matches any character not allowed in an ECS tag key. +// Allowed: letters, digits, underscore, hyphen, dot. Max length 128. +var ecsTagKeyInvalidChars = regexp.MustCompile(`[^a-zA-Z0-9_\-\.]`) + +// sanitizeTagKey makes a tag key safe for Alibaba Cloud ECS by replacing invalid characters +// with underscores and truncating to 128 characters. Keys starting with forbidden prefixes +// or that are empty after sanitization are skipped (returns empty string). +func sanitizeTagKey(key string) string { + for _, prefix := range ecsTagKeyForbiddenPrefixes { + if strings.HasPrefix(key, prefix) { + return "" + } + } + sanitized := ecsTagKeyInvalidChars.ReplaceAllString(key, "_") + if len(sanitized) > 128 { + sanitized = sanitized[:128] + } + if sanitized == "" { + return "" + } + return sanitized +} + // ecsClientInterface defines the interface for ECS client operations // This allows for easier testing with mocks type ecsClientInterface interface { @@ -448,16 +479,16 @@ type tagKV struct { value string } -// copyTags filters source tags, skipping nil entries, system-reserved keys (acs:*), -// and any key for which skipKey returns true. +// copyTags filters source tags, sanitizing keys (replacing invalid chars, dropping forbidden +// prefixes) and skipping any key for which skipKey returns true. func copyTags(keys []*string, values []*string, skipKey func(string) bool) []tagKV { var result []tagKV for i := range keys { if keys[i] == nil { continue } - k := tea.StringValue(keys[i]) - if isInvalidTagKey(k) || skipKey(k) { + k := sanitizeTagKey(tea.StringValue(keys[i])) + if k == "" || skipKey(k) { continue } v := "" @@ -478,7 +509,7 @@ func (b *VolumeSnapshotter) restoreDiskTags(snapshotTags []*ecs20140526.Describe if haveClusterName { result = append(result, &ecs20140526.CreateDiskRequestTag{ - Key: tea.String("kubernetes.io/cluster/" + clusterName), + Key: tea.String(sanitizeTagKey("kubernetes.io/cluster/" + clusterName)), Value: tea.String("owned"), }) result = append(result, &ecs20140526.CreateDiskRequestTag{ @@ -499,7 +530,7 @@ func (b *VolumeSnapshotter) restoreDiskTags(snapshotTags []*ecs20140526.Describe copied := copyTags(keys, values, func(k string) bool { // Skip old cluster ownership tags when we have a new cluster name - return haveClusterName && (strings.HasPrefix(k, "kubernetes.io/cluster/") || k == "KubernetesCluster") + return haveClusterName && (strings.HasPrefix(k, "kubernetes.io_cluster_") || k == "KubernetesCluster") }) for _, t := range copied { @@ -519,9 +550,16 @@ func (b *VolumeSnapshotter) snapshotTags(veleroTags map[string]string, volumeTag var result []*ecs20140526.CreateSnapshotRequestTag // Velero-assigned tags first (highest priority) + emittedKeys := make(map[string]bool) for k, v := range veleroTags { + safeKey := sanitizeTagKey(k) + if safeKey == "" { + b.log.Warnf("skipping tag with key %q: invalid for Alibaba Cloud ECS", k) + continue + } + emittedKeys[safeKey] = true result = append(result, &ecs20140526.CreateSnapshotRequestTag{ - Key: tea.String(k), + Key: tea.String(safeKey), Value: tea.String(v), }) } @@ -537,8 +575,8 @@ func (b *VolumeSnapshotter) snapshotTags(veleroTags map[string]string, volumeTag } copied := copyTags(keys, values, func(k string) bool { - _, found := veleroTags[k] - return found + // k is already sanitized by copyTags; skip if a Velero tag already claimed this key + return emittedKeys[k] }) for _, t := range copied { @@ -573,9 +611,15 @@ func (b *VolumeSnapshotter) snapshotTags(veleroTags map[string]string, volumeTag func (b *VolumeSnapshotter) determineVolumeAZ(snapshotTags []*ecs20140526.DescribeSnapshotsResponseBodySnapshotsSnapshotTagsTag) (string, error) { var originalZone, currentZone string - // Try to get originalZone from snapshot tags (the zone where the original volume was created) + // Try to get originalZone from snapshot tags (the zone where the original volume was created). + // Check both the current key and the legacy key (which contained a slash, invalid for ECS tags) + // so that snapshots created before this fix can still be restored to the correct zone. for _, tag := range snapshotTags { - if tag != nil && tea.StringValue(tag.TagKey) == originalVolumeAZTagKey { + if tag == nil { + continue + } + k := tea.StringValue(tag.TagKey) + if k == originalVolumeAZTagKey || k == legacyVolumeAZTagKey { originalZone = tea.StringValue(tag.TagValue) break } diff --git a/velero-plugin-alibabacloud/volume_snapshotter_test.go b/velero-plugin-alibabacloud/volume_snapshotter_test.go index e7c99221..f790536b 100644 --- a/velero-plugin-alibabacloud/volume_snapshotter_test.go +++ b/velero-plugin-alibabacloud/volume_snapshotter_test.go @@ -15,6 +15,7 @@ package main import ( "sort" + "strings" "testing" ecs20140526 "github.com/alibabacloud-go/ecs-20140526/v4/client" @@ -256,7 +257,8 @@ func TestRestoreDiskTags(t *testing.T) { }, expected: []*ecs20140526.CreateDiskRequestTag{ {Key: tea.String("KubernetesCluster"), Value: tea.String("old-cluster")}, - {Key: tea.String("kubernetes.io/cluster/old-cluster"), Value: tea.String("owned")}, + // slash in original key gets sanitized to underscore + {Key: tea.String("kubernetes.io_cluster_old-cluster"), Value: tea.String("owned")}, {Key: tea.String("alibaba-cloud-key"), Value: tea.String("alibaba-cloud-val")}, }, }, @@ -266,7 +268,8 @@ func TestRestoreDiskTags(t *testing.T) { snapshotTags: nil, expected: []*ecs20140526.CreateDiskRequestTag{ {Key: tea.String("KubernetesCluster"), Value: tea.String("current-cluster")}, - {Key: tea.String("kubernetes.io/cluster/current-cluster"), Value: tea.String("owned")}, + // slash in "kubernetes.io/cluster/" gets sanitized to underscore + {Key: tea.String("kubernetes.io_cluster_current-cluster"), Value: tea.String("owned")}, }, }, { @@ -277,7 +280,7 @@ func TestRestoreDiskTags(t *testing.T) { }, expected: []*ecs20140526.CreateDiskRequestTag{ {Key: tea.String("KubernetesCluster"), Value: tea.String("current-cluster")}, - {Key: tea.String("kubernetes.io/cluster/current-cluster"), Value: tea.String("owned")}, + {Key: tea.String("kubernetes.io_cluster_current-cluster"), Value: tea.String("owned")}, {Key: tea.String("alibaba-cloud-key"), Value: tea.String("alibaba-cloud-val")}, }, }, @@ -291,7 +294,7 @@ func TestRestoreDiskTags(t *testing.T) { }, expected: []*ecs20140526.CreateDiskRequestTag{ {Key: tea.String("KubernetesCluster"), Value: tea.String("current-cluster")}, - {Key: tea.String("kubernetes.io/cluster/current-cluster"), Value: tea.String("owned")}, + {Key: tea.String("kubernetes.io_cluster_current-cluster"), Value: tea.String("owned")}, {Key: tea.String("alibaba-cloud-key"), Value: tea.String("alibaba-cloud-val")}, }, }, @@ -1331,6 +1334,77 @@ func TestDetermineVolumeAZ(t *testing.T) { } } +func TestSanitizeTagKey(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "valid key unchanged", + input: "valid-key_1.0", + expected: "valid-key_1.0", + }, + { + name: "velero.io/backup slash replaced", + input: "velero.io/backup", + expected: "velero.io_backup", + }, + { + name: "velero.io/pv slash replaced", + input: "velero.io/pv", + expected: "velero.io_pv", + }, + { + name: "kubernetes.io/created-for/pvc/name slashes replaced", + input: "kubernetes.io/created-for/pvc/name", + expected: "kubernetes.io_created-for_pvc_name", + }, + { + name: "forbidden prefix aliyun", + input: "aliyunSomeKey", + expected: "", + }, + { + name: "forbidden prefix acs:", + input: "acs:SomeKey", + expected: "", + }, + { + name: "forbidden prefix http://", + input: "http://example.com", + expected: "", + }, + { + name: "forbidden prefix https://", + input: "https://example.com", + expected: "", + }, + { + name: "key truncated to 128 chars", + input: strings.Repeat("a", 200), + expected: strings.Repeat("a", 128), + }, + { + name: "empty key returns empty", + input: "", + expected: "", + }, + { + name: "colon replaced with underscore", + input: "some:key", + expected: "some_key", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := sanitizeTagKey(test.input) + assert.Equal(t, test.expected, result) + }) + } +} + func TestIsInvalidTagKey(t *testing.T) { tests := []struct { key string