diff --git a/examples/resources/humanitec_environment/import.sh b/examples/resources/humanitec_environment/import.sh new file mode 100644 index 0000000..fc2b05c --- /dev/null +++ b/examples/resources/humanitec_environment/import.sh @@ -0,0 +1 @@ +terraform import humanitec_environment.example application_id/environment_id diff --git a/examples/resources/humanitec_environment/resource.tf b/examples/resources/humanitec_environment/resource.tf new file mode 100644 index 0000000..c829fed --- /dev/null +++ b/examples/resources/humanitec_environment/resource.tf @@ -0,0 +1,6 @@ +resource "humanitec_environment" "example" { + app_id = "example-app" + id = "example" + name = "An example app" + type = "development" +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 8e841d0..61db663 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -207,6 +207,7 @@ func (p *HumanitecProvider) Resources(ctx context.Context) []func() resource.Res NewResourceArtefactVersion, NewResourceDefinitionCriteriaResource, NewResourceDefinitionResource, + NewResourceEnvironment, NewResourceEnvironmentType, NewResourceEnvironmentTypeUser, NewResourceKey, diff --git a/internal/provider/resource_environment.go b/internal/provider/resource_environment.go new file mode 100644 index 0000000..42e995c --- /dev/null +++ b/internal/provider/resource_environment.go @@ -0,0 +1,299 @@ +package provider + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "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/humanitec/humanitec-go-autogen" + "github.com/humanitec/humanitec-go-autogen/client" +) + +// Ensure provider defined types fully satisfy framework interfaces +var _ resource.Resource = &ResourceEnvironment{} +var _ resource.ResourceWithImportState = &ResourceEnvironment{} + +func NewResourceEnvironment() resource.Resource { + return &ResourceEnvironment{} +} + +// ResourceEnvironment defines the resource implementation. +type ResourceEnvironment struct { + client *humanitec.Client + orgID string +} + +type EnvironmentModel struct { + AppID types.String `tfsdk:"app_id"` + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Type types.String `tfsdk:"type"` + FromDeployID types.String `tfsdk:"from_deploy_id"` +} + +func (r *ResourceEnvironment) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_environment" +} + +func (r *ResourceEnvironment) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "An Environment is a space where an instance of an Application can be deployed. Environments consist of a Kubernetes namespace and any shared Resources (as configured by relevant Matching Rules).", + + Attributes: map[string]schema.Attribute{ + "app_id": schema.StringAttribute{ + MarkdownDescription: "The Application ID.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "id": schema.StringAttribute{ + MarkdownDescription: "The ID the Environment is referenced as.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The Human-friendly name for the Environment.", + Required: true, + }, + "type": schema.StringAttribute{ + MarkdownDescription: "The Environment Type. This is used for organizing and managing Environments.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "from_deploy_id": schema.StringAttribute{ + MarkdownDescription: "Defines the existing Deployment the new Environment will be based on.", + Optional: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + }, + } +} + +func (r *ResourceEnvironment) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + resdata, ok := req.ProviderData.(*HumanitecData) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = resdata.Client + r.orgID = resdata.OrgID +} + +func (r *ResourceEnvironment) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data *EnvironmentModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + appID := data.AppID.ValueString() + + var environment *client.EnvironmentResponse + createEnvironmentResp, err := r.client.CreateEnvironmentWithResponse(ctx, r.orgID, appID, client.EnvironmentDefinitionRequest{ + Id: data.ID.ValueString(), + Name: data.Name.ValueString(), + Type: data.Type.ValueStringPointer(), + FromDeployId: data.FromDeployID.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to create environment, got error: %s", err)) + return + } + switch createEnvironmentResp.StatusCode() { + case http.StatusCreated: + environment = createEnvironmentResp.JSON201 + case http.StatusBadRequest: + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to create environment, Humanitec returned bad request: %s", createEnvironmentResp.Body)) + return + case http.StatusNotFound: + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to create environment, environment not found: %s", createEnvironmentResp.Body)) + return + default: + resp.Diagnostics.AddError(HUM_API_ERR, fmt.Sprintf("Unable to create environment unexpected status code: %d, body: %s", createEnvironmentResp.StatusCode(), createEnvironmentResp.Body)) + return + } + + parseEnvironmentResponse(appID, environment, data) + + // Save data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ResourceEnvironment) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data *EnvironmentModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + appID := data.AppID.ValueString() + id := data.ID.ValueString() + + var environment *client.EnvironmentResponse + getEnvironmentResp, err := r.client.GetEnvironmentWithResponse(ctx, r.orgID, appID, id) + if err != nil { + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to get environment, got error: %s", err)) + return + } + switch getEnvironmentResp.StatusCode() { + case http.StatusOK: + environment = getEnvironmentResp.JSON200 + case http.StatusNotFound: + resp.Diagnostics.AddWarning("Environment not found", fmt.Sprintf("The environment (%s) was deleted outside Terraform", id)) + resp.State.RemoveResource(ctx) + return + default: + resp.Diagnostics.AddError(HUM_API_ERR, fmt.Sprintf("Unable to get environment, unexpected status code: %d, body: %s", getEnvironmentResp.StatusCode(), getEnvironmentResp.Body)) + return + } + + parseEnvironmentResponse(appID, environment, data) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ResourceEnvironment) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data, state *EnvironmentModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + appID := state.AppID.ValueString() + id := state.ID.ValueString() + + var environment *client.EnvironmentResponse + updateEnvironmentResp, err := r.client.UpdateEnvironmentWithResponse(ctx, r.orgID, appID, id, client.UpdateEnvironmentJSONRequestBody{ + Name: data.Name.ValueStringPointer(), + }) + if err != nil { + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to update environment, got error: %s", err)) + return + } + switch updateEnvironmentResp.StatusCode() { + case http.StatusOK: + environment = updateEnvironmentResp.JSON200 + case http.StatusBadRequest: + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to update environment, Humanitec returned bad request: %s", updateEnvironmentResp.Body)) + return + case http.StatusNotFound: + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to update environment, environment not found: %s", updateEnvironmentResp.Body)) + return + case http.StatusPreconditionFailed: + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to update environment, the state of Terraform resource do not match resource in Humanitec: %s", updateEnvironmentResp.Body)) + return + default: + resp.Diagnostics.AddError(HUM_API_ERR, fmt.Sprintf("Unable to update environment, unexpected status code: %d, body: %s", updateEnvironmentResp.StatusCode(), updateEnvironmentResp.Body)) + return + } + + parseEnvironmentResponse(appID, environment, data) + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ResourceEnvironment) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data *EnvironmentModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + appID := data.AppID.ValueString() + id := data.ID.ValueString() + + deleteEnvironmentResp, err := r.client.DeleteEnvironmentWithResponse(ctx, r.orgID, appID, id) + if err != nil { + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to delete environment, got error: %s", err)) + return + } + switch deleteEnvironmentResp.StatusCode() { + case http.StatusNoContent, http.StatusAccepted: + // Do nothing + case http.StatusNotFound: + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to delete environment, environment not found: %s", deleteEnvironmentResp.Body)) + return + case http.StatusPreconditionFailed: + resp.Diagnostics.AddError(HUM_CLIENT_ERR, fmt.Sprintf("Unable to delete environment, the state of Terraform resource do not match resource in Humanitec: %s", deleteEnvironmentResp.Body)) + return + default: + resp.Diagnostics.AddError(HUM_API_ERR, fmt.Sprintf("Unable to delete environment, unexpected status code: %d, body: %s", deleteEnvironmentResp.StatusCode(), deleteEnvironmentResp.Body)) + return + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *ResourceEnvironment) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, "/") + + // ensure idParts elements are not empty + for _, idPart := range idParts { + if idPart == "" { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + fmt.Sprintf("Expected import identifier with format: app_id/env_id. Got: %q", req.ID), + ) + return + } + } + + if len(idParts) == 2 { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("app_id"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), idParts[1])...) + } else { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + fmt.Sprintf("Expected import identifier with format: app_id/env_id. Got: %q", req.ID), + ) + return + } +} + +func parseEnvironmentResponse(appID string, res *client.EnvironmentResponse, data *EnvironmentModel) { + data.AppID = types.StringValue(appID) + data.FromDeployID = types.StringValue(res.FromDeploy.Id) + data.ID = types.StringValue(res.Id) + data.Name = types.StringValue(res.Name) + data.Type = types.StringValue(res.Type) +} diff --git a/internal/provider/resource_environment_test.go b/internal/provider/resource_environment_test.go new file mode 100644 index 0000000..cb517e9 --- /dev/null +++ b/internal/provider/resource_environment_test.go @@ -0,0 +1,87 @@ +package provider + +import ( + "fmt" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccResourceEnvironment(t *testing.T) { + id := fmt.Sprintf("env-%d", time.Now().UnixNano()) + appID := fmt.Sprintf("app-%d", time.Now().UnixNano()) + name := "Env Name" + updatedName := "New Env Name" + envType := "development" + fromDeployID := "" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: testAccCreateResourceEnvironment(appID, id, name, envType, fromDeployID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("humanitec_environment.env_test", "app_id", appID), + resource.TestCheckResourceAttr("humanitec_environment.env_test", "id", id), + resource.TestCheckResourceAttr("humanitec_environment.env_test", "name", name), + resource.TestCheckResourceAttr("humanitec_environment.env_test", "type", envType), + resource.TestCheckResourceAttr("humanitec_environment.env_test", "from_deploy_id", fromDeployID), + ), + }, + // Update testing + { + Config: testAccCreateResourceEnvironment(appID, id, updatedName, envType, fromDeployID), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("humanitec_environment.env_test", "app_id", appID), + resource.TestCheckResourceAttr("humanitec_environment.env_test", "id", id), + resource.TestCheckResourceAttr("humanitec_environment.env_test", "name", updatedName), + resource.TestCheckResourceAttr("humanitec_environment.env_test", "type", envType), + resource.TestCheckResourceAttr("humanitec_environment.env_test", "from_deploy_id", fromDeployID), + ), + }, + // ImportState testing + { + ResourceName: "humanitec_environment.env_test", + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + return fmt.Sprintf("%s/%s", appID, id), nil + }, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("humanitec_environment.env_test", "app_id", appID), + resource.TestCheckResourceAttr("humanitec_environment.env_test", "id", id), + resource.TestCheckResourceAttr("humanitec_environment.env_test", "name", updatedName), + resource.TestCheckResourceAttr("humanitec_environment.env_test", "type", envType), + resource.TestCheckResourceAttr("humanitec_environment.env_test", "from_deploy_id", fromDeployID), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} + +func testAccCreateResourceEnvironment(appID, id, name, envType, fromDeployID string) string { + fromDeployIDLine := "" + if fromDeployID != "" { + fromDeployIDLine = fmt.Sprintf(`from_deploy_id = "%s"`, fromDeployID) + } + + return fmt.Sprintf(` + resource "humanitec_application" "app_test" { + id = "%s" + name = "test-app" + } + + resource "humanitec_environment" "env_test" { + app_id = "%s" + id = "%s" + name = "%s" + type = "%s" + %s + } +`, appID, appID, id, name, envType, fromDeployIDLine) +} diff --git a/internal/provider/users_data_source_test.go b/internal/provider/users_data_source_test.go index 1f632ff..54d1a90 100644 --- a/internal/provider/users_data_source_test.go +++ b/internal/provider/users_data_source_test.go @@ -29,7 +29,7 @@ func TestAccUsersDataSource(t *testing.T) { }) } -func testAccCreateUsersDataSourceConfig(email string) string { +func testAccCreateUsersDataSourceConfig(email string) string { filtersString := "" if email != "" { filtersString = fmt.Sprintf(`filter = {