From f6c50c4ccdcb795bcc62862a5fce58f330f6c74c Mon Sep 17 00:00:00 2001 From: Sarah French <15078782+SarahFrench@users.noreply.github.com> Date: Fri, 15 Mar 2024 17:09:42 +0000 Subject: [PATCH] Add resource `name_from_id` provider-defined function (#10106) * Add `name_from_id` function, tests, docs * Fix whitespace in acc tests' HCL config * Skip provider-defined acceptance test in VCR, as VCR system doesn't use required Terraform version yet * Update documentation examples to meet feedback given in https://github.com/GoogleCloudPlatform/magic-modules/pull/10060 * Update `name_from_id` acc tests to not use networking-related resources * Update mmv1/third_party/terraform/functions/name_from_id_test.go --- .../terraform/functions/name_from_id.go | 59 +++++++++++ .../functions/name_from_id_internal_test.go | 97 +++++++++++++++++++ .../terraform/functions/name_from_id_test.go | 88 +++++++++++++++++ .../fwprovider/framework_provider.go.erb | 5 +- .../docs/functions/name_from_id.html.markdown | 70 +++++++++++++ 5 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 mmv1/third_party/terraform/functions/name_from_id.go create mode 100644 mmv1/third_party/terraform/functions/name_from_id_internal_test.go create mode 100644 mmv1/third_party/terraform/functions/name_from_id_test.go create mode 100644 mmv1/third_party/terraform/website/docs/functions/name_from_id.html.markdown diff --git a/mmv1/third_party/terraform/functions/name_from_id.go b/mmv1/third_party/terraform/functions/name_from_id.go new file mode 100644 index 000000000000..6361eaf980da --- /dev/null +++ b/mmv1/third_party/terraform/functions/name_from_id.go @@ -0,0 +1,59 @@ +package functions + +import ( + "context" + "regexp" + + "github.com/hashicorp/terraform-plugin-framework/function" +) + +var _ function.Function = NameFromIdFunction{} + +func NewNameFromIdFunction() function.Function { + return &NameFromIdFunction{} +} + +type NameFromIdFunction struct{} + +func (f NameFromIdFunction) Metadata(ctx context.Context, req function.MetadataRequest, resp *function.MetadataResponse) { + resp.Name = "name_from_id" +} + +func (f NameFromIdFunction) Definition(ctx context.Context, req function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + Summary: "Returns the short-form name of a resource within a provided resource's id, resource URI, self link, or full resource name.", + Description: "Takes a single string argument, which should be a resource's id, resource URI, self link, or full resource name. This function will return the short-form name of a resource from the input string, or raise an error due to a problem with the input string. The function returns the final element in the input string as the resource's name, e.g. when the function is passed the id \"projects/my-project/zones/us-central1-c/instances/my-instance\" as an argument it will return \"my-instance\".", + Parameters: []function.Parameter{ + function.StringParameter{ + Name: "id", + Description: "A string of a resource's id, resource URI, self link, or full resource name. For example, \"projects/my-project/zones/us-central1-c/instances/my-instance\", \"https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-c/instances/my-instance\" and \"//gkehub.googleapis.com/projects/my-project/locations/us-central1/memberships/my-membership\" are valid values", + }, + }, + Return: function.StringReturn{}, + } +} + +func (f NameFromIdFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) { + // Load arguments from function call + var arg0 string + resp.Diagnostics.Append(req.Arguments.GetArgument(ctx, 0, &arg0)...) + + if resp.Diagnostics.HasError() { + return + } + + // Prepare how we'll identify resource name from input string + regex := regexp.MustCompile("/(?P[^/]+)$") // Should match the pattern below + template := "$ResourceName" // Should match the submatch identifier in the regex + pattern := "resourceType/{name}$" // Human-readable pseudo-regex pattern used in errors and warnings + + // Validate input + ValidateElementFromIdArguments(arg0, regex, pattern, resp) + if resp.Diagnostics.HasError() { + return + } + + // Get and return element from input string + name := GetElementFromId(arg0, regex, template) + resp.Diagnostics.Append(resp.Result.Set(ctx, name)...) +} diff --git a/mmv1/third_party/terraform/functions/name_from_id_internal_test.go b/mmv1/third_party/terraform/functions/name_from_id_internal_test.go new file mode 100644 index 000000000000..1bbae9f93f25 --- /dev/null +++ b/mmv1/third_party/terraform/functions/name_from_id_internal_test.go @@ -0,0 +1,97 @@ +package functions + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +func TestFunctionRun_name_from_id(t *testing.T) { + t.Parallel() + + name := "foobar" + + // Happy path inputs + validId := fmt.Sprintf("projects/my-project/zones/us-central1-c/instances/%s", name) + validSelfLink := fmt.Sprintf("https://www.googleapis.com/compute/v1/%s", validId) + validOpStyleResourceName := fmt.Sprintf("//gkehub.googleapis.com/projects/my-project/locations/us-central1/memberships/%s", name) + + // Unhappy path inputs + invalidInput := "this isn't a URI or id" + + testCases := map[string]struct { + request function.RunRequest + expected function.RunResponse + }{ + "it returns the expected output value when given a valid resource id input": { + request: function.RunRequest{ + Arguments: function.NewArgumentsData([]attr.Value{types.StringValue(validId)}), + }, + expected: function.RunResponse{ + Result: function.NewResultData(types.StringValue(name)), + }, + }, + "it returns the expected output value when given a valid resource self_link input": { + request: function.RunRequest{ + Arguments: function.NewArgumentsData([]attr.Value{types.StringValue(validSelfLink)}), + }, + expected: function.RunResponse{ + Result: function.NewResultData(types.StringValue(name)), + }, + }, + "it returns the expected output value when given a valid OP style resource name input": { + request: function.RunRequest{ + Arguments: function.NewArgumentsData([]attr.Value{types.StringValue(validOpStyleResourceName)}), + }, + expected: function.RunResponse{ + Result: function.NewResultData(types.StringValue(name)), + }, + }, + "it returns an error when given input with no submatches": { + request: function.RunRequest{ + Arguments: function.NewArgumentsData([]attr.Value{types.StringValue(invalidInput)}), + }, + expected: function.RunResponse{ + Result: function.NewResultData(types.StringNull()), + Diagnostics: diag.Diagnostics{ + diag.NewArgumentErrorDiagnostic( + 0, + noMatchesErrorSummary, + fmt.Sprintf("The input string \"%s\" doesn't contain the expected pattern \"resourceType/{name}$\".", invalidInput), + ), + }, + }, + }, + } + + for name, testCase := range testCases { + tn, tc := name, testCase + + t.Run(tn, func(t *testing.T) { + t.Parallel() + + // Arrange + got := function.RunResponse{ + Result: function.NewResultData(basetypes.StringValue{}), + } + + // Act + NewNameFromIdFunction().Run(context.Background(), tc.request, &got) + + // Assert + if diff := cmp.Diff(got.Result, tc.expected.Result); diff != "" { + t.Errorf("unexpected diff between expected and received result: %s", diff) + } + if diff := cmp.Diff(got.Diagnostics, tc.expected.Diagnostics); diff != "" { + t.Errorf("unexpected diff between expected and received diagnostics: %s", diff) + } + }) + } +} diff --git a/mmv1/third_party/terraform/functions/name_from_id_test.go b/mmv1/third_party/terraform/functions/name_from_id_test.go new file mode 100644 index 000000000000..df6ea3c036e5 --- /dev/null +++ b/mmv1/third_party/terraform/functions/name_from_id_test.go @@ -0,0 +1,88 @@ +package functions_test + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-provider-google/google/acctest" +) + +func TestAccProviderFunction_name_from_id(t *testing.T) { + t.Parallel() + // Skipping due to requiring TF 1.8.0 in VCR systems : https://github.com/hashicorp/terraform-provider-google/issues/17451 + acctest.SkipIfVcr(t) + + context := map[string]interface{}{ + "function_name": "name_from_id", + "output_name": "name", + "resource_name": fmt.Sprintf("tf-test-name-id-func-%s", acctest.RandString(t, 10)), + } + + nameRegex := regexp.MustCompile(fmt.Sprintf("^%s$", context["resource_name"])) + + acctest.VcrTest(t, resource.TestCase{ + ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t), + Steps: []resource.TestStep{ + { + // Can get the project from a resource's id in one step + // Uses google_pubsub_topic resource's id attribute with format projects/{{project}}/topics/{{name}} + Config: testProviderFunction_get_project_from_resource_id(context), + Check: resource.ComposeTestCheckFunc( + resource.TestMatchOutput(context["output_name"].(string), nameRegex), + ), + }, + { + // Can get the project from a resource's self_link in one step + // Uses google_compute_disk resource's self_link attribute + Config: testProviderFunction_get_project_from_resource_self_link(context), + Check: resource.ComposeTestCheckFunc( + resource.TestMatchOutput(context["output_name"].(string), nameRegex), + ), + }, + }, + }) +} + +func testProviderFunction_get_name_from_resource_id(context map[string]interface{}) string { + return acctest.Nprintf(` +# terraform block required for provider function to be found +terraform { + required_providers { + google = { + source = "hashicorp/google" + } + } +} + +resource "google_pubsub_topic" "default" { + name = "%{resource_name}" +} + +output "%{output_name}" { + value = provider::google::%{function_name}(google_pubsub_topic.default.id) +} +`, context) +} + +func testProviderFunction_get_name_from_resource_self_link(context map[string]interface{}) string { + return acctest.Nprintf(` +# terraform block required for provider function to be found +terraform { + required_providers { + google = { + source = "hashicorp/google" + } + } +} + +resource "google_compute_disk" "default" { + name = "%{resource_name}" +} + +output "%{output_name}" { + value = provider::google::%{function_name}(google_compute_disk.default.self_link) +} +`, context) +} diff --git a/mmv1/third_party/terraform/fwprovider/framework_provider.go.erb b/mmv1/third_party/terraform/fwprovider/framework_provider.go.erb index 8325e059b045..0aec3eaaf331 100644 --- a/mmv1/third_party/terraform/fwprovider/framework_provider.go.erb +++ b/mmv1/third_party/terraform/fwprovider/framework_provider.go.erb @@ -303,10 +303,11 @@ func (p *FrameworkProvider) Resources(_ context.Context) []func() resource.Resou // Functions defines the provider functions implemented in the provider. func (p *FrameworkProvider) Functions(_ context.Context) []func() function.Function { return []func() function.Function{ - functions.NewProjectFromIdFunction, - functions.NewRegionFromZoneFunction, functions.NewLocationFromIdFunction, + functions.NewNameFromIdFunction, + functions.NewProjectFromIdFunction, functions.NewRegionFromIdFunction, + functions.NewRegionFromZoneFunction, functions.NewZoneFromIdFunction, } } \ No newline at end of file diff --git a/mmv1/third_party/terraform/website/docs/functions/name_from_id.html.markdown b/mmv1/third_party/terraform/website/docs/functions/name_from_id.html.markdown new file mode 100644 index 000000000000..d45b589ed8fa --- /dev/null +++ b/mmv1/third_party/terraform/website/docs/functions/name_from_id.html.markdown @@ -0,0 +1,70 @@ +--- +page_title: name_from_id Function - terraform-provider-google +description: |- + Returns the project within a provided resource id, self link, or OP style resource name. +--- + +# Function: name_from_id + +Returns the short-form name within a provided resource's id, resource URI, self link, or full resource name. + +For more information about using provider-defined functions with Terraform [see the official documentation](https://developer.hashicorp.com/terraform/plugin/framework/functions/concepts). + +## Example Usage + +### Use with the `google` provider + +```terraform +terraform { + required_providers { + google = { + source = "hashicorp/google" + } + } +} + +resource "google_pubsub_topic" "default" { + name = "my-topic" +} + +# Value is "my-topic" +output "function_output" { + value = provider::google::name_from_id(google_pubsub_topic.default.id) +} +``` + +### Use with the `google-beta` provider + +```terraform +terraform { + required_providers { + google-beta = { + source = "hashicorp/google-beta" + } + } +} + +resource "google_pubsub_topic" "default" { + # provider argument omitted - provisioning by google or google-beta doesn't impact this example + name = "my-topic" +} + +# Value is "my-topic" +output "function_output" { + value = provider::google-beta::name_from_id(google_pubsub_topic.default.id) +} +``` + +## Signature + +```text +name_from_id(id string) string +``` + +## Arguments + +1. `id` (String) A string of a resource's id, resource URI, self link, or full resource name. For example, these are all valid values: + +* `"projects/my-project/zones/us-central1-c/instances/my-instance"` +* `"https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-c/instances/my-instance"` +* `"//gkehub.googleapis.com/projects/my-project/locations/us-central1/memberships/my-membership"`