From f18154d23bc5c4cfded786186d6d3050adc1a422 Mon Sep 17 00:00:00 2001 From: Aman Mahendroo Date: Tue, 3 Nov 2026 03:22:02 +0000 Subject: [PATCH 1/3] networkconnectivity: add interconnect_key_group resource --- .../InterconnectKeyGroup.yaml | 160 ++++++++++++++++ ...onnectivity_interconnect_key_group.go.tmpl | 175 ++++++++++++++++++ ...tivity_interconnect_key_group_keys.go.tmpl | 43 +++++ ...terconnect_key_group_interconnects.go.tmpl | 35 ++++ ...tivity_interconnect_key_group_keys.go.tmpl | 73 ++++++++ ...onnectivity_interconnect_key_group.go.tmpl | 61 ++++++ ...ivity_interconnect_key_group_basic.tf.tmpl | 18 ++ ...tivity_interconnect_key_group_test.go.tmpl | 170 +++++++++++++++++ 8 files changed, 735 insertions(+) create mode 100644 mmv1/products/networkconnectivity/InterconnectKeyGroup.yaml create mode 100644 mmv1/templates/terraform/constants/network_connectivity_interconnect_key_group.go.tmpl create mode 100644 mmv1/templates/terraform/custom_expand/network_connectivity_interconnect_key_group_keys.go.tmpl create mode 100644 mmv1/templates/terraform/custom_flatten/network_connectivity_interconnect_key_group_interconnects.go.tmpl create mode 100644 mmv1/templates/terraform/custom_flatten/network_connectivity_interconnect_key_group_keys.go.tmpl create mode 100644 mmv1/templates/terraform/pre_update/network_connectivity_interconnect_key_group.go.tmpl create mode 100644 mmv1/templates/terraform/samples/services/networkconnectivity/network_connectivity_interconnect_key_group_basic.tf.tmpl create mode 100644 mmv1/third_party/terraform/services/networkconnectivity/resource_network_connectivity_interconnect_key_group_test.go.tmpl diff --git a/mmv1/products/networkconnectivity/InterconnectKeyGroup.yaml b/mmv1/products/networkconnectivity/InterconnectKeyGroup.yaml new file mode 100644 index 000000000000..aa547769002c --- /dev/null +++ b/mmv1/products/networkconnectivity/InterconnectKeyGroup.yaml @@ -0,0 +1,160 @@ +# Copyright 2026 Google Inc. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +--- +name: InterconnectKeyGroup +description: | + An InterconnectKeyGroup holds a set of customer-provided MACsec keys (CKN/CAK pairs) that one or + more Cloud Interconnect connections can use for MACsec ("bring your own key"). Link an + Interconnect to a key group with the `macsec.interconnect_key_group` field of + `google_compute_interconnect`. + + Individual keys can't be changed in place. To rotate keys, add a new key (with a `start_time` + at least 6 hours in the future) and remove the old key once the new key is active. Keys can be + added and removed while the key group is attached to Interconnects; the changes are pushed to + every linked Interconnect without disabling MACsec. +min_version: beta +references: + guides: + MACsec for Cloud Interconnect: https://cloud.google.com/network-connectivity/docs/interconnect/concepts/macsec-overview + api: https://cloud.google.com/network-connectivity/docs/reference/networkconnectivity/rest/v1beta/projects.locations.interconnectKeyGroups +docs: + warning: | + `keys.cak_wo` is a write-only argument and requires Terraform 1.11 or later. The CAK is never + stored in Terraform state or plan files, and the API never returns it. +base_url: projects/{{project}}/locations/{{region}}/interconnectKeyGroups +self_link: projects/{{project}}/locations/{{region}}/interconnectKeyGroups/{{name}} +create_url: projects/{{project}}/locations/{{region}}/interconnectKeyGroups?interconnectKeyGroupId={{name}} +update_verb: PATCH +update_mask: true +timeouts: + insert_minutes: 20 + update_minutes: 30 + delete_minutes: 20 +async: + type: OpAsync + operation: + base_url: '{{op_id}}' + actions: [create, update, delete] + result: + resource_inside_response: false +custom_code: + constants: templates/terraform/constants/network_connectivity_interconnect_key_group.go.tmpl + pre_update: templates/terraform/pre_update/network_connectivity_interconnect_key_group.go.tmpl +custom_diff: + - networkConnectivityInterconnectKeyGroupKeysCustomizeDiff +samples: + - name: network_connectivity_interconnect_key_group_basic + primary_resource_id: default + min_version: beta + steps: + - name: network_connectivity_interconnect_key_group_basic + resource_id_vars: + key_group_name: my-key-group + # The API never returns the CAK and cak_wo is write-only, so it can't be verified on import. + ignore_read_extra: + - keys.0.cak_wo +parameters: + - name: region + type: String + description: | + The region of the key group. Only Interconnects in this region can use it. + required: true + immutable: true + url_param_only: true + - name: name + type: String + description: | + The name of the key group. + required: true + immutable: true + url_param_only: true +properties: + - name: createTime + type: Time + description: | + The time the key group was created. + output: true + - name: updateTime + type: Time + description: | + The time the key group was last updated. + output: true + - name: labels + type: KeyValueLabels + description: | + Labels as key-value pairs. + - name: description + type: String + description: | + An optional description of the key group. + - name: keys + type: Array + description: | + The MACsec keys in this group. A key group can have at most 5 keys, and the start times of + any two keys must be at least 6 hours apart. When the key group is created, keys can have a + `start_time` in the past; keys added later must start at least 6 hours in the future. + + The order of the keys doesn't matter. Keys can't be changed in place: to change a key, add a + key with a new name and remove the old one. + custom_expand: templates/terraform/custom_expand/network_connectivity_interconnect_key_group_keys.go.tmpl + custom_flatten: templates/terraform/custom_flatten/network_connectivity_interconnect_key_group_keys.go.tmpl + item_type: + type: NestedObject + properties: + - name: name + type: String + required: true + diff_suppress_func: 'interconnectKeyGroupKeysDiffSuppress' + description: | + A name for this key, unique within the key group: 1-255 lowercase letters and digits. + The API only accepts alphanumeric names; uppercase letters aren't allowed because the + provider uses the name as an update-mask path (`keys.`), and mask paths are + case-converted. + validation: + regex: ^[a-z0-9]{1,255}$ + - name: startTime + type: String + required: true + diff_suppress_func: 'interconnectKeyGroupKeysDiffSuppress' + description: | + The RFC3339 timestamp from which this key takes effect, for example + `2026-10-01T00:00:00Z`. + - name: ckn + type: String + required: true + diff_suppress_func: 'interconnectKeyGroupKeysDiffSuppress' + description: | + The MACsec connectivity association key name (CKN): a 64-character hexadecimal string. + validation: + regex: ^[0-9a-fA-F]{64}$ + - name: cakWo + api_name: cak + type: String + write_only: true + description: | + The MACsec connectivity association key (CAK): a 64-character hexadecimal string. + Write-only (requires Terraform 1.11 or later): it is sent to the API but never stored in + state or plan files, and the API never returns it. Required for every key that is being + added. Changing only `cak_wo` of an existing key has no effect; to change a CAK, add a + new key and remove the old one. + validation: + regex: ^[0-9a-fA-F]{64}$ + - name: interconnects + type: Array + description: | + The names of the Interconnects that use this key group. + output: true + custom_flatten: templates/terraform/custom_flatten/network_connectivity_interconnect_key_group_interconnects.go.tmpl + item_type: + type: String diff --git a/mmv1/templates/terraform/constants/network_connectivity_interconnect_key_group.go.tmpl b/mmv1/templates/terraform/constants/network_connectivity_interconnect_key_group.go.tmpl new file mode 100644 index 000000000000..21d88e6d4634 --- /dev/null +++ b/mmv1/templates/terraform/constants/network_connectivity_interconnect_key_group.go.tmpl @@ -0,0 +1,175 @@ +{{/* + The license inside this block applies to this file + Copyright 2026 Google Inc. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ -}} +// interconnectKeyGroupCaksFromConfig returns key name -> CAK for every `keys` block in the raw +// configuration. `cak_wo` is write-only, so it is never in state or plan and can only be read from +// the raw configuration. The second return value is false if any part of the configuration is still +// unknown (for example, during plan when values come from resources that are not created yet). +func interconnectKeyGroupCaksFromConfig(raw cty.Value) (map[string]string, bool) { + caks := make(map[string]string) + if raw.IsNull() || !raw.IsKnown() { + return caks, false + } + keys := raw.GetAttr("keys") + if keys.IsNull() { + return caks, true + } + if !keys.IsKnown() { + return caks, false + } + complete := true + for it := keys.ElementIterator(); it.Next(); { + _, k := it.Element() + if k.IsNull() || !k.IsKnown() { + complete = false + continue + } + name, cak := k.GetAttr("name"), k.GetAttr("cak_wo") + if !name.IsKnown() || !cak.IsKnown() { + complete = false + continue + } + if name.IsNull() || cak.IsNull() || !name.Type().Equals(cty.String) || !cak.Type().Equals(cty.String) { + continue + } + caks[name.AsString()] = cak.AsString() + } + return caks, complete +} + +// interconnectKeyGroupSameTime reports whether two RFC3339 timestamps are the same instant. The API +// may normalize the format (for example "Z" vs "+00:00", or fractional seconds). +func interconnectKeyGroupSameTime(a, b string) bool { + if a == b { + return true + } + ta, errA := time.Parse(time.RFC3339Nano, a) + tb, errB := time.Parse(time.RFC3339Nano, b) + return errA == nil && errB == nil && ta.Equal(tb) +} + +// interconnectKeyGroupKeysByName indexes a `keys` list by key name. +func interconnectKeyGroupKeysByName(v interface{}) map[string]map[string]interface{} { + out := make(map[string]map[string]interface{}) + l, _ := v.([]interface{}) + for _, raw := range l { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + if name, _ := m["name"].(string); name != "" { + out[name] = m + } + } + return out +} + +// interconnectKeyGroupSameKeySet reports whether two keys lists contain the same keys (by name, +// start_time instant and ckn), ignoring order. cak_wo is ignored: it is write-only, and the planned +// value can still carry the config value during CustomizeDiff, which is why the generic +// unordered_list set comparison can't be used here. +func interconnectKeyGroupSameKeySet(o, n interface{}) bool { + oldKeys, newKeys := interconnectKeyGroupKeysByName(o), interconnectKeyGroupKeysByName(n) + ol, _ := o.([]interface{}) + nl, _ := n.([]interface{}) + if len(oldKeys) != len(newKeys) || len(ol) != len(oldKeys) || len(nl) != len(newKeys) { + return false + } + for name, nk := range newKeys { + ok, found := oldKeys[name] + if !found { + return false + } + ns, _ := nk["start_time"].(string) + os, _ := ok["start_time"].(string) + nc, _ := nk["ckn"].(string) + oc, _ := ok["ckn"].(string) + if !interconnectKeyGroupSameTime(os, ns) || !strings.EqualFold(oc, nc) { + return false + } + } + return true +} + +// interconnectKeyGroupKeysDiffSuppress hides diffs on keys.N.{name,start_time,ckn} when the keys +// are only reordered (same keys by name, start_time instant and ckn), and when a start_time or ckn +// is equivalent (timestamp format, hex case). keys can't use ResourceDiff.Clear (it isn't computed, +// and blocks with write-only attributes can't be computed), so reorders are suppressed per field. +func interconnectKeyGroupKeysDiffSuppress(k, old, new string, d *schema.ResourceData) bool { + if old == new { + return true + } + if strings.HasSuffix(k, ".start_time") && interconnectKeyGroupSameTime(old, new) { + return true + } + if strings.HasSuffix(k, ".ckn") && strings.EqualFold(old, new) { + return true + } + if d.Id() == "" { + return false + } + o, n := d.GetChange("keys") + return interconnectKeyGroupSameKeySet(o, n) +} + +// networkConnectivityInterconnectKeyGroupKeysCustomizeDiff enforces at plan time that: +// - key names are unique; +// - existing keys are not changed in place (the API treats individual keys as immutable; to +// change start_time or ckn, add a new key and remove the old one); +// - every key that is being added has a cak_wo. +func networkConnectivityInterconnectKeyGroupKeysCustomizeDiff(_ context.Context, diff *schema.ResourceDiff, meta interface{}) error { + if !diff.HasChange("keys") { + return nil + } + o, n := diff.GetChange("keys") + oldKeys := interconnectKeyGroupKeysByName(o) + caks, caksComplete := interconnectKeyGroupCaksFromConfig(diff.GetRawConfig()) + + seen := make(map[string]bool) + newList, _ := n.([]interface{}) + for _, raw := range newList { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + name, _ := m["name"].(string) + if name == "" { + // Unknown at plan time. + continue + } + if seen[name] { + return fmt.Errorf("keys: duplicate key name %q", name) + } + seen[name] = true + + prev, exists := oldKeys[name] + if !exists { + if caksComplete && caks[name] == "" { + return fmt.Errorf("keys: key %q is being added, so cak_wo must be set", name) + } + continue + } + + newStart, _ := m["start_time"].(string) + oldStart, _ := prev["start_time"].(string) + newCkn, _ := m["ckn"].(string) + oldCkn, _ := prev["ckn"].(string) + startChanged := newStart != "" && !interconnectKeyGroupSameTime(oldStart, newStart) + cknChanged := newCkn != "" && !strings.EqualFold(oldCkn, newCkn) + if startChanged || cknChanged { + return fmt.Errorf("keys: key %q already exists and keys can't be changed in place. "+ + "To change its start_time or ckn, add a key with a new name and remove %q "+ + "(or remove %q in one apply and add it back in a later apply)", name, name, name) + } + } + return nil +} diff --git a/mmv1/templates/terraform/custom_expand/network_connectivity_interconnect_key_group_keys.go.tmpl b/mmv1/templates/terraform/custom_expand/network_connectivity_interconnect_key_group_keys.go.tmpl new file mode 100644 index 000000000000..f42b1063821a --- /dev/null +++ b/mmv1/templates/terraform/custom_expand/network_connectivity_interconnect_key_group_keys.go.tmpl @@ -0,0 +1,43 @@ +{{/* + The license inside this block applies to this file + Copyright 2026 Google Inc. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ -}} +// The API models keys as a map keyed by key name; Terraform models them as a list (write-only +// arguments aren't allowed inside sets). cak_wo is write-only, so it's read from the raw +// configuration by key name instead of from v. +func expand{{$.GetPrefix}}{{$.TitlelizeProperty}}(v interface{}, d tpgresource.TerraformResourceData, config *transport_tpg.Config) (interface{}, error) { + l, _ := v.([]interface{}) + if len(l) == 0 { + return nil, nil + } + caks, _ := interconnectKeyGroupCaksFromConfig(d.GetRawConfig()) + keys := make(map[string]interface{}, len(l)) + for _, raw := range l { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + name, _ := m["name"].(string) + if name == "" { + continue + } + key := map[string]interface{}{ + "name": name, + "startTime": m["start_time"], + "ckn": m["ckn"], + } + if cak := caks[name]; cak != "" { + key["cak"] = cak + } + keys[name] = key + } + return keys, nil +} diff --git a/mmv1/templates/terraform/custom_flatten/network_connectivity_interconnect_key_group_interconnects.go.tmpl b/mmv1/templates/terraform/custom_flatten/network_connectivity_interconnect_key_group_interconnects.go.tmpl new file mode 100644 index 000000000000..d186ab7ca02c --- /dev/null +++ b/mmv1/templates/terraform/custom_flatten/network_connectivity_interconnect_key_group_interconnects.go.tmpl @@ -0,0 +1,35 @@ +{{/* + The license inside this block applies to this file + Copyright 2026 Google Inc. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ -}} +// The API returns a map of Interconnect name -> {name}. Expose the sorted names. +func flatten{{$.GetPrefix}}{{$.TitlelizeProperty}}(v interface{}, d *schema.ResourceData, config *transport_tpg.Config) interface{} { + m, ok := v.(map[string]interface{}) + if !ok || len(m) == 0 { + return nil + } + names := make([]string, 0, len(m)) + for k, raw := range m { + name := k + if status, ok := raw.(map[string]interface{}); ok { + if n, _ := status["name"].(string); n != "" { + name = n + } + } + names = append(names, name) + } + sort.Strings(names) + out := make([]interface{}, len(names)) + for i, n := range names { + out[i] = n + } + return out +} diff --git a/mmv1/templates/terraform/custom_flatten/network_connectivity_interconnect_key_group_keys.go.tmpl b/mmv1/templates/terraform/custom_flatten/network_connectivity_interconnect_key_group_keys.go.tmpl new file mode 100644 index 000000000000..321ad1a05d6d --- /dev/null +++ b/mmv1/templates/terraform/custom_flatten/network_connectivity_interconnect_key_group_keys.go.tmpl @@ -0,0 +1,73 @@ +{{/* + The license inside this block applies to this file + Copyright 2026 Google Inc. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ -}} +// Converts the API's keys map back into a list. Keys already in state keep their position (so +// reordering in config never causes a diff) and keep their configured formatting when the API value +// is equivalent; keys only known to the API are appended in name order. The CAK is never returned by +// the API and cak_wo is write-only, so it's never set here. +func flatten{{$.GetPrefix}}{{$.TitlelizeProperty}}(v interface{}, d *schema.ResourceData, config *transport_tpg.Config) interface{} { + apiKeys, ok := v.(map[string]interface{}) + if !ok || len(apiKeys) == 0 { + return nil + } + + flattenKey := func(name string, apiKey interface{}, prior map[string]interface{}) map[string]interface{} { + k, _ := apiKey.(map[string]interface{}) + startTime, _ := k["startTime"].(string) + ckn, _ := k["ckn"].(string) + if prior != nil { + if p, _ := prior["start_time"].(string); interconnectKeyGroupSameTime(p, startTime) { + startTime = p + } + if p, _ := prior["ckn"].(string); strings.EqualFold(p, ckn) { + ckn = p + } + } + if n, _ := k["name"].(string); n != "" { + name = n + } + return map[string]interface{}{ + "name": name, + "start_time": startTime, + "ckn": ckn, + } + } + + transformed := make([]interface{}, 0, len(apiKeys)) + seen := make(map[string]bool, len(apiKeys)) + prior, _ := d.Get("keys").([]interface{}) + for _, raw := range prior { + p, ok := raw.(map[string]interface{}) + if !ok { + continue + } + name, _ := p["name"].(string) + apiKey, found := apiKeys[name] + if !found || seen[name] { + continue + } + transformed = append(transformed, flattenKey(name, apiKey, p)) + seen[name] = true + } + + remaining := make([]string, 0, len(apiKeys)) + for name := range apiKeys { + if !seen[name] { + remaining = append(remaining, name) + } + } + sort.Strings(remaining) + for _, name := range remaining { + transformed = append(transformed, flattenKey(name, apiKeys[name], nil)) + } + return transformed +} diff --git a/mmv1/templates/terraform/pre_update/network_connectivity_interconnect_key_group.go.tmpl b/mmv1/templates/terraform/pre_update/network_connectivity_interconnect_key_group.go.tmpl new file mode 100644 index 000000000000..08c639123050 --- /dev/null +++ b/mmv1/templates/terraform/pre_update/network_connectivity_interconnect_key_group.go.tmpl @@ -0,0 +1,61 @@ +{{/* + The license inside this block applies to this file + Copyright 2026 Google Inc. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ -}} +// Individual keys are immutable in the API. Instead of replacing the whole keys map (which would +// require re-sending every existing CAK), send only the keys being added or removed, each as its own +// "keys." update-mask path. A path whose key is absent from the body removes that key. +// In-place changes to existing keys are rejected at plan time by the CustomizeDiff. +if d.HasChange("keys") { + o, n := d.GetChange("keys") + oldKeys := interconnectKeyGroupKeysByName(o) + newKeys := interconnectKeyGroupKeysByName(n) + allKeys, _ := obj["keys"].(map[string]interface{}) + + keysBody := make(map[string]interface{}) + var keyPaths []string + for name := range newKeys { + if _, existed := oldKeys[name]; !existed { + keyPaths = append(keyPaths, "keys."+name) + if k, ok := allKeys[name]; ok { + keysBody[name] = k + } + } + } + for name := range oldKeys { + if _, kept := newKeys[name]; !kept { + keyPaths = append(keyPaths, "keys."+name) + } + } + sort.Strings(keyPaths) + + newMask := make([]string, 0, len(updateMask)+len(keyPaths)) + for _, p := range updateMask { + if p != "keys" { + newMask = append(newMask, p) + } + } + updateMask = append(newMask, keyPaths...) + + if len(keysBody) > 0 { + obj["keys"] = keysBody + } else { + delete(obj, "keys") + } + + url, err = transport_tpg.AddQueryParams(url, map[string]string{"updateMask": strings.Join(updateMask, ",")}) + if err != nil { + return err + } +} else { + // keys isn't in the update mask, so don't send any CAKs. + delete(obj, "keys") +} diff --git a/mmv1/templates/terraform/samples/services/networkconnectivity/network_connectivity_interconnect_key_group_basic.tf.tmpl b/mmv1/templates/terraform/samples/services/networkconnectivity/network_connectivity_interconnect_key_group_basic.tf.tmpl new file mode 100644 index 000000000000..61f7e1245d77 --- /dev/null +++ b/mmv1/templates/terraform/samples/services/networkconnectivity/network_connectivity_interconnect_key_group_basic.tf.tmpl @@ -0,0 +1,18 @@ +resource "google_network_connectivity_interconnect_key_group" "{{$.PrimaryResourceId}}" { + provider = google-beta + name = "{{index $.ResourceIdVars "key_group_name"}}" + region = "us-east4" + description = "MACsec keys for my Interconnects" + labels = { + env = "test" + } + + keys { + name = "key1" + start_time = "2026-01-01T00:00:00Z" + ckn = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + # Write-only (Terraform 1.11+): sent to the API but never stored in state or plan files. + # In real configurations, pass this from an ephemeral variable or ephemeral resource. + cak_wo = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + } +} diff --git a/mmv1/third_party/terraform/services/networkconnectivity/resource_network_connectivity_interconnect_key_group_test.go.tmpl b/mmv1/third_party/terraform/services/networkconnectivity/resource_network_connectivity_interconnect_key_group_test.go.tmpl new file mode 100644 index 000000000000..69d412530a17 --- /dev/null +++ b/mmv1/third_party/terraform/services/networkconnectivity/resource_network_connectivity_interconnect_key_group_test.go.tmpl @@ -0,0 +1,170 @@ +package networkconnectivity_test +{{- if ne $.TargetVersionName "ga" }} + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + + "github.com/hashicorp/terraform-provider-google/google/acctest" +) + +// Covers the custom keys handling: updating other fields without re-sending keys, reordering keys +// without a diff, removing a key (update mask `keys.`), and import (cak_wo is write-only and +// never returned by the API). Adding a key after creation needs a start_time at least 6 hours in +// the future, which can't be replayed by VCR, so that path is covered by manual testing. +func TestAccNetworkConnectivityInterconnectKeyGroup_update(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "random_suffix": acctest.RandString(t, 10), + } + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderBetaFactories(t), + CheckDestroy: testAccCheckNetworkConnectivityInterconnectKeyGroupDestroyProducer(t), + Steps: []resource.TestStep{ + { + Config: testAccNetworkConnectivityInterconnectKeyGroup_twoKeys(context), + }, + { + ResourceName: "google_network_connectivity_interconnect_key_group.default", + ImportState: true, + ImportStateVerify: true, + // The API never returns the CAK and cak_wo is write-only. + ImportStateVerifyIgnore: []string{"keys.0.cak_wo", "keys.1.cak_wo", "labels", "terraform_labels"}, + }, + { + Config: testAccNetworkConnectivityInterconnectKeyGroup_twoKeysNewDescription(context), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("google_network_connectivity_interconnect_key_group.default", plancheck.ResourceActionUpdate), + }, + }, + }, + { + // Reordering keys is not a diff. + Config: testAccNetworkConnectivityInterconnectKeyGroup_twoKeysReordered(context), + ConfigPlanChecks: resource.ConfigPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectEmptyPlan(), + }, + }, + }, + { + Config: testAccNetworkConnectivityInterconnectKeyGroup_secondKeyOnly(context), + }, + { + ResourceName: "google_network_connectivity_interconnect_key_group.default", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"keys.0.cak_wo", "labels", "terraform_labels"}, + }, + }, + }) +} + +func testAccNetworkConnectivityInterconnectKeyGroup_twoKeys(context map[string]interface{}) string { + return acctest.Nprintf(` +resource "google_network_connectivity_interconnect_key_group" "default" { + provider = google-beta + name = "tf-test-ikg-%{random_suffix}" + region = "us-east4" + description = "initial description" + labels = { + env = "test" + } + + keys { + name = "key1" + start_time = "2026-01-01T00:00:00Z" + ckn = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + cak_wo = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + } + keys { + name = "key2" + start_time = "2026-01-02T00:00:00Z" + ckn = "1123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + cak_wo = "eedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + } +} +`, context) +} + +func testAccNetworkConnectivityInterconnectKeyGroup_twoKeysNewDescription(context map[string]interface{}) string { + return acctest.Nprintf(` +resource "google_network_connectivity_interconnect_key_group" "default" { + provider = google-beta + name = "tf-test-ikg-%{random_suffix}" + region = "us-east4" + description = "updated description" + labels = { + env = "test" + } + + keys { + name = "key1" + start_time = "2026-01-01T00:00:00Z" + ckn = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + cak_wo = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + } + keys { + name = "key2" + start_time = "2026-01-02T00:00:00Z" + ckn = "1123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + cak_wo = "eedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + } +} +`, context) +} + +func testAccNetworkConnectivityInterconnectKeyGroup_twoKeysReordered(context map[string]interface{}) string { + return acctest.Nprintf(` +resource "google_network_connectivity_interconnect_key_group" "default" { + provider = google-beta + name = "tf-test-ikg-%{random_suffix}" + region = "us-east4" + description = "updated description" + labels = { + env = "test" + } + + keys { + name = "key2" + start_time = "2026-01-02T00:00:00Z" + ckn = "1123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + cak_wo = "eedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + } + keys { + name = "key1" + start_time = "2026-01-01T00:00:00Z" + ckn = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + cak_wo = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + } +} +`, context) +} + +func testAccNetworkConnectivityInterconnectKeyGroup_secondKeyOnly(context map[string]interface{}) string { + return acctest.Nprintf(` +resource "google_network_connectivity_interconnect_key_group" "default" { + provider = google-beta + name = "tf-test-ikg-%{random_suffix}" + region = "us-east4" + description = "updated description" + labels = { + env = "test" + } + + keys { + name = "key2" + start_time = "2026-01-02T00:00:00Z" + ckn = "1123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + cak_wo = "eedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + } +} +`, context) +} +{{- end }} From 470739469d23e3193d1554622a2c73a856dd5522 Mon Sep 17 00:00:00 2001 From: Aman Mahendroo Date: Tue, 3 Nov 2026 03:22:08 +0000 Subject: [PATCH 2/3] compute: add macsec.interconnect_key_group to interconnect --- mmv1/products/compute/Interconnect.yaml | 26 ++- .../terraform/constants/interconnect.go.tmpl | 32 ++- .../compute_interconnect_macsec.go.tmpl | 50 +++++ .../compute_interconnect.go.tmpl | 45 ++++ ...ompute_interconnect_key_group_test.go.tmpl | 209 ++++++++++++++++++ 5 files changed, 360 insertions(+), 2 deletions(-) create mode 100644 mmv1/templates/terraform/custom_flatten/compute_interconnect_macsec.go.tmpl create mode 100644 mmv1/templates/terraform/update_encoder/compute_interconnect.go.tmpl create mode 100644 mmv1/third_party/terraform/services/compute/resource_compute_interconnect_key_group_test.go.tmpl diff --git a/mmv1/products/compute/Interconnect.yaml b/mmv1/products/compute/Interconnect.yaml index 2e8430bc2e65..a1fd9464323f 100644 --- a/mmv1/products/compute/Interconnect.yaml +++ b/mmv1/products/compute/Interconnect.yaml @@ -29,6 +29,7 @@ async: base_url: '{{op_id}}' include_in_tgc_next: true custom_code: + update_encoder: templates/terraform/update_encoder/compute_interconnect.go.tmpl constants: templates/terraform/constants/interconnect.go.tmpl post_create: templates/terraform/post_create/labels.tmpl samples: @@ -337,13 +338,13 @@ properties: output: true - name: macsec type: NestedObject + custom_flatten: templates/terraform/custom_flatten/compute_interconnect_macsec.go.tmpl description: | Configuration that enables Media Access Control security (MACsec) on the Cloud Interconnect connection between Google and your on-premises router. properties: - name: preSharedKeys type: Array - required: true description: | A keychain placeholder describing a set of named key objects along with their start times. A MACsec CKN/CAK is generated for each key in the key chain. @@ -388,6 +389,29 @@ properties: traffic if the MKA session cannot be established. By default, the Interconnect connection is configured with a must-secure security policy that drops all traffic if the MKA session cannot be established with your router. + - name: interconnectKeyGroup + type: String + min_version: beta + description: | + The InterconnectKeyGroup that provides customer-provided MACsec keys for this + Interconnect, in the format + `projects/{project}/locations/{region}/interconnectKeyGroups/{name}` (for example, the + `id` of a `google_network_connectivity_interconnect_key_group`). At least one of + `pre_shared_keys` or `interconnect_key_group` must be set. + + The API enforces these rules: + * MACsec can't be enabled when the Interconnect is created. Create it with + `macsec_enabled = false` and no `macsec` block, then enable MACsec in a later apply. + * The key group can't be removed while MACsec is enabled. Set `macsec_enabled = false` + in the same apply that removes it. + * To switch to a different key group, remove the current one in one apply (with MACsec + disabled), then add the new one in a later apply. + * To migrate from pre-shared keys without downtime, add `interconnect_key_group` while + keeping one `pre_shared_keys` entry, then remove `pre_shared_keys` in a later apply. + diff_suppress_func: 'computeInterconnectKeyGroupDiffSuppress' + at_least_one_of: + - 'macsec.0.pre_shared_keys' + - 'macsec.0.interconnect_key_group' - name: macsecEnabled type: Boolean description: | diff --git a/mmv1/templates/terraform/constants/interconnect.go.tmpl b/mmv1/templates/terraform/constants/interconnect.go.tmpl index a9e930ed2169..a2e3b940d2fe 100644 --- a/mmv1/templates/terraform/constants/interconnect.go.tmpl +++ b/mmv1/templates/terraform/constants/interconnect.go.tmpl @@ -1,3 +1,33 @@ func InterconnectTypeDiffSuppress(k, old, new string, d *schema.ResourceData) bool { return old == "IT_PRIVATE" && new == "DEDICATED" -} \ No newline at end of file +} + +// computeInterconnectKeyGroupDiffSuppress treats two InterconnectKeyGroup references as equal when +// they name the same key group: full URL vs relative name, and project number vs project ID. +func computeInterconnectKeyGroupDiffSuppress(_, old, new string, _ *schema.ResourceData) bool { + if old == new { + return true + } + parse := func(s string) []string { + if i := strings.Index(s, "projects/"); i >= 0 { + s = s[i:] + } + return strings.Split(strings.Trim(s, "/"), "/") + } + o, n := parse(old), parse(new) + valid := func(p []string) bool { + return len(p) == 6 && p[0] == "projects" && p[2] == "locations" && p[4] == "interconnectKeyGroups" + } + if !valid(o) || !valid(n) || o[3] != n[3] || o[5] != n[5] { + return false + } + if o[1] == n[1] { + return true + } + // The API may return the project number where the config uses the project ID, or vice versa. + isNumber := func(s string) bool { + _, err := strconv.ParseUint(s, 10, 64) + return err == nil + } + return isNumber(o[1]) != isNumber(n[1]) +} diff --git a/mmv1/templates/terraform/custom_flatten/compute_interconnect_macsec.go.tmpl b/mmv1/templates/terraform/custom_flatten/compute_interconnect_macsec.go.tmpl new file mode 100644 index 000000000000..b54f0f2955e0 --- /dev/null +++ b/mmv1/templates/terraform/custom_flatten/compute_interconnect_macsec.go.tmpl @@ -0,0 +1,50 @@ +{{/* + The license inside this block applies to this file + Copyright 2026 Google Inc. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ -}} +// After MACsec configuration is removed (pre-shared keys and key group cleared), the API keeps +// returning an empty `macsec` object with `failOpen: false`. Treat that as "no macsec block" so +// that a config without a macsec block doesn't show a permanent diff. +func flatten{{$.GetPrefix}}{{$.TitlelizeProperty}}(v interface{}, d *schema.ResourceData, config *transport_tpg.Config) interface{} { + original, ok := v.(map[string]interface{}) + if !ok || len(original) == 0 { + return nil + } + psks, _ := original["preSharedKeys"].([]interface{}) + failOpen, _ := original["failOpen"].(bool) + kg, _ := original["interconnectKeyGroup"].(string) + if len(psks) == 0 && kg == "" && !failOpen { + return nil + } + transformed := map[string]interface{}{ + "fail_open": failOpen, + } + pskList := make([]interface{}, 0, len(psks)) + for _, raw := range psks { + p, ok := raw.(map[string]interface{}) + if !ok || len(p) == 0 { + continue + } + psk := map[string]interface{}{ + "name": p["name"], + "start_time": p["startTime"], + } + if fo, ok := p["failOpen"]; ok { + psk["fail_open"] = fo + } + pskList = append(pskList, psk) + } + transformed["pre_shared_keys"] = pskList +{{- if ne $.ResourceMetadata.TargetVersionName "ga" }} + transformed["interconnect_key_group"] = kg +{{- end }} + return []interface{}{transformed} +} diff --git a/mmv1/templates/terraform/update_encoder/compute_interconnect.go.tmpl b/mmv1/templates/terraform/update_encoder/compute_interconnect.go.tmpl new file mode 100644 index 000000000000..01998701c047 --- /dev/null +++ b/mmv1/templates/terraform/update_encoder/compute_interconnect.go.tmpl @@ -0,0 +1,45 @@ +{{/* + The license inside this block applies to this file + Copyright 2026 Google Inc. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ -}} +// macsecEnabled=false is an empty value, so the generated code leaves it out of the PATCH body and +// MACsec stays enabled. Send it explicitly when it is being turned off. (Skip the setLabels body.) +if _, isSetLabels := obj["labelFingerprint"]; !isSetLabels && d.HasChange("macsec_enabled") && !d.Get("macsec_enabled").(bool) { + obj["macsecEnabled"] = false +} +{{- if ne $.TargetVersionName "ga" }} +// Compute PATCH leaves omitted fields unchanged, and the generated code omits empty values. Send +// explicit empty values when the user removes the key group or all pre-shared keys, so that they +// are actually cleared on the Interconnect (detaching a key group, or the final step of a +// pre-shared key -> key group migration). +// This encoder also runs for the setLabels request; leave that body alone. +if _, isSetLabels := obj["labelFingerprint"]; isSetLabels { + return obj, nil +} +oldKg, newKg := d.GetChange("macsec.0.interconnect_key_group") +oldPsks, newPsks := d.GetChange("macsec.0.pre_shared_keys") +clearKg := oldKg.(string) != "" && newKg.(string) == "" +clearPsks := len(oldPsks.([]interface{})) > 0 && len(newPsks.([]interface{})) == 0 +if clearKg || clearPsks { + macsec, ok := obj["macsec"].(map[string]interface{}) + if !ok || macsec == nil { + macsec = make(map[string]interface{}) + } + if clearKg { + macsec["interconnectKeyGroup"] = nil + } + if clearPsks { + macsec["preSharedKeys"] = []interface{}{} + } + obj["macsec"] = macsec +} +{{- end }} +return obj, nil diff --git a/mmv1/third_party/terraform/services/compute/resource_compute_interconnect_key_group_test.go.tmpl b/mmv1/third_party/terraform/services/compute/resource_compute_interconnect_key_group_test.go.tmpl new file mode 100644 index 000000000000..946af7d9842a --- /dev/null +++ b/mmv1/third_party/terraform/services/compute/resource_compute_interconnect_key_group_test.go.tmpl @@ -0,0 +1,209 @@ +package compute_test +{{- if ne $.TargetVersionName "ga" }} + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + + "github.com/hashicorp/terraform-provider-google/google/acctest" + _ "github.com/hashicorp/terraform-provider-google/google/services/compute" + _ "github.com/hashicorp/terraform-provider-google/google/services/networkconnectivity" +) + +// Covers macsec.interconnect_key_group: migrating from pre-shared keys to a key group with MACsec +// on (PSK -> PSK + key group -> key group), import, and detaching (MACsec off and the key group +// removed in the same apply, which must clear the field on the API and leave a clean plan). +func TestAccComputeInterconnect_interconnectKeyGroup(t *testing.T) { + t.Parallel() + + context := map[string]interface{}{ + "random_suffix": acctest.RandString(t, 10), + } + + acctest.VcrTest(t, resource.TestCase{ + PreCheck: func() { acctest.AccTestPreCheck(t) }, + ProtoV5ProviderFactories: acctest.ProtoV5ProviderBetaFactories(t), + CheckDestroy: testAccCheckComputeInterconnectDestroyProducer(t), + Steps: []resource.TestStep{ + { + // MACsec can't be enabled on create. + Config: testAccComputeInterconnect_interconnectKeyGroupMacsecOff(context), + }, + { + Config: testAccComputeInterconnect_interconnectKeyGroupPsk(context), + }, + { + Config: testAccComputeInterconnect_interconnectKeyGroupPskAndKeyGroup(context), + }, + { + Config: testAccComputeInterconnect_interconnectKeyGroupKeyGroup(context), + }, + { + ResourceName: "google_compute_interconnect.example-interconnect", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"labels", "location", "terraform_labels"}, + }, + { + // Detach: MACsec off and the macsec block removed in one apply. + Config: testAccComputeInterconnect_interconnectKeyGroupMacsecOff(context), + }, + { + ResourceName: "google_compute_interconnect.example-interconnect", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{"labels", "location", "terraform_labels"}, + }, + }, + }) +} + +func testAccComputeInterconnect_interconnectKeyGroupMacsecOff(context map[string]interface{}) string { + return acctest.Nprintf(` +data "google_project" "project" { + provider = google-beta +} + +resource "google_network_connectivity_interconnect_key_group" "default" { + provider = google-beta + name = "tf-test-ikg-%{random_suffix}" + region = "us-east4" + keys { + name = "key1" + start_time = "2026-01-01T00:00:00Z" + ckn = "2123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + cak_wo = "dedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + } +} + +resource "google_compute_interconnect" "example-interconnect" { + provider = google-beta + name = "tf-test-example-interconnect%{random_suffix}" + customer_name = "internal_customer" # Special customer only available for Google testing. + interconnect_type = "DEDICATED" + link_type = "LINK_TYPE_ETHERNET_100G_LR" # MACsec is always available on 100G links. + location = "https://www.googleapis.com/compute/v1/projects/${data.google_project.project.project_id}/global/interconnectLocations/z2z-us-east4-zone1-pniada-z" # Special location only available for Google testing. + requested_link_count = 1 + admin_enabled = true + macsec_enabled = false + noc_contact_email = "user@example.com" +} +`, context) +} + +func testAccComputeInterconnect_interconnectKeyGroupPsk(context map[string]interface{}) string { + return acctest.Nprintf(` +data "google_project" "project" { + provider = google-beta +} + +resource "google_network_connectivity_interconnect_key_group" "default" { + provider = google-beta + name = "tf-test-ikg-%{random_suffix}" + region = "us-east4" + keys { + name = "key1" + start_time = "2026-01-01T00:00:00Z" + ckn = "2123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + cak_wo = "dedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + } +} + +resource "google_compute_interconnect" "example-interconnect" { + provider = google-beta + name = "tf-test-example-interconnect%{random_suffix}" + customer_name = "internal_customer" # Special customer only available for Google testing. + interconnect_type = "DEDICATED" + link_type = "LINK_TYPE_ETHERNET_100G_LR" # MACsec is always available on 100G links. + location = "https://www.googleapis.com/compute/v1/projects/${data.google_project.project.project_id}/global/interconnectLocations/z2z-us-east4-zone1-pniada-z" # Special location only available for Google testing. + requested_link_count = 1 + admin_enabled = true + macsec_enabled = true + noc_contact_email = "user@example.com" + macsec { + pre_shared_keys { + name = "test-key" + start_time = "2023-07-01T21:00:01.000Z" + } + } +} +`, context) +} + +func testAccComputeInterconnect_interconnectKeyGroupPskAndKeyGroup(context map[string]interface{}) string { + return acctest.Nprintf(` +data "google_project" "project" { + provider = google-beta +} + +resource "google_network_connectivity_interconnect_key_group" "default" { + provider = google-beta + name = "tf-test-ikg-%{random_suffix}" + region = "us-east4" + keys { + name = "key1" + start_time = "2026-01-01T00:00:00Z" + ckn = "2123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + cak_wo = "dedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + } +} + +resource "google_compute_interconnect" "example-interconnect" { + provider = google-beta + name = "tf-test-example-interconnect%{random_suffix}" + customer_name = "internal_customer" # Special customer only available for Google testing. + interconnect_type = "DEDICATED" + link_type = "LINK_TYPE_ETHERNET_100G_LR" # MACsec is always available on 100G links. + location = "https://www.googleapis.com/compute/v1/projects/${data.google_project.project.project_id}/global/interconnectLocations/z2z-us-east4-zone1-pniada-z" # Special location only available for Google testing. + requested_link_count = 1 + admin_enabled = true + macsec_enabled = true + noc_contact_email = "user@example.com" + macsec { + interconnect_key_group = google_network_connectivity_interconnect_key_group.default.id + pre_shared_keys { + name = "test-key" + start_time = "2023-07-01T21:00:01.000Z" + } + } +} +`, context) +} + +func testAccComputeInterconnect_interconnectKeyGroupKeyGroup(context map[string]interface{}) string { + return acctest.Nprintf(` +data "google_project" "project" { + provider = google-beta +} + +resource "google_network_connectivity_interconnect_key_group" "default" { + provider = google-beta + name = "tf-test-ikg-%{random_suffix}" + region = "us-east4" + keys { + name = "key1" + start_time = "2026-01-01T00:00:00Z" + ckn = "2123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + cak_wo = "dedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210" + } +} + +resource "google_compute_interconnect" "example-interconnect" { + provider = google-beta + name = "tf-test-example-interconnect%{random_suffix}" + customer_name = "internal_customer" # Special customer only available for Google testing. + interconnect_type = "DEDICATED" + link_type = "LINK_TYPE_ETHERNET_100G_LR" # MACsec is always available on 100G links. + location = "https://www.googleapis.com/compute/v1/projects/${data.google_project.project.project_id}/global/interconnectLocations/z2z-us-east4-zone1-pniada-z" # Special location only available for Google testing. + requested_link_count = 1 + admin_enabled = true + macsec_enabled = true + noc_contact_email = "user@example.com" + macsec { + interconnect_key_group = google_network_connectivity_interconnect_key_group.default.id + } +} +`, context) +} +{{- end }} From 5d1c63f335c7c771a7e6538a561bf72edeee024d Mon Sep 17 00:00:00 2001 From: Aman Mahendroo Date: Tue, 3 Nov 2026 03:22:15 +0000 Subject: [PATCH 3/3] Re-run VCR