Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Release provider-defined functions #10288

Merged
merged 20 commits into from
Mar 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
566f948
Sync main branch into FEATURE-BRANCH-provider-functions (#10032)
SarahFrench Feb 22, 2024
2b6ef53
Add support for provider-defined functions (#10013)
SarahFrench Feb 22, 2024
8c31f79
Add `project_from_id` provider-defined function (#10021)
SarahFrench Feb 28, 2024
880aad8
Add `location_from_id` provider-defined function (#10061)
SarahFrench Mar 1, 2024
cfe060a
Sync main feature branch provider functions (#10138)
SarahFrench Mar 8, 2024
f830c0a
Skip provider-defined functions' acc tests in VCR mode (#10156)
SarahFrench Mar 11, 2024
5e798ac
Add `region_from_zone` provider function (#10073)
BBBmau Mar 11, 2024
9ea00b9
Refactor `location_from_id` acc test to provide its own location valu…
SarahFrench Mar 12, 2024
194527b
Refactor `project_from_id` acc test to use non-networking resources (…
SarahFrench Mar 12, 2024
be5cdb0
Add `region_from_id` and `zone_from_id` provider-defined functions (#…
SarahFrench Mar 12, 2024
d1fbbd6
Fix node type in region_from_id acc test (#10177)
SarahFrench Mar 13, 2024
533afb6
Sync main into FEATURE-BRANCH-provider-functions (#10198)
SarahFrench Mar 15, 2024
f6c50c4
Add resource `name_from_id` provider-defined function (#10106)
SarahFrench Mar 15, 2024
139ef1c
Fix acceptance test for `name_from_id` function (#10262)
SarahFrench Mar 22, 2024
f0f29d2
Update hashicorp/terraform-plugin-framework, terraform-plugin-mux, te…
SarahFrench Mar 22, 2024
06c363e
Update example usage of location_from_id, project_from_id, and region…
SarahFrench Mar 23, 2024
5846824
Update hashicorp/terraform-plugin-framework to v1.7.0 (#10257)
SarahFrench Mar 25, 2024
8821b6c
Sync main feature branch provider functions (#10273)
SarahFrench Mar 26, 2024
cbed6e5
Sync main feature branch provider functions (#10291)
SarahFrench Mar 26, 2024
239b060
Merge branch 'main' into FEATURE-BRANCH-provider-functions
SarahFrench Mar 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions mmv1/provider/terraform/common~compile.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@
-%>
'<%= dir -%>/<%= fname.delete_suffix(".erb") -%>': 'third_party/terraform/framework_models/<%= fname -%>'
<% end -%>
<%
Dir["third_party/terraform/functions/*.go.erb"].each do |file_path|
fname = file_path.split('/')[-1]
-%>
'<%= dir -%>/functions/<%= fname.delete_suffix(".erb") -%>': 'third_party/terraform/functions/<%= fname -%>'
<% end -%>
<%
Dir["third_party/terraform/scripts/**/*.erb"].each do |file_path|
fname = file_path.delete_prefix("third_party/terraform/")
Expand Down
7 changes: 7 additions & 0 deletions mmv1/provider/terraform/common~copy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@
'<%= dir -%>/envvar/<%= fname -%>': 'third_party/terraform/envvar/<%= fname -%>'
<% end -%>

<%
Dir["third_party/terraform/functions/*.go"].each do |file_path|
fname = file_path.split('/')[-1]
-%>
'<%= dir -%>/functions/<%= fname -%>': 'third_party/terraform/functions/<%= fname -%>'
<% end -%>

<%
Dir["third_party/terraform/scripts/**/*.*"].each do |file_path|
next if file_path.end_with?('.erb')
Expand Down
35 changes: 35 additions & 0 deletions mmv1/third_party/terraform/functions/element_from_id.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package functions

import (
"context"
"fmt"
"log"
"regexp"

"github.com/hashicorp/terraform-plugin-framework/function"
)

// ValidateElementFromIdArguments is reusable validation logic used in provider-defined functions that use the GetElementFromId function
func ValidateElementFromIdArguments(ctx context.Context, input string, regex *regexp.Regexp, pattern string, functionName string) *function.FuncError {
submatches := regex.FindAllStringSubmatchIndex(input, -1)

// Zero matches means unusable input; error returned
if len(submatches) == 0 {
return function.NewArgumentFuncError(0, fmt.Sprintf("The input string \"%s\" doesn't contain the expected pattern \"%s\".", input, pattern))
}

// >1 matches means input usable but not ideal; debug log
if len(submatches) > 1 {
log.Printf("[DEBUG] Provider-defined function %s was called with input string: %s. This contains more than one match for the pattern %s. Terraform will use the first found match.", functionName, input, pattern)
}

return nil
}

// 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))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package functions_test

import (
"context"
"regexp"
"testing"

tpg_functions "github.com/hashicorp/terraform-provider-google/google/functions"
)

func TestFunctionInternals_ValidateElementFromIdArguments(t *testing.T) {

// Values here are matched to test case values below
regex := regexp.MustCompile("two/(?P<Element>[^/]+)/")
pattern := "two/{two}/"

cases := map[string]struct {
Input string
ExpectedElement string
ExpectError bool
}{
"it sets an error if no match is found": {
Input: "one/element-1/three/element-3",
ExpectError: true,
},
"it doesn't set an error if more than one match is found": {
Input: "two/element-2/two/element-2/two/element-2",
ExpectedElement: "element-2",
},
}

for tn, tc := range cases {
t.Run(tn, func(t *testing.T) {

// Arrange
ctx := context.Background()

// Act
err := tpg_functions.ValidateElementFromIdArguments(ctx, tc.Input, regex, pattern, "function-name-here") // last arg value is inconsequential for this test

// Assert
if err != nil && !tc.ExpectError {
t.Fatalf("Unexpected error(s) were set in response diags: %s", err.Text)
}
if err == nil && tc.ExpectError {
t.Fatal("Expected error(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<Element>[^/]+)/")
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)
}
})
}
}
62 changes: 62 additions & 0 deletions mmv1/third_party/terraform/functions/location_from_id.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package functions

import (
"context"
"regexp"

"github.com/hashicorp/terraform-plugin-framework/function"
)

var _ function.Function = LocationFromIdFunction{}

func NewLocationFromIdFunction() function.Function {
return &LocationFromIdFunction{
name: "location_from_id",
}
}

type LocationFromIdFunction struct {
name string // Makes function name available in Run logic for logging purposes
}

func (f LocationFromIdFunction) Metadata(ctx context.Context, req function.MetadataRequest, resp *function.MetadataResponse) {
resp.Name = f.name
}

func (f LocationFromIdFunction) Definition(ctx context.Context, req function.DefinitionRequest, resp *function.DefinitionResponse) {
resp.Definition = function.Definition{
Summary: "Returns the location name within a provided resource id, self link, or OP style resource name.",
Description: "Takes a single string argument, which should be a resource id, self link, or OP style resource name. This function will either return the location name from the input string or raise an error due to no location being present in the string. The function uses the presence of \"locations/{{location}}/\" in the input string to identify the location name, e.g. when the function is passed the id \"projects/my-project/locations/us-central1/services/my-service\" as an argument it will return \"us-central1\".",
Parameters: []function.Parameter{
function.StringParameter{
Name: "id",
Description: "A string of a resource's id, a resource's self link, or an OP style resource name. For example, \"projects/my-project/locations/us-central1/services/my-service\" and \"https://run.googleapis.com/v2/projects/my-project/locations/us-central1/services/my-service\" are valid values containing locations",
},
},
Return: function.StringReturn{},
}
}

func (f LocationFromIdFunction) Run(ctx context.Context, req function.RunRequest, resp *function.RunResponse) {
// Load arguments from function call
var arg0 string
resp.Error = function.ConcatFuncErrors(req.Arguments.GetArgument(ctx, 0, &arg0))
if resp.Error != nil {
return
}

// Prepare how we'll identify location name from input string
regex := regexp.MustCompile("locations/(?P<LocationName>[^/]+)/") // Should match the pattern below
template := "$LocationName" // Should match the submatch identifier in the regex
pattern := "locations/{location}/" // Human-readable pseudo-regex pattern used in errors and warnings

// Validate input
resp.Error = function.ConcatFuncErrors(ValidateElementFromIdArguments(ctx, arg0, regex, pattern, f.name))
if resp.Error != nil {
return
}

// Get and return element from input string
location := GetElementFromId(arg0, regex, template)
resp.Error = function.ConcatFuncErrors(resp.Result.Set(ctx, location))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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/function"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-framework/types/basetypes"
)

func TestFunctionRun_location_from_id(t *testing.T) {
t.Parallel()

location := "us-central1"

// Happy path inputs
validId := fmt.Sprintf("projects/my-project/locations/%s/services/my-service", location)
validSelfLink := fmt.Sprintf("https://run.googleapis.com/v2/%s", validId)
validOpStyleResourceName := fmt.Sprintf("//run.googleapis.com/v2/%s", validId)

// Unhappy path inputs
repetitiveInput := fmt.Sprintf("https://run.googleapis.com/v2/projects/my-project/locations/%s/locations/not-this-one/services/my-service", location) // Multiple /locations/{{location}}/
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(location)),
},
},
"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(location)),
},
},
"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(location)),
},
},
"it returns the first submatch (with no error) when given repetitive input": {
request: function.RunRequest{
Arguments: function.NewArgumentsData([]attr.Value{types.StringValue(repetitiveInput)}),
},
expected: function.RunResponse{
Result: function.NewResultData(types.StringValue(location)),
},
},
"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()),
Error: function.NewArgumentFuncError(0, fmt.Sprintf("The input string \"%s\" doesn't contain the expected pattern \"locations/{location}/\".", 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
NewLocationFromIdFunction().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.Error, tc.expected.Error); diff != "" {
t.Errorf("unexpected diff between expected and received errors: %s", diff)
}
})
}
}
75 changes: 75 additions & 0 deletions mmv1/third_party/terraform/functions/location_from_id_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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_location_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)

location := "us-central1"
locationRegex := regexp.MustCompile(fmt.Sprintf("^%s$", location))

context := map[string]interface{}{
"function_name": "location_from_id",
"output_name": "location",
"resource_name": fmt.Sprintf("tf-test-location-id-func-%s", acctest.RandString(t, 10)),
"resource_location": location,
}

acctest.VcrTest(t, resource.TestCase{
ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactories(t),
Steps: []resource.TestStep{
{
// Can get the location from a resource's id in one step
// Uses google_cloud_run_service resource's id attribute with format projects/{project}/locations/{location}/services/{service}.
Config: testProviderFunction_get_location_from_resource_id(context),
Check: resource.ComposeTestCheckFunc(
resource.TestMatchOutput(context["output_name"].(string), locationRegex),
),
},
},
})
}

func testProviderFunction_get_location_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_cloud_run_service" "default" {
name = "%{resource_name}"
location = "%{resource_location}"

template {
spec {
containers {
image = "us-docker.pkg.dev/cloudrun/container/hello"
}
}
}

traffic {
percent = 100
latest_revision = true
}
}

output "%{output_name}" {
value = provider::google::%{function_name}(google_cloud_run_service.default.id)
}
`, context)
}
Loading
Loading