Repository: ansible/terraform-provider-aap
Pull request #61: Fixes issue where a deleted AAP Job causes terraform plan to fail
Author: login: dleehr
Created: 2025-05-06T14:25:48Z | Merged: 2025-05-08T15:34:06Z

Updates the behavior for the `aap_job` resource to handle a 404 error in the `Read` method. A 404 error indicates the job recorded in terraform state was created by the provider but has since been deleted from AAP. As noted in #59, this scenario is common because AAP cleans up old jobs by default and results in plan failures that require manual state modification.

With this change, we remove the resource from the state if a 404 is encountered, so that the plan marks it as disappeared and plans to re-create. On apply, it is re-created. I've verified the acceptance tests locally against AAP 2.5.

This PR also updates the regex patterns in the acceptance tests to support AAP 2.5 and configured `main.go` to support debugging with a `-debug` flag.

Fixes #59 

Linked issue #59: Terraform Plan Fails After AAP Job Cleanup
Terraform Provider Version: ansible/terraform-provider-aap v1.1.2
Terraform version: 1.11.3

Problem Description:

Terraform's aap_job resource allows launching Ansible Automation Platform (AAP) jobs. If Terraform performs a new plan after an existing AAP job has been deleted by AAP's scheduled "Cleanup Job Schedule" (a built-in management job), the Terraform plan fails with a 404 error.

The error received is:

```
Error: Unexpected HTTP status code received for GET request to path https://<aap>/api/controller/v2/jobs/85/ with aap_job.vm_demo_job
on main.tf line 52, in resource "aap_job" "vm_demo_job":
resource "aap_job" "vm_demo_job" {
Expected one of ([200]), got (404). Response details: map [detail:No Job matches the given query.]
```

Steps to Reproduce:

Use Terraform to create an aap_job resource.

Let the job complete.

Allow the AAP Cleanup Job Schedule (automatically generated management job) to remove the completed job.

Run terraform plan again.

Expected Behavior:
Terraform gracefully handles missing previous jobs by recognising the job no longer exists and plans to create a new job if required.

Actual Behavior:
Terraform fails during the planning phase due to the missing job, resulting in a 404 error.

Potential Root Cause:
The provider attempts to fetch the job state without handling scenarios where the job has been deleted by AAP management schedules. The provider should handle 404 responses gracefully.

Suggested Resolution:
Modify the provider logic to:

Detect a 404 status on job fetch.

Consider the job as non-existent and plan accordingly (e.g., show a forced new resource or clean state).

Terraform Configuration Snippet:

```
resource "aap_job" "vm_demo_job" {
  job_template_id = var.job_template_id
  inventory_id    = aap_inventory.vm_inventory.id
}
```

Environment:
AAP version: 2.5
Additional Context:
The AAP built-in scheduled job is named: "Cleanup Job Schedule" (Management job).



Diff:
diff --git a/changelogs/fragments/20250507-fix-aap-job-disappear.yaml b/changelogs/fragments/20250507-fix-aap-job-disappear.yaml
new file mode 100644
index 00000000..2e2838f3
--- /dev/null
+++ b/changelogs/fragments/20250507-fix-aap-job-disappear.yaml
@@ -0,0 +1,3 @@
+---
+bugfixes:
+  - Fix plan failure when AAP job created by provider are deleted outside of terraform (https://github.com/ansible/terraform-provider-aap/pull/61).
diff --git a/docs/resources/job.md b/docs/resources/job.md
index a74fc53d..1395be49 100644
--- a/docs/resources/job.md
+++ b/docs/resources/job.md
@@ -16,6 +16,8 @@ This resource always creates a new job in AAP. A destroy will not delete a job c
 
 -> **Note** To pass an inventory to an aap_job resource, the underlying job template *must* have been conigured to prompt for the inventory on launch.
 
+!> **Warning** If an AAP Job launched by this resource is deleted from AAP, the resource will be removed from the state and a new job will be created to replace it.
+
 
 ## Example Usage
 
diff --git a/internal/provider/client.go b/internal/provider/client.go
index c45790f3..f43bb745 100644
--- a/internal/provider/client.go
+++ b/internal/provider/client.go
@@ -18,6 +18,7 @@ type ProviderHTTPClient interface {
 	doRequest(method string, path string, data io.Reader) (*http.Response, []byte, error)
 	Create(path string, data io.Reader) ([]byte, diag.Diagnostics)
 	Get(path string) ([]byte, diag.Diagnostics)
+	GetWithStatus(path string) ([]byte, diag.Diagnostics, int)
 	Update(path string, data io.Reader) ([]byte, diag.Diagnostics)
 	Delete(path string) ([]byte, diag.Diagnostics)
 	setApiEndpoint() diag.Diagnostics
@@ -148,9 +149,14 @@ func (c *AAPClient) Create(path string, data io.Reader) ([]byte, diag.Diagnostic
 }
 
 // Get sends a GET request to the provided path, checks for errors, and returns the response body with any errors as diagnostics.
-func (c *AAPClient) Get(path string) ([]byte, diag.Diagnostics) {
+func (c *AAPClient) GetWithStatus(path string) ([]byte, diag.Diagnostics, int) {
 	getResponse, body, err := c.doRequest("GET", path, nil)
 	diags := ValidateResponse(getResponse, body, err, []int{http.StatusOK})
+	return body, diags, getResponse.StatusCode
+}
+
+func (c *AAPClient) Get(path string) ([]byte, diag.Diagnostics) {
+	body, diags, _ := c.GetWithStatus(path)
 	return body, diags
 }
 
diff --git a/internal/provider/group_resource_test.go b/internal/provider/group_resource_test.go
index c6649aee..fca61d5f 100644
--- a/internal/provider/group_resource_test.go
+++ b/internal/provider/group_resource_test.go
@@ -195,7 +195,7 @@ func TestAccGroupResource(t *testing.T) {
 					testAccCheckGroupResourceValues(&groupApiModel, groupName, "", ""),
 					resource.TestCheckResourceAttr("aap_group.test", "name", groupName),
 					resource.TestCheckResourceAttrPair("aap_group.test", "inventory_id", "aap_inventory.test", "id"),
-					resource.TestMatchResourceAttr("aap_group.test", "url", regexp.MustCompile("^/api/v2/groups/[0-9]*/$")),
+					resource.TestMatchResourceAttr("aap_group.test", "url", regexp.MustCompile("^/api(/controller)?/v2/groups/[0-9]*/$")),
 				),
 			},
 			{
@@ -207,7 +207,7 @@ func TestAccGroupResource(t *testing.T) {
 					resource.TestCheckResourceAttrPair("aap_group.test", "inventory_id", "aap_inventory.test", "id"),
 					resource.TestCheckResourceAttr("aap_group.test", "description", description),
 					resource.TestCheckResourceAttr("aap_group.test", "variables", variables),
-					resource.TestMatchResourceAttr("aap_group.test", "url", regexp.MustCompile("^/api/v2/groups/[0-9]*/$")),
+					resource.TestMatchResourceAttr("aap_group.test", "url", regexp.MustCompile("^/api(/controller)?/v2/groups/[0-9]*/$")),
 				),
 			},
 		},
diff --git a/internal/provider/host_resource_test.go b/internal/provider/host_resource_test.go
index 7790f9fc..0747e88c 100644
--- a/internal/provider/host_resource_test.go
+++ b/internal/provider/host_resource_test.go
@@ -282,7 +282,7 @@ func TestAccHostResource(t *testing.T) {
 					resource.TestCheckResourceAttr("aap_host.test", "name", hostName),
 					resource.TestCheckResourceAttrPair("aap_host.test", "inventory_id", "aap_inventory.test", "id"),
 					resource.TestCheckResourceAttr("aap_host.test", "enabled", "true"),
-					resource.TestMatchResourceAttr("aap_host.test", "url", regexp.MustCompile("^/api/v2/hosts/[0-9]*/$")),
+					resource.TestMatchResourceAttr("aap_host.test", "url", regexp.MustCompile("^/api(/controller)?/v2/hosts/[0-9]*/$")),
 					resource.TestCheckResourceAttrSet("aap_host.test", "id"),
 				),
 			},
@@ -297,7 +297,7 @@ func TestAccHostResource(t *testing.T) {
 					resource.TestCheckResourceAttr("aap_host.test", "description", updatedDescription),
 					resource.TestCheckResourceAttr("aap_host.test", "variables", updatedVariables),
 					resource.TestCheckResourceAttr("aap_host.test", "enabled", "true"),
-					resource.TestMatchResourceAttr("aap_host.test", "url", regexp.MustCompile("^/api/v2/hosts/[0-9]*/$")),
+					resource.TestMatchResourceAttr("aap_host.test", "url", regexp.MustCompile("^/api(/controller)?/v2/hosts/[0-9]*/$")),
 					resource.TestCheckResourceAttrSet("aap_host.test", "id"),
 				),
 			},
diff --git a/internal/provider/job_resource.go b/internal/provider/job_resource.go
index fabe04e3..7d802b6f 100644
--- a/internal/provider/job_resource.go
+++ b/internal/provider/job_resource.go
@@ -175,7 +175,17 @@ func (r *JobResource) Read(ctx context.Context, req resource.ReadRequest, resp *
 	}
 
 	// Get latest job data from AAP
-	readResponseBody, diags := r.client.Get(data.URL.ValueString())
+	readResponseBody, diags, status := r.client.GetWithStatus(data.URL.ValueString())
+
+	// Check if the response is 404, meaning the job does not exist and should be recreated
+	if status == http.StatusNotFound {
+		resp.Diagnostics.AddWarning(
+			"Job not found",
+			"The job was not found. It may have been deleted. The job will be recreated.",
+		)
+		resp.State.RemoveResource(ctx)
+		return
+	}
 	resp.Diagnostics.Append(diags...)
 	if resp.Diagnostics.HasError() {
 		return
diff --git a/internal/provider/job_resource_test.go b/internal/provider/job_resource_test.go
index 965602ed..7d2afd5b 100644
--- a/internal/provider/job_resource_test.go
+++ b/internal/provider/job_resource_test.go
@@ -275,7 +275,7 @@ func TestAccAAPJob_basic(t *testing.T) {
 				Check: resource.ComposeAggregateTestCheckFunc(
 					resource.TestMatchResourceAttr(resourceName, "status", regexp.MustCompile("^(failed|pending|running|complete|successful|waiting)$")),
 					resource.TestMatchResourceAttr(resourceName, "job_type", regexp.MustCompile("^(run|check)$")),
-					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api/v2/jobs/[0-9]*/$")),
+					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api(/controller)?/v2/jobs/[0-9]*/$")),
 					testAccCheckJobExists,
 				),
 			},
@@ -299,7 +299,7 @@ func TestAccAAPJob_UpdateWithSameParameters(t *testing.T) {
 				Check: resource.ComposeAggregateTestCheckFunc(
 					resource.TestMatchResourceAttr(resourceName, "status", regexp.MustCompile("^(failed|pending|running|complete|successful|waiting)$")),
 					resource.TestMatchResourceAttr(resourceName, "job_type", regexp.MustCompile("^(run|check)$")),
-					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api/v2/jobs/[0-9]*/$")),
+					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api(/controller)?/v2/jobs/[0-9]*/$")),
 					testAccCheckJobUpdate(&jobURLBefore, false),
 				),
 			},
@@ -308,7 +308,7 @@ func TestAccAAPJob_UpdateWithSameParameters(t *testing.T) {
 				Check: resource.ComposeAggregateTestCheckFunc(
 					resource.TestMatchResourceAttr(resourceName, "status", regexp.MustCompile("^(failed|pending|running|complete|successful|waiting)$")),
 					resource.TestMatchResourceAttr(resourceName, "job_type", regexp.MustCompile("^(run|check)$")),
-					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api/v2/jobs/[0-9]*/$")),
+					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api(/controller)?/v2/jobs/[0-9]*/$")),
 					testAccCheckJobUpdate(&jobURLBefore, false),
 				),
 			},
@@ -332,7 +332,7 @@ func TestAccAAPJob_UpdateWithNewInventoryId(t *testing.T) {
 				Check: resource.ComposeAggregateTestCheckFunc(
 					resource.TestMatchResourceAttr(resourceName, "status", regexp.MustCompile("^(failed|pending|running|complete|successful|waiting)$")),
 					resource.TestMatchResourceAttr(resourceName, "job_type", regexp.MustCompile("^(run|check)$")),
-					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api/v2/jobs/[0-9]*/$")),
+					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api(/controller)?/v2/jobs/[0-9]*/$")),
 					testAccCheckJobUpdate(&jobURLBefore, false),
 				),
 			},
@@ -341,10 +341,10 @@ func TestAccAAPJob_UpdateWithNewInventoryId(t *testing.T) {
 				Check: resource.ComposeAggregateTestCheckFunc(
 					resource.TestMatchResourceAttr(resourceName, "status", regexp.MustCompile("^(failed|pending|running|complete|successful|waiting)$")),
 					resource.TestMatchResourceAttr(resourceName, "job_type", regexp.MustCompile("^(run|check)$")),
-					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api/v2/jobs/[0-9]*/$")),
+					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api(/controller)?/v2/jobs/[0-9]*/$")),
 					testAccCheckJobUpdate(&jobURLBefore, true),
 					// Wait for the job to finish so the inventory can be deleted
-					testAccCheckJobPause("aap_job.test"),
+					testAccCheckJobPause(resourceName),
 				),
 			},
 		},
@@ -367,7 +367,7 @@ func TestAccAAPJob_UpdateWithTrigger(t *testing.T) {
 				Check: resource.ComposeAggregateTestCheckFunc(
 					resource.TestMatchResourceAttr(resourceName, "status", regexp.MustCompile("^(failed|pending|running|complete|successful|waiting)$")),
 					resource.TestMatchResourceAttr(resourceName, "job_type", regexp.MustCompile("^(run|check)$")),
-					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api/v2/jobs/[0-9]*/$")),
+					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api(/controller)?/v2/jobs/[0-9]*/$")),
 					testAccCheckJobUpdate(&jobURLBefore, false),
 				),
 			},
@@ -376,7 +376,7 @@ func TestAccAAPJob_UpdateWithTrigger(t *testing.T) {
 				Check: resource.ComposeAggregateTestCheckFunc(
 					resource.TestMatchResourceAttr(resourceName, "status", regexp.MustCompile("^(failed|pending|running|complete|successful|waiting)$")),
 					resource.TestMatchResourceAttr(resourceName, "job_type", regexp.MustCompile("^(run|check)$")),
-					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api/v2/jobs/[0-9]*/$")),
+					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api(/controller)?/v2/jobs/[0-9]*/$")),
 					testAccCheckJobUpdate(&jobURLBefore, true),
 				),
 			},
@@ -448,3 +448,65 @@ resource "aap_job" "test" {
 }
 `, jobTemplateID)
 }
+
+func TestAccAAPJob_disappears(t *testing.T) {
+	var jobUrl string
+
+	jobTemplateID := os.Getenv("AAP_TEST_JOB_TEMPLATE_ID")
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:                 func() { testAccJobResourcePreCheck(t) },
+		ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
+		Steps: []resource.TestStep{
+			// Apply a basic terraform plan that creates an AAP Job and records it to state with a URL.
+			{
+				Config: testAccBasicJob(jobTemplateID),
+				Check: resource.ComposeAggregateTestCheckFunc(
+					resource.TestMatchResourceAttr(resourceName, "status", regexp.MustCompile("^(failed|pending|running|complete|successful|waiting)$")),
+					resource.TestMatchResourceAttr(resourceName, "job_type", regexp.MustCompile("^(run|check)$")),
+					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api(/controller)?/v2/jobs/[0-9]*/$")),
+					testAccCheckJobUpdate(&jobUrl, false),
+				),
+			},
+			// Wait for the job to finish.
+			{
+				Config: testAccBasicJob(jobTemplateID),
+				Check: resource.ComposeAggregateTestCheckFunc(
+					resource.TestMatchResourceAttr(resourceName, "status", regexp.MustCompile("^(failed|pending|running|complete|successful|waiting)$")),
+					resource.TestMatchResourceAttr(resourceName, "job_type", regexp.MustCompile("^(run|check)$")),
+					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api(/controller)?/v2/jobs/[0-9]*/$")),
+					// Wait for the job to finish so the inventory can be deleted
+					testAccCheckJobPause(resourceName),
+				),
+			},
+			// Confirm the job is finished (fewer options in status), then delete directly via API, outside of terraform.
+			{
+				Config: testAccBasicJob(jobTemplateID),
+				Check: resource.ComposeAggregateTestCheckFunc(
+					resource.TestMatchResourceAttr(resourceName, "status", regexp.MustCompile("^(failed|complete|successful)$")),
+					resource.TestMatchResourceAttr(resourceName, "job_type", regexp.MustCompile("^(run|check)$")),
+					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api(/controller)?/v2/jobs/[0-9]*/$")),
+					testAccDeleteJob(&jobUrl),
+				),
+				ExpectNonEmptyPlan: true,
+			},
+			// Apply the plan again and confirm the job is re-created with a different URL.
+			{
+				Config: testAccBasicJob(jobTemplateID),
+				Check: resource.ComposeAggregateTestCheckFunc(
+					resource.TestMatchResourceAttr(resourceName, "status", regexp.MustCompile("^(failed|pending|running|complete|successful|waiting)$")),
+					resource.TestMatchResourceAttr(resourceName, "job_type", regexp.MustCompile("^(run|check)$")),
+					resource.TestMatchResourceAttr(resourceName, "url", regexp.MustCompile("^/api(/controller)?/v2/jobs/[0-9]*/$")),
+					testAccCheckJobUpdate(&jobUrl, true),
+				),
+			},
+		},
+	})
+}
+
+func testAccDeleteJob(jobUrl *string) func(s *terraform.State) error {
+	return func(_ *terraform.State) error {
+		_, err := testDeleteResource(*jobUrl)
+		return err
+	}
+}
diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go
index 0302a6b4..efe461cf 100644
--- a/internal/provider/provider_test.go
+++ b/internal/provider/provider_test.go
@@ -2,6 +2,7 @@ package provider
 
 import (
 	"fmt"
+	"net/http"
 	"os"
 	"testing"
 
@@ -41,7 +42,7 @@ func testAccPreCheck(t *testing.T) {
 	}
 }
 
-func testGetResource(urlPath string) ([]byte, error) {
+func testMethodResource(method string, urlPath string) ([]byte, error) {
 	host := os.Getenv("AAP_HOST")
 	username := os.Getenv("AAP_USERNAME")
 	password := os.Getenv("AAP_PASSWORD")
@@ -51,7 +52,14 @@ func testGetResource(urlPath string) ([]byte, error) {
 		return nil, fmt.Errorf("%v", diags.Errors())
 	}
 
-	body, diags := client.Get(urlPath)
+	var body []byte
+	switch method {
+	case http.MethodGet:
+		body, diags = client.Get(urlPath)
+	case http.MethodDelete:
+		body, diags = client.Delete(urlPath)
+	}
+
 	if diags.HasError() {
 		return nil, fmt.Errorf("%v", diags.Errors())
 	}
@@ -59,6 +67,14 @@ func testGetResource(urlPath string) ([]byte, error) {
 	return body, nil
 }
 
+func testGetResource(urlPath string) ([]byte, error) {
+	return testMethodResource(http.MethodGet, urlPath)
+}
+
+func testDeleteResource(urlPath string) ([]byte, error) {
+	return testMethodResource(http.MethodDelete, urlPath)
+}
+
 func TestReadValues(t *testing.T) {
 	testTable := []struct {
 		name               string
diff --git a/main.go b/main.go
index 0f2de43d..a96a6f8a 100644
--- a/main.go
+++ b/main.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"context"
+	"flag"
 	"log"
 
 	"github.com/ansible/terraform-provider-aap/internal/provider"
@@ -15,9 +16,14 @@ var (
 )
 
 func main() {
+	var debug bool
+
+	flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve")
+	flag.Parse()
+
 	opts := providerserver.ServeOpts{
-		// TODO: Update this string with the published name of your provider.
 		Address: "registry.terraform.io/ansible/aap",
+		Debug:   debug,
 	}
 
 	err := providerserver.Serve(context.Background(), provider.New(version), opts)
diff --git a/templates/resources/job.md.tmpl b/templates/resources/job.md.tmpl
index 0324b5f7..aa6291cc 100644
--- a/templates/resources/job.md.tmpl
+++ b/templates/resources/job.md.tmpl
@@ -10,6 +10,8 @@ description: |-
 
 -> **Note** To pass an inventory to an aap_job resource, the underlying job template *must* have been conigured to prompt for the inventory on launch.
 
+!> **Warning** If an AAP Job launched by this resource is deleted from AAP, the resource will be removed from the state and a new job will be created to replace it.
+
 {{ if .HasExample }}
 ## Example Usage
 


Reviews:
user: login: PabloHiro
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T14:58:43Z
user: login: lranjbar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T16:31:17Z
user: login: lranjbar
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T16:32:27Z
user: login: dleehr
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T17:19:15Z
user: login: PabloHiro
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T17:21:07Z
user: login: dleehr
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T17:22:57Z
user: login: dleehr
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T17:36:21Z
user: login: dleehr
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T17:45:27Z
user: login: dleehr
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-06T17:49:00Z
user: login: PabloHiro
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T17:52:43Z
user: login: PabloHiro
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T08:00:01Z
user: login: lranjbar
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T23:07:18Z

Review comments:
diff_hunk: @@ -195,7 +195,7 @@ func TestAccGroupResource(t *testing.T) {
 					testAccCheckGroupResourceValues(&groupApiModel, groupName, "", ""),
 					resource.TestCheckResourceAttr("aap_group.test", "name", groupName),
 					resource.TestCheckResourceAttrPair("aap_group.test", "inventory_id", "aap_inventory.test", "id"),
-					resource.TestMatchResourceAttr("aap_group.test", "url", regexp.MustCompile("^/api/v2/groups/[0-9]*/$")),
+					resource.TestMatchResourceAttr("aap_group.test", "url", regexp.MustCompile("^/api(/controller)?/v2/groups/[0-9]*/$")),
path: internal/provider/group_resource_test.go
user: login: PabloHiro
body: Not asking for this to be changed, but thinking out loud.

I wonder if instead of using regex, and thinking far into the future, we can have this as a `urlPrefix` variable so we don't have to put it all over the code.
created_at: 2025-05-06T14:50:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,6 +18,7 @@ type ProviderHTTPClient interface {
 	doRequest(method string, path string, data io.Reader) (*http.Response, []byte, error)
 	Create(path string, data io.Reader) ([]byte, diag.Diagnostics)
 	Get(path string) ([]byte, diag.Diagnostics)
path: internal/provider/client.go
user: login: PabloHiro
body: Now that we have `GetStatus` instead of `Get`, should this be removed from the interface definition?
created_at: 2025-05-06T14:52:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -175,7 +175,17 @@ func (r *JobResource) Read(ctx context.Context, req resource.ReadRequest, resp *
 	}
 
 	// Get latest job data from AAP
-	readResponseBody, diags := r.client.Get(data.URL.ValueString())
+	readResponseBody, diags, status := r.client.GetWithStatus(data.URL.ValueString())
+
+	// Check if the response is 404, meaning the job does not exist and should be recreated
+	if status == 404 {
+		resp.Diagnostics.AddWarning(
+			"Job not found",
+			"The job was not found. It may have been deleted. The job will be recreated.",
path: internal/provider/job_resource.go
user: login: PabloHiro
body: It is possible to detect if the job was present in the state right? Can we only create the warning when the job was present in the state? I think this warning can be confusing for a user running this for the first time.

created_at: 2025-05-06T14:58:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,7 +195,7 @@ func TestAccGroupResource(t *testing.T) {
 					testAccCheckGroupResourceValues(&groupApiModel, groupName, "", ""),
 					resource.TestCheckResourceAttr("aap_group.test", "name", groupName),
 					resource.TestCheckResourceAttrPair("aap_group.test", "inventory_id", "aap_inventory.test", "id"),
-					resource.TestMatchResourceAttr("aap_group.test", "url", regexp.MustCompile("^/api/v2/groups/[0-9]*/$")),
+					resource.TestMatchResourceAttr("aap_group.test", "url", regexp.MustCompile("^/api(/controller)?/v2/groups/[0-9]*/$")),
path: internal/provider/group_resource_test.go
user: login: lranjbar
body: At the very least we could make it a constant at the top of the `_test.go` file. That would decrease the running time of the tests. This is not super important for a` _test.go` file but if you find yourself constructing the same string more than once it makes sense to figure out how to do that only once.

Golang's `regexp.MustCompile` is set up to be able to be used with constants. Making this a constant will take advantage of the language's compilation to reduce running time. This is one of those cases where in Golang you can move more time into the compile to reduce running time.
created_at: 2025-05-06T16:31:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,6 +18,7 @@ type ProviderHTTPClient interface {
 	doRequest(method string, path string, data io.Reader) (*http.Response, []byte, error)
 	Create(path string, data io.Reader) ([]byte, diag.Diagnostics)
 	Get(path string) ([]byte, diag.Diagnostics)
path: internal/provider/client.go
user: login: lranjbar
body: This interface is reflected across all the resources in the provider so we still need Get() in the interface.
created_at: 2025-05-06T16:32:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,6 +18,7 @@ type ProviderHTTPClient interface {
 	doRequest(method string, path string, data io.Reader) (*http.Response, []byte, error)
 	Create(path string, data io.Reader) ([]byte, diag.Diagnostics)
 	Get(path string) ([]byte, diag.Diagnostics)
path: internal/provider/client.go
user: login: dleehr
body: Yeah, what @lranjbar said. Originally I started by changing `Get` to return 3 values, but as you can imagine that resulted in a lot of changed lines in code that didn't care about the status.

So I thought it was cleaner to refactor this into two functions. Most of the time we don't need the status value but it's there when we do.
created_at: 2025-05-06T17:19:15Z
author_association: COLLABORATOR
diff_hunk: @@ -18,6 +18,7 @@ type ProviderHTTPClient interface {
 	doRequest(method string, path string, data io.Reader) (*http.Response, []byte, error)
 	Create(path string, data io.Reader) ([]byte, diag.Diagnostics)
 	Get(path string) ([]byte, diag.Diagnostics)
path: internal/provider/client.go
user: login: PabloHiro
body: It does not seem like I can resolve threads in this repo yet, but consider this closed please, thank you both for the explanation :)
created_at: 2025-05-06T17:21:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -195,7 +195,7 @@ func TestAccGroupResource(t *testing.T) {
 					testAccCheckGroupResourceValues(&groupApiModel, groupName, "", ""),
 					resource.TestCheckResourceAttr("aap_group.test", "name", groupName),
 					resource.TestCheckResourceAttrPair("aap_group.test", "inventory_id", "aap_inventory.test", "id"),
-					resource.TestMatchResourceAttr("aap_group.test", "url", regexp.MustCompile("^/api/v2/groups/[0-9]*/$")),
+					resource.TestMatchResourceAttr("aap_group.test", "url", regexp.MustCompile("^/api(/controller)?/v2/groups/[0-9]*/$")),
path: internal/provider/group_resource_test.go
user: login: dleehr
body: For sure - There is certainly duplication here that could be refactored, but not something I'm going to take on in **this** PR 😄. And as we understand more about what the purpose of these checks are, we may want to change this in other ways.

Certainly appreciate the observation 👍 


created_at: 2025-05-06T17:22:57Z
author_association: COLLABORATOR
diff_hunk: @@ -175,7 +175,17 @@ func (r *JobResource) Read(ctx context.Context, req resource.ReadRequest, resp *
 	}
 
 	// Get latest job data from AAP
-	readResponseBody, diags := r.client.Get(data.URL.ValueString())
+	readResponseBody, diags, status := r.client.GetWithStatus(data.URL.ValueString())
+
+	// Check if the response is 404, meaning the job does not exist and should be recreated
+	if status == 404 {
+		resp.Diagnostics.AddWarning(
+			"Job not found",
+			"The job was not found. It may have been deleted. The job will be recreated.",
path: internal/provider/job_resource.go
user: login: dleehr
body: Great question, but we don't actually need to detect that 😺 and the warning won't appear to users running this for the first time. That logic is already factored into the design of the provider via the terraform plugin framework. 

This method (`Read`) is only called when an `aap_job` resource **is** present in the state and has a `URL` recorded. See https://developer.hashicorp.com/terraform/plugin/framework/resources/read#define-read-method for similar examples. So we can only get a 404 if we already have a job URL, and that comes from the state.

When the Job does not yet exist, it's not in the state, so terraform calls `Create` instead, which [launches](https://github.com/ansible/terraform-provider-aap/blob/main/internal/provider/job_resource.go#L155) the job and [records it in the state](https://github.com/ansible/terraform-provider-aap/blob/main/internal/provider/job_resource.go#L161). See https://developer.hashicorp.com/terraform/plugin/framework/resources/create.

created_at: 2025-05-06T17:36:21Z
author_association: COLLABORATOR
diff_hunk: @@ -175,7 +175,17 @@ func (r *JobResource) Read(ctx context.Context, req resource.ReadRequest, resp *
 	}
 
 	// Get latest job data from AAP
-	readResponseBody, diags := r.client.Get(data.URL.ValueString())
+	readResponseBody, diags, status := r.client.GetWithStatus(data.URL.ValueString())
+
+	// Check if the response is 404, meaning the job does not exist and should be recreated
+	if status == 404 {
+		resp.Diagnostics.AddWarning(
+			"Job not found",
+			"The job was not found. It may have been deleted. The job will be recreated.",
path: internal/provider/job_resource.go
user: login: dleehr
body: With a fresh project `main.tf` and no existing state:

```hcl
terraform {
  required_providers {
    aap = {
      source = "ansible/aap"
      version = "1.1.2"
    }
  }
}

provider "aap" {
  host = "http://localhost:9080"
  username = "admin"
  password = "..."
}

data "aap_inventory" "my_inventory" {
  id = 1
}

resource "aap_job" "my_job" {
  job_template_id = 7
  inventory_id = data.aap_inventory.my_inventory.id
}

```

The output of `terraform plan` below:

```
$ terraform plan
data.aap_inventory.my_inventory: Reading...
data.aap_inventory.my_inventory: Read complete after 0s [name=Demo Inventory]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aap_job.my_job will be created
  + resource "aap_job" "my_job" {
      + ignored_fields  = (known after apply)
      + inventory_id    = 1
      + job_template_id = 7
      + job_type        = (known after apply)
      + status          = (known after apply)
      + url             = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.
created_at: 2025-05-06T17:45:27Z
author_association: COLLABORATOR
diff_hunk: @@ -175,7 +175,17 @@ func (r *JobResource) Read(ctx context.Context, req resource.ReadRequest, resp *
 	}
 
 	// Get latest job data from AAP
-	readResponseBody, diags := r.client.Get(data.URL.ValueString())
+	readResponseBody, diags, status := r.client.GetWithStatus(data.URL.ValueString())
+
+	// Check if the response is 404, meaning the job does not exist and should be recreated
+	if status == 404 {
+		resp.Diagnostics.AddWarning(
+			"Job not found",
+			"The job was not found. It may have been deleted. The job will be recreated.",
path: internal/provider/job_resource.go
user: login: dleehr
body: I applied the above. While the job exists, `terraform plan` shows no changes:

```
$ terraform plan
data.aap_inventory.my_inventory: Reading...
data.aap_inventory.my_inventory: Read complete after 0s [name=Demo Inventory]
aap_job.my_job: Refreshing state...

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.
```

I delete the job in AAP and re-run `terraform plan`. That's when it shows the warning.

```
$ terraform plan
data.aap_inventory.my_inventory: Reading...
data.aap_inventory.my_inventory: Read complete after 0s [name=Demo Inventory]
aap_job.my_job: Refreshing state...

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aap_job.my_job will be created
  + resource "aap_job" "my_job" {
      + ignored_fields  = (known after apply)
      + inventory_id    = 1
      + job_template_id = 7
      + job_type        = (known after apply)
      + status          = (known after apply)
      + url             = (known after apply)
    }

Plan: 1 to add, 0 to change, 0 to destroy.
╷
│ Warning: Job not found
│ 
│   with aap_job.my_job,
│   on main.tf line 20, in resource "aap_job" "my_job":
│   20: resource "aap_job" "my_job" {
│ 
│ The job was not found. It may have been deleted. The job will be recreated.
╵

───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform apply" now.
```
created_at: 2025-05-06T17:49:00Z
author_association: COLLABORATOR
diff_hunk: @@ -175,7 +175,17 @@ func (r *JobResource) Read(ctx context.Context, req resource.ReadRequest, resp *
 	}
 
 	// Get latest job data from AAP
-	readResponseBody, diags := r.client.Get(data.URL.ValueString())
+	readResponseBody, diags, status := r.client.GetWithStatus(data.URL.ValueString())
+
+	// Check if the response is 404, meaning the job does not exist and should be recreated
+	if status == 404 {
+		resp.Diagnostics.AddWarning(
+			"Job not found",
+			"The job was not found. It may have been deleted. The job will be recreated.",
path: internal/provider/job_resource.go
user: login: PabloHiro
body: Thank you Dan! The links above are great resources. Please consider this as closed.
created_at: 2025-05-06T17:52:42Z
author_association: CONTRIBUTOR

PR comments:
user: login: dleehr
created_at: 2025-05-06T14:27:32Z
body: Looks like I need a changelog fragment for this. Edit: and docs!
author_association: COLLABORATOR
user: login: dleehr
created_at: 2025-05-06T14:41:46Z
body: <details>

<summary>Local acceptance test output (click to expand)</summary>

```
$ git rev-parse HEAD
b92bd085d03c28b3173cf4da46f5c3467eefa48a

$ TF_ACC=1 AAP_HOST="http://localhost:9080" go test -count=1 -v ./...
?   	github.com/ansible/terraform-provider-aap	[no test files]
=== RUN   TestComputeURLPath
=== RUN   TestComputeURLPath/case_1
=== RUN   TestComputeURLPath/case_2
=== RUN   TestComputeURLPath/case_3
=== RUN   TestComputeURLPath/case_4
--- PASS: TestComputeURLPath (0.00s)
    --- PASS: TestComputeURLPath/case_1 (0.00s)
    --- PASS: TestComputeURLPath/case_2 (0.00s)
    --- PASS: TestComputeURLPath/case_3 (0.00s)
    --- PASS: TestComputeURLPath/case_4 (0.00s)
=== RUN   TestReadApiEndpoint
=== RUN   TestReadApiEndpoint/AAP_2.4
=== RUN   TestReadApiEndpoint/AAP_2.5+
--- PASS: TestReadApiEndpoint (0.00s)
    --- PASS: TestReadApiEndpoint/AAP_2.4 (0.00s)
    --- PASS: TestReadApiEndpoint/AAP_2.5+ (0.00s)
=== RUN   TestGroupResourceSchema
=== PAUSE TestGroupResourceSchema
=== RUN   TestGroupResourceCreateRequestBody
=== RUN   TestGroupResourceCreateRequestBody/test_with_unknown_values
=== RUN   TestGroupResourceCreateRequestBody/test_with_null_values
=== RUN   TestGroupResourceCreateRequestBody/test_with_some_values
=== RUN   TestGroupResourceCreateRequestBody/test_with_all_values
--- PASS: TestGroupResourceCreateRequestBody (0.00s)
    --- PASS: TestGroupResourceCreateRequestBody/test_with_unknown_values (0.00s)
    --- PASS: TestGroupResourceCreateRequestBody/test_with_null_values (0.00s)
    --- PASS: TestGroupResourceCreateRequestBody/test_with_some_values (0.00s)
    --- PASS: TestGroupResourceCreateRequestBody/test_with_all_values (0.00s)
=== RUN   TestGroupResourceParseHttpResponse
=== RUN   TestGroupResourceParseHttpResponse/test_with_JSON_error
=== RUN   TestGroupResourceParseHttpResponse/test_with_missing_values
=== RUN   TestGroupResourceParseHttpResponse/test_with_all_values
--- PASS: TestGroupResourceParseHttpResponse (0.00s)
    --- PASS: TestGroupResourceParseHttpResponse/test_with_JSON_error (0.00s)
    --- PASS: TestGroupResourceParseHttpResponse/test_with_missing_values (0.00s)
    --- PASS: TestGroupResourceParseHttpResponse/test_with_all_values (0.00s)
=== RUN   TestAccGroupResource
--- PASS: TestAccGroupResource (5.60s)
=== RUN   TestSliceDifference
--- PASS: TestSliceDifference (0.00s)
=== RUN   TestExtractIDs
--- PASS: TestExtractIDs (0.00s)
=== RUN   TestHostResourceSchema
=== PAUSE TestHostResourceSchema
=== RUN   TestHostResourceCreateRequestBody
=== RUN   TestHostResourceCreateRequestBody/test_with_unknown_values
=== RUN   TestHostResourceCreateRequestBody/test_with_null_values
=== RUN   TestHostResourceCreateRequestBody/test_with_some_values
=== RUN   TestHostResourceCreateRequestBody/test_with_all_values
--- PASS: TestHostResourceCreateRequestBody (0.00s)
    --- PASS: TestHostResourceCreateRequestBody/test_with_unknown_values (0.00s)
    --- PASS: TestHostResourceCreateRequestBody/test_with_null_values (0.00s)
    --- PASS: TestHostResourceCreateRequestBody/test_with_some_values (0.00s)
    --- PASS: TestHostResourceCreateRequestBody/test_with_all_values (0.00s)
=== RUN   TestHostResourceParseHttpResponse
=== RUN   TestHostResourceParseHttpResponse/test_with_JSON_error
=== RUN   TestHostResourceParseHttpResponse/test_with_missing_values
=== RUN   TestHostResourceParseHttpResponse/test_with_all_values
--- PASS: TestHostResourceParseHttpResponse (0.00s)
    --- PASS: TestHostResourceParseHttpResponse/test_with_JSON_error (0.00s)
    --- PASS: TestHostResourceParseHttpResponse/test_with_missing_values (0.00s)
    --- PASS: TestHostResourceParseHttpResponse/test_with_all_values (0.00s)
=== RUN   TestAccHostResource
--- PASS: TestAccHostResource (6.04s)
=== RUN   TestInventoryDataSourceSchema
=== PAUSE TestInventoryDataSourceSchema
=== RUN   TestInventoryDataSourceParseHttpResponse
=== RUN   TestInventoryDataSourceParseHttpResponse/JSON_error
=== RUN   TestInventoryDataSourceParseHttpResponse/missing_values
=== RUN   TestInventoryDataSourceParseHttpResponse/all_values
--- PASS: TestInventoryDataSourceParseHttpResponse (0.00s)
    --- PASS: TestInventoryDataSourceParseHttpResponse/JSON_error (0.00s)
    --- PASS: TestInventoryDataSourceParseHttpResponse/missing_values (0.00s)
    --- PASS: TestInventoryDataSourceParseHttpResponse/all_values (0.00s)
=== RUN   TestAccInventoryDataSource
--- PASS: TestAccInventoryDataSource (2.17s)
=== RUN   TestInventoryResourceSchema
=== PAUSE TestInventoryResourceSchema
=== RUN   TestInventoryResourceGenerateRequestBody
=== RUN   TestInventoryResourceGenerateRequestBody/unknown_values
=== RUN   TestInventoryResourceGenerateRequestBody/null_values
=== RUN   TestInventoryResourceGenerateRequestBody/provided_values
--- PASS: TestInventoryResourceGenerateRequestBody (0.00s)
    --- PASS: TestInventoryResourceGenerateRequestBody/unknown_values (0.00s)
    --- PASS: TestInventoryResourceGenerateRequestBody/null_values (0.00s)
    --- PASS: TestInventoryResourceGenerateRequestBody/provided_values (0.00s)
=== RUN   TestInventoryResourceParseHttpResponse
=== RUN   TestInventoryResourceParseHttpResponse/JSON_error
=== RUN   TestInventoryResourceParseHttpResponse/missing_values
=== RUN   TestInventoryResourceParseHttpResponse/all_values
--- PASS: TestInventoryResourceParseHttpResponse (0.00s)
    --- PASS: TestInventoryResourceParseHttpResponse/JSON_error (0.00s)
    --- PASS: TestInventoryResourceParseHttpResponse/missing_values (0.00s)
    --- PASS: TestInventoryResourceParseHttpResponse/all_values (0.00s)
=== RUN   TestAccInventoryResource
--- PASS: TestAccInventoryResource (4.32s)
=== RUN   TestJobResourceSchema
=== PAUSE TestJobResourceSchema
=== RUN   TestJobResourceCreateRequestBody
=== RUN   TestJobResourceCreateRequestBody/unknown_values
=== RUN   TestJobResourceCreateRequestBody/null_values
=== RUN   TestJobResourceCreateRequestBody/extra_vars_only
=== RUN   TestJobResourceCreateRequestBody/inventory_vars_only
=== RUN   TestJobResourceCreateRequestBody/combined
=== RUN   TestJobResourceCreateRequestBody/manual_triggers
--- PASS: TestJobResourceCreateRequestBody (0.00s)
    --- PASS: TestJobResourceCreateRequestBody/unknown_values (0.00s)
    --- PASS: TestJobResourceCreateRequestBody/null_values (0.00s)
    --- PASS: TestJobResourceCreateRequestBody/extra_vars_only (0.00s)
    --- PASS: TestJobResourceCreateRequestBody/inventory_vars_only (0.00s)
    --- PASS: TestJobResourceCreateRequestBody/combined (0.00s)
    --- PASS: TestJobResourceCreateRequestBody/manual_triggers (0.00s)
=== RUN   TestJobResourceParseHttpResponse
=== RUN   TestJobResourceParseHttpResponse/JSON_error
=== RUN   TestJobResourceParseHttpResponse/no_ignored_fields
=== RUN   TestJobResourceParseHttpResponse/ignored_fields
--- PASS: TestJobResourceParseHttpResponse (0.00s)
    --- PASS: TestJobResourceParseHttpResponse/JSON_error (0.00s)
    --- PASS: TestJobResourceParseHttpResponse/no_ignored_fields (0.00s)
    --- PASS: TestJobResourceParseHttpResponse/ignored_fields (0.00s)
=== RUN   TestAccAAPJob_basic
--- PASS: TestAccAAPJob_basic (2.19s)
=== RUN   TestAccAAPJob_UpdateWithSameParameters
--- PASS: TestAccAAPJob_UpdateWithSameParameters (3.05s)
=== RUN   TestAccAAPJob_UpdateWithNewInventoryId
--- PASS: TestAccAAPJob_UpdateWithNewInventoryId (42.01s)
=== RUN   TestAccAAPJob_UpdateWithTrigger
--- PASS: TestAccAAPJob_UpdateWithTrigger (3.21s)
=== RUN   TestAccAAPJob_disappears
--- PASS: TestAccAAPJob_disappears (44.08s)
=== RUN   TestReadValues
=== RUN   TestReadValues/No_defined_values
=== RUN   TestReadValues/Using_env_variables_only
=== RUN   TestReadValues/Using_both_configuration_and_envs_value
=== RUN   TestReadValues/Using_configuration_value
=== RUN   TestReadValues/Bad_value_for_env_variable
=== RUN   TestReadValues/Using_null_values_in_configuration
--- PASS: TestReadValues (0.00s)
    --- PASS: TestReadValues/No_defined_values (0.00s)
    --- PASS: TestReadValues/Using_env_variables_only (0.00s)
    --- PASS: TestReadValues/Using_both_configuration_and_envs_value (0.00s)
    --- PASS: TestReadValues/Using_configuration_value (0.00s)
    --- PASS: TestReadValues/Bad_value_for_env_variable (0.00s)
    --- PASS: TestReadValues/Using_null_values_in_configuration (0.00s)
=== RUN   TestGetURL
=== RUN   TestGetURL/https://example.com
=== RUN   TestGetURL/https://example.com/
=== RUN   TestGetURL/https://example.com#01
=== RUN   TestGetURL/invalid-url
--- PASS: TestGetURL (0.00s)
    --- PASS: TestGetURL/https://example.com (0.00s)
    --- PASS: TestGetURL/https://example.com/ (0.00s)
    --- PASS: TestGetURL/https://example.com#01 (0.00s)
    --- PASS: TestGetURL/invalid-url (0.00s)
=== RUN   TestParseStringValue
=== RUN   TestParseStringValue/Test_non-empty_string
=== RUN   TestParseStringValue/Test_empty_string
--- PASS: TestParseStringValue (0.00s)
    --- PASS: TestParseStringValue/Test_non-empty_string (0.00s)
    --- PASS: TestParseStringValue/Test_empty_string (0.00s)
=== RUN   TestParseNormalizedValue
=== RUN   TestParseNormalizedValue/Test_non-empty_string
=== RUN   TestParseNormalizedValue/Test_empty_string
--- PASS: TestParseNormalizedValue (0.00s)
    --- PASS: TestParseNormalizedValue/Test_non-empty_string (0.00s)
    --- PASS: TestParseNormalizedValue/Test_empty_string (0.00s)
=== CONT  TestGroupResourceSchema
=== CONT  TestInventoryResourceSchema
=== CONT  TestInventoryDataSourceSchema
=== CONT  TestJobResourceSchema
=== CONT  TestHostResourceSchema
--- PASS: TestInventoryResourceSchema (0.00s)
--- PASS: TestGroupResourceSchema (0.00s)
--- PASS: TestInventoryDataSourceSchema (0.00s)
--- PASS: TestJobResourceSchema (0.00s)
--- PASS: TestHostResourceSchema (0.00s)
PASS
ok  	github.com/ansible/terraform-provider-aap/internal/provider	113.012s
=== RUN   TestAAPCustomStringTypeValidate
=== PAUSE TestAAPCustomStringTypeValidate
=== RUN   TestAAPCustomStringTypeValueFromTerraform
=== PAUSE TestAAPCustomStringTypeValueFromTerraform
=== RUN   TestAAPCustomStringStringSemanticEquals
=== PAUSE TestAAPCustomStringStringSemanticEquals
=== CONT  TestAAPCustomStringTypeValidate
=== RUN   TestAAPCustomStringTypeValidate/yaml_string_no_newline
=== CONT  TestAAPCustomStringStringSemanticEquals
=== RUN   TestAAPCustomStringStringSemanticEquals/not_equal_-_mismatched_field_values
=== PAUSE TestAAPCustomStringStringSemanticEquals/not_equal_-_mismatched_field_values
=== RUN   TestAAPCustomStringStringSemanticEquals/not_equal_-_mismatched_field_names
=== PAUSE TestAAPCustomStringStringSemanticEquals/not_equal_-_mismatched_field_names
=== PAUSE TestAAPCustomStringTypeValidate/yaml_string_no_newline
=== CONT  TestAAPCustomStringTypeValueFromTerraform
=== RUN   TestAAPCustomStringTypeValidate/wrong-value-type
=== RUN   TestAAPCustomStringTypeValueFromTerraform/true
=== PAUSE TestAAPCustomStringTypeValueFromTerraform/true
=== PAUSE TestAAPCustomStringTypeValidate/wrong-value-type
=== RUN   TestAAPCustomStringTypeValueFromTerraform/unknown
=== RUN   TestAAPCustomStringTypeValidate/empty-struct
=== PAUSE TestAAPCustomStringTypeValueFromTerraform/unknown
=== PAUSE TestAAPCustomStringTypeValidate/empty-struct
=== RUN   TestAAPCustomStringTypeValueFromTerraform/null
=== RUN   TestAAPCustomStringTypeValidate/null
=== PAUSE TestAAPCustomStringTypeValueFromTerraform/null
=== RUN   TestAAPCustomStringTypeValueFromTerraform/wrongType
=== PAUSE TestAAPCustomStringTypeValidate/null
=== PAUSE TestAAPCustomStringTypeValueFromTerraform/wrongType
=== RUN   TestAAPCustomStringTypeValidate/unknown
=== CONT  TestAAPCustomStringTypeValueFromTerraform/wrongType
=== RUN   TestAAPCustomStringStringSemanticEquals/not_equal_-_additional_field
=== PAUSE TestAAPCustomStringStringSemanticEquals/not_equal_-_additional_field
=== RUN   TestAAPCustomStringStringSemanticEquals/not_equal_-_array_item_order_difference
=== PAUSE TestAAPCustomStringStringSemanticEquals/not_equal_-_array_item_order_difference
=== RUN   TestAAPCustomStringStringSemanticEquals/semantically_equal_-_object_byte-for-byte_match
=== PAUSE TestAAPCustomStringStringSemanticEquals/semantically_equal_-_object_byte-for-byte_match
=== CONT  TestAAPCustomStringTypeValueFromTerraform/true
=== PAUSE TestAAPCustomStringTypeValidate/unknown
=== CONT  TestAAPCustomStringTypeValueFromTerraform/null
=== CONT  TestAAPCustomStringTypeValueFromTerraform/unknown
--- PASS: TestAAPCustomStringTypeValueFromTerraform (0.00s)
    --- PASS: TestAAPCustomStringTypeValueFromTerraform/wrongType (0.00s)
    --- PASS: TestAAPCustomStringTypeValueFromTerraform/true (0.00s)
    --- PASS: TestAAPCustomStringTypeValueFromTerraform/null (0.00s)
    --- PASS: TestAAPCustomStringTypeValueFromTerraform/unknown (0.00s)
=== RUN   TestAAPCustomStringStringSemanticEquals/semantically_equal_-_object_whitespace_difference
=== RUN   TestAAPCustomStringTypeValidate/json_object
=== PAUSE TestAAPCustomStringTypeValidate/json_object
=== RUN   TestAAPCustomStringTypeValidate/json_string
=== PAUSE TestAAPCustomStringStringSemanticEquals/semantically_equal_-_object_whitespace_difference
=== PAUSE TestAAPCustomStringTypeValidate/json_string
=== RUN   TestAAPCustomStringTypeValidate/yaml_string
=== PAUSE TestAAPCustomStringTypeValidate/yaml_string
=== CONT  TestAAPCustomStringTypeValidate/yaml_string_no_newline
=== CONT  TestAAPCustomStringTypeValidate/json_object
=== CONT  TestAAPCustomStringTypeValidate/null
=== CONT  TestAAPCustomStringTypeValidate/json_string
=== RUN   TestAAPCustomStringStringSemanticEquals/semantically_equal_-_yaml_no_difference
=== PAUSE TestAAPCustomStringStringSemanticEquals/semantically_equal_-_yaml_no_difference
=== RUN   TestAAPCustomStringStringSemanticEquals/semantically_equal_-_yaml_no_difference_with_newline
=== PAUSE TestAAPCustomStringStringSemanticEquals/semantically_equal_-_yaml_no_difference_with_newline
=== CONT  TestAAPCustomStringStringSemanticEquals/not_equal_-_mismatched_field_values
=== CONT  TestAAPCustomStringStringSemanticEquals/semantically_equal_-_yaml_no_difference_with_newline
=== CONT  TestAAPCustomStringStringSemanticEquals/semantically_equal_-_yaml_no_difference
=== CONT  TestAAPCustomStringStringSemanticEquals/semantically_equal_-_object_whitespace_difference
=== CONT  TestAAPCustomStringTypeValidate/empty-struct
=== CONT  TestAAPCustomStringTypeValidate/wrong-value-type
=== CONT  TestAAPCustomStringTypeValidate/unknown
=== CONT  TestAAPCustomStringTypeValidate/yaml_string
=== CONT  TestAAPCustomStringStringSemanticEquals/semantically_equal_-_object_byte-for-byte_match
=== CONT  TestAAPCustomStringStringSemanticEquals/not_equal_-_array_item_order_difference
=== CONT  TestAAPCustomStringStringSemanticEquals/not_equal_-_additional_field
=== CONT  TestAAPCustomStringStringSemanticEquals/not_equal_-_mismatched_field_names
--- PASS: TestAAPCustomStringTypeValidate (0.00s)
    --- PASS: TestAAPCustomStringTypeValidate/null (0.00s)
    --- PASS: TestAAPCustomStringTypeValidate/yaml_string (0.00s)
    --- PASS: TestAAPCustomStringTypeValidate/empty-struct (0.00s)
    --- PASS: TestAAPCustomStringTypeValidate/unknown (0.00s)
    --- PASS: TestAAPCustomStringTypeValidate/json_object (0.00s)
    --- PASS: TestAAPCustomStringTypeValidate/wrong-value-type (0.00s)
    --- PASS: TestAAPCustomStringTypeValidate/json_string (0.00s)
    --- PASS: TestAAPCustomStringTypeValidate/yaml_string_no_newline (0.00s)
--- PASS: TestAAPCustomStringStringSemanticEquals (0.00s)
    --- PASS: TestAAPCustomStringStringSemanticEquals/semantically_equal_-_object_byte-for-byte_match (0.00s)
    --- PASS: TestAAPCustomStringStringSemanticEquals/not_equal_-_array_item_order_difference (0.00s)
    --- PASS: TestAAPCustomStringStringSemanticEquals/not_equal_-_additional_field (0.00s)
    --- PASS: TestAAPCustomStringStringSemanticEquals/not_equal_-_mismatched_field_names (0.00s)
    --- PASS: TestAAPCustomStringStringSemanticEquals/not_equal_-_mismatched_field_values (0.00s)
    --- PASS: TestAAPCustomStringStringSemanticEquals/semantically_equal_-_yaml_no_difference_with_newline (0.00s)
    --- PASS: TestAAPCustomStringStringSemanticEquals/semantically_equal_-_yaml_no_difference (0.00s)
    --- PASS: TestAAPCustomStringStringSemanticEquals/semantically_equal_-_object_whitespace_difference (0.00s)
PASS
ok  	github.com/ansible/terraform-provider-aap/internal/provider/customtypes	0.492s
```

</details>
author_association: COLLABORATOR
user: login: dleehr
created_at: 2025-05-07T18:43:53Z
body: > Looks like I need a changelog fragment for this. Edit: and docs!

Done in 3a1f5d25409063c976fe48c4a1a50a28e0e23169.

lint check is failing, see #63 
author_association: COLLABORATOR