From 304d56167f1e4ac5e1b5a13138165a13fb95d8df Mon Sep 17 00:00:00 2001 From: Alina Buzachis Date: Fri, 19 Jan 2024 15:11:31 +0100 Subject: [PATCH] Add host resource Signed-off-by: Alina Buzachis --- examples/resources/host/main.tf | 30 ++++ internal/provider/host_resource.go | 203 ++++++++++++++---------- internal/provider/host_resource_test.go | 185 +++++++++++++++++++++ internal/provider/job_resource.go | 25 +-- internal/provider/provider.go | 1 + internal/provider/utils.go | 24 +++ 6 files changed, 365 insertions(+), 103 deletions(-) create mode 100644 examples/resources/host/main.tf create mode 100644 internal/provider/host_resource_test.go diff --git a/examples/resources/host/main.tf b/examples/resources/host/main.tf new file mode 100644 index 0000000..9654638 --- /dev/null +++ b/examples/resources/host/main.tf @@ -0,0 +1,30 @@ +terraform { + required_providers { + aap = { + source = "ansible/aap" + } + } +} + +provider "aap" { + host = "https://localhost:8043" + username = "test" + password = "test" + insecure_skip_verify = true +} + +resource "aap_host" "sample" { + inventory_id = 1 + name = "tf_host" + variables = jsonencode( + { + "foo": "bar" + } + ) + group_id = 2 + disassociate_group = true +} + +output "host" { + value = aap_host.sample +} diff --git a/internal/provider/host_resource.go b/internal/provider/host_resource.go index 656f67d..556f2c4 100644 --- a/internal/provider/host_resource.go +++ b/internal/provider/host_resource.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) // Ensure the implementation satisfies the expected interfaces. @@ -52,8 +53,8 @@ func (r *HostResource) Schema(_ context.Context, _ resource.SchemaRequest, resp "inventory_id": schema.Int64Attribute{ Required: true, }, - "instance_id": schema.StringAttribute{ - Required: true, + "instance_id": schema.Int64Attribute{ + Optional: true, }, "name": schema.StringAttribute{ Required: true, @@ -72,18 +73,35 @@ func (r *HostResource) Schema(_ context.Context, _ resource.SchemaRequest, resp Optional: true, CustomType: jsontypes.NormalizedType{}, }, + "enabled": schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Defaults true.", + }, + "group_id": schema.Int64Attribute{ + Optional: true, + Description: "Set this option to associate an existing group with a host.", + }, + "disassociate_group": schema.BoolAttribute{ + Optional: true, + Description: "Set group_id and and disassociate_group options to remove " + + "the group from a host without deleting the group.", + }, }, } } // HostResourceModel maps the resource schema data. type HostResourceModel struct { - InventoryId types.Int64 `tfsdk:"inventory_id"` - InstanceId types.Int64 `tfsdk:"instance_id"` - Name types.String `tfsdk:"name"` - Description types.String `tfsdk:"description"` - URL types.String `tfsdk:"host_url"` - Variables jsontypes.Normalized `tfsdk:"variables"` + InventoryId types.Int64 `tfsdk:"inventory_id"` + InstanceId types.Int64 `tfsdk:"instance_id"` + Name types.String `tfsdk:"name"` + Description types.String `tfsdk:"description"` + URL types.String `tfsdk:"host_url"` + Variables jsontypes.Normalized `tfsdk:"variables"` + Enabled types.Bool `tfsdk:"enabled"` + GroupId types.Int64 `tfsdk:"group_id"` + DisassociateGroup types.Bool `tfsdk:"disassociate_group"` } func (d *HostResourceModel) GetURL() string { @@ -108,11 +126,24 @@ func (d *HostResourceModel) CreateRequestBody() ([]byte, diag.Diagnostics) { // Variables if IsValueProvided(d.Variables) { - // var vars map[string]interface{} - // diags.Append(d.Variables.Unmarshal(&vars)...) body["variables"] = d.Variables.ValueString() } + // Groups + if IsValueProvided(d.GroupId) { + body["id"] = d.GroupId.ValueInt64() + } + + // DisassociateGroup + if IsValueProvided(d.DisassociateGroup) { + // DisassociateGroup value does not really matter + // To remove a group from a host you only need to pass this parameter + // Add it to the body only if set to true + if d.DisassociateGroup.ValueBool() { + body["disassociate_group"] = true + } + } + // URL if IsValueProvided(d.URL) { body["url"] = d.URL.ValueString() @@ -123,11 +154,17 @@ func (d *HostResourceModel) CreateRequestBody() ([]byte, diag.Diagnostics) { body["description"] = d.Description.ValueString() } + // Enabled + if IsValueProvided(d.Enabled) { + body["enabled"] = d.Enabled.ValueBool() + } + json_raw, err := json.Marshal(body) if err != nil { diags.Append(diag.NewErrorDiagnostic("Body JSON Marshal Error", err.Error())) return nil, diags } + return json_raw, diags } @@ -135,20 +172,43 @@ func (d *HostResourceModel) ParseHttpResponse(body []byte) error { /* Unmarshal the json string */ result := make(map[string]interface{}) - err := json.Unmarshal([]byte(body), &result) + err := json.Unmarshal(body, &result) if err != nil { return err } d.Name = types.StringValue(result["name"].(string)) - d.Description = types.StringValue(result["description"].(string)) d.URL = types.StringValue(result["url"].(string)) + if result["description"] != "" { + d.Description = types.StringValue(result["description"].(string)) + } else { + d.Description = types.StringNull() + } + + if result["variables"] != "" { + d.Variables = jsontypes.NewNormalizedValue(result["variables"].(string)) + } else { + d.Variables = jsontypes.NewNormalizedNull() + } + + if r, ok := result["group_id"]; ok { + d.GroupId = basetypes.NewInt64Value(int64(r.(float64))) + } + + if r, ok := result["disassociate_group"]; ok && r != nil { + d.DisassociateGroup = basetypes.NewBoolValue(r.(bool)) + } + + if r, ok := result["enabled"]; ok && r != nil { + d.Enabled = basetypes.NewBoolValue(r.(bool)) + } + return nil } // Configure adds the provider configured client to the resource. -func (d *HostResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { +func (d *HostResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { if req.ProviderData == nil { return } @@ -166,41 +226,45 @@ func (d *HostResource) Configure(ctx context.Context, req resource.ConfigureRequ d.client = client } - -func (r HostResource) CreateHost(data HostResourceModelInterface) diag.Diagnostics { +func MakeReqData(data HostResourceModelInterface) (io.Reader, diag.Diagnostics) { var diags diag.Diagnostics var req_data io.Reader = nil + req_body, diagCreateReq := data.CreateRequestBody() diags.Append(diagCreateReq...) + if diags.HasError() { - return diags + return nil, diags } + if req_body != nil { req_data = bytes.NewReader(req_body) } + return req_data, diags +} + +func (r HostResource) CreateHost(data HostResourceModelInterface) diag.Diagnostics { + req_data, diags := MakeReqData(data) resp, body, err := r.client.doRequest(http.MethodPost, "/api/v2/hosts/", req_data) - if err != nil { - diags.AddError("Body JSON Marshal 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 (%s)", http.StatusCreated, resp.Status)) - return diags - } + diags.Append(IsResponseValid(resp, err, http.StatusCreated)...) + err = data.ParseHttpResponse(body) if err != nil { diags.AddError("error while parsing the json response: ", err.Error()) return diags } + return diags } +func (r HostResource) AssociateGroup(data HostResourceModelInterface) diag.Diagnostics { + req_data, diags := MakeReqData(data) + resp, _, err := r.client.doRequest(http.MethodPost, data.GetURL()+"/groups/", req_data) + diags.Append(IsResponseValid(resp, err, http.StatusNoContent)...) + + return diags +} func (r HostResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data HostResourceModel @@ -219,29 +283,26 @@ func (r HostResource) Create(ctx context.Context, req resource.CreateRequest, re // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + + if IsValueProvided((&data).GroupId) { + resp.Diagnostics.Append(r.AssociateGroup(&data)...) + if resp.Diagnostics.HasError() { + return + } + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + } } func (r HostResource) DeleteHost(data HostResourceModelInterface) diag.Diagnostics { var diags diag.Diagnostics resp, _, err := r.client.doRequest(http.MethodDelete, data.GetURL(), nil) - if err != nil { - diags.AddError("Body JSON Marshal Error", err.Error()) - return diags - } - if resp == nil { - diags.AddError("Http response Error", "no http response from server") - return diags - } - if resp.StatusCode != http.StatusNoContent { - diags.AddError("Unexpected Http Status code", - fmt.Sprintf("expected (%d) got (%s)", http.StatusNoContent, resp.Status)) - return diags - } + diags.Append(IsResponseValid(resp, err, http.StatusNoContent)...) + return diags } - func (r HostResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var data HostResourceModel @@ -258,36 +319,16 @@ func (r HostResource) Delete(ctx context.Context, req resource.DeleteRequest, re } func (r HostResource) UpdateHost(data HostResourceModelInterface) diag.Diagnostics { - var diags diag.Diagnostics - var req_data io.Reader = nil - req_body, diagCreateReq := data.CreateRequestBody() - diags.Append(diagCreateReq...) - if diags.HasError() { - return diags - } - if req_body != nil { - req_data = bytes.NewReader(req_body) - } + req_data, diags := MakeReqData(data) resp, body, err := r.client.doRequest(http.MethodPut, data.GetURL(), req_data) + diags.Append(IsResponseValid(resp, err, http.StatusOK)...) - if err != nil { - diags.AddError("Body JSON Marshal Error", err.Error()) - return diags - } - if resp == nil { - diags.AddError("Http response Error", "no http response from server") - return diags - } - if resp.StatusCode != http.StatusOK { - diags.AddError("Unexpected Http Status code", - fmt.Sprintf("expected (%d) got (%s)", http.StatusOK, resp.Status)) - return diags - } err = data.ParseHttpResponse(body) if err != nil { diags.AddError("error while parsing the json response: ", err.Error()) return diags } + return diags } @@ -296,7 +337,9 @@ func (r HostResource) Update(ctx context.Context, req resource.UpdateRequest, re var data_with_URL HostResourceModel // Read Terraform plan and state data into the model - // The URL is generated once the host is created. To update the correct host, we retrieve the state data and append the URL from the state data to the plan data. + // The URL is generated once the host is created. + // To update the correct host, we retrieve the state data + // and append the URL from the state data to the plan data. resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) resp.Diagnostics.Append(req.State.Get(ctx, &data_with_URL)...) data.URL = data_with_URL.URL @@ -308,25 +351,23 @@ func (r HostResource) Update(ctx context.Context, req resource.UpdateRequest, re // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + + if IsValueProvided((&data).GroupId) { + resp.Diagnostics.Append(r.AssociateGroup(&data)...) + if resp.Diagnostics.HasError() { + return + } + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) + } } func (r HostResource) ReadHost(data HostResourceModelInterface) diag.Diagnostics { var diags diag.Diagnostics // Read existing Host - host_url := data.GetURL() + host_url := data.GetURL() resp, body, err := r.client.doRequest(http.MethodGet, host_url, nil) - if err != nil { - diags.AddError("Get Error", err.Error()) - return diags - } - if resp == nil { - diags.AddError("Http response Error", "no http response from server") - return diags - } - if resp.StatusCode != http.StatusOK { - diags.AddError("Unexpected Http Status code", - fmt.Sprintf("expected (%d) got (%s)", http.StatusOK, resp.Status)) - } + diags.Append(IsResponseValid(resp, err, http.StatusOK)...) err = data.ParseHttpResponse(body) if err != nil { @@ -352,4 +393,4 @@ func (r HostResource) Read(ctx context.Context, req resource.ReadRequest, resp * } // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) -} \ No newline at end of file +} diff --git a/internal/provider/host_resource_test.go b/internal/provider/host_resource_test.go new file mode 100644 index 0000000..a591f68 --- /dev/null +++ b/internal/provider/host_resource_test.go @@ -0,0 +1,185 @@ +package provider + +import ( + "bytes" + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + fwresource "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" +) + +func TestHostResourceSchema(t *testing.T) { + t.Parallel() + + ctx := context.Background() + schemaRequest := fwresource.SchemaRequest{} + schemaResponse := &fwresource.SchemaResponse{} + + // Instantiate the HostResource and call its Schema method + NewHostResource().Schema(ctx, schemaRequest, schemaResponse) + + if schemaResponse.Diagnostics.HasError() { + t.Fatalf("Schema method diagnostics: %+v", schemaResponse.Diagnostics) + } + + // Validate the schema + diagnostics := schemaResponse.Schema.ValidateImplementation(ctx) + + if diagnostics.HasError() { + t.Fatalf("Schema validation diagnostics: %+v", diagnostics) + } +} + +func TestHostResourceCreateRequestBody(t *testing.T) { + var testTable = []struct { + name string + input HostResourceModel + expected []byte + }{ + { + name: "test with unknown values", + input: HostResourceModel{ + Name: types.StringValue("test host"), + Description: types.StringUnknown(), + URL: types.StringUnknown(), + Variables: jsontypes.NewNormalizedUnknown(), + GroupId: types.Int64Unknown(), + DisassociateGroup: basetypes.NewBoolValue(false), + Enabled: basetypes.NewBoolValue(false), + InventoryId: types.Int64Unknown(), + InstanceId: types.Int64Unknown(), + }, + expected: []byte(`{"enabled":false,"instance_id":0,"inventory":0,"name":"test host"}`), + }, + { + name: "test with null values", + input: HostResourceModel{ + Name: types.StringValue("test host"), + Description: types.StringNull(), + URL: types.StringNull(), + Variables: jsontypes.NewNormalizedNull(), + GroupId: types.Int64Null(), + DisassociateGroup: basetypes.NewBoolValue(false), + Enabled: basetypes.NewBoolValue(false), + InventoryId: types.Int64Null(), + InstanceId: types.Int64Null(), + }, + expected: []byte(`{"enabled":false,"instance_id":0,"inventory":0,"name":"test host"}`), + }, + { + name: "test with some values", + input: HostResourceModel{ + Name: types.StringValue("host1"), + Description: types.StringNull(), + URL: types.StringValue("/api/v2/hosts/1/"), + Variables: jsontypes.NewNormalizedValue("{\"foo\":\"bar\"}"), + }, + expected: []byte( + `{"instance_id":0,"inventory":0,"name":"host1","url":"/api/v2/hosts/1/",` + + `"variables":"{\"foo\":\"bar\"}"}`, + ), + }, + { + name: "test with group id", + input: HostResourceModel{ + Name: types.StringValue("host1"), + Description: types.StringNull(), + URL: types.StringValue("/api/v2/hosts/1/"), + Variables: jsontypes.NewNormalizedValue("{\"foo\":\"bar\"}"), + GroupId: basetypes.NewInt64Value(2), + }, + expected: []byte( + `{"id":2,"instance_id":0,"inventory":0,"name":"host1","url":"/api/v2/hosts/1/",` + + `"variables":"{\"foo\":\"bar\"}"}`, + ), + }, + } + + for _, test := range testTable { + t.Run(test.name, func(t *testing.T) { + actual, diags := test.input.CreateRequestBody() + if diags.HasError() { + t.Fatal(diags.Errors()) + } + if !bytes.Equal(test.expected, actual) { + t.Errorf("Expected (%s) not equal to actual (%s)", test.expected, actual) + } + }) + } +} + +// CustomError is a custom error type +type CustomError struct { + Message string +} + +// Implement the error interface for Cu +func (e CustomError) Error() string { + return e.Message +} + +func TestHostResourceParseHttpResponse(t *testing.T) { + customErr := CustomError{ + Message: "invalid character 'N' looking for beginning of value", + } + emptyError := CustomError{} + + var testTable = []struct { + name string + input []byte + expected HostResourceModel + errors error + }{ + { + name: "test with JSON error", + input: []byte("Not valid JSON"), + expected: HostResourceModel{}, + errors: customErr, + }, + { + name: "test with missing values", + input: []byte(`{"name": "host1", "url": "/api/v2/hosts/1/", "description": "", "variables": "", "group_id": 2}`), + expected: HostResourceModel{ + Name: types.StringValue("host1"), + URL: types.StringValue("/api/v2/hosts/1/"), + Description: types.StringNull(), + GroupId: types.Int64Value(2), + Variables: jsontypes.NewNormalizedNull(), + }, + errors: emptyError, + }, + { + name: "test with all values", + input: []byte( + `{"description":"A basic test host","group_id":1,"name":"host1","disassociate_group":false,` + + `"enabled":false,"url":"/api/v2/hosts/1/","variables":"{\"foo\":\"bar\",\"nested\":{\"foobar\":\"baz\"}}"}`, + ), + expected: HostResourceModel{ + Name: types.StringValue("host1"), + URL: types.StringValue("/api/v2/hosts/1/"), + Description: types.StringValue("A basic test host"), + GroupId: types.Int64Value(1), + DisassociateGroup: basetypes.NewBoolValue(false), + Variables: jsontypes.NewNormalizedValue("{\"foo\":\"bar\",\"nested\":{\"foobar\":\"baz\"}}"), + Enabled: basetypes.NewBoolValue(false), + }, + errors: emptyError, + }, + } + + for _, test := range testTable { + t.Run(test.name, func(t *testing.T) { + resource := HostResourceModel{} + err := resource.ParseHttpResponse(test.input) + if test.errors != nil && err != nil && test.errors.Error() != err.Error() { + t.Errorf("Expected error diagnostics (%s), actual was (%s)", test.errors, err) + } + if test.expected != resource { + t.Errorf("Expected (%s) not equal to actual (%s)", test.expected, resource) + } + }) + } +} diff --git a/internal/provider/job_resource.go b/internal/provider/job_resource.go index 684c3cf..93d8ffc 100644 --- a/internal/provider/job_resource.go +++ b/internal/provider/job_resource.go @@ -203,20 +203,8 @@ func (r JobResource) CreateJob(data JobResourceModelInterface) diag.Diagnostics var postURL = "/api/v2/job_templates/" + data.GetTemplateID() + "/launch/" resp, body, err := r.client.doRequest(http.MethodPost, postURL, reqData) + diags.Append(IsResponseValid(resp, err, http.StatusCreated)...) - 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)) - return diags - } err = data.ParseHTTPResponse(body) if err != nil { diags.AddError("error while parsing the json response: ", err.Error()) @@ -227,18 +215,11 @@ func (r JobResource) CreateJob(data JobResourceModelInterface) diag.Diagnostics func (r JobResource) ReadJob(data JobResourceModelInterface) error { // Read existing Job + var diags diag.Diagnostics 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) - } + diags.Append(IsResponseValid(resp, err, http.StatusOK)...) err = data.ParseHTTPResponse(body) if err != nil { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index b3d4821..b9779b6 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -161,6 +161,7 @@ func (p *aapProvider) Resources(_ context.Context) []func() resource.Resource { return []func() resource.Resource{ NewJobResource, NewGroupResource, + NewHostResource, } } diff --git a/internal/provider/utils.go b/internal/provider/utils.go index 4562c9e..45a1ac9 100644 --- a/internal/provider/utils.go +++ b/internal/provider/utils.go @@ -1,9 +1,33 @@ package provider import ( + "fmt" + "net/http" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" ) func IsValueProvided(value attr.Value) bool { return !value.IsNull() && !value.IsUnknown() } + +func IsResponseValid(resp *http.Response, err error, expected_status int) diag.Diagnostics { + var diags diag.Diagnostics + + if err != nil { + diags.AddError("Body JSON Marshal Error", err.Error()) + return diags + } + if resp == nil { + diags.AddError("Http response Error", "no http response from server") + return diags + } + if resp.StatusCode != expected_status { + diags.AddError("Unexpected Http Status code", + fmt.Sprintf("expected (%d) got (%s)", expected_status, resp.Status)) + return diags + } + + return diags +}