From d6e8d1744de33596e237a544986737c4bd5c9718 Mon Sep 17 00:00:00 2001 From: Julien Duchesne Date: Wed, 6 Mar 2024 14:18:59 -0500 Subject: [PATCH] Typed resource ID helper (#1395) Each resource's ID is composed of n (>=1) string or integer elements We can generalize this behavior and do the parsing/formatting in the helper function This is extracted from https://github.com/grafana/terraform-provider-grafana/pull/1391 and is part of a push to have standardized IDs for all resources, allowing for easier generation of TF code! --- internal/common/resource_id.go | 100 ++++++++++++++++-- .../cloud/resource_cloud_access_policy.go | 26 +++-- .../resource_cloud_access_policy_token.go | 26 +++-- ...resource_cloud_access_policy_token_test.go | 10 +- .../resources/cloud/resource_cloud_api_key.go | 24 +++-- .../resources/cloud/resource_cloud_plugin.go | 22 ++-- ...source_cloud_stack_service_account_test.go | 21 +--- 7 files changed, 167 insertions(+), 62 deletions(-) diff --git a/internal/common/resource_id.go b/internal/common/resource_id.go index c463e6431..b797e1fe9 100644 --- a/internal/common/resource_id.go +++ b/internal/common/resource_id.go @@ -5,32 +5,58 @@ import ( "log" "os" "path/filepath" + "reflect" + "strconv" "strings" ) +type ResourceIDFieldType string + var ( - defaultSeparator = ":" - allIDs = []*ResourceID{} + defaultSeparator = ":" + ResourceIDFieldTypeInt = ResourceIDFieldType("int") + ResourceIDFieldTypeString = ResourceIDFieldType("string") + allIDs = []*ResourceID{} ) +type ResourceIDField struct { + Name string + Type ResourceIDFieldType + // Optional bool // Unimplemented. Will be used for org ID +} + +func StringIDField(name string) ResourceIDField { + return ResourceIDField{ + Name: name, + Type: ResourceIDFieldTypeString, + } +} + +func IntIDField(name string) ResourceIDField { + return ResourceIDField{ + Name: name, + Type: ResourceIDFieldTypeInt, + } +} + type ResourceID struct { resourceName string separators []string - expectedFields []string + expectedFields []ResourceIDField } -func NewResourceID(resourceName string, expectedFields ...string) *ResourceID { +func NewResourceID(resourceName string, expectedFields ...ResourceIDField) *ResourceID { return newResourceIDWithSeparators(resourceName, []string{defaultSeparator}, expectedFields...) } // Deprecated: Use NewResourceID instead // We should standardize on a single separator, so that function should only be used for old resources // On major versions, switch to NewResourceID and remove uses of this function -func NewResourceIDWithLegacySeparator(resourceName, legacySeparator string, expectedFields ...string) *ResourceID { +func NewResourceIDWithLegacySeparator(resourceName, legacySeparator string, expectedFields ...ResourceIDField) *ResourceID { return newResourceIDWithSeparators(resourceName, []string{defaultSeparator, legacySeparator}, expectedFields...) } -func newResourceIDWithSeparators(resourceName string, separators []string, expectedFields ...string) *ResourceID { +func newResourceIDWithSeparators(resourceName string, separators []string, expectedFields ...ResourceIDField) *ResourceID { tfID := &ResourceID{ resourceName: resourceName, separators: separators, @@ -43,31 +69,83 @@ func newResourceIDWithSeparators(resourceName string, separators []string, expec func (id *ResourceID) Example() string { fields := make([]string, len(id.expectedFields)) for i := range fields { - fields[i] = fmt.Sprintf("{{ %s }}", id.expectedFields[i]) + fields[i] = fmt.Sprintf("{{ %s }}", id.expectedFields[i].Name) } return fmt.Sprintf(`terraform import %s.name %q `, id.resourceName, strings.Join(fields, defaultSeparator)) } +// Make creates a resource ID from the given parts +// The parts must have the correct number of fields and types func (id *ResourceID) Make(parts ...any) string { if len(parts) != len(id.expectedFields) { panic(fmt.Sprintf("expected %d fields, got %d", len(id.expectedFields), len(parts))) // This is a coding error, so panic is appropriate } stringParts := make([]string, len(parts)) for i, part := range parts { - stringParts[i] = fmt.Sprintf("%v", part) + // Unwrap pointers + if reflect.ValueOf(part).Kind() == reflect.Ptr { + part = reflect.ValueOf(part).Elem().Interface() + } + expectedField := id.expectedFields[i] + switch expectedField.Type { + case ResourceIDFieldTypeInt: + asInt, ok := part.(int64) + if !ok { + panic(fmt.Sprintf("expected int64 for field %q, got %T", expectedField.Name, part)) // This is a coding error, so panic is appropriate + } + stringParts[i] = strconv.FormatInt(asInt, 10) + case ResourceIDFieldTypeString: + asString, ok := part.(string) + if !ok { + panic(fmt.Sprintf("expected string for field %q, got %T", expectedField.Name, part)) // This is a coding error, so panic is appropriate + } + stringParts[i] = asString + } } + return strings.Join(stringParts, defaultSeparator) } -func (id *ResourceID) Split(resourceID string) ([]string, error) { +// Single parses a resource ID into a single value +func (id *ResourceID) Single(resourceID string) (any, error) { + parts, err := id.Split(resourceID) + if err != nil { + return nil, err + } + return parts[0], nil +} + +// Split parses a resource ID into its parts +// The parts will be cast to the expected types +func (id *ResourceID) Split(resourceID string) ([]any, error) { for _, sep := range id.separators { parts := strings.Split(resourceID, sep) if len(parts) == len(id.expectedFields) { - return parts, nil + partsAsAny := make([]any, len(parts)) + for i, part := range parts { + expectedField := id.expectedFields[i] + switch expectedField.Type { + case ResourceIDFieldTypeInt: + asInt, err := strconv.ParseInt(part, 10, 64) + if err != nil { + return nil, fmt.Errorf("expected int for field %q, got %q", expectedField.Name, part) + } + partsAsAny[i] = asInt + case ResourceIDFieldTypeString: + partsAsAny[i] = part + } + } + + return partsAsAny, nil } } - return nil, fmt.Errorf("id %q does not match expected format. Should be in the format: %s", resourceID, strings.Join(id.expectedFields, defaultSeparator)) + + expectedFieldNames := make([]string, len(id.expectedFields)) + for i, f := range id.expectedFields { + expectedFieldNames[i] = f.Name + } + return nil, fmt.Errorf("id %q does not match expected format. Should be in the format: %s", resourceID, strings.Join(expectedFieldNames, defaultSeparator)) } // GenerateImportFiles generates import files for all resources that use a helper defined in this package diff --git a/internal/resources/cloud/resource_cloud_access_policy.go b/internal/resources/cloud/resource_cloud_access_policy.go index 160d5b62d..5d0223870 100644 --- a/internal/resources/cloud/resource_cloud_access_policy.go +++ b/internal/resources/cloud/resource_cloud_access_policy.go @@ -13,7 +13,15 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) -var ResourceAccessPolicyID = common.NewResourceIDWithLegacySeparator("grafana_cloud_access_policy", "/", "region", "policyId") //nolint:staticcheck +var ( + //nolint:staticcheck + resourceAccessPolicyID = common.NewResourceIDWithLegacySeparator( + "grafana_cloud_access_policy", + "/", + common.StringIDField("region"), + common.StringIDField("policyId"), + ) +) func resourceAccessPolicy() *schema.Resource { return &schema.Resource{ @@ -146,13 +154,13 @@ func createCloudAccessPolicy(ctx context.Context, d *schema.ResourceData, client return apiError(err) } - d.SetId(ResourceAccessPolicyID.Make(region, result.Id)) + d.SetId(resourceAccessPolicyID.Make(region, result.Id)) return readCloudAccessPolicy(ctx, d, client) } func updateCloudAccessPolicy(ctx context.Context, d *schema.ResourceData, client *gcom.APIClient) diag.Diagnostics { - split, err := ResourceAccessPolicyID.Split(d.Id()) + split, err := resourceAccessPolicyID.Split(d.Id()) if err != nil { return diag.FromErr(err) } @@ -163,7 +171,7 @@ func updateCloudAccessPolicy(ctx context.Context, d *schema.ResourceData, client displayName = d.Get("name").(string) } - req := client.AccesspoliciesAPI.PostAccessPolicy(ctx, id).Region(region).XRequestId(ClientRequestID()). + req := client.AccesspoliciesAPI.PostAccessPolicy(ctx, id.(string)).Region(region.(string)).XRequestId(ClientRequestID()). PostAccessPolicyRequest(gcom.PostAccessPolicyRequest{ DisplayName: &displayName, Scopes: common.ListToStringSlice(d.Get("scopes").(*schema.Set).List()), @@ -177,13 +185,13 @@ func updateCloudAccessPolicy(ctx context.Context, d *schema.ResourceData, client } func readCloudAccessPolicy(ctx context.Context, d *schema.ResourceData, client *gcom.APIClient) diag.Diagnostics { - split, err := ResourceAccessPolicyID.Split(d.Id()) + split, err := resourceAccessPolicyID.Split(d.Id()) if err != nil { return diag.FromErr(err) } region, id := split[0], split[1] - result, _, err := client.AccesspoliciesAPI.GetAccessPolicy(ctx, id).Region(region).Execute() + result, _, err := client.AccesspoliciesAPI.GetAccessPolicy(ctx, id.(string)).Region(region.(string)).Execute() if err, shouldReturn := common.CheckReadError("access policy", d, err); shouldReturn { return err } @@ -198,19 +206,19 @@ func readCloudAccessPolicy(ctx context.Context, d *schema.ResourceData, client * if updated := result.UpdatedAt; updated != nil { d.Set("updated_at", updated.Format(time.RFC3339)) } - d.SetId(ResourceAccessPolicyID.Make(region, result.Id)) + d.SetId(resourceAccessPolicyID.Make(region, result.Id)) return nil } func deleteCloudAccessPolicy(ctx context.Context, d *schema.ResourceData, client *gcom.APIClient) diag.Diagnostics { - split, err := ResourceAccessPolicyID.Split(d.Id()) + split, err := resourceAccessPolicyID.Split(d.Id()) if err != nil { return diag.FromErr(err) } region, id := split[0], split[1] - _, _, err = client.AccesspoliciesAPI.DeleteAccessPolicy(ctx, id).Region(region).XRequestId(ClientRequestID()).Execute() + _, _, err = client.AccesspoliciesAPI.DeleteAccessPolicy(ctx, id.(string)).Region(region.(string)).XRequestId(ClientRequestID()).Execute() return apiError(err) } diff --git a/internal/resources/cloud/resource_cloud_access_policy_token.go b/internal/resources/cloud/resource_cloud_access_policy_token.go index cec946775..b56f1bd68 100644 --- a/internal/resources/cloud/resource_cloud_access_policy_token.go +++ b/internal/resources/cloud/resource_cloud_access_policy_token.go @@ -11,7 +11,15 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) -var ResourceAccessPolicyTokenID = common.NewResourceIDWithLegacySeparator("grafana_cloud_access_policy_token", "/", "region", "tokenId") //nolint:staticcheck +var ( + //nolint:staticcheck + resourceAccessPolicyTokenID = common.NewResourceIDWithLegacySeparator( + "grafana_cloud_access_policy_token", + "/", + common.StringIDField("region"), + common.StringIDField("tokenId"), + ) +) func resourceAccessPolicyToken() *schema.Resource { return &schema.Resource{ @@ -117,14 +125,14 @@ func createCloudAccessPolicyToken(ctx context.Context, d *schema.ResourceData, c return apiError(err) } - d.SetId(ResourceAccessPolicyTokenID.Make(region, result.Id)) + d.SetId(resourceAccessPolicyTokenID.Make(region, result.Id)) d.Set("token", result.Token) return readCloudAccessPolicyToken(ctx, d, client) } func updateCloudAccessPolicyToken(ctx context.Context, d *schema.ResourceData, client *gcom.APIClient) diag.Diagnostics { - split, err := ResourceAccessPolicyTokenID.Split(d.Id()) + split, err := resourceAccessPolicyTokenID.Split(d.Id()) if err != nil { return diag.FromErr(err) } @@ -135,7 +143,7 @@ func updateCloudAccessPolicyToken(ctx context.Context, d *schema.ResourceData, c displayName = d.Get("name").(string) } - req := client.TokensAPI.PostToken(ctx, id).Region(region).XRequestId(ClientRequestID()).PostTokenRequest(gcom.PostTokenRequest{ + req := client.TokensAPI.PostToken(ctx, id.(string)).Region(region.(string)).XRequestId(ClientRequestID()).PostTokenRequest(gcom.PostTokenRequest{ DisplayName: &displayName, }) if _, _, err := req.Execute(); err != nil { @@ -146,13 +154,13 @@ func updateCloudAccessPolicyToken(ctx context.Context, d *schema.ResourceData, c } func readCloudAccessPolicyToken(ctx context.Context, d *schema.ResourceData, client *gcom.APIClient) diag.Diagnostics { - split, err := ResourceAccessPolicyTokenID.Split(d.Id()) + split, err := resourceAccessPolicyTokenID.Split(d.Id()) if err != nil { return diag.FromErr(err) } region, id := split[0], split[1] - result, _, err := client.TokensAPI.GetToken(ctx, id).Region(region).Execute() + result, _, err := client.TokensAPI.GetToken(ctx, id.(string)).Region(region.(string)).Execute() if err, shouldReturn := common.CheckReadError("policy token", d, err); shouldReturn { return err } @@ -168,18 +176,18 @@ func readCloudAccessPolicyToken(ctx context.Context, d *schema.ResourceData, cli if result.UpdatedAt != nil { d.Set("updated_at", result.UpdatedAt.Format(time.RFC3339)) } - d.SetId(ResourceAccessPolicyTokenID.Make(region, result.Id)) + d.SetId(resourceAccessPolicyTokenID.Make(region, result.Id)) return nil } func deleteCloudAccessPolicyToken(ctx context.Context, d *schema.ResourceData, client *gcom.APIClient) diag.Diagnostics { - split, err := ResourceAccessPolicyTokenID.Split(d.Id()) + split, err := resourceAccessPolicyTokenID.Split(d.Id()) if err != nil { return diag.FromErr(err) } region, id := split[0], split[1] - _, _, err = client.TokensAPI.DeleteToken(ctx, id).Region(region).XRequestId(ClientRequestID()).Execute() + _, _, err = client.TokensAPI.DeleteToken(ctx, id.(string)).Region(region.(string)).XRequestId(ClientRequestID()).Execute() return apiError(err) } diff --git a/internal/resources/cloud/resource_cloud_access_policy_token_test.go b/internal/resources/cloud/resource_cloud_access_policy_token_test.go index bb0d8b983..3edcad190 100644 --- a/internal/resources/cloud/resource_cloud_access_policy_token_test.go +++ b/internal/resources/cloud/resource_cloud_access_policy_token_test.go @@ -154,7 +154,7 @@ func testAccCloudAccessPolicyCheckExists(rn string, a *gcom.AuthAccessPolicy) re return fmt.Errorf("resource id not set") } - region, id, _ := strings.Cut(rs.Primary.ID, "/") + region, id, _ := strings.Cut(rs.Primary.ID, ":") client := testutils.Provider.Meta().(*common.Client).GrafanaCloudAPI policy, _, err := client.AccesspoliciesAPI.GetAccessPolicy(context.Background(), id).Region(region).Execute() @@ -179,7 +179,7 @@ func testAccCloudAccessPolicyTokenCheckExists(rn string, a *gcom.AuthToken) reso return fmt.Errorf("resource id not set") } - region, id, _ := strings.Cut(rs.Primary.ID, "/") + region, id, _ := strings.Cut(rs.Primary.ID, ":") client := testutils.Provider.Meta().(*common.Client).GrafanaCloudAPI token, _, err := client.TokensAPI.GetToken(context.Background(), id).Region(region).Execute() @@ -195,6 +195,9 @@ func testAccCloudAccessPolicyTokenCheckExists(rn string, a *gcom.AuthToken) reso func testAccCloudAccessPolicyCheckDestroy(region string, a *gcom.AuthAccessPolicy) resource.TestCheckFunc { return func(s *terraform.State) error { + if a == nil { + return nil + } client := testutils.Provider.Meta().(*common.Client).GrafanaCloudAPI policy, _, err := client.AccesspoliciesAPI.GetAccessPolicy(context.Background(), *a.Id).Region(region).Execute() if err == nil && policy.Name != "" { @@ -207,6 +210,9 @@ func testAccCloudAccessPolicyCheckDestroy(region string, a *gcom.AuthAccessPolic func testAccCloudAccessPolicyTokenCheckDestroy(region string, a *gcom.AuthToken) resource.TestCheckFunc { return func(s *terraform.State) error { + if a == nil { + return nil + } client := testutils.Provider.Meta().(*common.Client).GrafanaCloudAPI token, _, err := client.TokensAPI.GetToken(context.Background(), *a.Id).Region(region).Execute() if err == nil && token.Name != "" { diff --git a/internal/resources/cloud/resource_cloud_api_key.go b/internal/resources/cloud/resource_cloud_api_key.go index a3875c765..9ed76462c 100644 --- a/internal/resources/cloud/resource_cloud_api_key.go +++ b/internal/resources/cloud/resource_cloud_api_key.go @@ -11,8 +11,16 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" ) -var ResourceAPIKeyID = common.NewResourceIDWithLegacySeparator("grafana_cloud_api_key", "-", "orgSlug", "apiKeyName") //nolint:staticcheck -var cloudAPIKeyRoles = []string{"Viewer", "Editor", "Admin", "MetricsPublisher", "PluginPublisher"} +var ( + cloudAPIKeyRoles = []string{"Viewer", "Editor", "Admin", "MetricsPublisher", "PluginPublisher"} + //nolint:staticcheck + resourceAPIKeyID = common.NewResourceIDWithLegacySeparator( + "grafana_cloud_api_key", + "-", + common.StringIDField("orgSlug"), + common.StringIDField("apiKeyName"), + ) +) func resourceAPIKey() *schema.Resource { return &schema.Resource{ @@ -81,19 +89,19 @@ func resourceAPIKeyCreate(ctx context.Context, d *schema.ResourceData, c *gcom.A } d.Set("key", *resp.Token) - d.SetId(ResourceAPIKeyID.Make(org, resp.Name)) + d.SetId(resourceAPIKeyID.Make(org, resp.Name)) return resourceAPIKeyRead(ctx, d, c) } func resourceAPIKeyRead(ctx context.Context, d *schema.ResourceData, c *gcom.APIClient) diag.Diagnostics { - split, err := ResourceAPIKeyID.Split(d.Id()) + split, err := resourceAPIKeyID.Split(d.Id()) if err != nil { return diag.FromErr(err) } org, name := split[0], split[1] - resp, _, err := c.OrgsAPI.GetApiKey(ctx, name, org).Execute() + resp, _, err := c.OrgsAPI.GetApiKey(ctx, name.(string), org.(string)).Execute() if err != nil { return apiError(err) } @@ -101,19 +109,19 @@ func resourceAPIKeyRead(ctx context.Context, d *schema.ResourceData, c *gcom.API d.Set("name", resp.Name) d.Set("role", resp.Role) d.Set("cloud_org_slug", org) - d.SetId(ResourceAPIKeyID.Make(org, resp.Name)) + d.SetId(resourceAPIKeyID.Make(org, resp.Name)) return nil } func resourceAPIKeyDelete(ctx context.Context, d *schema.ResourceData, c *gcom.APIClient) diag.Diagnostics { - split, err := ResourceAPIKeyID.Split(d.Id()) + split, err := resourceAPIKeyID.Split(d.Id()) if err != nil { return diag.FromErr(err) } org, name := split[0], split[1] - _, err = c.OrgsAPI.DelApiKey(ctx, name, org).XRequestId(ClientRequestID()).Execute() + _, err = c.OrgsAPI.DelApiKey(ctx, name.(string), org.(string)).XRequestId(ClientRequestID()).Execute() d.SetId("") return apiError(err) } diff --git a/internal/resources/cloud/resource_cloud_plugin.go b/internal/resources/cloud/resource_cloud_plugin.go index 1b46e557c..91c9ec156 100644 --- a/internal/resources/cloud/resource_cloud_plugin.go +++ b/internal/resources/cloud/resource_cloud_plugin.go @@ -9,7 +9,15 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -var ResourcePluginInstallationID = common.NewResourceIDWithLegacySeparator("grafana_cloud_plugin_installation", "_", "stackSlug", "pluginSlug") //nolint:staticcheck +var ( + //nolint:staticcheck + resourcePluginInstallationID = common.NewResourceIDWithLegacySeparator( + "grafana_cloud_plugin_installation", + "_", + common.StringIDField("stackSlug"), + common.StringIDField("pluginSlug"), + ) +) func resourcePluginInstallation() *schema.Resource { return &schema.Resource{ @@ -69,19 +77,19 @@ func resourcePluginInstallationCreate(ctx context.Context, d *schema.ResourceDat return apiError(err) } - d.SetId(ResourcePluginInstallationID.Make(stackSlug, pluginSlug)) + d.SetId(resourcePluginInstallationID.Make(stackSlug, pluginSlug)) return nil } func resourcePluginInstallationRead(ctx context.Context, d *schema.ResourceData, client *gcom.APIClient) diag.Diagnostics { - split, err := ResourcePluginInstallationID.Split(d.Id()) + split, err := resourcePluginInstallationID.Split(d.Id()) if err != nil { return diag.FromErr(err) } stackSlug, pluginSlug := split[0], split[1] - installation, _, err := client.InstancesAPI.GetInstancePlugin(ctx, stackSlug, pluginSlug).Execute() + installation, _, err := client.InstancesAPI.GetInstancePlugin(ctx, stackSlug.(string), pluginSlug.(string)).Execute() if err, shouldReturn := common.CheckReadError("plugin", d, err); shouldReturn { return err } @@ -89,18 +97,18 @@ func resourcePluginInstallationRead(ctx context.Context, d *schema.ResourceData, d.Set("stack_slug", installation.InstanceSlug) d.Set("slug", installation.PluginSlug) d.Set("version", installation.Version) - d.SetId(ResourcePluginInstallationID.Make(stackSlug, pluginSlug)) + d.SetId(resourcePluginInstallationID.Make(stackSlug, pluginSlug)) return nil } func resourcePluginInstallationDelete(ctx context.Context, d *schema.ResourceData, client *gcom.APIClient) diag.Diagnostics { - split, err := ResourcePluginInstallationID.Split(d.Id()) + split, err := resourcePluginInstallationID.Split(d.Id()) if err != nil { return diag.FromErr(err) } stackSlug, pluginSlug := split[0], split[1] - _, _, err = client.InstancesAPI.DeleteInstancePlugin(ctx, stackSlug, pluginSlug).XRequestId(ClientRequestID()).Execute() + _, _, err = client.InstancesAPI.DeleteInstancePlugin(ctx, stackSlug.(string), pluginSlug.(string)).XRequestId(ClientRequestID()).Execute() return apiError(err) } diff --git a/internal/resources/cloud/resource_cloud_stack_service_account_test.go b/internal/resources/cloud/resource_cloud_stack_service_account_test.go index 49e9a5fdf..4849e01c9 100644 --- a/internal/resources/cloud/resource_cloud_stack_service_account_test.go +++ b/internal/resources/cloud/resource_cloud_stack_service_account_test.go @@ -3,7 +3,6 @@ package cloud_test import ( "context" "fmt" - "strings" "testing" "github.com/grafana/grafana-com-public-clients/go/gcom" @@ -87,24 +86,14 @@ func testAccGrafanaAuthCheckServiceAccounts(stack *gcom.FormattedApiInstance, ex return fmt.Errorf("failed to get service accounts: %w", err) } - var foundSAs []string - for _, sa := range response.Payload.ServiceAccounts { - if !strings.HasPrefix(sa.Name, "test-api-key-") { - foundSAs = append(foundSAs, sa.Name) - if sa.Tokens == 0 { - return fmt.Errorf("expected to find at least one token for service account %s", sa.Name) - } - } - } - - if len(foundSAs) != len(expectedSAs) { - return fmt.Errorf("expected %d keys, got %d", len(expectedSAs), len(foundSAs)) - } for _, expectedSA := range expectedSAs { found := false - for _, foundSA := range foundSAs { - if expectedSA == foundSA { + for _, sa := range response.Payload.ServiceAccounts { + if sa.Name == expectedSA { found = true + if sa.Tokens == 0 { + return fmt.Errorf("expected to find at least one token for service account %s", sa.Name) + } break } }