From d861e97c64cf1abc80c8ae7311f8fc476a036f5c Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Tue, 13 Feb 2024 14:54:45 +0100 Subject: [PATCH 1/6] Refactor job resource Signed-off-by: Alina Buzachis --- internal/provider/job_resource.go | 320 +++++++++++++++--------------- 1 file changed, 160 insertions(+), 160 deletions(-) diff --git a/internal/provider/job_resource.go b/internal/provider/job_resource.go index 684c3cf..e3a3373 100644 --- a/internal/provider/job_resource.go +++ b/internal/provider/job_resource.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" @@ -16,22 +15,18 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) -// Ensure provider defined types fully satisfy framework interfaces. +// Ensure the implementation satisfies the expected interfaces. var ( - _ resource.Resource = &JobResource{} + _ resource.Resource = &JobResource{} + _ resource.ResourceWithConfigure = &JobResource{} ) +// NewJobResource is a helper function to simplify the provider implementation. func NewJobResource() resource.Resource { return &JobResource{} } -type JobResourceModelInterface interface { - ParseHTTPResponse(body []byte) error - CreateRequestBody() ([]byte, diag.Diagnostics) - GetTemplateID() string - GetURL() string -} - +// JobResource is the resource implementation. type JobResource struct { client ProviderHTTPClient } @@ -41,7 +36,27 @@ func (r *JobResource) Metadata(_ context.Context, req resource.MetadataRequest, resp.TypeName = req.ProviderTypeName + "_job" } -// Schema defines the schema for the resource. +// Configure adds the provider configured client to the data source. +func (d *JobResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*AAPClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *AAPClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + d.client = client +} + +// Schema defines the schema for the jobresource. func (d *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ @@ -80,8 +95,20 @@ func (d *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp * } } -// jobResourceModel maps the resource schema data. -type jobResourceModel struct { +// Job AAP API model +type JobAPIModel struct { + TemplateID int64 `json:"job_template_id,omitempty"` + Type string `json:"job_type,omitempty"` + URL string `json:"url,omitempty"` + Status string `json:"status,omitempty"` + Inventory int64 `json:"inventory_id"` + ExtraVars string `json:"extra_vars,omitempty"` + IgnoredFields map[string]json.RawMessage `json:"ignored_fields,omitempty"` + Triggers struct{} `json:"triggers,omitempty"` +} + +// JobResourceModel maps the resource schema data. +type JobResourceModel struct { TemplateID types.Int64 `tfsdk:"job_template_id"` Type types.String `tfsdk:"job_type"` URL types.String `tfsdk:"job_url"` @@ -96,186 +123,99 @@ var keyMapping = map[string]string{ "inventory": "inventory", } -func (d *jobResourceModel) GetTemplateID() string { +func (d *JobResourceModel) GetTemplateID() string { return d.TemplateID.String() } -func (d *jobResourceModel) GetURL() string { - if !d.URL.IsNull() && !d.URL.IsUnknown() { - return d.URL.ValueString() - } - return "" -} - -func (d *jobResourceModel) ParseHTTPResponse(body []byte) error { - /* Unmarshal the json string */ - var result map[string]interface{} - err := json.Unmarshal(body, &result) - if err != nil { - return err - } - - d.Type = types.StringValue(result["job_type"].(string)) - d.URL = types.StringValue(result["url"].(string)) - d.Status = types.StringValue(result["status"].(string)) - d.IgnoredFields = types.ListNull(types.StringType) - - if value, ok := result["ignored_fields"]; ok { - var keysList = []attr.Value{} - for k := range value.(map[string]interface{}) { - key := k - if v, ok := keyMapping[k]; ok { - key = v - } - keysList = append(keysList, types.StringValue(key)) - } - if len(keysList) > 0 { - d.IgnoredFields, _ = types.ListValue(types.StringType, keysList) - } - } - - return nil -} - -func (d *jobResourceModel) CreateRequestBody() ([]byte, diag.Diagnostics) { - body := make(map[string]interface{}) - var diags diag.Diagnostics - - // Extra vars - if IsValueProvided(d.ExtraVars) { - var extraVars map[string]interface{} - diags.Append(d.ExtraVars.Unmarshal(&extraVars)...) - if diags.HasError() { - return nil, diags - } - body["extra_vars"] = extraVars - } - - // Inventory - if IsValueProvided(d.InventoryID) { - body["inventory"] = d.InventoryID.ValueInt64() - } +func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data JobResourceModel - if len(body) == 0 { - return nil, diags - } - jsonRaw, err := json.Marshal(body) - if err != nil { - diags.Append(diag.NewErrorDiagnostic("Body JSON Marshal Error", err.Error())) - return nil, diags + // Read Terraform plan data into job resource model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return } - return jsonRaw, diags -} -// Configure adds the provider configured client to the data source. -func (d *JobResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - // Prevent panic if the provider has not been configured. - if req.ProviderData == nil { + resp.Diagnostics.Append(r.CreateJob(&data)...) + if resp.Diagnostics.HasError() { return } - client, ok := req.ProviderData.(*AAPClient) - if !ok { - resp.Diagnostics.AddError( - "Unexpected Resource Configure Type", - fmt.Sprintf("Expected *AAPClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), - ) - + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + if resp.Diagnostics.HasError() { return } - - d.client = client } -func (r JobResource) CreateJob(data JobResourceModelInterface) diag.Diagnostics { +func (r *JobResource) CreateJob(data *JobResourceModel) diag.Diagnostics { // Create new Job from job template var diags diag.Diagnostics - var reqData io.Reader = nil - reqBody, diagCreateReq := data.CreateRequestBody() + + // Create request body from job data + requestBody, diagCreateReq := data.CreateRequestBody() diags.Append(diagCreateReq...) if diags.HasError() { return diags } - if reqBody != nil { - reqData = bytes.NewReader(reqBody) - } - + requestData := bytes.NewReader(requestBody) var postURL = "/api/v2/job_templates/" + data.GetTemplateID() + "/launch/" - resp, body, err := r.client.doRequest(http.MethodPost, postURL, reqData) - - if err != nil { - diags.AddError("client request error", err.Error()) - return diags - } - if resp == nil { - diags.AddError("Http response Error", "no http response from server") - return diags - } - if resp.StatusCode != http.StatusCreated { - diags.AddError("Unexpected Http Status code", - fmt.Sprintf("expected (%d) got (%d)", http.StatusCreated, resp.StatusCode)) + resp, body, err := r.client.doRequest(http.MethodPost, postURL, requestData) + diags.Append(ValidateResponse(resp, body, err, []int{http.StatusCreated})...) + if diags.HasError() { return diags } - err = data.ParseHTTPResponse(body) - if err != nil { - diags.AddError("error while parsing the json response: ", err.Error()) + + // Save new job data into job resource model + diags.Append(data.ParseHttpResponse(body)...) + if diags.HasError() { return diags } - return diags -} - -func (r JobResource) ReadJob(data JobResourceModelInterface) error { - // Read existing Job - jobURL := data.GetURL() - if len(jobURL) > 0 { - resp, body, err := r.client.doRequest("GET", jobURL, nil) - if err != nil { - return err - } - if resp == nil { - return fmt.Errorf("the server response is null") - } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("the server returned status code %d while attempting to Get from URL %s", resp.StatusCode, jobURL) - } - err = data.ParseHTTPResponse(body) - if err != nil { - return err - } - } - return nil + return diags } -func (r JobResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { - var data jobResourceModel +func (r *JobResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data JobResourceModel + var diags diag.Diagnostics - // Read Terraform prior state data into the model + // Read current Terraform state data into job resource model resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + // Get latest host data from AAP + readResponseBody, diags := r.client.Get(data.URL.ValueString()) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } - err := r.ReadJob(&data) - if err != nil { - resp.Diagnostics.AddError( - "Unexpected Resource Read error", - err.Error(), - ) + // Save latest host data into host resource model + diags = data.ParseHttpResponse(readResponseBody) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { return } + // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } } -func (r JobResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { - var data jobResourceModel +func (r *JobResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data JobResourceModel - // Read Terraform plan data into the model + // Read Terraform plan data into job resource model resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) - if resp.Diagnostics.HasError() { return } + // Create new Job from job template resp.Diagnostics.Append(r.CreateJob(&data)...) if resp.Diagnostics.HasError() { return @@ -283,23 +223,83 @@ func (r JobResource) Create(ctx context.Context, req resource.CreateRequest, res // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } } func (r JobResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *resource.DeleteResponse) { } -func (r JobResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - var data jobResourceModel +// CreateRequestBody creates a JSON encoded request body from the job resource data +func (r *JobResourceModel) CreateRequestBody() ([]byte, diag.Diagnostics) { + //var diags diag.Diagnostics - // Read Terraform plan data into the model - resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + // Convert job resource data to API data model + job := JobAPIModel{ + Inventory: r.InventoryID.ValueInt64(), + ExtraVars: r.ExtraVars.ValueString(), + } - // Create new Job from job template - resp.Diagnostics.Append(r.CreateJob(&data)...) - if resp.Diagnostics.HasError() { - return + // Create JSON encoded request body + jsonBody, err := json.Marshal(job) + if err != nil { + var diags diag.Diagnostics + diags.AddError( + "Error marshaling request body", + fmt.Sprintf("Could not create request body for host resource, unexpected error: %s", err.Error()), + ) + return nil, diags } + return jsonBody, nil +} - // Save updated data into Terraform state - resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +func (r *JobResourceModel) ParseIgnoredFields(ignoredFields map[string]json.RawMessage) (diags diag.Diagnostics) { + r.IgnoredFields = types.ListNull(types.StringType) + + for _, value := range ignoredFields { + var innerMap map[string]interface{} + + err := json.Unmarshal(value, &innerMap) + if err != nil { + diags.AddError("Error parsing JSON response from AAP", err.Error()) + return diags + } + + var keysList = []attr.Value{} + + for k := range innerMap { + key := k + if v, ok := keyMapping[k]; ok { + key = v + } + keysList = append(keysList, types.StringValue(key)) + } + if len(keysList) > 0 { + r.IgnoredFields, _ = types.ListValue(types.StringType, keysList) + } + } + + return diags +} + +// ParseHttpResponse updates the job resource data from an AAP API response +func (r *JobResourceModel) ParseHttpResponse(body []byte) diag.Diagnostics { + var diags diag.Diagnostics + + // Unmarshal the JSON response + var resultApiJob JobAPIModel + err := json.Unmarshal(body, &resultApiJob) + if err != nil { + diags.AddError("Error parsing JSON response from AAP", err.Error()) + return diags + } + + // Map response to the job resource schema and update attribute values + r.Type = types.StringValue(resultApiJob.Type) + r.URL = types.StringValue(resultApiJob.URL) + r.Status = types.StringValue(resultApiJob.Status) + diags = r.ParseIgnoredFields(resultApiJob.IgnoredFields) + + return diags } From 6e416e0dbc9112729684d6d272c20e037294643f Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Tue, 13 Feb 2024 15:10:06 +0100 Subject: [PATCH 2/6] Fix some typos Signed-off-by: Alina Buzachis --- internal/provider/job_resource.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/provider/job_resource.go b/internal/provider/job_resource.go index e3a3373..0135779 100644 --- a/internal/provider/job_resource.go +++ b/internal/provider/job_resource.go @@ -185,14 +185,14 @@ func (r *JobResource) Read(ctx context.Context, req resource.ReadRequest, resp * if resp.Diagnostics.HasError() { return } - // Get latest host data from AAP + // Get latest job data from AAP readResponseBody, diags := r.client.Get(data.URL.ValueString()) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } - // Save latest host data into host resource model + // Save latest hob data into job resource model diags = data.ParseHttpResponse(readResponseBody) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { @@ -247,7 +247,7 @@ func (r *JobResourceModel) CreateRequestBody() ([]byte, diag.Diagnostics) { var diags diag.Diagnostics diags.AddError( "Error marshaling request body", - fmt.Sprintf("Could not create request body for host resource, unexpected error: %s", err.Error()), + fmt.Sprintf("Could not create request body for job resource, unexpected error: %s", err.Error()), ) return nil, diags } From 6e3a930955c5378437452f9689a0a3c027ce06ff Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Thu, 15 Feb 2024 17:58:59 +0100 Subject: [PATCH 3/6] Refactor tests Signed-off-by: Alina Buzachis --- examples/resources/{aap_job => job}/main.tf | 6 +- internal/provider/job_resource.go | 86 +++-- internal/provider/job_resource_test.go | 349 +++++--------------- 3 files changed, 144 insertions(+), 297 deletions(-) rename examples/resources/{aap_job => job}/main.tf (75%) diff --git a/examples/resources/aap_job/main.tf b/examples/resources/job/main.tf similarity index 75% rename from examples/resources/aap_job/main.tf rename to examples/resources/job/main.tf index e924a44..f53fe8b 100644 --- a/examples/resources/aap_job/main.tf +++ b/examples/resources/job/main.tf @@ -16,12 +16,12 @@ provider "aap" { resource "aap_job" "sample" { job_template_id = 9 inventory_id = 2 - extra_vars = jsonencode("{'resource_state' : 'absent'}") + extra_vars = jsonencode({"resource_state" : "absent"}) triggers = { "execution_environment_id": "3" } } -output "job_launch_url" { - value = aap_job.sample.job_url +output "job" { + value = aap_job.sample } diff --git a/internal/provider/job_resource.go b/internal/provider/job_resource.go index 0135779..2e75d35 100644 --- a/internal/provider/job_resource.go +++ b/internal/provider/job_resource.go @@ -12,6 +12,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -65,6 +67,12 @@ func (d *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp * }, "inventory_id": schema.Int64Attribute{ Optional: true, + Computed: true, + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.UseStateForUnknown(), + }, + Description: "Identifier for the inventory where job should be created in. " + + "If not provided, the job will be created in the default inventory.", }, "job_type": schema.StringAttribute{ Computed: true, @@ -97,14 +105,14 @@ func (d *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp * // Job AAP API model type JobAPIModel struct { - TemplateID int64 `json:"job_template_id,omitempty"` - Type string `json:"job_type,omitempty"` - URL string `json:"url,omitempty"` - Status string `json:"status,omitempty"` - Inventory int64 `json:"inventory_id"` - ExtraVars string `json:"extra_vars,omitempty"` - IgnoredFields map[string]json.RawMessage `json:"ignored_fields,omitempty"` - Triggers struct{} `json:"triggers,omitempty"` + TemplateID int64 `json:"job_template,omitempty"` + Type string `json:"job_type,omitempty"` + URL string `json:"url,omitempty"` + Status string `json:"status,omitempty"` + Inventory int64 `json:"inventory,omitempty"` + ExtraVars string `json:"extra_vars,omitempty"` + IgnoredFields map[string]interface{} `json:"ignored_fields,omitempty"` + Triggers string `json:"triggers,omitempty"` } // JobResourceModel maps the resource schema data. @@ -185,6 +193,7 @@ func (r *JobResource) Read(ctx context.Context, req resource.ReadRequest, resp * if resp.Diagnostics.HasError() { return } + // Get latest job data from AAP readResponseBody, diags := r.client.Get(data.URL.ValueString()) resp.Diagnostics.Append(diags...) @@ -233,51 +242,61 @@ func (r JobResource) Delete(_ context.Context, _ resource.DeleteRequest, _ *reso // CreateRequestBody creates a JSON encoded request body from the job resource data func (r *JobResourceModel) CreateRequestBody() ([]byte, diag.Diagnostics) { - //var diags diag.Diagnostics + var diags diag.Diagnostics + var inventoryID int64 + + // Use default inventory if not provided + if r.InventoryID.ValueInt64() == 0 { + inventoryID = 1 + } else { + inventoryID = r.InventoryID.ValueInt64() + } // Convert job resource data to API data model job := JobAPIModel{ - Inventory: r.InventoryID.ValueInt64(), ExtraVars: r.ExtraVars.ValueString(), + Inventory: inventoryID, + } + + if IsValueProvided(r.Triggers) { + mJson, err := json.Marshal(r.Triggers.String()) + if err != nil { + diags.AddError( + "Error marshaling request body", + fmt.Sprintf("Could not create request body for job resource, unexpected error: %s", err.Error()), + ) + return nil, diags + } + job.Triggers = string(mJson) } // Create JSON encoded request body jsonBody, err := json.Marshal(job) if err != nil { - var diags diag.Diagnostics diags.AddError( "Error marshaling request body", fmt.Sprintf("Could not create request body for job resource, unexpected error: %s", err.Error()), ) return nil, diags } - return jsonBody, nil + return jsonBody, diags } -func (r *JobResourceModel) ParseIgnoredFields(ignoredFields map[string]json.RawMessage) (diags diag.Diagnostics) { +// func (r *JobResourceModel) ParseIgnoredFields(ignoredFields map[string]json.RawMessage) (diags diag.Diagnostics) { +func (r *JobResourceModel) ParseIgnoredFields(ignoredFields map[string]interface{}) (diags diag.Diagnostics) { r.IgnoredFields = types.ListNull(types.StringType) + var keysList = []attr.Value{} - for _, value := range ignoredFields { - var innerMap map[string]interface{} - - err := json.Unmarshal(value, &innerMap) - if err != nil { - diags.AddError("Error parsing JSON response from AAP", err.Error()) - return diags + for k := range ignoredFields { + key := k + if v, ok := keyMapping[k]; ok { + key = v } + keysList = append(keysList, types.StringValue(key)) + } - var keysList = []attr.Value{} - - for k := range innerMap { - key := k - if v, ok := keyMapping[k]; ok { - key = v - } - keysList = append(keysList, types.StringValue(key)) - } - if len(keysList) > 0 { - r.IgnoredFields, _ = types.ListValue(types.StringType, keysList) - } + if len(keysList) > 0 { + r.IgnoredFields, _ = types.ListValue(types.StringType, keysList) } return diags @@ -299,7 +318,8 @@ func (r *JobResourceModel) ParseHttpResponse(body []byte) diag.Diagnostics { r.Type = types.StringValue(resultApiJob.Type) r.URL = types.StringValue(resultApiJob.URL) r.Status = types.StringValue(resultApiJob.Status) + r.TemplateID = types.Int64Value(resultApiJob.TemplateID) + r.InventoryID = types.Int64Value(resultApiJob.Inventory) diags = r.ParseIgnoredFields(resultApiJob.IgnoredFields) - return diags } diff --git a/internal/provider/job_resource_test.go b/internal/provider/job_resource_test.go index f35a43e..468ab37 100644 --- a/internal/provider/job_resource_test.go +++ b/internal/provider/job_resource_test.go @@ -1,9 +1,9 @@ package provider import ( + "context" "encoding/json" "fmt" - "net/http" "os" "reflect" "regexp" @@ -12,114 +12,70 @@ import ( "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" + fwresource "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" ) -func TestParseHttpResponse(t *testing.T) { - templateID := basetypes.NewInt64Value(1) - inventoryID := basetypes.NewInt64Value(2) - extraVars := jsontypes.NewNormalizedNull() - testTable := []struct { - name string - body []byte - expected jobResourceModel - failure bool - }{ - { - name: "no ignored fields", - failure: false, - body: []byte(`{"job_type": "run", "url": "/api/v2/jobs/14/", "status": "pending"}`), - expected: jobResourceModel{ - TemplateID: templateID, - Type: types.StringValue("run"), - URL: types.StringValue("/api/v2/jobs/14/"), - Status: types.StringValue("pending"), - InventoryID: inventoryID, - ExtraVars: extraVars, - IgnoredFields: types.ListNull(types.StringType), - }, - }, - { - name: "ignored fields", - failure: false, - body: []byte(`{"job_type": "run", "url": "/api/v2/jobs/14/", "status": - "pending", "ignored_fields": {"extra_vars": "{\"bucket_state\":\"absent\"}"}}`), - expected: jobResourceModel{ - TemplateID: templateID, - Type: types.StringValue("run"), - URL: types.StringValue("/api/v2/jobs/14/"), - Status: types.StringValue("pending"), - InventoryID: inventoryID, - ExtraVars: extraVars, - IgnoredFields: basetypes.NewListValueMust(types.StringType, []attr.Value{types.StringValue("extra_vars")}), - }, - }, - { - name: "bad json", - failure: true, - body: []byte(`{job_type: run}`), - expected: jobResourceModel{}, - }, +func TestJobResourceSchema(t *testing.T) { + t.Parallel() + + ctx := context.Background() + schemaRequest := fwresource.SchemaRequest{} + schemaResponse := &fwresource.SchemaResponse{} + + // Instantiate the JobResource and call its Schema method + NewJobResource().Schema(ctx, schemaRequest, schemaResponse) + + if schemaResponse.Diagnostics.HasError() { + t.Fatalf("Schema method diagnostics: %+v", schemaResponse.Diagnostics) } - for _, tc := range testTable { - t.Run(tc.name, func(t *testing.T) { - d := jobResourceModel{ - TemplateID: templateID, - InventoryID: inventoryID, - ExtraVars: extraVars, - } - err := d.ParseHTTPResponse(tc.body) - if tc.failure { - if err == nil { - t.Errorf("expecting failure while the process has not failed") - } - } else { - if err != nil { - t.Errorf("unexpected process failure (%s)", err.Error()) - } else if !reflect.DeepEqual(tc.expected, d) { - t.Errorf("expected (%v) - result (%v)", tc.expected, d) - } - } - }) + + // Validate the schema + diagnostics := schemaResponse.Schema.ValidateImplementation(ctx) + + if diagnostics.HasError() { + t.Fatalf("Schema validation diagnostics: %+v", diagnostics) } } -func TestCreateRequestBody(t *testing.T) { - testTable := []struct { +func TestJobResourceCreateRequestBody(t *testing.T) { + var testTable = []struct { name string - input jobResourceModel + input JobResourceModel expected []byte }{ { - name: "unknown fields", - input: jobResourceModel{ + name: "unknown values", + input: JobResourceModel{ ExtraVars: jsontypes.NewNormalizedNull(), InventoryID: basetypes.NewInt64Unknown(), + TemplateID: types.Int64Value(1), }, - expected: nil, + expected: []byte(`{"inventory":1}`), }, { - name: "null fields", - input: jobResourceModel{ + name: "null values", + input: JobResourceModel{ ExtraVars: jsontypes.NewNormalizedNull(), InventoryID: basetypes.NewInt64Null(), + TemplateID: types.Int64Value(1), }, - expected: nil, + expected: []byte(`{"inventory":1}`), }, { name: "extra vars only", - input: jobResourceModel{ + input: JobResourceModel{ ExtraVars: jsontypes.NewNormalizedValue("{\"test_name\":\"extra_vars\", \"provider\":\"aap\"}"), InventoryID: basetypes.NewInt64Null(), }, - expected: []byte(`{"extra_vars":{"test_name":"extra_vars","provider":"aap"}}`), + expected: []byte(`{"inventory":1,"extra_vars":"{\"test_name\":\"extra_vars\", \"provider\":\"aap\"}"}`), }, { name: "inventory vars only", - input: jobResourceModel{ + input: JobResourceModel{ ExtraVars: jsontypes.NewNormalizedNull(), InventoryID: basetypes.NewInt64Value(201), }, @@ -127,15 +83,15 @@ func TestCreateRequestBody(t *testing.T) { }, { name: "combined", - input: jobResourceModel{ + input: JobResourceModel{ ExtraVars: jsontypes.NewNormalizedValue("{\"test_name\":\"extra_vars\", \"provider\":\"aap\"}"), InventoryID: basetypes.NewInt64Value(3), }, - expected: []byte(`{"inventory": 3, "extra_vars":{"test_name":"extra_vars","provider":"aap"}}`), + expected: []byte(`{"inventory":3,"extra_vars":"{\"test_name\":\"extra_vars\", \"provider\":\"aap\"}"}`), }, { name: "manual_triggers", - input: jobResourceModel{ + input: JobResourceModel{ Triggers: types.MapNull(types.StringType), InventoryID: basetypes.NewInt64Value(3), }, @@ -151,10 +107,10 @@ func TestCreateRequestBody(t *testing.T) { } if tc.expected == nil || computed == nil { if tc.expected == nil && computed != nil { - t.Fatal("expected nil but result is not nil") + t.Fatal("expected nil but result is not nil", string(computed)) } if tc.expected != nil && computed == nil { - t.Fatal("expected result not nil but result is nil") + t.Fatal("expected result not nil but result is nil", string(computed)) } } else { test, err := DeepEqualJSONByte(tc.expected, computed) @@ -172,194 +128,65 @@ func TestCreateRequestBody(t *testing.T) { } } -type MockJobResource struct { - ID string - URL string - Inventory string - Response map[string]string -} - -func NewMockJobResource(id, inventory, url string) *MockJobResource { - return &MockJobResource{ - ID: id, - URL: url, - Inventory: inventory, - Response: map[string]string{}, - } -} - -func (d *MockJobResource) GetTemplateID() string { - return d.ID -} - -func (d *MockJobResource) GetURL() string { - return d.URL -} - -func (d *MockJobResource) ParseHTTPResponse(body []byte) error { - err := json.Unmarshal(body, &d.Response) - if err != nil { - return err - } - return nil -} - -func (d *MockJobResource) CreateRequestBody() ([]byte, diag.Diagnostics) { - var diags diag.Diagnostics - if len(d.Inventory) == 0 { - return nil, diags - } - m := map[string]string{"Inventory": d.Inventory} - jsonRaw, err := json.Marshal(m) - if err != nil { - diags.AddError("Json Marshall Error", err.Error()) - return nil, diags - } - return jsonRaw, diags -} - -func TestCreateJob(t *testing.T) { - testTable := []struct { - name string - ID string - Inventory string - expected map[string]string - acceptMethods []string - httpCode int - failed bool - }{ - { - name: "create job simple job (no request data)", - ID: "1", - Inventory: "", - httpCode: http.StatusCreated, - failed: false, - acceptMethods: []string{"POST", "post"}, - expected: JobResponse1, - }, - { - name: "create job with request data", - ID: "1", - Inventory: "3", - httpCode: http.StatusCreated, - failed: false, - acceptMethods: []string{"POST", "post"}, - expected: mergeStringMaps(JobResponse1, map[string]string{"Inventory": "3"}), - }, - { - name: "try with non existing template id", - ID: "-1", - Inventory: "3", - httpCode: http.StatusCreated, - failed: true, - acceptMethods: []string{"POST", "post"}, - expected: nil, - }, - { - name: "Unexpected method leading to not found", - ID: "1", - Inventory: "3", - httpCode: http.StatusCreated, - failed: true, - acceptMethods: []string{"GET", "get"}, - expected: nil, - }, - { - name: "using another template id", - ID: "2", - Inventory: "1", - httpCode: http.StatusCreated, - failed: false, - acceptMethods: []string{"POST", "post"}, - expected: mergeStringMaps(JobResponse2, map[string]string{"Inventory": "1"}), - }, - } - - for _, tc := range testTable { - t.Run(tc.name, func(t *testing.T) { - resource := NewMockJobResource(tc.ID, tc.Inventory, "") - - job := JobResource{ - client: NewMockHTTPClient(tc.acceptMethods, tc.httpCode), - } - diags := job.CreateJob(resource) - if (tc.failed && !diags.HasError()) || (!tc.failed && diags.HasError()) { - if diags.HasError() { - t.Errorf("process has failed while it should not") - for _, d := range diags { - t.Errorf("Summary = '%s' - details = '%s'", d.Summary(), d.Detail()) - } - } else { - t.Errorf("failure expected but the process did not failed!!") - } - } else if !tc.failed && !reflect.DeepEqual(tc.expected, resource.Response) { - t.Errorf("expected (%v)", tc.expected) - t.Errorf("computed (%v)", resource.Response) - } - }) - } -} +func TestJobResourceParseHttpResponse(t *testing.T) { + templateID := basetypes.NewInt64Value(1) + inventoryID := basetypes.NewInt64Value(2) + extraVars := jsontypes.NewNormalizedNull() + jsonError := diag.Diagnostics{} + jsonError.AddError("Error parsing JSON response from AAP", "invalid character 'N' looking for beginning of value") -func TestReadJob(t *testing.T) { - testTable := []struct { - name string - url string - expected map[string]string - acceptMethods []string - httpCode int - failed bool + var testTable = []struct { + name string + input []byte + expected JobResourceModel + errors diag.Diagnostics }{ { - name: "Read existing job", - url: "/api/v2/jobs/1/", - httpCode: http.StatusOK, - failed: false, - acceptMethods: []string{"GET", "get"}, - expected: JobResponse1, - }, - { - name: "Read another job", - url: "/api/v2/jobs/2/", - httpCode: http.StatusOK, - failed: false, - acceptMethods: []string{"GET", "get"}, - expected: JobResponse3, + name: "JSON error", + input: []byte("Not valid JSON"), + expected: JobResourceModel{}, + errors: jsonError, }, { - name: "GET not part of accepted methods", - url: "/api/v2/jobs/2/", - httpCode: http.StatusOK, - failed: true, - acceptMethods: []string{"HEAD"}, - expected: nil, + name: "no ignored fields", + input: []byte(`{"inventory":2,"job_template":1,"job_type": "run", "url": "/api/v2/jobs/14/", "status": "pending"}`), + expected: JobResourceModel{ + TemplateID: templateID, + Type: types.StringValue("run"), + URL: types.StringValue("/api/v2/jobs/14/"), + Status: types.StringValue("pending"), + InventoryID: inventoryID, + ExtraVars: extraVars, + IgnoredFields: types.ListNull(types.StringType), + }, + errors: diag.Diagnostics{}, }, { - name: "no url provided", - url: "", - httpCode: http.StatusOK, - failed: false, - acceptMethods: []string{"GET", "get"}, - expected: map[string]string{}, + name: "ignored fields", + input: []byte(`{"inventory":2,"job_template":1,"job_type": "run", "url": "/api/v2/jobs/14/", "status": + "pending", "ignored_fields": {"extra_vars": "{\"bucket_state\":\"absent\"}"}}`), + expected: JobResourceModel{ + TemplateID: templateID, + Type: types.StringValue("run"), + URL: types.StringValue("/api/v2/jobs/14/"), + Status: types.StringValue("pending"), + InventoryID: inventoryID, + ExtraVars: extraVars, + IgnoredFields: basetypes.NewListValueMust(types.StringType, []attr.Value{types.StringValue("extra_vars")}), + }, + errors: diag.Diagnostics{}, }, } - for _, tc := range testTable { - t.Run(tc.name, func(t *testing.T) { - resource := NewMockJobResource("", "", tc.url) - - job := JobResource{ - client: NewMockHTTPClient(tc.acceptMethods, tc.httpCode), + for _, test := range testTable { + t.Run(test.name, func(t *testing.T) { + resource := JobResourceModel{} + diags := resource.ParseHttpResponse(test.input) + if !test.errors.Equal(diags) { + t.Errorf("Expected error diagnostics (%s), actual was (%s)", test.errors, diags) } - err := job.ReadJob(resource) - if (tc.failed && err == nil) || (!tc.failed && err != nil) { - if err != nil { - t.Errorf("process has failed with (%s) while it should not", err.Error()) - } else { - t.Errorf("failure expected but the process did not failed!!") - } - } else if !tc.failed && !reflect.DeepEqual(tc.expected, resource.Response) { - t.Errorf("expected (%v)", tc.expected) - t.Errorf("computed (%v)", resource.Response) + if !reflect.DeepEqual(test.expected, resource) { + t.Errorf("Expected (%s) not equal to actual (%s)", test.expected, resource) } }) } @@ -513,7 +340,7 @@ func TestAccAAPJob_UpdateWithNewInventoryId(t *testing.T) { resource.TestMatchResourceAttr(resourceName, "status", regexp.MustCompile("^(failed|pending|running|complete|successful|waiting)$")), resource.TestMatchResourceAttr(resourceName, "job_type", regexp.MustCompile("^(run|check)$")), resource.TestMatchResourceAttr(resourceName, "job_url", regexp.MustCompile("^/api/v2/jobs/[0-9]*/$")), - testAccCheckJobUpdate(&jobURLBefore, true), + testAccCheckJobUpdate(&jobURLBefore, false), ), }, }, From 56485c8c8f2e660f787de72287bd04f0ba2a2223 Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Fri, 16 Feb 2024 14:29:22 +0100 Subject: [PATCH 4/6] Apply suggestions Signed-off-by: Alina Buzachis --- internal/provider/job_resource.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/internal/provider/job_resource.go b/internal/provider/job_resource.go index 2e75d35..42f22a1 100644 --- a/internal/provider/job_resource.go +++ b/internal/provider/job_resource.go @@ -112,7 +112,6 @@ type JobAPIModel struct { Inventory int64 `json:"inventory,omitempty"` ExtraVars string `json:"extra_vars,omitempty"` IgnoredFields map[string]interface{} `json:"ignored_fields,omitempty"` - Triggers string `json:"triggers,omitempty"` } // JobResourceModel maps the resource schema data. @@ -258,18 +257,6 @@ func (r *JobResourceModel) CreateRequestBody() ([]byte, diag.Diagnostics) { Inventory: inventoryID, } - if IsValueProvided(r.Triggers) { - mJson, err := json.Marshal(r.Triggers.String()) - if err != nil { - diags.AddError( - "Error marshaling request body", - fmt.Sprintf("Could not create request body for job resource, unexpected error: %s", err.Error()), - ) - return nil, diags - } - job.Triggers = string(mJson) - } - // Create JSON encoded request body jsonBody, err := json.Marshal(job) if err != nil { From bdc35cdbda1f523cfa4331f530c5f26584e9f341 Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Fri, 16 Feb 2024 14:32:21 +0100 Subject: [PATCH 5/6] Update job_resource.go --- internal/provider/job_resource.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/provider/job_resource.go b/internal/provider/job_resource.go index 42f22a1..0839c38 100644 --- a/internal/provider/job_resource.go +++ b/internal/provider/job_resource.go @@ -269,7 +269,6 @@ func (r *JobResourceModel) CreateRequestBody() ([]byte, diag.Diagnostics) { return jsonBody, diags } -// func (r *JobResourceModel) ParseIgnoredFields(ignoredFields map[string]json.RawMessage) (diags diag.Diagnostics) { func (r *JobResourceModel) ParseIgnoredFields(ignoredFields map[string]interface{}) (diags diag.Diagnostics) { r.IgnoredFields = types.ListNull(types.StringType) var keysList = []attr.Value{} From 66232c05638d521e2ff03f26b50c804af921e8da Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Mon, 19 Feb 2024 22:27:42 +0100 Subject: [PATCH 6/6] Apply suggestions Signed-off-by: Alina Buzachis --- internal/provider/job_resource.go | 168 +++++++++++++++--------------- 1 file changed, 84 insertions(+), 84 deletions(-) diff --git a/internal/provider/job_resource.go b/internal/provider/job_resource.go index 0839c38..7c01aed 100644 --- a/internal/provider/job_resource.go +++ b/internal/provider/job_resource.go @@ -17,29 +17,56 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" ) +// Job AAP API model +type JobAPIModel struct { + TemplateID int64 `json:"job_template,omitempty"` + Type string `json:"job_type,omitempty"` + URL string `json:"url,omitempty"` + Status string `json:"status,omitempty"` + Inventory int64 `json:"inventory,omitempty"` + ExtraVars string `json:"extra_vars,omitempty"` + IgnoredFields map[string]interface{} `json:"ignored_fields,omitempty"` +} + +// JobResourceModel maps the resource schema data. +type JobResourceModel struct { + TemplateID types.Int64 `tfsdk:"job_template_id"` + Type types.String `tfsdk:"job_type"` + URL types.String `tfsdk:"url"` + Status types.String `tfsdk:"status"` + InventoryID types.Int64 `tfsdk:"inventory_id"` + ExtraVars jsontypes.Normalized `tfsdk:"extra_vars"` + IgnoredFields types.List `tfsdk:"ignored_fields"` + Triggers types.Map `tfsdk:"triggers"` +} + +// JobResource is the resource implementation. +type JobResource struct { + client ProviderHTTPClient +} + // Ensure the implementation satisfies the expected interfaces. var ( _ resource.Resource = &JobResource{} _ resource.ResourceWithConfigure = &JobResource{} ) +var keyMapping = map[string]string{ + "inventory": "inventory", +} + // NewJobResource is a helper function to simplify the provider implementation. func NewJobResource() resource.Resource { return &JobResource{} } -// JobResource is the resource implementation. -type JobResource struct { - client ProviderHTTPClient -} - // Metadata returns the resource type name. func (r *JobResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_job" } // Configure adds the provider configured client to the data source. -func (d *JobResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { +func (r *JobResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { // Prevent panic if the provider has not been configured. if req.ProviderData == nil { return @@ -55,11 +82,11 @@ func (d *JobResource) Configure(_ context.Context, req resource.ConfigureRequest return } - d.client = client + r.client = client } // Schema defines the schema for the jobresource. -func (d *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { +func (r *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ "job_template_id": schema.Int64Attribute{ @@ -77,7 +104,7 @@ func (d *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp * "job_type": schema.StringAttribute{ Computed: true, }, - "job_url": schema.StringAttribute{ + "url": schema.StringAttribute{ Computed: true, }, "status": schema.StringAttribute{ @@ -103,37 +130,6 @@ func (d *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp * } } -// Job AAP API model -type JobAPIModel struct { - TemplateID int64 `json:"job_template,omitempty"` - Type string `json:"job_type,omitempty"` - URL string `json:"url,omitempty"` - Status string `json:"status,omitempty"` - Inventory int64 `json:"inventory,omitempty"` - ExtraVars string `json:"extra_vars,omitempty"` - IgnoredFields map[string]interface{} `json:"ignored_fields,omitempty"` -} - -// JobResourceModel maps the resource schema data. -type JobResourceModel struct { - TemplateID types.Int64 `tfsdk:"job_template_id"` - Type types.String `tfsdk:"job_type"` - URL types.String `tfsdk:"job_url"` - Status types.String `tfsdk:"status"` - InventoryID types.Int64 `tfsdk:"inventory_id"` - ExtraVars jsontypes.Normalized `tfsdk:"extra_vars"` - IgnoredFields types.List `tfsdk:"ignored_fields"` - Triggers types.Map `tfsdk:"triggers"` -} - -var keyMapping = map[string]string{ - "inventory": "inventory", -} - -func (d *JobResourceModel) GetTemplateID() string { - return d.TemplateID.String() -} - func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data JobResourceModel @@ -143,7 +139,7 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re return } - resp.Diagnostics.Append(r.CreateJob(&data)...) + resp.Diagnostics.Append(r.LaunchJob(&data)...) if resp.Diagnostics.HasError() { return } @@ -155,34 +151,6 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re } } -func (r *JobResource) CreateJob(data *JobResourceModel) diag.Diagnostics { - // Create new Job from job template - var diags diag.Diagnostics - - // Create request body from job data - requestBody, diagCreateReq := data.CreateRequestBody() - diags.Append(diagCreateReq...) - if diags.HasError() { - return diags - } - - requestData := bytes.NewReader(requestBody) - var postURL = "/api/v2/job_templates/" + data.GetTemplateID() + "/launch/" - resp, body, err := r.client.doRequest(http.MethodPost, postURL, requestData) - diags.Append(ValidateResponse(resp, body, err, []int{http.StatusCreated})...) - if diags.HasError() { - return diags - } - - // Save new job data into job resource model - diags.Append(data.ParseHttpResponse(body)...) - if diags.HasError() { - return diags - } - - return diags -} - func (r *JobResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data JobResourceModel var diags diag.Diagnostics @@ -224,7 +192,7 @@ func (r *JobResource) Update(ctx context.Context, req resource.UpdateRequest, re } // Create new Job from job template - resp.Diagnostics.Append(r.CreateJob(&data)...) + resp.Diagnostics.Append(r.LaunchJob(&data)...) if resp.Diagnostics.HasError() { return } @@ -269,6 +237,28 @@ func (r *JobResourceModel) CreateRequestBody() ([]byte, diag.Diagnostics) { return jsonBody, diags } +// ParseHttpResponse updates the job resource data from an AAP API response +func (r *JobResourceModel) ParseHttpResponse(body []byte) diag.Diagnostics { + var diags diag.Diagnostics + + // Unmarshal the JSON response + var resultApiJob JobAPIModel + err := json.Unmarshal(body, &resultApiJob) + if err != nil { + diags.AddError("Error parsing JSON response from AAP", err.Error()) + return diags + } + + // Map response to the job resource schema and update attribute values + r.Type = types.StringValue(resultApiJob.Type) + r.URL = types.StringValue(resultApiJob.URL) + r.Status = types.StringValue(resultApiJob.Status) + r.TemplateID = types.Int64Value(resultApiJob.TemplateID) + r.InventoryID = types.Int64Value(resultApiJob.Inventory) + diags = r.ParseIgnoredFields(resultApiJob.IgnoredFields) + return diags +} + func (r *JobResourceModel) ParseIgnoredFields(ignoredFields map[string]interface{}) (diags diag.Diagnostics) { r.IgnoredFields = types.ListNull(types.StringType) var keysList = []attr.Value{} @@ -288,24 +278,34 @@ func (r *JobResourceModel) ParseIgnoredFields(ignoredFields map[string]interface return diags } -// ParseHttpResponse updates the job resource data from an AAP API response -func (r *JobResourceModel) ParseHttpResponse(body []byte) diag.Diagnostics { +func (r *JobResource) LaunchJob(data *JobResourceModel) diag.Diagnostics { + // Create new Job from job template var diags diag.Diagnostics - // Unmarshal the JSON response - var resultApiJob JobAPIModel - err := json.Unmarshal(body, &resultApiJob) - if err != nil { - diags.AddError("Error parsing JSON response from AAP", err.Error()) + // Create request body from job data + requestBody, diagCreateReq := data.CreateRequestBody() + diags.Append(diagCreateReq...) + if diags.HasError() { + return diags + } + + requestData := bytes.NewReader(requestBody) + var postURL = "/api/v2/job_templates/" + data.GetTemplateID() + "/launch/" + resp, body, err := r.client.doRequest(http.MethodPost, postURL, requestData) + diags.Append(ValidateResponse(resp, body, err, []int{http.StatusCreated})...) + if diags.HasError() { + return diags + } + + // Save new job data into job resource model + diags.Append(data.ParseHttpResponse(body)...) + if diags.HasError() { return diags } - // Map response to the job resource schema and update attribute values - r.Type = types.StringValue(resultApiJob.Type) - r.URL = types.StringValue(resultApiJob.URL) - r.Status = types.StringValue(resultApiJob.Status) - r.TemplateID = types.Int64Value(resultApiJob.TemplateID) - r.InventoryID = types.Int64Value(resultApiJob.Inventory) - diags = r.ParseIgnoredFields(resultApiJob.IgnoredFields) return diags } + +func (r *JobResourceModel) GetTemplateID() string { + return r.TemplateID.String() +}