From b229e6914ebc70d4d630779cd2f42966c25cbcf7 Mon Sep 17 00:00:00 2001 From: Tavasya Ganpati Date: Mon, 2 Mar 2026 12:25:54 -0600 Subject: [PATCH 1/5] Add bob init files to gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 4f4a7c5962..61b7dca3e1 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ website/vendor # output binary terraform-provider-kubernetes __debug_bin + +# AI assistant rules +AGENTS.md +.bob/ From 5b57516fcd0f3fff6d8ec4e83683eaa19b75f9b8 Mon Sep 17 00:00:00 2001 From: Tavasya Ganpati Date: Tue, 3 Mar 2026 11:27:06 -0600 Subject: [PATCH 2/5] Update Waiter and validate and acceptace test --- .changelog/9999.txt | 3 + manifest/provider/validate.go | 9 -- manifest/provider/waiter.go | 108 +++++++++++------- .../testdata/Wait/wait_for_multiple_types.tf | 58 ++++++++++ manifest/test/acceptance/wait_test.go | 62 ++++++++++ 5 files changed, 192 insertions(+), 48 deletions(-) create mode 100644 .changelog/9999.txt create mode 100644 manifest/test/acceptance/testdata/Wait/wait_for_multiple_types.tf diff --git a/.changelog/9999.txt b/.changelog/9999.txt new file mode 100644 index 0000000000..dd5d22e730 --- /dev/null +++ b/.changelog/9999.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +resource/kubernetes_manifest: Allow multiple wait types (fields, condition, rollout) to be used together in the wait block +``` \ No newline at end of file diff --git a/manifest/provider/validate.go b/manifest/provider/validate.go index 5c6d4fb0f7..49d2ba903d 100644 --- a/manifest/provider/validate.go +++ b/manifest/provider/validate.go @@ -6,7 +6,6 @@ package provider import ( "context" "fmt" - "strings" "time" "github.com/hashicorp/terraform-plugin-go/tfprotov5" @@ -141,14 +140,6 @@ func (s *RawProviderServer) ValidateResourceTypeConfig(ctx context.Context, req waiters = append(waiters, k) } } - if len(waiters) > 1 { - resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ - Severity: tfprotov5.DiagnosticSeverityError, - Summary: "Invalid wait configuration", - Detail: fmt.Sprintf(`You may only set one of "%s".`, strings.Join(waiters, "\", \"")), - Attribute: tftypes.NewAttributePath().WithAttributeName("wait"), - }) - } } } if waitFor, ok := configVal["wait_for"]; ok && !waitFor.IsNull() { diff --git a/manifest/provider/waiter.go b/manifest/provider/waiter.go index bd47072040..5d551b6d54 100644 --- a/manifest/provider/waiter.go +++ b/manifest/provider/waiter.go @@ -58,15 +58,17 @@ func NewResourceWaiter(resource dynamic.ResourceInterface, resourceName string, return nil, err } + var waiters []Waiter + if v, ok := waitForBlockVal["rollout"]; ok { var rollout bool v.As(&rollout) if rollout { - return &RolloutWaiter{ + waiters = append(waiters, &RolloutWaiter{ resource, resourceName, hl, - }, nil + }) } } @@ -74,60 +76,68 @@ func NewResourceWaiter(resource dynamic.ResourceInterface, resourceName string, var conditionsBlocks []tftypes.Value v.As(&conditionsBlocks) if len(conditionsBlocks) > 0 { - return &ConditionsWaiter{ + waiters = append(waiters, &ConditionsWaiter{ resource, resourceName, conditionsBlocks, hl, - }, nil + }) } } fields, ok := waitForBlockVal["fields"] - if !ok || fields.IsNull() || !fields.IsKnown() { - return &NoopWaiter{}, nil - } + if ok && !fields.IsNull() && fields.IsKnown() { + if !fields.Type().Is(tftypes.Map{}) { + return nil, fmt.Errorf(`"fields" should be a map of strings`) + } - if !fields.Type().Is(tftypes.Map{}) { - return nil, fmt.Errorf(`"fields" should be a map of strings`) - } + var vm map[string]tftypes.Value + fields.As(&vm) + var matchers []FieldMatcher + + for k, v := range vm { + var expr string + v.As(&expr) + var re *regexp.Regexp + if expr == "*" { + // NOTE this is just a shorthand so the user doesn't have to + // type the expression below all the time + re = regexp.MustCompile("(.*)?") + } else { + var err error + re, err = regexp.Compile(expr) + if err != nil { + return nil, fmt.Errorf("invalid regular expression: %q", expr) + } + } - var vm map[string]tftypes.Value - fields.As(&vm) - var matchers []FieldMatcher - - for k, v := range vm { - var expr string - v.As(&expr) - var re *regexp.Regexp - if expr == "*" { - // NOTE this is just a shorthand so the user doesn't have to - // type the expression below all the time - re = regexp.MustCompile("(.*)?") - } else { - var err error - re, err = regexp.Compile(expr) + p, err := FieldPathToTftypesPath(k) if err != nil { - return nil, fmt.Errorf("invalid regular expression: %q", expr) + return nil, err } + matchers = append(matchers, FieldMatcher{p, re}) } - p, err := FieldPathToTftypesPath(k) - if err != nil { - return nil, err - } - matchers = append(matchers, FieldMatcher{p, re}) + waiters = append(waiters, &FieldWaiter{ + resource, + resourceName, + resourceType, + th, + matchers, + hl, + }) } - return &FieldWaiter{ - resource, - resourceName, - resourceType, - th, - matchers, - hl, - }, nil + // Return appropriate waiter based on count + if len(waiters) == 0 { + return &NoopWaiter{}, nil + } + + if len(waiters) == 1 { + return waiters[0], nil + } + return &CompositeWaiter{waiters: waiters, logger: hl}, nil } // FieldMatcher contains a tftypes.AttributePath to a field and a regexp to match on it @@ -237,6 +247,26 @@ func (w *NoopWaiter) Wait(_ context.Context) error { return nil } +// CompositeWaiter executes multiple waiters in sequence +type CompositeWaiter struct { + waiters []Waiter + logger hclog.Logger +} + +// Wait executes all waiters in sequence +func (w *CompositeWaiter) Wait(ctx context.Context) error { + w.logger.Info("[ApplyResourceChange][Wait] Starting composite wait...") + for i, waiter := range w.waiters { + w.logger.Info(fmt.Sprintf("[ApplyResourceChange][Wait] Executing waiter %d/%d", i+1, len(w.waiters))) + err := waiter.Wait(ctx) + if err != nil { + return fmt.Errorf("waiter %d failed: %w", i+1, err) + } + } + w.logger.Info("[ApplyResourceChange][Wait] All waiters completed successfully") + return nil +} + // FieldPathToTftypesPath takes a string representation of // a path to a field in dot/square bracket notation // and returns a tftypes.AttributePath diff --git a/manifest/test/acceptance/testdata/Wait/wait_for_multiple_types.tf b/manifest/test/acceptance/testdata/Wait/wait_for_multiple_types.tf new file mode 100644 index 0000000000..1a61346670 --- /dev/null +++ b/manifest/test/acceptance/testdata/Wait/wait_for_multiple_types.tf @@ -0,0 +1,58 @@ +# Copyright IBM Corp. 2017, 2026 +# SPDX-License-Identifier: MPL-2.0 + + +resource "kubernetes_manifest" "test" { + + manifest = { + apiVersion = "v1" + kind = "Pod" + + metadata = { + name = var.name + namespace = var.namespace + + annotations = { + "test.terraform.io" = "test" + } + + labels = { + app = "nginx" + } + } + + spec = { + containers = [ + { + name = "nginx" + image = "nginx:1.19" + + readinessProbe = { + initialDelaySeconds = 10 + + httpGet = { + path = "/" + port = 80 + } + } + } + ] + } + } + + wait { + fields = { + "status.phase" = "Running" + } + + condition { + type = "Ready" + status = "True" + } + + condition { + type = "ContainersReady" + status = "True" + } + } +} \ No newline at end of file diff --git a/manifest/test/acceptance/wait_test.go b/manifest/test/acceptance/wait_test.go index 642440be86..daac691d49 100644 --- a/manifest/test/acceptance/wait_test.go +++ b/manifest/test/acceptance/wait_test.go @@ -280,3 +280,65 @@ func TestKubernetesManifest_WaitFields_Annotations(t *testing.T) { tfstate.AssertOutputExists(t, "test") } + +func TestKubernetesManifest_WaitMultipleTypes_Pod(t *testing.T) { + ctx := context.Background() + + name := randName() + namespace := randName() + + reattachInfo, err := provider.ServeTest(ctx, hclog.Default(), t) + if err != nil { + t.Errorf("Failed to create provider instance: %q", err) + } + + tf := tfhelper.RequireNewWorkingDir(ctx, t) + tf.SetReattachInfo(ctx, reattachInfo) + defer func() { + tf.Destroy(ctx) + tf.Close() + k8shelper.AssertNamespacedResourceDoesNotExist(t, "v1", "pods", namespace, name) + }() + + k8shelper.CreateNamespace(t, namespace) + defer k8shelper.DeleteResource(t, namespace, kubernetes.NewGroupVersionResource("v1", "namespaces")) + + tfvars := TFVARS{ + "namespace": namespace, + "name": name, + } + tfconfig := loadTerraformConfig(t, "Wait/wait_for_multiple_types.tf", tfvars) + tf.SetConfig(ctx, tfconfig) + tf.Init(ctx) + + startTime := time.Now() + err = tf.Apply(ctx) + if err != nil { + t.Fatalf("Failed to apply: %q", err) + } + + k8shelper.AssertNamespacedResourceExists(t, "v1", "pods", namespace, name) + + // NOTE We set a readinessProbe in the fixture with a delay of 10s + // so the apply should take at least 10 seconds to complete. + minDuration := time.Duration(10) * time.Second + applyDuration := time.Since(startTime) + if applyDuration < minDuration { + t.Fatalf("the apply should have taken at least %s", minDuration) + } + + st, err := tf.State(ctx) + if err != nil { + t.Fatalf("Failed to get state: %q", err) + } + tfstate := tfstatehelper.NewHelper(st) + tfstate.AssertAttributeValues(t, tfstatehelper.AttributeValues{ + "kubernetes_manifest.test.wait.0.fields": map[string]interface{}{ + "status.phase": "Running", + }, + "kubernetes_manifest.test.wait.0.condition.0.type": "Ready", + "kubernetes_manifest.test.wait.0.condition.0.status": "True", + "kubernetes_manifest.test.wait.0.condition.1.type": "ContainersReady", + "kubernetes_manifest.test.wait.0.condition.1.status": "True", + }) +} From ef068245f03cbaa7a3d7e7174a90ddf350bfbc84 Mon Sep 17 00:00:00 2001 From: Tavasya Ganpati Date: Tue, 3 Mar 2026 11:41:03 -0600 Subject: [PATCH 3/5] Update manifest docs --- docs/resources/manifest.md | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/resources/manifest.md b/docs/resources/manifest.md index 0f69d902e4..fae93b8032 100644 --- a/docs/resources/manifest.md +++ b/docs/resources/manifest.md @@ -53,7 +53,7 @@ Optional: Optional: -- `condition` (Block List) (see [below for nested schema](#nestedblock--wait--condition)) +- `condition` (Block List) Wait for specific conditions to be met. Multiple condition blocks can be specified. (see [below for nested schema](#nestedblock--wait--condition)) - `fields` (Map of String) A map of paths to fields to wait for a specific field value. - `rollout` (Boolean) Wait for rollout to complete on resources that support `kubectl rollout status`. @@ -166,7 +166,7 @@ Note the import ID as the last argument to the import command. This ID points Te ## Using `wait` to block create and update calls -The `kubernetes_manifest` resource supports the ability to block create and update calls until a field is set or has a particular value by specifying the `wait` block. This is useful for when you create resources like Jobs and Services when you want to wait for something to happen after the resource is created by the API server before Terraform should consider the resource created. +The `kubernetes_manifest` resource supports the ability to block create and update calls until certain conditions are met by specifying the `wait` block. Multiple wait types can be combined in a single `wait` block, and they will be evaluated sequentially. This is useful for when you create resources like Jobs and Services when you want to wait for something to happen after the resource is created by the API server before Terraform should consider the resource created. `wait` supports supports a `fields` attribute which allows you specify a map of fields paths to regular expressions. You can also specify `*` if you just want to wait for a field to have any value. @@ -231,6 +231,38 @@ resource "kubernetes_manifest" "test" { } ``` +## Combining Multiple Wait Types + +You can combine `fields`, `condition`, and `rollout` in a single `wait` block. The provider will evaluate them sequentially in the order: rollout → conditions → fields. + +```terraform +resource "kubernetes_manifest" "test" { + manifest = { + // ... + } + + wait { + # Wait for specific field values + fields = { + "status.phase" = "Running" + } + + + # Wait for multiple conditions + condition { + type = "Ready" + status = "True" + } + + condition { + type = "ContainersReady" + status = "True" + } + } +} +``` + + ## Configuring `field_manager` The `kubernetes_manifest` exposes configuration of the field manager through the optional `field_manager` block. From 86822d8a59a3fd024f1dcaf140ffbb9cbd790811 Mon Sep 17 00:00:00 2001 From: Tavasya Ganpati Date: Tue, 3 Mar 2026 11:56:02 -0600 Subject: [PATCH 4/5] Update changelog name --- .changelog/{9999.txt => 2851.txt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .changelog/{9999.txt => 2851.txt} (100%) diff --git a/.changelog/9999.txt b/.changelog/2851.txt similarity index 100% rename from .changelog/9999.txt rename to .changelog/2851.txt From b698ad32fea84430b6dd0b77dc41c62f371285b1 Mon Sep 17 00:00:00 2001 From: Tavasya Ganpati Date: Tue, 3 Mar 2026 12:20:39 -0600 Subject: [PATCH 5/5] Update validate.go for lint --- manifest/provider/validate.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/manifest/provider/validate.go b/manifest/provider/validate.go index 49d2ba903d..b8429352cf 100644 --- a/manifest/provider/validate.go +++ b/manifest/provider/validate.go @@ -127,7 +127,6 @@ func (s *RawProviderServer) ValidateResourceTypeConfig(ctx context.Context, req if len(waitBlock) > 0 { var w map[string]tftypes.Value waitBlock[0].As(&w) - waiters := []string{} for k, ww := range w { if !ww.IsNull() { if k == "condition" { @@ -137,7 +136,6 @@ func (s *RawProviderServer) ValidateResourceTypeConfig(ctx context.Context, req continue } } - waiters = append(waiters, k) } } }