diff --git a/docs/resources/manifest.md b/docs/resources/manifest.md index 0f69d902e4..65a36aef0d 100644 --- a/docs/resources/manifest.md +++ b/docs/resources/manifest.md @@ -2,7 +2,7 @@ subcategory: "manifest" page_title: "Kubernetes: kubernetes_manifest" description: |- - The resource provides a way to create and manage custom resources + The resource provides a way to create and manage custom resources --- # kubernetes_manifest @@ -25,6 +25,7 @@ Once applied, the `object` attribute contains the state of the resource as retur - `computed_fields` (List of String) List of manifest fields whose values can be altered by the API server during 'apply'. Defaults to: ["metadata.annotations", "metadata.labels"] - `field_manager` (Block List, Max: 1) Configure field manager options. (see [below for nested schema](#nestedblock--field_manager)) - `object` (Dynamic) The resulting resource state, as returned by the API server after applying the desired state from `manifest`. +- `skip_validation` (Boolean) When set to `true`, skips validation of the manifest against the Kubernetes API server during the plan phase. This can be useful when the cluster is not available or when you want to defer validation until apply time. Defaults to `false`. - `timeouts` (Block List, Max: 1) (see [below for nested schema](#nestedblock--timeouts)) - `wait` (Block List, Max: 1) Configure waiter options. (see [below for nested schema](#nestedblock--wait)) - `wait_for` (Object, Deprecated) A map of attribute paths and desired patterns to be matched. After each apply the provider will wait for all attributes listed here to reach a value that matches the desired pattern. (see [below for nested schema](#nestedatt--wait_for)) @@ -79,7 +80,7 @@ Optional: ### Before you use this resource -- This resource requires API access during planning time. This means the cluster has to be accessible at plan time and thus cannot be created in the same apply operation. We recommend only using this resource for custom resources or resources not yet fully supported by the provider. +- This resource requires API access during planning time by default. This means the cluster has to be accessible at plan time and thus cannot be created in the same apply operation. However, you can use the `skip_validation` attribute to defer validation until apply time, which allows creating the cluster and its resources in the same Terraform run. We recommend only using this resource for custom resources or resources not yet fully supported by the provider. - This resource uses [Server-side Apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/) to carry out apply operations. A minimum Kubernetes version of 1.16.x is required, but versions 1.17+ are strongly recommended as the SSA implementation in Kubernetes 1.16.x is incomplete and unstable. @@ -299,3 +300,63 @@ A field path is a string that describes the fully qualified address of a field w > type(kubernetes_manifest.my-secret.object.data) map(string) ``` + +## Skipping validation during plan phase + +By default, the `kubernetes_manifest` resource validates the manifest against the Kubernetes API server during the plan phase. This includes checking if the resource type exists, verifying CRD schemas, and validating namespace requirements. However, there are scenarios where you may want to skip this validation: + +- When the cluster is not available during plan time (e.g., creating the cluster and its resources in the same Terraform run) +- When deploying CRDs and their custom resources in the same apply operation +- When you want to defer validation until apply time + +To skip validation during plan phase, set the `skip_validation` attribute to `true`: + +```terraform +resource "kubernetes_manifest" "test" { + manifest = { + apiVersion = "apps/v1" + kind = "Deployment" + metadata = { + name = "test-deployment" + namespace = "default" + } + spec = { + replicas = 3 + selector = { + matchLabels = { + app = "test" + } + } + template = { + metadata = { + labels = { + app = "test" + } + } + spec = { + containers = [ + { + name = "nginx" + image = "nginx:1.21" + } + ] + } + } + } + } + + skip_validation = true +} +``` + +**Important notes:** + +- When `skip_validation` is enabled, the provider will not connect to the Kubernetes cluster during plan phase, which means: + - CRD validation will be skipped + - Namespace validation will be skipped + - OpenAPI schema validation will be skipped + - The resource will be treated as requiring replacement on updates + +- Validation will still occur during the apply phase when the resource is actually created or updated in the cluster. + +- It is recommended to use this feature only when necessary, as it reduces the ability to catch configuration errors early during planning. diff --git a/manifest/provider/plan.go b/manifest/provider/plan.go index 052963c5e3..56b79a0051 100644 --- a/manifest/provider/plan.go +++ b/manifest/provider/plan.go @@ -15,6 +15,7 @@ import ( "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic" ) @@ -197,10 +198,27 @@ func (s *RawProviderServer) PlanResourceChange(ctx context.Context, req *tfproto return resp, nil } + // Check if skip_validation is enabled + skipValidation := false + if skipVal, ok := proposedVal["skip_validation"]; ok && !skipVal.IsNull() && skipVal.IsKnown() { + err := skipVal.As(&skipValidation) + if err != nil { + resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Failed to parse skip_validation attribute", + Detail: err.Error(), + }) + return resp, nil + } + } + // test if credentials are valid - we're going to need them further down - resp.Diagnostics = append(resp.Diagnostics, s.checkValidCredentials(ctx)...) - if len(resp.Diagnostics) > 0 { - return resp, nil + // Skip validation if skip_validation is true + if !skipValidation { + resp.Diagnostics = append(resp.Diagnostics, s.checkValidCredentials(ctx)...) + if len(resp.Diagnostics) > 0 { + return resp, nil + } } computedFields := make(map[string]*tftypes.AttributePath) @@ -285,91 +303,178 @@ func (s *RawProviderServer) PlanResourceChange(ctx context.Context, req *tfproto return resp, nil } - rm, err := s.getRestMapper() - if err != nil { - resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ - Severity: tfprotov5.DiagnosticSeverityError, - Summary: "Failed to create K8s RESTMapper client", - Detail: err.Error(), - }) - return resp, nil - } - gvk, err := GVKFromTftypesObject(&ppMan, rm) - if err != nil { - rd := &tfprotov5.Diagnostic{ - Severity: tfprotov5.DiagnosticSeverityError, - Summary: "API did not recognize GroupVersionKind from manifest (CRD may not be installed)", - Detail: err.Error(), + var gvk schema.GroupVersionKind + var ns bool + + if !skipValidation { + rm, err := s.getRestMapper() + if err != nil { + resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Failed to create K8s RESTMapper client", + Detail: err.Error(), + }) + return resp, nil } - resp.Diagnostics = append(resp.Diagnostics, rd) - if canDeferr && meta.IsNoMatchError(err) { - // request deferral when client configuration not fully known - resp.Deferred = &tfprotov5.Deferred{ - Reason: tfprotov5.DeferredReasonResourceConfigUnknown, + gvk, err = GVKFromTftypesObject(&ppMan, rm) + if err != nil { + rd := &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "API did not recognize GroupVersionKind from manifest (CRD may not be installed)", + Detail: err.Error(), + } + resp.Diagnostics = append(resp.Diagnostics, rd) + if canDeferr && meta.IsNoMatchError(err) { + // request deferral when client configuration not fully known + resp.Deferred = &tfprotov5.Deferred{ + Reason: tfprotov5.DeferredReasonResourceConfigUnknown, + } + rd.Severity = tfprotov5.DiagnosticSeverityWarning } - rd.Severity = tfprotov5.DiagnosticSeverityWarning + return resp, nil } - return resp, nil - } - vdiags := s.validateResourceOnline(&ppMan) - if len(vdiags) > 0 { - resp.Diagnostics = append(resp.Diagnostics, vdiags...) - return resp, nil - } - - ns, err := IsResourceNamespaced(gvk, rm) - if err != nil { - resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ - Severity: tfprotov5.DiagnosticSeverityError, - Summary: "Failed to discover scope of resource", - Detail: err.Error(), - }) - return resp, nil - } - if ns && !isImported { - resp.RequiresReplace = append(resp.RequiresReplace, - tftypes.NewAttributePath().WithAttributeName("manifest").WithAttributeName("metadata").WithAttributeName("namespace"), - ) - } + vdiags := s.validateResourceOnline(&ppMan) + if len(vdiags) > 0 { + resp.Diagnostics = append(resp.Diagnostics, vdiags...) + return resp, nil + } - // Request a complete type for the resource from the OpenAPI spec - objectType, hints, err := s.TFTypeFromOpenAPI(ctx, gvk, false) - if err != nil { - return resp, fmt.Errorf("failed to determine resource type ID: %s", err) - } + ns, err = IsResourceNamespaced(gvk, rm) + if err != nil { + resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Failed to discover scope of resource", + Detail: err.Error(), + }) + return resp, nil + } + } else { + // When skip_validation is true, we need to extract basic info from manifest + // without connecting to the cluster + manifestMap := make(map[string]tftypes.Value) + err := ppMan.As(&manifestMap) + if err != nil { + resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Failed to parse manifest for skip_validation mode", + Detail: err.Error(), + }) + return resp, nil + } - if !objectType.Is(tftypes.Object{}) { - // non-structural resources have no schema so we just use the - // type information we can get from the config - objectType = ppMan.Type() + // Extract basic GVK info from manifest + var apiVersion, kind string + if apiVal, ok := manifestMap["apiVersion"]; ok && !apiVal.IsNull() { + apiVal.As(&apiVersion) + } + if kindVal, ok := manifestMap["kind"]; ok && !kindVal.IsNull() { + kindVal.As(&kind) + } - resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ - Severity: tfprotov5.DiagnosticSeverityWarning, - Summary: "This custom resource does not have an associated OpenAPI schema.", - Detail: "We could not find an OpenAPI schema for this custom resource. Updates to this resource will cause a forced replacement.", - }) + if apiVersion == "" || kind == "" { + resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Missing required fields in manifest for skip_validation mode", + Detail: "Both apiVersion and kind are required when skip_validation is enabled", + }) + return resp, nil + } - fieldManagerName, forceConflicts, err := s.getFieldManagerConfig(proposedVal) + // Parse GroupVersion from apiVersion + gv, err := schema.ParseGroupVersion(apiVersion) if err != nil { resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ Severity: tfprotov5.DiagnosticSeverityError, - Summary: "Could not extract field_manager config", + Summary: "Invalid apiVersion format", Detail: err.Error(), }) return resp, nil } - err = s.dryRun(ctx, ppMan, fieldManagerName, forceConflicts, ns) + gvk = gv.WithKind(kind) + + // For skip_validation mode, we assume the resource is namespaced if namespace is present + // This is a best-effort guess since we can't query the API + metadata, ok := manifestMap["metadata"] + if ok && !metadata.IsNull() { + var metadataMap map[string]tftypes.Value + if err := metadata.As(&metadataMap); err == nil { + if namespaceVal, exists := metadataMap["namespace"]; exists && !namespaceVal.IsNull() { + var namespace string + if err := namespaceVal.As(&namespace); err == nil && namespace != "" { + ns = true + } + } + } + } + } + if ns && !isImported { + resp.RequiresReplace = append(resp.RequiresReplace, + tftypes.NewAttributePath().WithAttributeName("manifest").WithAttributeName("metadata").WithAttributeName("namespace"), + ) + } + + // Request a complete type for the resource from the OpenAPI spec + var objectType tftypes.Type + var hints map[string]string + + if !skipValidation { + objectType, hints, err = s.TFTypeFromOpenAPI(ctx, gvk, false) if err != nil { + return resp, fmt.Errorf("failed to determine resource type ID: %s", err) + } + + if !objectType.Is(tftypes.Object{}) { + // non-structural resources have no schema so we just use the + // type information we can get from the config + objectType = ppMan.Type() + resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ - Severity: tfprotov5.DiagnosticSeverityError, - Summary: "Dry-run failed for non-structured resource", - Detail: fmt.Sprintf("A dry-run apply was performed for this resource but was unsuccessful: %v", err), + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "This custom resource does not have an associated OpenAPI schema.", + Detail: "We could not find an OpenAPI schema for this custom resource. Updates to this resource will cause a forced replacement.", }) - return resp, nil + + fieldManagerName, forceConflicts, err := s.getFieldManagerConfig(proposedVal) + if err != nil { + resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Could not extract field_manager config", + Detail: err.Error(), + }) + return resp, nil + } + + err = s.dryRun(ctx, ppMan, fieldManagerName, forceConflicts, ns) + if err != nil { + resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Dry-run failed for non-structured resource", + Detail: fmt.Sprintf("A dry-run apply was performed for this resource but was unsuccessful: %v", err), + }) + return resp, nil + } + + resp.RequiresReplace = []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("manifest"), + tftypes.NewAttributePath().WithAttributeName("object"), + } } + } else { + // When skip_validation is true, use the manifest type directly + // This means we won't have OpenAPI schema validation + objectType = ppMan.Type() + hints = make(map[string]string) + + resp.Diagnostics = append(resp.Diagnostics, &tfprotov5.Diagnostic{ + Severity: tfprotov5.DiagnosticSeverityWarning, + Summary: "Skipping OpenAPI schema validation", + Detail: "When skip_validation is enabled, the resource will not be validated against the Kubernetes API schema during plan. Validation will occur during apply.", + }) + // For skip_validation mode, we need to force replacement since we can't + // determine the proper schema for comparison resp.RequiresReplace = []*tftypes.AttributePath{ tftypes.NewAttributePath().WithAttributeName("manifest"), tftypes.NewAttributePath().WithAttributeName("object"), diff --git a/manifest/provider/provider.go b/manifest/provider/provider.go index 50f0c4224a..5f9c658116 100644 --- a/manifest/provider/provider.go +++ b/manifest/provider/provider.go @@ -210,6 +210,12 @@ func GetProviderResourceSchema() map[string]*tfprotov5.Schema { Description: "List of manifest fields whose values can be altered by the API server during 'apply'. Defaults to: [\"metadata.annotations\", \"metadata.labels\"]", Optional: true, }, + { + Name: "skip_validation", + Type: tftypes.Bool, + Description: "When set to true, skips validation of the manifest against the Kubernetes API server during the plan phase. This can be useful when the cluster is not available or when you want to defer validation until apply time.", + Optional: true, + }, }, }, },