From 388e85c9d0cf769c34e3ba4e5f7407e2dfd54fc4 Mon Sep 17 00:00:00 2001 From: Modular Magician Date: Wed, 28 Feb 2024 18:00:28 +0000 Subject: [PATCH] Add `project_from_id` provider-defined function (#10021) [upstream:8c31f796e6398267d8a383b1e804dd28837440bb] Signed-off-by: Modular Magician --- .changelog/10021.txt | 3 + google-beta/functions/element_from_id.go | 44 +++++++ .../element_from_id_internal_test.go | 97 +++++++++++++++ google-beta/functions/main.go | 3 - google-beta/functions/project_from_id.go | 61 ++++++++++ .../project_from_id_internal_test.go | 115 ++++++++++++++++++ google-beta/functions/project_from_id_test.go | 96 +++++++++++++++ google-beta/fwprovider/framework_provider.go | 5 +- .../functions/project_from_id.html.markdown | 61 ++++++++++ 9 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 .changelog/10021.txt create mode 100644 google-beta/functions/element_from_id.go create mode 100644 google-beta/functions/element_from_id_internal_test.go delete mode 100644 google-beta/functions/main.go create mode 100644 google-beta/functions/project_from_id.go create mode 100644 google-beta/functions/project_from_id_internal_test.go create mode 100644 google-beta/functions/project_from_id_test.go create mode 100644 website/docs/functions/project_from_id.html.markdown diff --git a/.changelog/10021.txt b/.changelog/10021.txt new file mode 100644 index 0000000000..475b6be445 --- /dev/null +++ b/.changelog/10021.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +provider: added provider-defined function `project_from_id` for retrieving the project id from a resource's self link or id +``` \ No newline at end of file diff --git a/google-beta/functions/element_from_id.go b/google-beta/functions/element_from_id.go new file mode 100644 index 0000000000..d362a61b95 --- /dev/null +++ b/google-beta/functions/element_from_id.go @@ -0,0 +1,44 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package functions + +import ( + "fmt" + "regexp" + + "github.com/hashicorp/terraform-plugin-framework/function" +) + +const noMatchesErrorSummary string = "No matches present in the input string" +const ambiguousMatchesWarningSummary string = "Ambiguous input string could contain more than one match" + +// ValidateElementFromIdArguments is reusable validation logic used in provider-defined functions that use the getElementFromId function +func ValidateElementFromIdArguments(input string, regex *regexp.Regexp, pattern string, resp *function.RunResponse) { + submatches := regex.FindAllStringSubmatchIndex(input, -1) + + // Zero matches means unusable input; error returned + if len(submatches) == 0 { + resp.Diagnostics.AddArgumentError( + 0, + noMatchesErrorSummary, + fmt.Sprintf("The input string \"%s\" doesn't contain the expected pattern \"%s\".", input, pattern), + ) + } + + // >1 matches means input usable but not ideal; issue warning + if len(submatches) > 1 { + resp.Diagnostics.AddArgumentWarning( + 0, + ambiguousMatchesWarningSummary, + fmt.Sprintf("The input string \"%s\" contains more than one match for the pattern \"%s\". Terraform will use the first found match.", input, pattern), + ) + } +} + +// GetElementFromId is reusable logic that is used in multiple provider-defined functions for pulling elements out of self links and ids of resources and data sources +func GetElementFromId(input string, regex *regexp.Regexp, template string) string { + submatches := regex.FindAllStringSubmatchIndex(input, -1) + submatch := submatches[0] // Take the only / left-most submatch + dst := []byte{} + return string(regex.ExpandString(dst, template, input, submatch)) +} diff --git a/google-beta/functions/element_from_id_internal_test.go b/google-beta/functions/element_from_id_internal_test.go new file mode 100644 index 0000000000..1921c91269 --- /dev/null +++ b/google-beta/functions/element_from_id_internal_test.go @@ -0,0 +1,97 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package functions_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/function" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + tpg_functions "github.com/hashicorp/terraform-provider-google-beta/google-beta/functions" +) + +func TestFunctionInternals_ValidateElementFromIdArguments(t *testing.T) { + + // Values here are matched to test case values below + regex := regexp.MustCompile("two/(?P[^/]+)/") + pattern := "two/{two}/" + + cases := map[string]struct { + Input string + ExpectedElement string + ExpectError bool + ExpectWarning bool + }{ + "it sets an error in diags if no match is found": { + Input: "one/element-1/three/element-3", + ExpectError: true, + }, + "it sets a warning in diags if more than one match is found": { + Input: "two/element-2/two/element-2/two/element-2", + ExpectedElement: "element-2", + ExpectWarning: true, + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + + // Arrange + resp := function.RunResponse{ + Result: function.NewResultData(basetypes.StringValue{}), + } + + // Act + tpg_functions.ValidateElementFromIdArguments(tc.Input, regex, pattern, &resp) + + // Assert + if resp.Diagnostics.HasError() && !tc.ExpectError { + t.Fatalf("Unexpected error(s) were set in response diags: %s", resp.Diagnostics.Errors()) + } + if !resp.Diagnostics.HasError() && tc.ExpectError { + t.Fatal("Expected error(s) to be set in response diags, but there were none.") + } + if (resp.Diagnostics.WarningsCount() > 0) && !tc.ExpectWarning { + t.Fatalf("Unexpected warning(s) were set in response diags: %s", resp.Diagnostics.Warnings()) + } + if (resp.Diagnostics.WarningsCount() == 0) && tc.ExpectWarning { + t.Fatal("Expected warning(s) to be set in response diags, but there were none.") + } + }) + } +} + +func TestFunctionInternals_GetElementFromId(t *testing.T) { + + // Values here are matched to test case values below + regex := regexp.MustCompile("two/(?P[^/]+)/") + template := "$Element" + + cases := map[string]struct { + Input string + ExpectedElement string + }{ + "it can pull out a value from a string using a regex with a submatch": { + Input: "one/element-1/two/element-2/three/element-3", + ExpectedElement: "element-2", + }, + "it will pull out the first value from a string with more than one submatch": { + Input: "one/element-1/two/element-2/two/not-this-one/three/element-3", + ExpectedElement: "element-2", + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + + // Act + result := tpg_functions.GetElementFromId(tc.Input, regex, template) + + // Assert + if result != tc.ExpectedElement { + t.Fatalf("Expected function logic to retrieve %s from input %s, got %s", tc.ExpectedElement, tc.Input, result) + } + }) + } +} diff --git a/google-beta/functions/main.go b/google-beta/functions/main.go deleted file mode 100644 index f4e6aea50b..0000000000 --- a/google-beta/functions/main.go +++ /dev/null @@ -1,3 +0,0 @@ -// Copyright (c) HashiCorp, Inc. -// SPDX-License-Identifier: MPL-2.0 -package functions diff --git a/google-beta/functions/project_from_id.go b/google-beta/functions/project_from_id.go new file mode 100644 index 0000000000..4090a6b159 --- /dev/null +++ b/google-beta/functions/project_from_id.go @@ -0,0 +1,61 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package functions + +import ( + "context" + "regexp" + + "github.com/hashicorp/terraform-plugin-framework/function" +) + +var _ function.Function = ProjectFromIdFunction{} + +func NewProjectFromIdFunction() function.Function { + return &ProjectFromIdFunction{} +} + +type ProjectFromIdFunction struct{} + +func (f ProjectFromIdFunction) Metadata(ctx context.Context, req function.MetadataRequest, resp *function.MetadataResponse) { + resp.Name = "project_from_id" +} + +func (f ProjectFromIdFunction) Definition(ctx context.Context, req function.DefinitionRequest, resp *function.DefinitionResponse) { + resp.Definition = function.Definition{ + Summary: "Returns the project 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 either return the project name from the input string or raise an error due to no project being present in the string. The function uses the presence of \"projects/{{project}}/\" in the input string to identify the project 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-project\".", + 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 ProjectFromIdFunction) 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 project id from input string + regex := regexp.MustCompile("projects/(?P[^/]+)/") // Should match the pattern below + template := "$ProjectId" // Should match the submatch identifier in the regex + pattern := "projects/{project}/" // 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 + projectId := GetElementFromId(arg0, regex, template) + resp.Diagnostics.Append(resp.Result.Set(ctx, projectId)...) +} diff --git a/google-beta/functions/project_from_id_internal_test.go b/google-beta/functions/project_from_id_internal_test.go new file mode 100644 index 0000000000..f16046c8c4 --- /dev/null +++ b/google-beta/functions/project_from_id_internal_test.go @@ -0,0 +1,115 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +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_project_from_id(t *testing.T) { + t.Parallel() + + projectId := "my-project" + + // Happy path inputs + validId := fmt.Sprintf("projects/%s/zones/us-central1-c/instances/my-instance", projectId) + validSelfLink := fmt.Sprintf("https://www.googleapis.com/compute/v1/%s", validId) + validOpStyleResourceName := fmt.Sprintf("//gkehub.googleapis.com/projects/%s/locations/us-central1/memberships/my-membership", projectId) + + // Unhappy path inputs + repetitiveInput := fmt.Sprintf("https://www.googleapis.com/compute/v1/projects/%s/projects/not-this-1/projects/not-this-2/instances/my-instance", projectId) // Multiple /projects/{{project}}/ + invalidInput := "zones/us-central1-c/instances/my-instance" + + 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(projectId)), + }, + }, + "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(projectId)), + }, + }, + "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(projectId)), + }, + }, + "it returns a warning and the first submatch when given repetitive input": { + request: function.RunRequest{ + Arguments: function.NewArgumentsData([]attr.Value{types.StringValue(repetitiveInput)}), + }, + expected: function.RunResponse{ + Result: function.NewResultData(types.StringValue(projectId)), + Diagnostics: diag.Diagnostics{ + diag.NewArgumentWarningDiagnostic( + 0, + ambiguousMatchesWarningSummary, + fmt.Sprintf("The input string \"%s\" contains more than one match for the pattern \"projects/{project}/\". Terraform will use the first found match.", repetitiveInput), + ), + }, + }, + }, + "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 \"projects/{project}/\".", 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 + NewProjectFromIdFunction().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/google-beta/functions/project_from_id_test.go b/google-beta/functions/project_from_id_test.go new file mode 100644 index 0000000000..5803175159 --- /dev/null +++ b/google-beta/functions/project_from_id_test.go @@ -0,0 +1,96 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 +package functions_test + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/acctest" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/envvar" +) + +func TestAccProviderFunction_project_from_id(t *testing.T) { + t.Parallel() + + projectId := envvar.GetTestProjectFromEnv() + projectIdRegex := regexp.MustCompile(fmt.Sprintf("^%s$", projectId)) + + context := map[string]interface{}{ + "function_name": "project_from_id", + "output_name": "project_id", + "resource_name": fmt.Sprintf("tf-test-project-id-func-%s", acctest.RandString(t, 10)), + } + + 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), projectIdRegex), + ), + }, + { + // Can get the project from a resource's self_link in one step + // Uses google_compute_subnetwork resource's self_link attribute + Config: testProviderFunction_get_project_from_resource_self_link(context), + Check: resource.ComposeTestCheckFunc( + resource.TestMatchOutput(context["output_name"].(string), projectIdRegex), + ), + }, + }, + }) +} + +func testProviderFunction_get_project_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_project_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" + } + } +} + +data "google_compute_network" "default" { + name = "default" +} + +resource "google_compute_subnetwork" "default" { + name = "%{resource_name}" + ip_cidr_range = "10.2.0.0/16" + network = data.google_compute_network.default.id +} + +output "%{output_name}" { + value = provider::google::%{function_name}(google_compute_subnetwork.default.self_link) +} +`, context) +} diff --git a/google-beta/fwprovider/framework_provider.go b/google-beta/fwprovider/framework_provider.go index a693557bee..bd3aa4a338 100644 --- a/google-beta/fwprovider/framework_provider.go +++ b/google-beta/fwprovider/framework_provider.go @@ -16,6 +16,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-provider-google-beta/google-beta/functions" "github.com/hashicorp/terraform-provider-google-beta/google-beta/fwmodels" "github.com/hashicorp/terraform-provider-google-beta/google-beta/fwtransport" "github.com/hashicorp/terraform-provider-google-beta/google-beta/services/dns" @@ -1037,5 +1038,7 @@ 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 nil + return []func() function.Function{ + functions.NewProjectFromIdFunction, + } } diff --git a/website/docs/functions/project_from_id.html.markdown b/website/docs/functions/project_from_id.html.markdown new file mode 100644 index 0000000000..5ad4278f9b --- /dev/null +++ b/website/docs/functions/project_from_id.html.markdown @@ -0,0 +1,61 @@ +--- +page_title: project_from_id Function - terraform-provider-google +description: |- + Returns the project within a provided resource id, self link, or OP style resource name. +--- + +# Function: project_from_id + +Returns the project 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" + } + } +} + +# Value is "my-project" +output "function_output" { + value = provider::google::project_from_id("https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-c/instances/my-instance") +} +``` + +### Use with the `google-beta` provider + +```terraform +terraform { + required_providers { + google-beta = { + source = "hashicorp/google-beta" + } + } +} + +# Value is "my-project" +output "function_output" { + value = provider::google-beta::project_from_id("https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-c/instances/my-instance") +} +``` + +## Signature + +```text +project_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"`