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.
This commit is contained in:
xvlcwk
2024-02-05 00:19:43 +01:00
committed by chris
parent ce6854b8b6
commit a732c2009e
4 changed files with 406 additions and 0 deletions

View File

@@ -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
}

View File

@@ -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"),
),
},
},
})
}

View File

@@ -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"`
}

View File

@@ -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
}