Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
8712b73
add continue attribute for observability service alert config
PatrickKoss Sep 15, 2025
7ef4213
adjust the acceptance test observability
PatrickKoss Sep 15, 2025
702122b
adjust docs
PatrickKoss Sep 15, 2025
56eb265
add continue in another case
PatrickKoss Sep 17, 2025
b405ce7
Merge branch 'main' into main
PatrickKoss Sep 18, 2025
188f0b7
Merge branch 'stackitcloud:main' into main
PatrickKoss Sep 19, 2025
13fdd53
remove continue attribute from root
PatrickKoss Sep 19, 2025
113bbb9
fix acc test
PatrickKoss Sep 19, 2025
e073ec2
Merge branch 'stackitcloud:main' into main
PatrickKoss Sep 19, 2025
8118f17
fix docs
PatrickKoss Sep 19, 2025
3e1a403
fix unit tests
PatrickKoss Sep 19, 2025
039719f
remove route types
PatrickKoss Sep 19, 2025
6ffe516
Merge branch 'main' into main
rubenhoenle Sep 19, 2025
e74f9f8
Merge branch 'stackitcloud:main' into main
PatrickKoss Oct 29, 2025
4e99f0d
Merge branch 'stackitcloud:main' into main
PatrickKoss Nov 3, 2025
55183c5
Merge branch 'stackitcloud:main' into main
PatrickKoss Nov 10, 2025
ee3a0c8
add skip wait and set partial model
PatrickKoss Nov 10, 2025
76fc503
fix linting errors
PatrickKoss Nov 10, 2025
de09817
revert formatting
PatrickKoss Nov 11, 2025
e7649c2
revert formatting
PatrickKoss Nov 11, 2025
037cece
import state
PatrickKoss Nov 11, 2025
265836f
downlint lint from releases + remove read id check
PatrickKoss Nov 12, 2025
ba8ecc8
Merge branch 'main' into feature/dns-skip-wait
PatrickKoss Nov 12, 2025
1196efb
fix pipeline linting
PatrickKoss Nov 12, 2025
50f1f37
adjust SetModelFieldsToNull to handle complex objects and lists
PatrickKoss Nov 13, 2025
873f875
fix linting
PatrickKoss Nov 13, 2025
6e89bf9
fix linting
PatrickKoss Nov 13, 2025
b769ba1
add dns wait warn log for tf idempotency
PatrickKoss Nov 14, 2025
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
13 changes: 11 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
ROOT_DIR ?= $(shell git rev-parse --show-toplevel)
SCRIPTS_BASE ?= $(ROOT_DIR)/scripts

# https://github.com/golangci/golangci-lint/releases
GOLANGCI_VERSION = 1.64.8
GOLANGCI_LINT = bin/golangci-lint-$(GOLANGCI_VERSION)

# SETUP AND TOOL INITIALIZATION TASKS
project-help:
@$(SCRIPTS_BASE)/project.sh help

project-tools:
@$(SCRIPTS_BASE)/project.sh tools

# GOLANGCI-LINT INSTALLATION
$(GOLANGCI_LINT):
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | bash -s -- -b bin v$(GOLANGCI_VERSION)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running bash scripts blindly from a master branch of another repository is a no-go for me, sorry

Overall, what's the point of this? This whole thing feels wrong to me. For managing development dependencies there are things like dev containers, devenvs, nix flakes, ...

I'm aware we're not providing any of these currently, but this download process inside the Makefile seems pretty hacky to me 😄

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah that was a bit too ambitious. You know once you copy it from somewhere you always copy it :P
I replaced it with downloading from the releases which should be secure.

It is actually quite typically to download binaries that are needed to interact with the application (like linting, kubectl, kind, helm, etc) via scripts/make. In many stackit projects that is already the case. And there are also many opensource projects that do similar things like:

I guess many ways solve the same problem. Currently my biggest problem is that I cannot lint locally since there are version diffs between my installed golangci lint and the one in the pipeline. Therefore I want to have a make command that runs the same version in the pipeline as in our local env. Some might say that is the shift left approach.

@mv bin/golangci-lint "$(@)"

# LINT
lint-golangci-lint:
lint-golangci-lint: $(GOLANGCI_LINT)
@echo "Linting with golangci-lint"
@$(SCRIPTS_BASE)/lint-golangci-lint.sh
@$(SCRIPTS_BASE)/lint-golangci-lint.sh $(GOLANGCI_LINT)

lint-tf:
@echo "Linting terraform files"
Expand Down
13 changes: 7 additions & 6 deletions scripts/lint-golangci-lint.sh
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
#!/usr/bin/env bash
# This script lints the SDK modules and the internal examples
# Pre-requisites: golangci-lint
# Pre-requisites: golangci-lint (provided by Makefile or system)
set -eo pipefail

ROOT_DIR=$(git rev-parse --show-toplevel)
GOLANG_CI_YAML_PATH="${ROOT_DIR}/golang-ci.yaml"
GOLANG_CI_ARGS="--allow-parallel-runners --timeout=5m --config=${GOLANG_CI_YAML_PATH}"

if type -p golangci-lint >/dev/null; then
:
else
echo "golangci-lint not installed, unable to proceed."
# Use provided golangci-lint binary or fallback to system installation
GOLANGCI_LINT_BIN="${1:-golangci-lint}"

if [ ! -x "${GOLANGCI_LINT_BIN}" ] && ! type -p "${GOLANGCI_LINT_BIN}" >/dev/null; then
echo "golangci-lint not found at ${GOLANGCI_LINT_BIN} and not installed in PATH, unable to proceed."
exit 1
fi

cd ${ROOT_DIR}
golangci-lint run ${GOLANG_CI_ARGS}
${GOLANGCI_LINT_BIN} run ${GOLANG_CI_ARGS}
77 changes: 67 additions & 10 deletions stackit/internal/services/dns/recordset/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package dns
import (
"context"
"fmt"
"net/http"
"strings"

"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
Expand All @@ -16,6 +17,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/dns"
"github.com/stackitcloud/stackit-sdk-go/services/dns/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
Expand Down Expand Up @@ -219,15 +221,27 @@ func (r *recordSetResource) Create(ctx context.Context, req resource.CreateReque
}

// Write id attributes to state before polling via the wait handler - just in case anything goes wrong during the wait handler
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"project_id": projectId,
"zone_id": zoneId,
"record_set_id": *recordSetResp.Rrset.Id,
})
recordSetId := *recordSetResp.Rrset.Id
model.RecordSetId = types.StringValue(recordSetId)
model.Id = utils.BuildInternalTerraformId(projectId, zoneId, recordSetId)

// Set all unknown/null fields to null before saving state
if err := utils.SetModelFieldsToNull(ctx, &model); err != nil {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, but I just don't get why one would want to have this. What's the point of this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be because of weird clients. Currently we only set project_id and zone_id in the state before waiting. This lead to the following error if the waiting is skipped which some clients want:

"error": "cannot get a terraform workspace for resource: cannot ensure tfstate file: cannot check whether the state is empty: cannot work with a non-string id: <nil>", "errorVerbose": "cannot work with a non-string id: <nil>

So I have set the id as well in the helper function. I think I observed an error in the past that the client got an error because some field in the state were "unknown". But I can no longer find this error message anymore. So currently I get:

apply failed: Provider produced inconsistent result after apply: When applying changes to stackit_dns_zone.example-zone, provider "provider[\"registry.terraform.io/stackitcloud/stackit\"]" produced an unexpected new value: .description: was cty.StringVal("Example DNS zone for demonstration"), but now null.

      This is a bug in the provider, which should be reported in the provider's own issue tracker.
      Provider produced inconsistent result after apply: When applying changes to stackit_dns_zone.example-zone, provider "provider[\"registry.terraform.io/stackitcloud/stackit\"]" produced an unexpected new value: .dns_name: was cty.StringVal("patrick.test.patrick.patrick"), but now null.

      This is a bug in the provider, which should be reported in the provider's own issue tracker.
      Provider produced inconsistent result after apply: When applying changes to stackit_dns_zone.example-zone, provider "provider[\"registry.terraform.io/stackitcloud/stackit\"]" produced an unexpected new value: .name: was cty.StringVal("example-zone"), but now null.

      This is a bug in the provider, which should be reported in the provider's own issue tracker.
      Provider produced inconsistent result after apply: When applying changes to stackit_dns_zone.example-zone, provider "provider[\"registry.terraform.io/stackitcloud/stackit\"]" produced an unexpected new value: .is_reverse_zone: was cty.False, but now null.

      This is a bug in the provider, which should be reported in the provider's own issue tracker.
      Provider produced inconsistent result after apply: When applying changes to stackit_dns_zone.example-zone, provider "provider[\"registry.terraform.io/stackitcloud/stackit\"]" produced an unexpected new value: .type: was cty.StringVal("primary"), but now null.

      This is a bug in the provider, which should be reported in the provider's own issue tracker.

Because of it the client wants to destroy the resource:

"error": "cannot run plan: plan failed: Instance cannot be destroyed: Resource stackit_dns_zone.example-zone has lifecycle.prevent_destroy set, but the plan calls for this resource to be destroyed. To avoid this error and continue with the plan, either disable lifecycle.prevent_destroy or reduce the scope of the plan using the -target flag.", "errorVerbose": "plan failed: Instance cannot be destroyed: Resource stackit_dns_zone.example-zone has lifecycle.prevent_destroy set, but the plan calls for this resource to be destroyed. To avoid this error and continue with the plan, either disable lifecycle.prevent_destroy or reduce the scope of the plan using the -target flag.

For some reason if you set the fields to null instead of unknown the client accepts it and proceeds correctly. Maybe we need to take a look together into the topic. If you have some better ways to handle this case feel free to suggest :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to make sure I don't mess things up here, what do you mean with client?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

crossplane+upjet that then executes terraform cli commands

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

Messages like this are there for a reason by Terraform. You would break this behavior with this change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect that is a problem with complex objects, list of lists and list of complex objects in the utils function SetModelFieldsToNull.
I also tried adding the same logic as in zone to iaas network and added alot of unit tests to provoke the error and couldn´t reproduce. You can check it here if you want.

Can you provide the input parameters so I can add unit tests for this case to verify if it happens in the implementation or not?
Additionally you can check with in your setup as well if the added functionality resolves the issue.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or do you imply that it is perfectly fine to have errors? because if we want to use upjet to generate a crossplane provider we cannot accept such error since it simply does not work :D

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or do you imply that it is perfectly fine to have errors?

Clear no.

because if we want to use upjet to generate a crossplane provider we cannot accept such error since it simply does not work :D

Well, I guess it doesn't work because you modified the code of the terraform provider and didn't understand the impacts of your changes.

I have to start from scratch here: Unknown values are a core concept of Terraform (see https://developer.hashicorp.com/terraform/plugin/framework/handling-data/terraform-concepts#unknown-values). Unknown values are important for Terraform to apply resources in the correct order, ...

But what does this mean for us? After a terraform apply run which creates a new resource, all fields of the resource must be set by the Terraform provider to a value or to null explicitly. If this isn't done for a field of the resource, you will get a message like this:

image

Whenever you get a message like this it's clear that this is a bug in the Terraform provider. And I'm going to lean myself out of the window here and say this doesn't happen for the stackit_dns_record_set resource on the main branch of our STACKIT Terraform provider repository. 😄


Let me explain why

We create the resource on API side and then use the wait handler.

recordSetResp, err := r.client.CreateRecordSet(ctx, projectId, zoneId).CreateRecordSetPayload(*payload).Execute()
if err != nil || recordSetResp.Rrset == nil || recordSetResp.Rrset.Id == nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Calling API: %v", err))
return
}
// Write id attributes to state before polling via the wait handler - just in case anything goes wrong during the wait handler
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
"project_id": projectId,
"zone_id": zoneId,
"record_set_id": *recordSetResp.Rrset.Id,
})
if resp.Diagnostics.HasError() {
return
}
waitResp, err := wait.CreateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, *recordSetResp.Rrset.Id).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Instance creation waiting: %v", err))
return
}

After the wait handler we use the mapFields function to map the API response to the Terraform state model.

// Map response body to schema
err = mapFields(ctx, waitResp, &model)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Processing API payload: %v", err))
return
}
// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

Now comes the important part: Here is the section in the mapFields function, which makes sure all fields of the resource get set to a value or null. [1]

model.Id = utils.BuildInternalTerraformId(
model.ProjectId.ValueString(), model.ZoneId.ValueString(), recordSetId,
)
model.RecordSetId = types.StringPointerValue(recordSet.Id)
model.Active = types.BoolPointerValue(recordSet.Active)
model.Comment = types.StringPointerValue(recordSet.Comment)
model.Error = types.StringPointerValue(recordSet.Error)
if model.Name.IsNull() || model.Name.IsUnknown() {
model.Name = types.StringPointerValue(recordSet.Name)
}
model.FQDN = types.StringPointerValue(recordSet.Name)
model.State = types.StringValue(string(recordSet.GetState()))
model.TTL = types.Int64PointerValue(recordSet.Ttl)
model.Type = types.StringValue(string(recordSet.GetType()))

Well, and after that the model struct must be persisted in the Terraform state (this doesn't happen automatically):

// Set state to fully populated data
diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

To sum it up, here's what happens in the main branch implementation of this resource:

  1. Create request for the API resource
  2. (Write id fields to the state in case anything goes wrong during the wait handler)
  3. Wait handler to wait for creation of the API resource to complete
  4. Map API response to Terraform resource model struct (mapFields)
  5. Persist the Terraform model struct of the resource in the Terraform state

Now to your changes

Now to your changes and why it's not working (without setting all fields to null using your new reflection-powered util func):

In your func (r *recordSetResource) Create(...) ... implementation...

  • You also do the Create request for the API resource (see no. 1 above)
  • You write the id fields to the state (see no 2. above)
  • And then you jump out of the Create implementation of the Terraform resource prematurely with the code below.
	if !utils.ShouldWait() {
		tflog.Info(ctx, "Skipping wait; async mode for Crossplane/Upjet")
		return
	}

The problem is: This doesn't only skip the wait handler (no. 3 above), but also the mapFields func call (no. 4 above) which (as said) sets explicitly all values to a value or null.

Again, you just skip this. This is a core part of the resource implementation. You don't call it. That's why Terraform complains about unknown values. Terraform says this is a bug in the provider implementation, and it's correct.

But it's sadly not a bug in our implementation on the main branch, but in your implementation.

You circumvent this problem by setting all fields of the Terraform resource state model explicitly to null by using your new util func. This circumvents the problem (Terraform doesn't complain anymore about unknown values), but it doesn't really fix the problem (at least not in a clean way).

In fact setting all fields of the Terraform resource model struct to null circumvents existing checks of Terraform which we want to take advantage of during our resource implementations (at least for pure Terraform usage, without thinking of crossplane here).


[1] Btw, if you forget to set one field of the Terraform resource model struct to a value of null here during the implementation of the Terraform resource you will also get exactly the error After the apply operation, the provider still indicated an unknown value... from above. This is what I consider a terraform feature. As said, unknown values are a concept of Terraform

core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Setting model fields to null: %v", err))
return
}

diags = resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if resp.Diagnostics.HasError() {
return
}

if !utils.ShouldWait() {
tflog.Info(ctx, "Skipping wait; async mode for Crossplane/Upjet")
return
}

waitResp, err := wait.CreateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, *recordSetResp.Rrset.Id).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating record set", fmt.Sprintf("Instance creation waiting: %v", err))
Expand Down Expand Up @@ -264,8 +278,19 @@ func (r *recordSetResource) Read(ctx context.Context, req resource.ReadRequest,
ctx = tflog.SetField(ctx, "zone_id", zoneId)
ctx = tflog.SetField(ctx, "record_set_id", recordSetId)

if recordSetId == "" || zoneId == "" || projectId == "" {
tflog.Info(ctx, "Record set ID, zone ID, or project ID is empty, removing resource")
resp.State.RemoveResource(ctx)
return
}

recordSetResp, err := r.client.GetRecordSet(ctx, projectId, zoneId, recordSetId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading record set", fmt.Sprintf("Calling API: %v", err))
return
}
Expand Down Expand Up @@ -319,6 +344,12 @@ func (r *recordSetResource) Update(ctx context.Context, req resource.UpdateReque
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", err.Error())
return
}

if !utils.ShouldWait() {
tflog.Info(ctx, "Skipping wait; async mode for Crossplane/Upjet")
return
}

waitResp, err := wait.PartialUpdateRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating record set", fmt.Sprintf("Instance update waiting: %v", err))
Expand Down Expand Up @@ -358,8 +389,22 @@ func (r *recordSetResource) Delete(ctx context.Context, req resource.DeleteReque
// Delete existing record set
_, err := r.client.DeleteRecordSet(ctx, projectId, zoneId, recordSetId).Execute()
if err != nil {
// If resource is already gone (404 or 410), treat as success for idempotency
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok &&
(oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) {
tflog.Info(ctx, "Record set already deleted")
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting record set", fmt.Sprintf("Calling API: %v", err))
return
}

if !utils.ShouldWait() {
tflog.Info(ctx, "Skipping wait; async mode for Crossplane/Upjet")
return
}

_, err = wait.DeleteRecordSetWaitHandler(ctx, r.client, projectId, zoneId, recordSetId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting record set", fmt.Sprintf("Instance deletion waiting: %v", err))
Expand All @@ -380,11 +425,23 @@ func (r *recordSetResource) ImportState(ctx context.Context, req resource.Import
return
}

utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{
"project_id": idParts[0],
"zone_id": idParts[1],
"record_set_id": idParts[2],
})
var model Model
model.ProjectId = types.StringValue(idParts[0])
model.ZoneId = types.StringValue(idParts[1])
model.RecordSetId = types.StringValue(idParts[2])
model.Id = utils.BuildInternalTerraformId(idParts[0], idParts[1], idParts[2])

if err := utils.SetModelFieldsToNull(ctx, &model); err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing zone", fmt.Sprintf("Setting model fields to null: %v", err))
return
}

diags := resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if diags.HasError() {
return
}

tflog.Info(ctx, "DNS record set state imported")
}

Expand Down
78 changes: 67 additions & 11 deletions stackit/internal/services/dns/zone/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"math"
"net/http"
"strings"

dnsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/dns/utils"
Expand All @@ -21,6 +22,7 @@ import (
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-log/tflog"
"github.com/stackitcloud/stackit-sdk-go/core/oapierror"
"github.com/stackitcloud/stackit-sdk-go/services/dns"
"github.com/stackitcloud/stackit-sdk-go/services/dns/wait"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
Expand Down Expand Up @@ -300,13 +302,25 @@ func (r *zoneResource) Create(ctx context.Context, req resource.CreateRequest, r
return
}

// Write id attributes to state before polling via the wait handler - just in case anything goes wrong during the wait handler
// Save minimal state immediately after API call succeeds to ensure idempotency
zoneId := *createResp.Zone.Id
utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{
"project_id": projectId,
"zone_id": zoneId,
})
if resp.Diagnostics.HasError() {
model.ZoneId = types.StringValue(zoneId)
model.Id = utils.BuildInternalTerraformId(projectId, zoneId)

// Set all unknown/null fields to null before saving state
if err := utils.SetModelFieldsToNull(ctx, &model); err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating zone", fmt.Sprintf("Setting model fields to null: %v", err))
return
}

diags := resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if diags.HasError() {
return
}

if !utils.ShouldWait() {
tflog.Info(ctx, "Skipping wait; async mode for Crossplane/Upjet")
return
}

Expand Down Expand Up @@ -343,12 +357,24 @@ func (r *zoneResource) Read(ctx context.Context, req resource.ReadRequest, resp
ctx = tflog.SetField(ctx, "project_id", projectId)
ctx = tflog.SetField(ctx, "zone_id", zoneId)

if zoneId == "" {
tflog.Info(ctx, "Zone ID is empty, removing resource")
resp.State.RemoveResource(ctx)
return
}

zoneResp, err := r.client.GetZone(ctx, projectId, zoneId).Execute()
if err != nil {
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok && (oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) {
resp.State.RemoveResource(ctx)
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading zone", fmt.Sprintf("Calling API: %v", err))
return
}
if zoneResp != nil && zoneResp.Zone.State != nil && *zoneResp.Zone.State == dns.ZONESTATE_DELETE_SUCCEEDED {
if zoneResp != nil && zoneResp.Zone.State != nil &&
*zoneResp.Zone.State == dns.ZONESTATE_DELETE_SUCCEEDED {
resp.State.RemoveResource(ctx)
return
}
Expand Down Expand Up @@ -394,6 +420,12 @@ func (r *zoneResource) Update(ctx context.Context, req resource.UpdateRequest, r
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Calling API: %v", err))
return
}

if !utils.ShouldWait() {
tflog.Info(ctx, "Skipping wait; async mode for Crossplane/Upjet")
return
}

waitResp, err := wait.PartialUpdateZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating zone", fmt.Sprintf("Zone update waiting: %v", err))
Expand Down Expand Up @@ -431,9 +463,22 @@ func (r *zoneResource) Delete(ctx context.Context, req resource.DeleteRequest, r
// Delete existing zone
_, err := r.client.DeleteZone(ctx, projectId, zoneId).Execute()
if err != nil {
// If resource is already gone (404 or 410), treat as success for idempotency
oapiErr, ok := err.(*oapierror.GenericOpenAPIError) //nolint:errorlint //complaining that error.As should be used to catch wrapped errors, but this error should not be wrapped
if ok &&
(oapiErr.StatusCode == http.StatusNotFound || oapiErr.StatusCode == http.StatusGone) {
tflog.Info(ctx, "DNS zone already deleted")
return
}
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting zone", fmt.Sprintf("Calling API: %v", err))
return
}

if !utils.ShouldWait() {
tflog.Info(ctx, "Skipping wait; async mode for Crossplane/Upjet")
return
}

_, err = wait.DeleteZoneWaitHandler(ctx, r.client, projectId, zoneId).WaitWithContext(ctx)
if err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting zone", fmt.Sprintf("Zone deletion waiting: %v", err))
Expand All @@ -456,10 +501,21 @@ func (r *zoneResource) ImportState(ctx context.Context, req resource.ImportState
return
}

utils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]interface{}{
"project_id": idParts[0],
"zone_id": idParts[1],
})
var model Model
model.ProjectId = types.StringValue(idParts[0])
model.ZoneId = types.StringValue(idParts[1])
model.Id = utils.BuildInternalTerraformId(idParts[0], idParts[1])

if err := utils.SetModelFieldsToNull(ctx, &model); err != nil {
core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing zone", fmt.Sprintf("Setting model fields to null: %v", err))
return
}

diags := resp.State.Set(ctx, model)
resp.Diagnostics.Append(diags...)
if diags.HasError() {
return
}

tflog.Info(ctx, "DNS zone state imported")
}
Expand Down
Loading
Loading