From a732c2009e140087961ed9358af8ebcd9fe8d72e Mon Sep 17 00:00:00 2001 From: xvlcwk <33735558+xvlcwk@users.noreply.github.com.> Date: Mon, 5 Feb 2024 00:19:43 +0100 Subject: [PATCH] feat: Support repository access tokens This is very much a WIP. It will be extended quite a bit to support automatic recreation and similar stuff. For now it will only be pushed to be used internally. --- bitbucket/resource_repository_access_token.go | 212 ++++++++++++++++++ .../resource_repository_access_token_test.go | 51 +++++ .../util/access_token_resource_helper.go | 98 ++++++++ bitbucket/util/resource_helper.go | 45 ++++ 4 files changed, 406 insertions(+) create mode 100644 bitbucket/resource_repository_access_token.go create mode 100644 bitbucket/resource_repository_access_token_test.go create mode 100644 bitbucket/util/access_token_resource_helper.go create mode 100644 bitbucket/util/resource_helper.go diff --git a/bitbucket/resource_repository_access_token.go b/bitbucket/resource_repository_access_token.go new file mode 100644 index 0000000..334bc3e --- /dev/null +++ b/bitbucket/resource_repository_access_token.go @@ -0,0 +1,212 @@ +package bitbucket + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "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/types" + "github.com/xvlcwk-terraform/terraform-provider-bitbucketserver/bitbucket/util" + "io" + "net/http" +) + +type repositoryAccessTokenModel struct { + Id types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Permissions types.Set `tfsdk:"permissions"` + ExpireIn types.Int64 `tfsdk:"expire_in"` + Project types.String `tfsdk:"project"` + Repository types.String `tfsdk:"repository"` + Token types.String `tfsdk:"token"` + CreatedDate types.Int64 `tfsdk:"created_date"` +} + +type repositoryAccessTokenResource struct { + resourceHelper *util.AccessTokenResourceHelper +} + +func newRepositoryAccessTokenResource() resource.Resource { + return &repositoryAccessTokenResource{ + resourceHelper: util.NewAccessTokenResourceHelper(), + } +} + +// Ensure the implementation satisfies the desired interfaces. +var _ resource.ResourceWithConfigure = &repositoryAccessTokenResource{} + +// Metadata should return the full name of the resource. +func (r *repositoryAccessTokenResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_repository_access_token" +} + +// Schema should return the schema for this resource. +func (r *repositoryAccessTokenResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "An HTTP-Access token limited to the given repository", + Attributes: r.resourceHelper.Schema(map[string]schema.Attribute{ + "repository": schema.StringAttribute{ + Required: true, + Description: "The repository slug", + }, + }), + } +} + +func (r *repositoryAccessTokenResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data repositoryAccessTokenModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + payload, diagnostics := r.createRequestData(ctx, data) + + if diagnostics != nil { + resp.Diagnostics.Append(diagnostics...) + } + + tokenResponse, tokenErrorResponse := r.resourceHelper.Client.Put(r.getUrlForProject(data), bytes.NewBuffer(payload)) + response, convertingResponseDiagnostics := r.readResponse(tokenErrorResponse, tokenResponse, &data) + if convertingResponseDiagnostics != nil { + resp.Diagnostics.Append(convertingResponseDiagnostics) + return + } + + data.Token = types.StringValue(response.Token) + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} +func (r *repositoryAccessTokenResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data repositoryAccessTokenModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tokenResponse, tokenErrorResponse := r.resourceHelper.Client.Get(r.getUrlForId(data)) + _, diagnostic := r.readResponse(tokenErrorResponse, tokenResponse, &data) + if diagnostic != nil { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *repositoryAccessTokenResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data repositoryAccessTokenModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + payload, diagnostics := r.createRequestData(ctx, data) + + if diagnostics != nil { + resp.Diagnostics.Append(diagnostics...) + } + tokenResponse, tokenErrorResponse := r.resourceHelper.Client.Post(r.getUrlForId(data), bytes.NewBuffer(payload)) + _, convertingResponseDiagnostics := r.readResponse(tokenErrorResponse, tokenResponse, &data) + if convertingResponseDiagnostics != nil { + resp.Diagnostics.Append(convertingResponseDiagnostics) + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *repositoryAccessTokenResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data repositoryAccessTokenModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tokenResponse, tokenErrorResponse := r.resourceHelper.Client.Delete(r.getUrlForId(data)) + if tokenErrorResponse != nil { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "Unable to Delete Resource", + "An unexpected error occurred while deleting the resource. "+ + "Please report this issue to the provider developers.\n\n"+ + "Error: "+tokenErrorResponse.Error())) + } + + if tokenResponse.StatusCode != 204 { + resp.Diagnostics.Append(diag.NewErrorDiagnostic( + "Unable to Delete Resource", + "An unexpected statusCode occurred while deleting the resource. "+ + "Please report this issue to the provider developers.\n\n"+ + "Status: "+tokenResponse.Status)) + } +} + +func (r *repositoryAccessTokenResource) Configure(ctx context.Context, configureRequest resource.ConfigureRequest, configureResponse *resource.ConfigureResponse) { + r.resourceHelper.Configure(ctx, configureRequest, configureResponse) +} + +func (r *repositoryAccessTokenResource) createRequestData(ctx context.Context, data repositoryAccessTokenModel) ([]byte, diag.Diagnostics) { + var permissions []string + permissionConversionDiagnostics := data.Permissions.ElementsAs(ctx, &permissions, false) + if permissionConversionDiagnostics != nil { + return nil, permissionConversionDiagnostics + } + tokenRequest := &util.CreateAccessTokenRequest{ + ExpiryDays: data.ExpireIn.ValueInt64(), + Name: data.Name.ValueString(), + Permissions: permissions, + } + payload, jsonEncodingError := json.Marshal(tokenRequest) + if jsonEncodingError != nil { + return nil, diag.Diagnostics{diag.NewErrorDiagnostic("Marshalling error", fmt.Sprintf("Failed to encode %v. Is it valid Json?", tokenRequest))} + } + return payload, nil +} + +func (r *repositoryAccessTokenResource) readResponse(tokenErrorResponse error, tokenResponse *http.Response, data *repositoryAccessTokenModel) (*util.AccessTokenResponse, *diag.ErrorDiagnostic) { + if tokenErrorResponse != nil { + diagnostic := diag.NewErrorDiagnostic("http error", tokenErrorResponse.Error()) + return nil, &diagnostic + } + if tokenResponse.StatusCode != 200 { + diagnostic := diag.NewErrorDiagnostic("http error", fmt.Sprintf("Response Status: %d", tokenResponse.StatusCode)) + return nil, &diagnostic + } + + body, readBodyError := io.ReadAll(tokenResponse.Body) + if readBodyError != nil { + diagnostic := diag.NewErrorDiagnostic("Error reading response", "Failed to read response. Is it valid Json?") + return nil, &diagnostic + } + + response := &util.AccessTokenResponse{} + unmarshallError := json.Unmarshal(body, response) + if unmarshallError != nil { + diagnostic := diag.NewErrorDiagnostic("Error reading response", fmt.Sprintf("Failed to read response %v. Is it valid Json?, %v", string(body), unmarshallError)) + return nil, &diagnostic + } + + data.Id = types.StringValue(response.Id) + data.CreatedDate = types.Int64Value(response.CreatedDate) + data.Name = types.StringValue(response.Name) + return response, nil +} + +func (r *repositoryAccessTokenResource) getUrlForId(data repositoryAccessTokenModel) string { + return fmt.Sprintf("%v/%v", r.getUrlForProject(data), data.Id.ValueString()) +} + +func (r *repositoryAccessTokenResource) getUrlForProject(data repositoryAccessTokenModel) string { + projectKey := data.Project.ValueString() + repositorySlug := data.Repository.ValueString() + repositoryUrl := fmt.Sprintf("/rest/access-tokens/latest/projects/%v/repos/%v", projectKey, repositorySlug) + return repositoryUrl +} diff --git a/bitbucket/resource_repository_access_token_test.go b/bitbucket/resource_repository_access_token_test.go new file mode 100644 index 0000000..9c7f1a7 --- /dev/null +++ b/bitbucket/resource_repository_access_token_test.go @@ -0,0 +1,51 @@ +package bitbucket + +import ( + "fmt" + "math/rand" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccBitbucketResourceRepositoryAccessToken(t *testing.T) { + projectKey := fmt.Sprintf("TEST%v", rand.New(rand.NewSource(time.Now().UnixNano())).Int()) + + config := fmt.Sprintf(` + resource "bitbucketserver_project" "test" { + key = "%v" + name = "test-project-%v" + } + + resource "bitbucketserver_repository" "test" { + project = bitbucketserver_project.test.key + name = "repo" + } + + resource "bitbucketserver_repository_access_token" "test" { + project = bitbucketserver_project.test.key + repository = bitbucketserver_repository.test.slug + name = "newLabelForTest" + permissions = ["REPO_READ"] + } + `, projectKey, projectKey) + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: ProviderFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("bitbucketserver_repository_access_token.test", "id"), + resource.TestCheckResourceAttr("bitbucketserver_repository_access_token.test", "project", projectKey), + resource.TestCheckResourceAttr("bitbucketserver_repository_access_token.test", "repository", "repo"), + resource.TestCheckResourceAttr("bitbucketserver_repository_access_token.test", "name", "newLabelForTest"), + resource.TestCheckResourceAttr("bitbucketserver_repository_access_token.test", "permissions.#", "1"), + resource.TestCheckResourceAttr("bitbucketserver_repository_access_token.test", "permissions.0", "REPO_READ"), + resource.TestCheckResourceAttrSet("bitbucketserver_repository_access_token.test", "token"), + ), + }, + }, + }) +} diff --git a/bitbucket/util/access_token_resource_helper.go b/bitbucket/util/access_token_resource_helper.go new file mode 100644 index 0000000..ff2acd6 --- /dev/null +++ b/bitbucket/util/access_token_resource_helper.go @@ -0,0 +1,98 @@ +package util + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "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/xvlcwk-terraform/terraform-provider-bitbucketserver/bitbucket/util/client" +) + +type ( + // AccessTokenResourceHelper provides assistive snippets of logic to help reduce duplication in + // each resource definition. + AccessTokenResourceHelper struct { + Client *client.BitbucketClient + helper *ResourceHelper + } +) + +func NewAccessTokenResourceHelper() *AccessTokenResourceHelper { + return &AccessTokenResourceHelper{ + helper: NewResourceHelper(), + } +} + +func (r *AccessTokenResourceHelper) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + r.helper.Configure(ctx, req, resp) + r.Client = r.helper.client +} + +func (r *AccessTokenResourceHelper) Schema(s map[string]schema.Attribute) map[string]schema.Attribute { + s = r.helper.Schema(s) + if _, ok := s["id"]; !ok { + s["id"] = schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The id of the access token", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + } + } + if _, ok := s["name"]; !ok { + s["name"] = schema.StringAttribute{ + Required: true, + MarkdownDescription: "The label for the access token", + } + } + if _, ok := s["project"]; !ok { + s["project"] = schema.StringAttribute{ + Required: true, + Description: "The project slug", + } + } + if _, ok := s["token"]; !ok { + s["token"] = schema.StringAttribute{ + Computed: true, + Description: "The token. Only set if created by Terraform", + } + } + if _, ok := s["permissions"]; !ok { + s["permissions"] = schema.SetAttribute{ + Required: true, + Description: fmt.Sprintf("The permissions this access token has for repositories."), + ElementType: types.StringType, + } + } + if _, ok := s["expire_in"]; !ok { + s["expire_in"] = schema.Int64Attribute{ + Computed: true, + Description: "Expire in X Days. If not set it does not expire", + Default: int64default.StaticInt64(1095), + } + } + if _, ok := s["created_date"]; !ok { + s["created_date"] = schema.Int64Attribute{ + Computed: true, + Description: "Created Date", + } + } + return s +} + +type CreateAccessTokenRequest struct { + ExpiryDays int64 `json:"expiryDays" binding:"required"` + Name string `json:"name" binding:"required"` + Permissions []string `json:"permissions" binding:"required"` +} + +type AccessTokenResponse struct { + Token string `json:"token,omitempty"` // Only available on creation + Name string `json:"name" binding:"required"` + Id string `json:"id" binding:"required"` + CreatedDate int64 `json:"createdDate" binding:"required"` +} diff --git a/bitbucket/util/resource_helper.go b/bitbucket/util/resource_helper.go new file mode 100644 index 0000000..0dcffc6 --- /dev/null +++ b/bitbucket/util/resource_helper.go @@ -0,0 +1,45 @@ +package util + +import ( + "context" + "fmt" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + client2 "github.com/xvlcwk-terraform/terraform-provider-bitbucketserver/bitbucket/util/client" + bitbucketTypes "github.com/xvlcwk-terraform/terraform-provider-bitbucketserver/bitbucket/util/types" +) + +type ( + // ResourceHelper provides assistive snippets of logic to help reduce duplication in + // each resource definition. + ResourceHelper struct { + client *client2.BitbucketClient + } +) + +func NewResourceHelper() *ResourceHelper { + return &ResourceHelper{} +} + +// Configure should register the client for the resource. +func (r *ResourceHelper) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + provider, ok := req.ProviderData.(*bitbucketTypes.BitbucketServerProvider) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *bitbucket.BitbucketServerProvider, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = provider.BitbucketClient +} + +func (r *ResourceHelper) Schema(s map[string]schema.Attribute) map[string]schema.Attribute { + return s +}