diff --git a/docs/resources/org_access_token.md b/docs/resources/org_access_token.md new file mode 100644 index 0000000..2c621cc --- /dev/null +++ b/docs/resources/org_access_token.md @@ -0,0 +1,114 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "docker_org_access_token Resource - docker" +subcategory: "" +description: |- + Manages organization access tokens. + -> Note: This resource is only available when authenticated with a username and password as an owner of the org. + Example Usage + + resource "docker_org_access_token" "example" { + org_name = "my-organization" + label = "ci-token" + description = "Token for CI pulls" + resources = [ + { + type = "TYPE_REPO" + path = "my-organization/my-repository" + scopes = ["repo-pull"] + } + ] + expires_at = "2027-12-31T23:59:59Z" + } + + Public-Only Repositories + Use the special path */*/public to scope the token to public repositories only. + + resource "docker_org_access_token" "public_pull" { + org_name = "my-organization" + label = "public-pull-token" + + resources = [ + { + type = "TYPE_REPO" + path = "*/*/public" + scopes = ["repo-pull"] + } + ] + } +--- + +# docker_org_access_token (Resource) + +Manages organization access tokens. + +-> **Note**: This resource is only available when authenticated with a username and password as an owner of the org. + +## Example Usage + +```hcl +resource "docker_org_access_token" "example" { + org_name = "my-organization" + label = "ci-token" + description = "Token for CI pulls" + resources = [ + { + type = "TYPE_REPO" + path = "my-organization/my-repository" + scopes = ["repo-pull"] + } + ] + expires_at = "2027-12-31T23:59:59Z" +} +``` + +## Public-Only Repositories + +Use the special path `*/*/public` to scope the token to public repositories only. + +```hcl +resource "docker_org_access_token" "public_pull" { + org_name = "my-organization" + label = "public-pull-token" + + resources = [ + { + type = "TYPE_REPO" + path = "*/*/public" + scopes = ["repo-pull"] + } + ] +} +``` + + + + +## Schema + +### Required + +- `label` (String) Label for the access token +- `org_name` (String) The organization namespace +- `resources` (Attributes List) Resources this token has access to (see [below for nested schema](#nestedatt--resources)) + +### Optional + +- `description` (String) Description for the access token +- `expires_at` (String) Expiration date for the token. Changing this value recreates the token. + +### Read-Only + +- `created_at` (String) The creation time of the access token +- `created_by` (String) The user that created the access token +- `id` (String) The ID of the organization access token +- `token` (String, Sensitive) The organization access token. This value is only returned during creation. + + +### Nested Schema for `resources` + +Required: + +- `path` (String) The path of the resource +- `scopes` (List of String) The scopes this token has access to +- `type` (String) The type of resource diff --git a/internal/hubclient/client_org_access_token.go b/internal/hubclient/client_org_access_token.go new file mode 100644 index 0000000..c69efae --- /dev/null +++ b/internal/hubclient/client_org_access_token.go @@ -0,0 +1,118 @@ +/* + Copyright 2024 Docker Terraform Provider authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package hubclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" +) + +const ( + OrgAccessTokenTypeRepo = "TYPE_REPO" + OrgAccessTokenTypeOrg = "TYPE_ORG" +) + +type OrgAccessToken struct { + ID string `json:"id"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + CreatedBy string `json:"created_by"` + IsActive bool `json:"is_active"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at,omitempty"` + LastUsedAt string `json:"last_used_at,omitempty"` + Token string `json:"token,omitempty"` + Resources []OrgAccessTokenResource `json:"resources,omitempty"` +} + +type OrgAccessTokenResource struct { + Type string `json:"type"` + Path string `json:"path"` + Scopes []string `json:"scopes"` +} + +type OrgAccessTokenCreateParams struct { + Label string `json:"label"` + Description string `json:"description"` + Resources []OrgAccessTokenResource `json:"resources"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +type OrgAccessTokenUpdateParams struct { + Label string `json:"label"` + Description string `json:"description"` + Resources []OrgAccessTokenResource `json:"resources"` +} + +func (c *Client) CreateOrgAccessToken(ctx context.Context, orgName string, params OrgAccessTokenCreateParams) (OrgAccessToken, error) { + if orgName == "" { + return OrgAccessToken{}, fmt.Errorf("orgName is required") + } + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(params); err != nil { + return OrgAccessToken{}, err + } + + var accessToken OrgAccessToken + err := c.sendRequest(ctx, "POST", fmt.Sprintf("/orgs/%s/access-tokens", orgName), buf.Bytes(), &accessToken) + return accessToken, err +} + +func (c *Client) GetOrgAccessToken(ctx context.Context, orgName, accessTokenID string) (OrgAccessToken, error) { + if orgName == "" { + return OrgAccessToken{}, fmt.Errorf("orgName is required") + } + if accessTokenID == "" { + return OrgAccessToken{}, fmt.Errorf("accessTokenID is required") + } + + var accessToken OrgAccessToken + err := c.sendRequest(ctx, "GET", fmt.Sprintf("/orgs/%s/access-tokens/%s", orgName, accessTokenID), nil, &accessToken) + return accessToken, err +} + +func (c *Client) UpdateOrgAccessToken(ctx context.Context, orgName, accessTokenID string, params OrgAccessTokenUpdateParams) (OrgAccessToken, error) { + if orgName == "" { + return OrgAccessToken{}, fmt.Errorf("orgName is required") + } + if accessTokenID == "" { + return OrgAccessToken{}, fmt.Errorf("accessTokenID is required") + } + + buf := bytes.NewBuffer(nil) + if err := json.NewEncoder(buf).Encode(params); err != nil { + return OrgAccessToken{}, err + } + + var accessToken OrgAccessToken + err := c.sendRequest(ctx, "PATCH", fmt.Sprintf("/orgs/%s/access-tokens/%s", orgName, accessTokenID), buf.Bytes(), &accessToken) + return accessToken, err +} + +func (c *Client) DeleteOrgAccessToken(ctx context.Context, orgName, accessTokenID string) error { + if orgName == "" { + return fmt.Errorf("orgName is required") + } + if accessTokenID == "" { + return fmt.Errorf("accessTokenID is required") + } + + return c.sendRequest(ctx, "DELETE", fmt.Sprintf("/orgs/%s/access-tokens/%s", orgName, accessTokenID), nil, nil) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 03ee84d..ccbcb60 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -382,6 +382,7 @@ func getConfigfileKey(host string) string { func (p *DockerProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ NewAccessTokenResource, + NewOrgAccessTokenResource, NewOrgSettingImageAccessManagementResource, NewOrgSettingRegistryAccessManagementResource, NewOrgTeamResource, diff --git a/internal/provider/resource_org_access_token.go b/internal/provider/resource_org_access_token.go new file mode 100644 index 0000000..0188ae2 --- /dev/null +++ b/internal/provider/resource_org_access_token.go @@ -0,0 +1,438 @@ +/* + Copyright 2024 Docker Terraform Provider authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package provider + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/docker/terraform-provider-docker/internal/hubclient" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "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/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +var ( + _ resource.Resource = &OrgAccessTokenResource{} + _ resource.ResourceWithConfigure = &OrgAccessTokenResource{} + _ resource.ResourceWithImportState = &OrgAccessTokenResource{} +) + +var orgAccessTokenResourceEntryObjectType = types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "type": types.StringType, + "path": types.StringType, + "scopes": types.ListType{ElemType: types.StringType}, + }, +} + +func NewOrgAccessTokenResource() resource.Resource { + return &OrgAccessTokenResource{} +} + +type OrgAccessTokenResource struct { + client *hubclient.Client +} + +type OrgAccessTokenResourceModel struct { + ID types.String `tfsdk:"id"` + OrgName types.String `tfsdk:"org_name"` + Label types.String `tfsdk:"label"` + Description types.String `tfsdk:"description"` + Resources types.List `tfsdk:"resources"` + ExpiresAt types.String `tfsdk:"expires_at"` + Token types.String `tfsdk:"token"` + CreatedBy types.String `tfsdk:"created_by"` + CreatedAt types.String `tfsdk:"created_at"` +} + +type OrgAccessTokenResourceEntryModel struct { + Type types.String `tfsdk:"type"` + Path types.String `tfsdk:"path"` + Scopes types.List `tfsdk:"scopes"` +} + +func (r *OrgAccessTokenResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*hubclient.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *hubclient.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *OrgAccessTokenResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_org_access_token" +} + +func (r *OrgAccessTokenResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: `Manages organization access tokens. + +-> **Note**: This resource is only available when authenticated with a username and password as an owner of the org. + +## Example Usage + +` + "```hcl" + ` +resource "docker_org_access_token" "example" { + org_name = "my-organization" + label = "ci-token" + description = "Token for CI pulls" + resources = [ + { + type = "TYPE_REPO" + path = "my-organization/my-repository" + scopes = ["repo-pull"] + } + ] + expires_at = "2027-12-31T23:59:59Z" +} +` + "```" + ` + +## Public-Only Repositories + +Use the special path ` + "`*/*/public`" + ` to scope the token to public repositories only. + +` + "```hcl" + ` +resource "docker_org_access_token" "public_pull" { + org_name = "my-organization" + label = "public-pull-token" + + resources = [ + { + type = "TYPE_REPO" + path = "*/*/public" + scopes = ["repo-pull"] + } + ] +} +` + "```" + ` +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + MarkdownDescription: "The ID of the organization access token", + Computed: true, + }, + "org_name": schema.StringAttribute{ + MarkdownDescription: "The organization namespace", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "label": schema.StringAttribute{ + MarkdownDescription: "Label for the access token", + Required: true, + }, + "description": schema.StringAttribute{ + MarkdownDescription: "Description for the access token", + Optional: true, + }, + "resources": schema.ListNestedAttribute{ + MarkdownDescription: "Resources this token has access to", + Required: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + MarkdownDescription: "The type of resource", + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf(hubclient.OrgAccessTokenTypeRepo, hubclient.OrgAccessTokenTypeOrg), + }, + }, + "path": schema.StringAttribute{ + MarkdownDescription: "The path of the resource", + Required: true, + }, + "scopes": schema.ListAttribute{ + MarkdownDescription: "The scopes this token has access to", + Required: true, + ElementType: types.StringType, + }, + }, + }, + }, + "expires_at": schema.StringAttribute{ + MarkdownDescription: "Expiration date for the token. Changing this value recreates the token.", + Optional: true, + Validators: []validator.String{ + stringvalidator.RegexMatches( + regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$`), + "must be in ISO 8601 format, e.g., 2021-10-28T18:30:19.520861Z", + ), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "token": schema.StringAttribute{ + MarkdownDescription: "The organization access token. This value is only returned during creation.", + Computed: true, + Sensitive: true, + }, + "created_by": schema.StringAttribute{ + MarkdownDescription: "The user that created the access token", + Computed: true, + }, + "created_at": schema.StringAttribute{ + MarkdownDescription: "The creation time of the access token", + Computed: true, + }, + }, + } +} + +func (r *OrgAccessTokenResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data OrgAccessTokenResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + tokenResources, diags := expandOrgAccessTokenResources(ctx, data.Resources) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + createReq := hubclient.OrgAccessTokenCreateParams{ + Label: data.Label.ValueString(), + Description: stringValueOrEmpty(data.Description), + Resources: tokenResources, + } + if !data.ExpiresAt.IsNull() { + createReq.ExpiresAt = data.ExpiresAt.ValueString() + } + + at, err := r.client.CreateOrgAccessToken(ctx, data.OrgName.ValueString(), createReq) + if err != nil { + resp.Diagnostics.AddError("Unable to create org access token", err.Error()) + return + } + + model, diags := orgAccessTokenToModel(ctx, data.OrgName, data.Description, data.ExpiresAt, types.StringNull(), at) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +func (r *OrgAccessTokenResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var fromState OrgAccessTokenResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &fromState)...) + if resp.Diagnostics.HasError() { + return + } + + at, err := r.client.GetOrgAccessToken(ctx, fromState.OrgName.ValueString(), fromState.ID.ValueString()) + if isNotFound(err) { + resp.State.RemoveResource(ctx) + return + } else if err != nil { + resp.Diagnostics.AddError("Unable to read org access token", err.Error()) + return + } + + model, diags := orgAccessTokenToModel(ctx, fromState.OrgName, fromState.Description, fromState.ExpiresAt, fromState.Token, at) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +func (r *OrgAccessTokenResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var fromState OrgAccessTokenResourceModel + var fromPlan OrgAccessTokenResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &fromState)...) + resp.Diagnostics.Append(req.Plan.Get(ctx, &fromPlan)...) + if resp.Diagnostics.HasError() { + return + } + + tokenResources, diags := expandOrgAccessTokenResources(ctx, fromPlan.Resources) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + updateReq := hubclient.OrgAccessTokenUpdateParams{ + Label: fromPlan.Label.ValueString(), + Description: stringValueOrEmpty(fromPlan.Description), + Resources: tokenResources, + } + + at, err := r.client.UpdateOrgAccessToken(ctx, fromState.OrgName.ValueString(), fromState.ID.ValueString(), updateReq) + if err != nil { + resp.Diagnostics.AddError("Unable to update org access token", err.Error()) + return + } + + model, diags := orgAccessTokenToModel(ctx, fromState.OrgName, fromPlan.Description, fromState.ExpiresAt, fromState.Token, at) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &model)...) +} + +func (r *OrgAccessTokenResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data OrgAccessTokenResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.DeleteOrgAccessToken(ctx, data.OrgName.ValueString(), data.ID.ValueString()) + if isNotFound(err) { + return + } else if err != nil { + resp.Diagnostics.AddError("Unable to delete org access token", err.Error()) + return + } +} + +func (r *OrgAccessTokenResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + idParts := strings.Split(req.ID, "/") + if len(idParts) != 2 || idParts[0] == "" || idParts[1] == "" { + resp.Diagnostics.AddError( + "Unexpected Import Identifier", + fmt.Sprintf("Expected import identifier with format: org_name/id. Got: %q", req.ID), + ) + return + } + + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("org_name"), idParts[0])...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("id"), idParts[1])...) +} + +func expandOrgAccessTokenResources(ctx context.Context, resources types.List) ([]hubclient.OrgAccessTokenResource, diag.Diagnostics) { + var diags diag.Diagnostics + var resourceModels []OrgAccessTokenResourceEntryModel + diags.Append(resources.ElementsAs(ctx, &resourceModels, false)...) + if diags.HasError() { + return nil, diags + } + + tokenResources := make([]hubclient.OrgAccessTokenResource, 0, len(resourceModels)) + for _, resourceModel := range resourceModels { + var scopes []string + diags.Append(resourceModel.Scopes.ElementsAs(ctx, &scopes, false)...) + if diags.HasError() { + return nil, diags + } + + tokenResources = append(tokenResources, hubclient.OrgAccessTokenResource{ + Type: resourceModel.Type.ValueString(), + Path: resourceModel.Path.ValueString(), + Scopes: scopes, + }) + } + + return tokenResources, diags +} + +func flattenOrgAccessTokenResources(ctx context.Context, resources []hubclient.OrgAccessTokenResource) (types.List, diag.Diagnostics) { + resourceModels := make([]OrgAccessTokenResourceEntryModel, 0, len(resources)) + for _, resource := range resources { + scopes, diags := types.ListValueFrom(ctx, types.StringType, resource.Scopes) + if diags.HasError() { + return types.ListNull(orgAccessTokenResourceEntryObjectType), diags + } + + resourceModels = append(resourceModels, OrgAccessTokenResourceEntryModel{ + Type: types.StringValue(resource.Type), + Path: types.StringValue(resource.Path), + Scopes: scopes, + }) + } + + return types.ListValueFrom(ctx, orgAccessTokenResourceEntryObjectType, resourceModels) +} + +func orgAccessTokenToModel( + ctx context.Context, + orgName types.String, + descriptionFromState types.String, + expiresAtFromState types.String, + tokenFromState types.String, + at hubclient.OrgAccessToken, +) (OrgAccessTokenResourceModel, diag.Diagnostics) { + resources, diags := flattenOrgAccessTokenResources(ctx, at.Resources) + if diags.HasError() { + return OrgAccessTokenResourceModel{}, diags + } + + model := OrgAccessTokenResourceModel{ + ID: stringValueOrNull(at.ID), + OrgName: orgName, + Label: stringValueOrNull(at.Label), + Description: stringValueOrPreservedNull(at.Description, descriptionFromState), + Resources: resources, + ExpiresAt: stringValueOrPreservedNull(at.ExpiresAt, expiresAtFromState), + Token: stringValueOrPreservedNull(at.Token, tokenFromState), + CreatedBy: stringValueOrNull(at.CreatedBy), + CreatedAt: stringValueOrNull(at.CreatedAt), + } + + return model, diags +} + +func stringValueOrNull(value string) types.String { + if value == "" { + return types.StringNull() + } + return types.StringValue(value) +} + +func stringValueOrPreservedNull(value string, preserved types.String) types.String { + if value != "" { + return types.StringValue(value) + } + if !preserved.IsNull() && !preserved.IsUnknown() { + return preserved + } + return types.StringNull() +} + +func stringValueOrEmpty(value types.String) string { + if value.IsNull() || value.IsUnknown() { + return "" + } + return value.ValueString() +} diff --git a/internal/provider/resource_org_access_token_test.go b/internal/provider/resource_org_access_token_test.go new file mode 100644 index 0000000..a04d857 --- /dev/null +++ b/internal/provider/resource_org_access_token_test.go @@ -0,0 +1,166 @@ +/* + Copyright 2024 Docker Terraform Provider authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package provider + +import ( + "fmt" + "testing" + + "github.com/docker/terraform-provider-docker/internal/envvar" + "github.com/docker/terraform-provider-docker/internal/hubclient" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAccOrgAccessTokenResource(t *testing.T) { + orgName := envvar.GetWithDefault(envvar.AccTestOrganization) + label := "test-" + randString(10) + updatedLabel := "test-" + randString(10) + repoName := "repo-" + randString(8) + updatedRepoName := "repo-" + randString(8) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccOrgAccessTokenResourceConfig(orgName, label, "test description", repoName, "2029-12-31T23:59:59Z"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("docker_org_access_token.test", "id"), + resource.TestCheckResourceAttr("docker_org_access_token.test", "org_name", orgName), + resource.TestCheckResourceAttr("docker_org_access_token.test", "label", label), + resource.TestCheckResourceAttr("docker_org_access_token.test", "description", "test description"), + resource.TestCheckResourceAttr("docker_org_access_token.test", "resources.#", "1"), + resource.TestCheckResourceAttr("docker_org_access_token.test", "resources.0.type", hubclient.OrgAccessTokenTypeRepo), + resource.TestCheckResourceAttr("docker_org_access_token.test", "resources.0.path", orgName+"/"+repoName), + resource.TestCheckResourceAttr("docker_org_access_token.test", "resources.0.scopes.#", "1"), + resource.TestCheckResourceAttr("docker_org_access_token.test", "expires_at", "2029-12-31T23:59:59Z"), + resource.TestCheckResourceAttrSet("docker_org_access_token.test", "token"), + resource.TestCheckResourceAttrSet("docker_org_access_token.test", "created_by"), + resource.TestCheckResourceAttrSet("docker_org_access_token.test", "created_at"), + resource.TestCheckNoResourceAttr("docker_org_access_token.test", "is_active"), + resource.TestCheckNoResourceAttr("docker_org_access_token.test", "last_used_at"), + ), + }, + { + ResourceName: "docker_org_access_token.test", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIdentifierAttribute: "id", + ImportStateVerifyIgnore: []string{ + "token", + }, + ImportStateIdFunc: func(state *terraform.State) (string, error) { + return orgName + "/" + state.RootModule().Resources["docker_org_access_token.test"].Primary.Attributes["id"], nil + }, + }, + { + Config: testAccOrgAccessTokenResourceConfig(orgName, updatedLabel, "updated description", updatedRepoName, "2029-12-31T23:59:59Z"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("docker_org_access_token.test", "id"), + resource.TestCheckResourceAttr("docker_org_access_token.test", "label", updatedLabel), + resource.TestCheckResourceAttr("docker_org_access_token.test", "description", "updated description"), + resource.TestCheckResourceAttr("docker_org_access_token.test", "resources.0.path", orgName+"/"+updatedRepoName), + resource.TestCheckResourceAttrSet("docker_org_access_token.test", "token"), + ), + }, + }, + }) +} + +func TestAccOrgAccessTokenResource_ExpiresAtReplaces(t *testing.T) { + orgName := envvar.GetWithDefault(envvar.AccTestOrganization) + label := "test-" + randString(10) + repoName := "repo-" + randString(8) + var firstID string + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: testAccOrgAccessTokenResourceConfig(orgName, label, "test description", repoName, "2029-12-31T23:59:59Z"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet("docker_org_access_token.test", "id"), + storeResourceAttribute("docker_org_access_token.test", "id", &firstID), + ), + }, + { + Config: testAccOrgAccessTokenResourceConfig(orgName, label, "test description", repoName, "2030-12-31T23:59:59Z"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("docker_org_access_token.test", "expires_at", "2030-12-31T23:59:59Z"), + assertResourceAttributeChanged("docker_org_access_token.test", "id", firstID), + ), + }, + }, + }) +} + +func testAccOrgAccessTokenResourceConfig(orgName, label, description, repoName, expiresAt string) string { + return fmt.Sprintf(` +resource "docker_org_access_token" "test" { + org_name = "%s" + label = "%s" + description = "%s" + resources = [ + { + type = "%s" + path = "%s/%s" + scopes = ["repo-pull"] + } + ] + expires_at = "%s" +} +`, orgName, label, description, hubclient.OrgAccessTokenTypeRepo, orgName, repoName, expiresAt) +} + +func storeResourceAttribute(resourceName, attributeName string, destination *string) resource.TestCheckFunc { + return func(state *terraform.State) error { + resourceState, ok := state.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("resource %s not found in state", resourceName) + } + + value, ok := resourceState.Primary.Attributes[attributeName] + if !ok { + return fmt.Errorf("attribute %s not found for resource %s", attributeName, resourceName) + } + + *destination = value + return nil + } +} + +func assertResourceAttributeChanged(resourceName, attributeName, previousValue string) resource.TestCheckFunc { + return func(state *terraform.State) error { + resourceState, ok := state.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("resource %s not found in state", resourceName) + } + + value, ok := resourceState.Primary.Attributes[attributeName] + if !ok { + return fmt.Errorf("attribute %s not found for resource %s", attributeName, resourceName) + } + + if value == previousValue { + return fmt.Errorf("expected %s for resource %s to change", attributeName, resourceName) + } + + return nil + } +}