From 1498c4f5a7f06e1fa68fc693f80f098b493a3ef6 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Fri, 23 Feb 2024 21:50:44 +0100 Subject: [PATCH] Draft --- client/api.go | 86 ++++++ client/model.go | 50 ++++ .../resources/infisical_project/resource.tf | 22 ++ infisical/provider/project_resource.go | 267 ++++++++++++++++++ infisical/provider/provider.go | 1 + infisical/provider/secret_resource.go | 1 - 6 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 examples/resources/infisical_project/resource.tf create mode 100644 infisical/provider/project_resource.go diff --git a/client/api.go b/client/api.go index e9b2992..120c0fb 100644 --- a/client/api.go +++ b/client/api.go @@ -268,3 +268,89 @@ func (client Client) CallGetSingleRawSecretByNameV3(request GetSingleSecretByNam return secretsResponse, nil } + +func (client Client) CallCreateProject(request CreateProjectRequest) (CreateProjectResponse, error) { + + if request.Slug == "" { + request = CreateProjectRequest{ + ProjectName: request.ProjectName, + OrganizationId: request.OrganizationId, + } + } + + var projectResponse CreateProjectResponse + response, err := client.Config.HttpClient. + R(). + SetResult(&projectResponse). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post("api/v2/workspace") + + if err != nil { + return CreateProjectResponse{}, fmt.Errorf("CallCreateProject: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return CreateProjectResponse{}, fmt.Errorf("CallCreateProject: Unsuccessful response. [response=%s]", response) + } + + return projectResponse, nil +} + +func (client Client) CallDeleteProject(request DeleteProjectRequest) error { + var projectResponse DeleteProjectResponse + response, err := client.Config.HttpClient. + R(). + SetResult(&projectResponse). + SetHeader("User-Agent", USER_AGENT). + Delete(fmt.Sprintf("api/v2/workspace/%s", request.Slug)) + + if err != nil { + return fmt.Errorf("CallDeleteProject: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return fmt.Errorf("CallDeleteProject: Unsuccessful response. [response=%s]", response) + } + + return nil +} + +func (client Client) CallGetProject(request GetProjectRequest) (GetProjectResponse, error) { + var projectResponse GetProjectResponse + response, err := client.Config.HttpClient. + R(). + SetResult(&projectResponse). + SetHeader("User-Agent", USER_AGENT). + Get(fmt.Sprintf("api/v2/workspace/%s", request.Slug)) + + if err != nil { + return GetProjectResponse{}, fmt.Errorf("CallGetProject: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return GetProjectResponse{}, fmt.Errorf("CallGetProject: Unsuccessful response. [response=%s]", response) + } + + return projectResponse, nil +} + +func (client Client) CallUpdateProject(request UpdateProjectRequest) (UpdateProjectResponse, error) { + var projectResponse UpdateProjectResponse + response, err := client.Config.HttpClient. + R(). + SetResult(&projectResponse). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Patch(fmt.Sprintf("api/v2/workspace/%s", request.Slug)) + + if err != nil { + return UpdateProjectResponse{}, fmt.Errorf("CallUpdateProject: Unable to complete api request [err=%s]", err) + } + + if response.IsError() { + return UpdateProjectResponse{}, fmt.Errorf("CallUpdateProject: Unsuccessful response. [response=%s]", response) + } + + return projectResponse, nil +} diff --git a/client/model.go b/client/model.go index eba6102..f3c4126 100644 --- a/client/model.go +++ b/client/model.go @@ -37,6 +37,36 @@ type EncryptedSecretV3 struct { UpdatedAt time.Time `json:"updatedAt"` } +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + AutoCapitalization bool `json:"autoCapitalization"` + OrgID string `json:"orgId"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Version int `json:"version"` + + UpgradeStatus string `json:"upgradeStatus"` // can be null. if its null it will be converted to an empty string. +} + +type ProjectWithEnvironments struct { +} + +type CreateProjectResponse struct { + Project Project `json:"project"` +} + +type DeleteProjectResponse struct { + Project Project `json:"workspace"` +} + +type GetProjectResponse struct { + Project Project `json:"workspace"` +} + +type UpdateProjectResponse Project + type GetEncryptedSecretsV3Response struct { Secrets []EncryptedSecretV3 `json:"secrets"` } @@ -232,3 +262,23 @@ type UpdateRawSecretByNameV3Request struct { SecretPath string `json:"secretPath"` SecretValue string `json:"secretValue"` } + +type CreateProjectRequest struct { + ProjectName string `json:"projectName"` + Slug string `json:"slug"` + OrganizationId string `json:"organizationId"` +} + +type DeleteProjectRequest struct { + Slug string `json:"slug"` +} + +type GetProjectRequest struct { + Slug string `json:"slug"` +} + +type UpdateProjectRequest struct { + Slug string `json:"slug"` + ProjectName string `json:"name"` + AutoCapitalization bool `json:"autoCapitalization"` +} diff --git a/examples/resources/infisical_project/resource.tf b/examples/resources/infisical_project/resource.tf new file mode 100644 index 0000000..185434c --- /dev/null +++ b/examples/resources/infisical_project/resource.tf @@ -0,0 +1,22 @@ +terraform { + required_providers { + infisical = { + # version = + source = "hashicorp.com/edu/infisical" + } + } +} + +provider "infisical" { + host = "http://localhost:8080" # Only required if using self hosted instance of Infisical, default is https://app.infisical.com + client_id = "3f6135db-f237-421d-af66-a8f4e80d443b" + client_secret = "d1a9238d9fe9476e545ba92c25ece3866178e468d3b0b8f263af64026ac835bf" +} + +resource "infisical_project" "a-new-project" { + name = "new name123" + slug = "a-new-project-slug" + organization_id = "180870b7-f464-4740-8ffe-9d11c9245ea7" +} + + diff --git a/infisical/provider/project_resource.go b/infisical/provider/project_resource.go new file mode 100644 index 0000000..20f6ace --- /dev/null +++ b/infisical/provider/project_resource.go @@ -0,0 +1,267 @@ +package provider + +import ( + "context" + "fmt" + infisical "terraform-provider-infisical/client" + "time" + + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ resource.Resource = &projectResource{} +) + +// NewProjectResource is a helper function to simplify the provider implementation. +func NewProjectResource() resource.Resource { + return &projectResource{} +} + +// projectResource is the resource implementation. +type projectResource struct { + client *infisical.Client +} + +// projectResourceSourceModel describes the data source data model. +type projectResourceModel struct { + Slug types.String `tfsdk:"slug"` + OrganizationId types.String `tfsdk:"organization_id"` + Name types.String `tfsdk:"name"` + AutoCapitalization types.Bool `tfsdk:"auto_capitalization"` + ProjectId types.String `tfsdk:"project_Id"` + LastUpdated types.String `tfsdk:"last_updated"` +} + +// Metadata returns the resource type name. +func (r *projectResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_project" +} + +// Schema defines the schema for the resource. +func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "Create projects & save to Infisical", + Attributes: map[string]schema.Attribute{ + "slug": schema.StringAttribute{ + Description: "The slug of the project. This is optional when creating a project, but for all other operations it is required", + Required: true, + }, + "organization_id": schema.StringAttribute{ + Description: "The organization ID of the project", + Required: true, + }, + "name": schema.StringAttribute{ + Description: "The name of the project", + Required: true, + }, + "auto_capitalization": schema.StringAttribute{ + Description: "", + Required: true, + Computed: false, + }, + "project_Id": schema.StringAttribute{ + Computed: true, + }, + + "last_updated": schema.StringAttribute{ + Computed: true, + }, + }, + } +} + +// Configure adds the provider configured client to the resource. +func (r *projectResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*infisical.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +// Create creates the resource and sets the initial Terraform state. +func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + if r.client.Config.AuthStrategy != infisical.AuthStrategy.UNIVERSAL_MACHINE_IDENTITY { + resp.Diagnostics.AddError( + "Unable to create project", + "Only Machine Identity authentication is supported for this operation", + ) + return + } + + // Retrieve values from plan + var plan projectResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + res, err := r.client.CallCreateProject(infisical.CreateProjectRequest{ + OrganizationId: plan.OrganizationId.ValueString(), + ProjectName: plan.Name.ValueString(), + Slug: plan.Slug.ValueString(), + }) + + if err != nil { + resp.Diagnostics.AddError( + "Error creating project", + "Couldn't save project to Infiscial, unexpected error: "+err.Error(), + ) + return + } + + plan.AutoCapitalization = types.BoolValue(res.Project.AutoCapitalization) + plan.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) + plan.Name = types.StringValue(res.Project.Name) + plan.OrganizationId = types.StringValue(res.Project.OrgID) + plan.ProjectId = types.StringValue(res.Project.ID) + plan.Slug = types.StringValue(res.Project.Slug) + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + +} + +// Read refreshes the Terraform state with the latest data. +func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + if r.client.Config.AuthStrategy != infisical.AuthStrategy.UNIVERSAL_MACHINE_IDENTITY { + resp.Diagnostics.AddError( + "Unable to read project", + "Only Machine Identity authentication is supported for this operation", + ) + return + } + + // Get current state + var state projectResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Get the latest data from the API + res, err := r.client.CallGetProject(infisical.GetProjectRequest{ + Slug: state.Slug.ValueString(), + }) + + if err != nil { + resp.Diagnostics.AddError( + "Error reading project", + "Couldn't read project from Infiscial, unexpected error: "+err.Error(), + ) + return + } + + state.AutoCapitalization = types.BoolValue(res.Project.AutoCapitalization) + state.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) + state.Name = types.StringValue(res.Project.Name) + state.OrganizationId = types.StringValue(res.Project.OrgID) + state.ProjectId = types.StringValue(res.Project.ID) + state.Slug = types.StringValue(res.Project.Slug) + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + +} + +// Update updates the resource and sets the updated Terraform state on success. +func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + if r.client.Config.AuthStrategy != infisical.AuthStrategy.UNIVERSAL_MACHINE_IDENTITY { + resp.Diagnostics.AddError( + "Unable to update project", + "Only Machine Identity authentication is supported for this operation", + ) + return + } + + // Retrieve values from plan + var plan projectResourceModel + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + updatedProject, err := r.client.CallUpdateProject(infisical.UpdateProjectRequest{ + ProjectName: plan.Name.ValueString(), + AutoCapitalization: plan.AutoCapitalization.ValueBool(), + Slug: plan.Slug.ValueString(), + }) + + if err != nil { + resp.Diagnostics.AddError( + "Error updating project", + "Couldn't update project from Infiscial, unexpected error: "+err.Error(), + ) + return + } + + plan.AutoCapitalization = types.BoolValue(updatedProject.AutoCapitalization) + plan.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) + plan.Name = types.StringValue(updatedProject.Name) + plan.OrganizationId = types.StringValue(updatedProject.OrgID) + plan.ProjectId = types.StringValue(updatedProject.ID) + plan.Slug = types.StringValue(updatedProject.Slug) + + diags = resp.State.Set(ctx, plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + +} + +// Delete deletes the resource and removes the Terraform state on success. +func (r *projectResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + + if r.client.Config.AuthStrategy != infisical.AuthStrategy.UNIVERSAL_MACHINE_IDENTITY { + resp.Diagnostics.AddError( + "Unable to delete project", + "Only Machine Identity authentication is supported for this operation", + ) + return + } + + var state projectResourceModel + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.CallDeleteProject(infisical.DeleteProjectRequest{ + Slug: state.Slug.ValueString(), + }) + + if err != nil { + resp.Diagnostics.AddError( + "Error deleting project", + "Couldn't delete project from Infiscial, unexpected error: "+err.Error(), + ) + return + } + +} diff --git a/infisical/provider/provider.go b/infisical/provider/provider.go index 73243a6..bad1a44 100644 --- a/infisical/provider/provider.go +++ b/infisical/provider/provider.go @@ -157,5 +157,6 @@ func (p *infisicalProvider) DataSources(_ context.Context) []func() datasource.D func (p *infisicalProvider) Resources(_ context.Context) []func() resource.Resource { return []func() resource.Resource{ NewSecretResource, + NewProjectResource, } } diff --git a/infisical/provider/secret_resource.go b/infisical/provider/secret_resource.go index 11b8bb8..25f516f 100644 --- a/infisical/provider/secret_resource.go +++ b/infisical/provider/secret_resource.go @@ -224,7 +224,6 @@ func (r *secretResource) Create(ctx context.Context, req resource.CreateRequest, return } plan.LastUpdated = types.StringValue(time.Now().Format(time.RFC850)) - diags = resp.State.Set(ctx, plan) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() {