diff --git a/.github/workflows/issue-comment-created.yml b/.github/workflows/issue-comment-created.yml index 9d3337151..982f15d03 100644 --- a/.github/workflows/issue-comment-created.yml +++ b/.github/workflows/issue-comment-created.yml @@ -12,8 +12,19 @@ permissions: jobs: remove-labels: runs-on: ubuntu-latest + # This job contains steps which are expected to fail. + continue-on-error: true steps: + - name: Is comment from a collaborator? + uses: octokit/request-action@v2.x + with: + route: GET /repos/${{ github.repository }}/collaborators/${{ github.event.sender.login }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Only remove labels if they are NOT a collaborator. + # Reason being, a collaborator may post a comment and add a waiting-response label. - uses: actions-ecosystem/action-remove-labels@v1 + if: ${{ failure() }} with: labels: | stale diff --git a/.github/workflows/issue-opened.yml b/.github/workflows/issue-opened.yml index e15d6dc7a..278983713 100644 --- a/.github/workflows/issue-opened.yml +++ b/.github/workflows/issue-opened.yml @@ -12,9 +12,21 @@ permissions: jobs: add-labels: runs-on: ubuntu-latest + # This job contains steps which are expected to fail. + continue-on-error: true steps: + - name: Is opened by a collaborator? + uses: octokit/request-action@v2.x + with: + route: GET /repos/${{ github.repository }}/collaborators/${{ github.event.sender.login }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Only add triage labels if they are NOT a collaborator. + # This prevents the needs-triage label from being added. - uses: actions/checkout@v2 + if: ${{ failure() }} - uses: github/issue-labeler@v2.4 + if: ${{ failure() }} with: repo-token: "${{ secrets.GITHUB_TOKEN }}" configuration-path: .github/labeler-issue-triage.yml diff --git a/docs/data-sources/projects.md b/docs/data-sources/projects.md index 3ac0995f4..1cb9828e9 100644 --- a/docs/data-sources/projects.md +++ b/docs/data-sources/projects.md @@ -82,6 +82,7 @@ Read-Only: - **avatar_url** (String) - **build_coverage_regex** (String) - **ci_config_path** (String) +- **ci_forward_deployment_enabled** (Boolean) - **container_registry_enabled** (Boolean) - **created_at** (String) - **creator_id** (Number) diff --git a/docs/resources/group_access_token.md b/docs/resources/group_access_token.md new file mode 100644 index 000000000..989797bf5 --- /dev/null +++ b/docs/resources/group_access_token.md @@ -0,0 +1,64 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "gitlab_group_access_token Resource - terraform-provider-gitlab" +subcategory: "" +description: |- + This resource allows you to create and manage Group Access Token for your GitLab Groups. (Introduced in GitLab 14.7) +--- + +# gitlab_group_access_token (Resource) + +This resource allows you to create and manage Group Access Token for your GitLab Groups. (Introduced in GitLab 14.7) + +## Example Usage + +```terraform +resource "gitlab_group_access_token" "example" { + group = "25" + name = "Example project access token" + expires_at = "2020-03-14" + access_level = "developer" + + scopes = ["api"] +} + +resource "gitlab_group_variable" "example" { + group = "25" + key = "gat" + value = gitlab_group_access_token.example.token +} +``` + + +## Schema + +### Required + +- **group** (String) The ID or path of the group to add the group access token to. +- **name** (String) The name of the group access token. +- **scopes** (Set of String) The scope for the group access token. It determines the actions which can be performed when authenticating with this token. Valid values are: `api`, `read_api`, `read_registry`, `write_registry`, `read_repository`, `write_repository`. + +### Optional + +- **access_level** (String) The access level for the group access token. Valid values are: `guest`, `reporter`, `developer`, `maintainer`. +- **expires_at** (String) The token expires at midnight UTC on that date. The date must be in the format YYYY-MM-DD. Default is never. +- **id** (String) The ID of this resource. + +### Read-Only + +- **active** (Boolean) True if the token is active. +- **created_at** (String) Time the token has been created, RFC3339 format. +- **revoked** (Boolean) True if the token is revoked. +- **token** (String, Sensitive) The group access token. This is only populated when creating a new group access token. This attribute is not available for imported resources. +- **user_id** (Number) The user id associated to the token. + +## Import + +Import is supported using the following syntax: + +```shell +# A GitLab Group Access Token can be imported using a key composed of `:`, e.g. +terraform import gitlab_group_access_token.example "12345:1" + +# ATTENTION: the `token` resource attribute is not available for imported resources as this information cannot be read from the GitLab API. +``` diff --git a/docs/resources/project.md b/docs/resources/project.md index 1905525bd..5b13a8ace 100644 --- a/docs/resources/project.md +++ b/docs/resources/project.md @@ -48,6 +48,7 @@ resource "gitlab_project" "example-two" { - **archived** (Boolean) Whether the project is in read-only mode (archived). Repositories can be archived/unarchived by toggling this parameter. - **build_coverage_regex** (String) Test coverage parsing for the project. - **ci_config_path** (String) Custom Path to CI config file. +- **ci_forward_deployment_enabled** (Boolean) When a new deployment job starts, skip older deployment jobs that are still pending. - **container_registry_enabled** (Boolean) Enable container registry for the project. - **default_branch** (String) The default branch for the project. - **description** (String) A description of the project. diff --git a/docs/resources/user.md b/docs/resources/user.md index f610fc357..fe0ec7685 100644 --- a/docs/resources/user.md +++ b/docs/resources/user.md @@ -51,6 +51,7 @@ resource "gitlab_user" "example" { - **projects_limit** (Number) Integer, defaults to 0. Number of projects user can create. - **reset_password** (Boolean) Boolean, defaults to false. Send user password reset link. - **skip_confirmation** (Boolean) Boolean, defaults to true. Whether to skip confirmation. +- **state** (String) String, defaults to 'active'. The state of the user account. Valid values are either 'active' or 'blocked' ## Import diff --git a/examples/resources/gitlab_group_access_token/import.sh b/examples/resources/gitlab_group_access_token/import.sh new file mode 100644 index 000000000..754bb7880 --- /dev/null +++ b/examples/resources/gitlab_group_access_token/import.sh @@ -0,0 +1,4 @@ +# A GitLab Group Access Token can be imported using a key composed of `:`, e.g. +terraform import gitlab_group_access_token.example "12345:1" + +# ATTENTION: the `token` resource attribute is not available for imported resources as this information cannot be read from the GitLab API. diff --git a/examples/resources/gitlab_group_access_token/resource.tf b/examples/resources/gitlab_group_access_token/resource.tf new file mode 100644 index 000000000..5af1acda3 --- /dev/null +++ b/examples/resources/gitlab_group_access_token/resource.tf @@ -0,0 +1,14 @@ +resource "gitlab_group_access_token" "example" { + group = "25" + name = "Example project access token" + expires_at = "2020-03-14" + access_level = "developer" + + scopes = ["api"] +} + +resource "gitlab_group_variable" "example" { + group = "25" + key = "gat" + value = gitlab_group_access_token.example.token +} diff --git a/go.mod b/go.mod index 38ab13bba..23086934e 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,12 @@ go 1.16 require ( github.com/apparentlymart/go-cidr v1.1.0 // indirect github.com/aws/aws-sdk-go v1.37.0 // indirect + github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 github.com/hashicorp/go-retryablehttp v0.7.0 github.com/hashicorp/hcl/v2 v2.8.2 // indirect github.com/hashicorp/terraform-plugin-sdk/v2 v2.10.1 github.com/mitchellh/hashstructure v1.1.0 github.com/onsi/gomega v1.18.1 - github.com/xanzy/go-gitlab v0.54.4 + github.com/xanzy/go-gitlab v0.55.0 google.golang.org/api v0.34.0 // indirect ) diff --git a/go.sum b/go.sum index 1c8970852..0b59cbec9 100644 --- a/go.sum +++ b/go.sum @@ -344,8 +344,8 @@ github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaU github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= -github.com/xanzy/go-gitlab v0.54.4 h1:3CFEdQ9O+bFx3BsyuOK0gqgLPwnT2rwnPOjudV07wTw= -github.com/xanzy/go-gitlab v0.54.4/go.mod h1:F0QEXwmqiBUxCgJm8fE9S+1veX4XC9Z4cfaAbqwk4YM= +github.com/xanzy/go-gitlab v0.55.0 h1:klg5EgPYtsF6QlnVFpOpv/FmhqQxCUUG7zLJIL1NKz8= +github.com/xanzy/go-gitlab v0.55.0/go.mod h1:F0QEXwmqiBUxCgJm8fE9S+1veX4XC9Z4cfaAbqwk4YM= github.com/xanzy/ssh-agent v0.3.0 h1:wUMzuKtKilRgBAD1sUb8gOwwRr2FGoBVumcjoOACClI= github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/internal/provider/data_source_gitlab_group_membership.go b/internal/provider/data_source_gitlab_group_membership.go index 351a340f3..05a128e31 100644 --- a/internal/provider/data_source_gitlab_group_membership.go +++ b/internal/provider/data_source_gitlab_group_membership.go @@ -101,7 +101,6 @@ var _ = registerDataSource("gitlab_group_membership", func() *schema.Resource { func dataSourceGitlabGroupMembershipRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { client := meta.(*gitlab.Client) - var gm []*gitlab.GroupMember var group *gitlab.Group var err error @@ -129,15 +128,32 @@ func dataSourceGitlabGroupMembershipRead(ctx context.Context, d *schema.Resource log.Printf("[INFO] Reading Gitlab group memberships") // Get group memberships - gm, _, err = client.Groups.ListGroupMembers(group.ID, &gitlab.ListGroupMembersOptions{}, gitlab.WithContext(ctx)) - if err != nil { - return diag.FromErr(err) + listOptions := &gitlab.ListGroupMembersOptions{ + ListOptions: gitlab.ListOptions{ + PerPage: 20, + Page: 1, + }, + } + + var allGms []*gitlab.GroupMember + for { + gms, resp, err := client.Groups.ListGroupMembers(group.ID, listOptions, gitlab.WithContext(ctx)) + if err != nil { + return diag.FromErr(err) + } + + allGms = append(allGms, gms...) + + if resp.NextPage == 0 { + break + } + listOptions.Page = resp.NextPage } d.Set("group_id", group.ID) d.Set("full_path", group.FullPath) - d.Set("members", flattenGitlabMembers(d, gm)) // lintignore: XR004 // TODO: Resolve this tfproviderlint issue + d.Set("members", flattenGitlabMembers(d, allGms)) // lintignore: XR004 // TODO: Resolve this tfproviderlint issue var optionsHash strings.Builder optionsHash.WriteString(strconv.Itoa(group.ID)) diff --git a/internal/provider/data_source_gitlab_group_membership_test.go b/internal/provider/data_source_gitlab_group_membership_test.go index 8f6676181..04cb0deea 100644 --- a/internal/provider/data_source_gitlab_group_membership_test.go +++ b/internal/provider/data_source_gitlab_group_membership_test.go @@ -44,6 +44,27 @@ func TestAccDataSourceGitlabMembership_basic(t *testing.T) { }) } +func TestAccDataSourceGitlabMembership_pagination(t *testing.T) { + testAccCheck(t) + + userCount := 21 + + group := testAccCreateGroups(t, 1)[0] + users := testAccCreateUsers(t, userCount) + testAccAddGroupMembers(t, group.ID, users) + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceGitlabGroupMembershipPagination(group.ID), + Check: resource.TestCheckResourceAttr("data.gitlab_group_membership.this", "members.#", fmt.Sprintf("%d", userCount)), + }, + }, + }) +} + func testAccDataSourceGitlabGroupMembershipConfig(rInt int) string { return fmt.Sprintf(` resource "gitlab_group" "foo" { @@ -89,3 +110,11 @@ data "gitlab_group_membership" "foomaintainers" { access_level = "maintainer" }`, rInt, rInt) } + +func testAccDataSourceGitlabGroupMembershipPagination(groupId int) string { + return fmt.Sprintf(` +data "gitlab_group_membership" "this" { + group_id = "%d" + access_level = "developer" +}`, groupId) +} diff --git a/internal/provider/data_source_gitlab_projects.go b/internal/provider/data_source_gitlab_projects.go index 922330939..27476e00a 100644 --- a/internal/provider/data_source_gitlab_projects.go +++ b/internal/provider/data_source_gitlab_projects.go @@ -164,6 +164,7 @@ func flattenProjects(projects []*gitlab.Project) (values []map[string]interface{ "custom_attributes": project.CustomAttributes, "packages_enabled": project.PackagesEnabled, "build_coverage_regex": project.BuildCoverageRegex, + "ci_forward_deployment_enabled": project.CIForwardDeploymentEnabled, } values = append(values, v) } @@ -771,6 +772,11 @@ var _ = registerDataSource("gitlab_projects", func() *schema.Resource { Type: schema.TypeString, Computed: true, }, + "ci_forward_deployment_enabled": { + Description: "When a new deployment job starts, skip older deployment jobs that are still pending.", + Type: schema.TypeBool, + Computed: true, + }, }, }, }, @@ -811,15 +817,24 @@ func dataSourceGitlabProjectsRead(ctx context.Context, d *schema.ResourceData, m var withProgrammingLanguagePtr *string var withSharedPtr *bool - if data, ok := d.GetOk("archived"); ok { + // NOTE: `GetOkExists()` is deprecated, but until there is a replacement we need to use it. + // see https://github.com/hashicorp/terraform-plugin-sdk/pull/350#issuecomment-597888969 + + // nolint:staticcheck // SA1019 ignore deprecated GetOkExists + // lintignore: XR001 // TODO: replace with alternative for GetOkExists + if data, ok := d.GetOkExists("archived"); ok { d := data.(bool) archivedPtr = &d } - if data, ok := d.GetOk("include_subgroups"); ok { + // nolint:staticcheck // SA1019 ignore deprecated GetOkExists + // lintignore: XR001 // TODO: replace with alternative for GetOkExists + if data, ok := d.GetOkExists("include_subgroups"); ok { d := data.(bool) includeSubGroupsPtr = &d } - if data, ok := d.GetOk("membership"); ok { + // nolint:staticcheck // SA1019 ignore deprecated GetOkExists + // lintignore: XR001 // TODO: replace with alternative for GetOkExists + if data, ok := d.GetOkExists("membership"); ok { d := data.(bool) membershipPtr = &d } @@ -830,7 +845,9 @@ func dataSourceGitlabProjectsRead(ctx context.Context, d *schema.ResourceData, m d := data.(string) orderByPtr = &d } - if data, ok := d.GetOk("owned"); ok { + // nolint:staticcheck // SA1019 ignore deprecated GetOkExists + // lintignore: XR001 // TODO: replace with alternative for GetOkExists + if data, ok := d.GetOkExists("owned"); ok { d := data.(bool) ownedPtr = &d } @@ -838,7 +855,9 @@ func dataSourceGitlabProjectsRead(ctx context.Context, d *schema.ResourceData, m d := data.(string) searchPtr = &d } - if data, ok := d.GetOk("simple"); ok { + // nolint:staticcheck // SA1019 ignore deprecated GetOkExists + // lintignore: XR001 // TODO: replace with alternative for GetOkExists + if data, ok := d.GetOkExists("simple"); ok { d := data.(bool) simplePtr = &d } @@ -846,26 +865,36 @@ func dataSourceGitlabProjectsRead(ctx context.Context, d *schema.ResourceData, m d := data.(string) sortPtr = &d } - if data, ok := d.GetOk("starred"); ok { + // nolint:staticcheck // SA1019 ignore deprecated GetOkExists + // lintignore: XR001 // TODO: replace with alternative for GetOkExists + if data, ok := d.GetOkExists("starred"); ok { d := data.(bool) starredPtr = &d } - if data, ok := d.GetOk("statistics"); ok { + // nolint:staticcheck // SA1019 ignore deprecated GetOkExists + // lintignore: XR001 // TODO: replace with alternative for GetOkExists + if data, ok := d.GetOkExists("statistics"); ok { d := data.(bool) statisticsPtr = &d } if data, ok := d.GetOk("visibility"); ok { visibilityPtr = gitlab.Visibility(gitlab.VisibilityValue(data.(string))) } - if data, ok := d.GetOk("with_custom_attributes"); ok { + // nolint:staticcheck // SA1019 ignore deprecated GetOkExists + // lintignore: XR001 // TODO: replace with alternative for GetOkExists + if data, ok := d.GetOkExists("with_custom_attributes"); ok { d := data.(bool) withCustomAttributesPtr = &d } - if data, ok := d.GetOk("with_issues_enabled"); ok { + // nolint:staticcheck // SA1019 ignore deprecated GetOkExists + // lintignore: XR001 // TODO: replace with alternative for GetOkExists + if data, ok := d.GetOkExists("with_issues_enabled"); ok { d := data.(bool) withIssuesEnabledPtr = &d } - if data, ok := d.GetOk("with_merge_requests_enabled"); ok { + // nolint:staticcheck // SA1019 ignore deprecated GetOkExists + // lintignore: XR001 // TODO: replace with alternative for GetOkExists + if data, ok := d.GetOkExists("with_merge_requests_enabled"); ok { d := data.(bool) withMergeRequestsEnabledPtr = &d } @@ -873,7 +902,9 @@ func dataSourceGitlabProjectsRead(ctx context.Context, d *schema.ResourceData, m d := data.(string) withProgrammingLanguagePtr = &d } - if data, ok := d.GetOk("with_shared"); ok { + // nolint:staticcheck // SA1019 ignore deprecated GetOkExists + // lintignore: XR001 // TODO: replace with alternative for GetOkExists + if data, ok := d.GetOkExists("with_shared"); ok { d := data.(bool) withSharedPtr = &d } diff --git a/internal/provider/data_source_gitlab_projects_test.go b/internal/provider/data_source_gitlab_projects_test.go index ead88bcf0..f1284d4ce 100644 --- a/internal/provider/data_source_gitlab_projects_test.go +++ b/internal/provider/data_source_gitlab_projects_test.go @@ -2,11 +2,12 @@ package provider import ( "fmt" - "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" "strconv" "strings" "testing" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) @@ -96,6 +97,52 @@ func TestAccDataGitlabProjectsGroups(t *testing.T) { }) } +func TestAccDataGitlabProjects_searchArchivedRepository(t *testing.T) { + rInt := acctest.RandInt() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: testAccDataGitlabProjectsConfigGetProjectArchivedRepositoryAll(rInt), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr( + "data.gitlab_projects.search", + "projects.0.name", + fmt.Sprintf("archived-%d", rInt), + ), + resource.TestCheckResourceAttr( + "data.gitlab_projects.search", + "projects.1.name", + fmt.Sprintf("not-archived-%d", rInt), + ), + ), + }, + { + Config: testAccDataGitlabProjectsConfigGetProjectArchivedRepository(rInt, "true"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr( + "data.gitlab_projects.search", + "projects.0.name", + fmt.Sprintf("archived-%d", rInt), + ), + ), + }, + { + Config: testAccDataGitlabProjectsConfigGetProjectArchivedRepository(rInt, "false"), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr( + "data.gitlab_projects.search", + "projects.0.name", + fmt.Sprintf("not-archived-%d", rInt), + ), + ), + }, + }, + }) +} + func testAccDataSourceGitlabProjects(src string, n string) resource.TestCheckFunc { return func(s *terraform.State) error { @@ -167,6 +214,68 @@ data "gitlab_projects" "search" { `, projectName, projectName) } +func testAccDataGitlabProjectsConfigGetProjectArchivedRepositoryAll(rInt int) string { + return fmt.Sprintf(` +resource "gitlab_group" "test" { + name = "test-%d" + path = "test-%d" +} + +resource "gitlab_project" "archived_repo" { + name = "archived-%d" + namespace_id = gitlab_group.test.id + archived = true +} + +resource "gitlab_project" "not_archived_repo" { + name = "not-archived-%d" + namespace_id = gitlab_group.test.id + archived = false +} + +data "gitlab_projects" "search" { + group_id = gitlab_group.test.id + // NOTE: is required to have deterministic results + order_by = "name" + sort = "asc" + + depends_on = [gitlab_project.archived_repo, gitlab_project.not_archived_repo] +} + `, rInt, rInt, rInt, rInt) +} + +func testAccDataGitlabProjectsConfigGetProjectArchivedRepository(rInt int, archived string) string { + return fmt.Sprintf(` +resource "gitlab_group" "test" { + name = "test-%d" + path = "test-%d" +} + +resource "gitlab_project" "archived_repo" { + name = "archived-%d" + namespace_id = gitlab_group.test.id + archived = true +} + +resource "gitlab_project" "not_archived_repo" { + name = "not-archived-%d" + namespace_id = gitlab_group.test.id + archived = false +} + +data "gitlab_projects" "search" { + group_id = gitlab_group.test.id + // NOTE: is required to have deterministic results + order_by = "name" + sort = "asc" + + archived = %s + + depends_on = [gitlab_project.archived_repo, gitlab_project.not_archived_repo] +} + `, rInt, rInt, rInt, rInt, archived) +} + func testAccDataGitlabProjectsConfigGetGroupProjectsByGroupId(groupName string, projectName string) string { return fmt.Sprintf(` resource "gitlab_group" "testGroup" { diff --git a/internal/provider/resource_gitlab_group.go b/internal/provider/resource_gitlab_group.go index 10f77694c..b145c288a 100644 --- a/internal/provider/resource_gitlab_group.go +++ b/internal/provider/resource_gitlab_group.go @@ -208,7 +208,9 @@ func resourceGitlabGroupCreate(ctx context.Context, d *schema.ResourceData, meta options.ParentID = gitlab.Int(v.(int)) } - if v, ok := d.GetOk("default_branch_protection"); ok { + // nolint:staticcheck // SA1019 ignore deprecated GetOkExists + // lintignore: XR001 // TODO: replace with alternative for GetOkExists + if v, ok := d.GetOkExists("default_branch_protection"); ok { options.DefaultBranchProtection = gitlab.Int(v.(int)) } diff --git a/internal/provider/resource_gitlab_group_access_token.go b/internal/provider/resource_gitlab_group_access_token.go new file mode 100644 index 000000000..4f3e836f9 --- /dev/null +++ b/internal/provider/resource_gitlab_group_access_token.go @@ -0,0 +1,237 @@ +package provider + +import ( + "context" + "fmt" + "log" + "strconv" + "time" + + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + gitlab "github.com/xanzy/go-gitlab" +) + +var validGroupAccessTokenScopes = []string{ + "api", + "read_api", + "read_registry", + "write_registry", + "read_repository", + "write_repository", +} +var validAccessLevels = []string{ + "guest", + "reporter", + "developer", + "maintainer", +} + +var _ = registerResource("gitlab_group_access_token", func() *schema.Resource { + return &schema.Resource{ + Description: "This resource allows you to create and manage Group Access Token for your GitLab Groups. (Introduced in GitLab 14.7)", + + CreateContext: resourceGitlabGroupAccessTokenCreate, + ReadContext: resourceGitlabGroupAccessTokenRead, + DeleteContext: resourceGitlabGroupAccessTokenDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "group": { + Description: "The ID or path of the group to add the group access token to.", + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "name": { + Description: "The name of the group access token.", + Type: schema.TypeString, + Required: true, + ForceNew: true, + }, + "scopes": { + Description: fmt.Sprintf("The scope for the group access token. It determines the actions which can be performed when authenticating with this token. Valid values are: %s.", renderValueListForDocs(validGroupAccessTokenScopes)), + Type: schema.TypeSet, + Required: true, + ForceNew: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringInSlice(validGroupAccessTokenScopes, false), + }, + }, + "access_level": { + Description: fmt.Sprintf("The access level for the group access token. Valid values are: %s.", renderValueListForDocs(validAccessLevels)), + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Default: accessLevelValueToName[gitlab.MaintainerPermissions], + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice(validAccessLevels, false)), + }, + "expires_at": { + Description: "The token expires at midnight UTC on that date. The date must be in the format YYYY-MM-DD. Default is never.", + Type: schema.TypeString, + Optional: true, + ForceNew: true, + ValidateDiagFunc: func(i interface{}, p cty.Path) diag.Diagnostics { + v := i.(string) + + if _, err := time.Parse("2006-01-02", v); err != nil { + return diag.Errorf("expected %q to be a valid YYYY-MM-DD date, got %q: %+v", p, i, err) + } + + return nil + }, + }, + "token": { + Description: "The group access token. This is only populated when creating a new group access token. This attribute is not available for imported resources.", + Type: schema.TypeString, + Computed: true, + Sensitive: true, + }, + "active": { + Description: "True if the token is active.", + Type: schema.TypeBool, + Computed: true, + }, + "created_at": { + Description: "Time the token has been created, RFC3339 format.", + Type: schema.TypeString, + Computed: true, + }, + "revoked": { + Description: "True if the token is revoked.", + Type: schema.TypeBool, + Computed: true, + }, + "user_id": { + Description: "The user id associated to the token.", + Type: schema.TypeInt, + Computed: true, + }, + }, + } +}) + +func resourceGitlabGroupAccessTokenCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + client := meta.(*gitlab.Client) + + group := d.Get("group").(string) + options := &gitlab.CreateGroupAccessTokenOptions{ + Name: gitlab.String(d.Get("name").(string)), + Scopes: stringSetToStringSlice(d.Get("scopes").(*schema.Set)), + } + if v, ok := d.GetOk("access_level"); ok { + accessLevel := accessLevelNameToValue[v.(string)] + options.AccessLevel = &accessLevel + } + + log.Printf("[DEBUG] create gitlab GroupAccessToken %s (scopes: %s, access_level: %v) for group ID %s", *options.Name, options.Scopes, options.AccessLevel, group) + + if v, ok := d.GetOk("expires_at"); ok { + parsedExpiresAt, err := time.Parse("2006-01-02", v.(string)) + if err != nil { + return diag.Errorf("Invalid expires_at date: %v", err) + } + parsedExpiresAtISOTime := gitlab.ISOTime(parsedExpiresAt) + options.ExpiresAt = &parsedExpiresAtISOTime + log.Printf("[DEBUG] create gitlab GroupAccessToken %s with expires_at %s for group ID %s", *options.Name, *options.ExpiresAt, group) + } + + groupAccessToken, _, err := client.GroupAccessTokens.CreateGroupAccessToken(group, options, gitlab.WithContext(ctx)) + if err != nil { + return diag.FromErr(err) + } + + log.Printf("[DEBUG] created gitlab GroupAccessToken %d - %s for group ID %s", groupAccessToken.ID, *options.Name, group) + + tokenId := strconv.Itoa(groupAccessToken.ID) + d.SetId(buildTwoPartID(&group, &tokenId)) + // NOTE: the token can only be read once after creating it + d.Set("token", groupAccessToken.Token) + + return resourceGitlabGroupAccessTokenRead(ctx, d, meta) +} + +func resourceGitlabGroupAccessTokenRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + group, tokenId, err := parseTwoPartID(d.Id()) + if err != nil { + return diag.Errorf("Error parsing ID: %s", d.Id()) + } + + client := meta.(*gitlab.Client) + + groupAccessTokenId, err := strconv.Atoi(tokenId) + if err != nil { + return diag.Errorf("%s cannot be converted to int", tokenId) + } + + log.Printf("[DEBUG] read gitlab GroupAccessToken %d, group ID %s", groupAccessTokenId, group) + + //there is a slight possibility to not find an existing item, for example + // 1. item is #101 (ie, in the 2nd page) + // 2. I load first page (ie. I don't find my target item) + // 3. A concurrent operation remove item 99 (ie, my target item shift to 1st page) + // 4. a concurrent operation add an item + // 5: I load 2nd page (ie. I don't find my target item) + // 6. Total pages and total items properties are unchanged (from the perspective of the reader) + + page := 1 + for page != 0 { + groupAccessTokens, response, err := client.GroupAccessTokens.ListGroupAccessTokens(group, &gitlab.ListGroupAccessTokensOptions{Page: page, PerPage: 100}, gitlab.WithContext(ctx)) + if err != nil { + return diag.FromErr(err) + } + + for _, groupAccessToken := range groupAccessTokens { + if groupAccessToken.ID == groupAccessTokenId { + + d.Set("group", group) + d.Set("name", groupAccessToken.Name) + if groupAccessToken.ExpiresAt != nil { + d.Set("expires_at", groupAccessToken.ExpiresAt.String()) + } + d.Set("active", groupAccessToken.Active) + d.Set("created_at", groupAccessToken.CreatedAt.Format(time.RFC3339)) + d.Set("access_level", accessLevelValueToName[groupAccessToken.AccessLevel]) + d.Set("revoked", groupAccessToken.Revoked) + d.Set("user_id", groupAccessToken.UserID) + + err = d.Set("scopes", groupAccessToken.Scopes) + if err != nil { + return diag.FromErr(err) + } + + return nil + } + } + + page = response.NextPage + } + + log.Printf("[DEBUG] failed to read gitlab GroupAccessToken %d, group ID %s", groupAccessTokenId, group) + d.SetId("") + return nil +} + +func resourceGitlabGroupAccessTokenDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + + group, tokenId, err := parseTwoPartID(d.Id()) + if err != nil { + return diag.Errorf("Error parsing ID: %s", d.Id()) + } + + client := meta.(*gitlab.Client) + + groupAccessTokenId, err := strconv.Atoi(tokenId) + if err != nil { + return diag.Errorf("%s cannot be converted to int", tokenId) + } + + log.Printf("[DEBUG] Delete gitlab GroupAccessToken %s", d.Id()) + _, err = client.GroupAccessTokens.DeleteGroupAccessToken(group, groupAccessTokenId, gitlab.WithContext(ctx)) + return diag.FromErr(err) +} diff --git a/internal/provider/resource_gitlab_group_access_token_test.go b/internal/provider/resource_gitlab_group_access_token_test.go new file mode 100644 index 000000000..b55b91406 --- /dev/null +++ b/internal/provider/resource_gitlab_group_access_token_test.go @@ -0,0 +1,249 @@ +package provider + +import ( + "fmt" + "strconv" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + gitlab "github.com/xanzy/go-gitlab" +) + +func TestAccGitlabGroupAccessToken_basic(t *testing.T) { + var gat testAccGitlabGroupAccessTokenWrapper + var groupVariable gitlab.GroupVariable + + testAccCheck(t) + testGroup := testAccCreateGroups(t, 1)[0] + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGitlabGroupAccessTokenDestroy, + Steps: []resource.TestStep{ + // Create a Group and a Group Access Token + { + Config: testAccGitlabGroupAccessTokenConfig(testGroup.ID), + Check: resource.ComposeTestCheckFunc( + testAccCheckGitlabGroupAccessTokenExists("gitlab_group_access_token.this", &gat), + testAccCheckGitlabGroupAccessTokenAttributes(&gat, &testAccGitlabGroupAccessTokenExpectedAttributes{ + name: "my group token", + scopes: map[string]bool{"read_repository": true, "api": true, "write_repository": true, "read_api": true}, + expiresAt: "2099-01-01", + accessLevel: gitlab.AccessLevelValue(gitlab.DeveloperPermissions), + }), + ), + }, + // Update the Group Access Token to change the parameters + { + Config: testAccGitlabGroupAccessTokenUpdateConfig(testGroup.ID), + Check: resource.ComposeTestCheckFunc( + testAccCheckGitlabGroupAccessTokenExists("gitlab_group_access_token.this", &gat), + testAccCheckGitlabGroupAccessTokenAttributes(&gat, &testAccGitlabGroupAccessTokenExpectedAttributes{ + name: "my new group token", + scopes: map[string]bool{"read_repository": false, "api": true, "write_repository": false, "read_api": false}, + expiresAt: "2099-05-01", + accessLevel: gitlab.AccessLevelValue(gitlab.MaintainerPermissions), + }), + ), + }, + // Add a CICD variable with Group Access Token value + { + Config: testAccGitlabGroupAccessTokenUpdateConfigWithCICDvar(testGroup.ID), + Check: resource.ComposeTestCheckFunc( + testAccCheckGitlabGroupAccessTokenExists("gitlab_group_access_token.this", &gat), + testAccCheckGitlabGroupVariableExists("gitlab_group_variable.var", &groupVariable), + testAccCheckGitlabGroupAccessTokenAttributes(&gat, &testAccGitlabGroupAccessTokenExpectedAttributes{ + name: "my new group token", + scopes: map[string]bool{"read_repository": false, "api": true, "write_repository": false, "read_api": false}, + expiresAt: "2099-05-01", + accessLevel: gitlab.AccessLevelValue(gitlab.MaintainerPermissions), + }), + ), + }, + //Restore Group Access Token initial parameters + { + Config: testAccGitlabGroupAccessTokenConfig(testGroup.ID), + Check: resource.ComposeTestCheckFunc( + testAccCheckGitlabGroupAccessTokenExists("gitlab_group_access_token.this", &gat), + testAccCheckGitlabGroupAccessTokenAttributes(&gat, &testAccGitlabGroupAccessTokenExpectedAttributes{ + name: "my group token", + scopes: map[string]bool{"read_repository": true, "api": true, "write_repository": true, "read_api": true}, + expiresAt: "2099-01-01", + accessLevel: gitlab.AccessLevelValue(gitlab.DeveloperPermissions), + }), + ), + }, + // Verify import + { + ResourceName: "gitlab_group_access_token.this", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + // the token is only known during creating. We explicitly mention this limitation in the docs. + "token", + }, + }, + }, + }) +} + +func testAccCheckGitlabGroupAccessTokenExists(n string, gat *testAccGitlabGroupAccessTokenWrapper) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[n] + if !ok { + return fmt.Errorf("Not Found: %s", n) + } + + group, tokenString, err := parseTwoPartID(rs.Primary.ID) + if err != nil { + return fmt.Errorf("Error parsing ID: %s", rs.Primary.ID) + } + groupAccessTokenID, err := strconv.Atoi(tokenString) + if err != nil { + return fmt.Errorf("%s cannot be converted to int", tokenString) + } + + groupId := rs.Primary.Attributes["group"] + if groupId == "" { + return fmt.Errorf("No group ID is set") + } + if groupId != group { + return fmt.Errorf("Group [%s] in group identifier [%s] it's different from group stored into the state [%s]", group, rs.Primary.ID, groupId) + } + + tokens, _, err := testGitlabClient.GroupAccessTokens.ListGroupAccessTokens(groupId, nil) + if err != nil { + return err + } + + for _, token := range tokens { + if token.ID == groupAccessTokenID { + gat.groupAccessToken = token + gat.group = groupId + gat.token = rs.Primary.Attributes["token"] + return nil + } + } + return fmt.Errorf("Group Access Token does not exist") + } +} + +type testAccGitlabGroupAccessTokenExpectedAttributes struct { + name string + scopes map[string]bool + expiresAt string + accessLevel gitlab.AccessLevelValue +} + +type testAccGitlabGroupAccessTokenWrapper struct { + groupAccessToken *gitlab.GroupAccessToken + group string + token string +} + +func testAccCheckGitlabGroupAccessTokenAttributes(gatWrap *testAccGitlabGroupAccessTokenWrapper, want *testAccGitlabGroupAccessTokenExpectedAttributes) resource.TestCheckFunc { + return func(s *terraform.State) error { + gat := gatWrap.groupAccessToken + if gat.Name != want.name { + return fmt.Errorf("got Name %q; want %q", gat.Name, want.name) + } + + if gat.ExpiresAt.String() != want.expiresAt { + return fmt.Errorf("got ExpiresAt %q; want %q", gat.ExpiresAt.String(), want.expiresAt) + } + + if gat.AccessLevel != want.accessLevel { + return fmt.Errorf("got AccessLevel %q; want %q", gat.AccessLevel, want.accessLevel) + } + + for _, scope := range gat.Scopes { + if !want.scopes[scope] { + return fmt.Errorf("got a not wanted Scope %q, received %v", scope, gat.Scopes) + } + want.scopes[scope] = false + } + for k, v := range want.scopes { + if v { + return fmt.Errorf("not got a wanted Scope %q, received %v", k, gat.Scopes) + } + } + + git, err := gitlab.NewClient(gatWrap.token, gitlab.WithBaseURL(testGitlabClient.BaseURL().String())) + if err != nil { + return fmt.Errorf("Cannot use the token to instantiate a new client %s", err) + } + _, _, err = git.Groups.GetGroup(gatWrap.group, nil) + if err != nil { + return fmt.Errorf("Cannot use the token to perform an API call %s", err) + } + + return nil + } +} + +func testAccCheckGitlabGroupAccessTokenDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "gitlab_group" { + continue + } + + group, resp, err := testGitlabClient.Groups.GetGroup(rs.Primary.ID, nil) + if err == nil { + if group != nil && fmt.Sprintf("%d", group.ID) == rs.Primary.ID { + if group.MarkedForDeletionOn == nil { + return fmt.Errorf("Group still exists") + } + } + } + if resp.StatusCode != 404 { + return err + } + return nil + } + return nil +} + +func testAccGitlabGroupAccessTokenConfig(groupId int) string { + return fmt.Sprintf(` +resource "gitlab_group_access_token" "this" { + name = "my group token" + group = %d + expires_at = "2099-01-01" + access_level = "developer" + scopes = ["read_repository" , "api", "write_repository", "read_api"] +} + `, groupId) +} + +func testAccGitlabGroupAccessTokenUpdateConfig(groupId int) string { + return fmt.Sprintf(` +resource "gitlab_group_access_token" "this" { + name = "my new group token" + group = %d + expires_at = "2099-05-01" + access_level = "maintainer" + scopes = ["api"] +} + `, groupId) +} + +func testAccGitlabGroupAccessTokenUpdateConfigWithCICDvar(groupId int) string { + return fmt.Sprintf(` +resource "gitlab_group_access_token" "this" { + name = "my new group token" + group = %d + expires_at = "2099-05-01" + access_level = "maintainer" + scopes = ["api"] +} + +resource "gitlab_group_variable" "var" { + group = %d + key = "my_grp_access_token" + value = gitlab_group_access_token.this.token + } + + `, groupId, groupId) +} diff --git a/internal/provider/resource_gitlab_group_label.go b/internal/provider/resource_gitlab_group_label.go index 7c8a70afd..19d82e653 100644 --- a/internal/provider/resource_gitlab_group_label.go +++ b/internal/provider/resource_gitlab_group_label.go @@ -85,7 +85,7 @@ func resourceGitlabGroupLabelRead(ctx context.Context, d *schema.ResourceData, m page := 1 labelsLen := 0 for page == 1 || labelsLen != 0 { - labels, _, err := client.GroupLabels.ListGroupLabels(group, &gitlab.ListGroupLabelsOptions{Page: page}, gitlab.WithContext(ctx)) + labels, _, err := client.GroupLabels.ListGroupLabels(group, &gitlab.ListGroupLabelsOptions{ListOptions: gitlab.ListOptions{Page: page}}, gitlab.WithContext(ctx)) if err != nil { return diag.FromErr(err) } diff --git a/internal/provider/resource_gitlab_group_label_test.go b/internal/provider/resource_gitlab_group_label_test.go index 70f04d757..536269099 100644 --- a/internal/provider/resource_gitlab_group_label_test.go +++ b/internal/provider/resource_gitlab_group_label_test.go @@ -140,7 +140,7 @@ func testAccCheckGitlabGroupLabelExists(n string, label *gitlab.GroupLabel) reso return fmt.Errorf("No group ID is set") } - labels, _, err := testGitlabClient.GroupLabels.ListGroupLabels(groupName, &gitlab.ListGroupLabelsOptions{PerPage: 1000}) + labels, _, err := testGitlabClient.GroupLabels.ListGroupLabels(groupName, &gitlab.ListGroupLabelsOptions{ListOptions: gitlab.ListOptions{PerPage: 1000}}) if err != nil { return err } diff --git a/internal/provider/resource_gitlab_group_ldap_link_test.go b/internal/provider/resource_gitlab_group_ldap_link_test.go index caee8e450..288560bc0 100644 --- a/internal/provider/resource_gitlab_group_ldap_link_test.go +++ b/internal/provider/resource_gitlab_group_ldap_link_test.go @@ -1,10 +1,8 @@ package provider import ( - "encoding/json" "errors" "fmt" - "io/ioutil" "testing" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" @@ -14,15 +12,13 @@ import ( ) func TestAccGitlabGroupLdapLink_basic(t *testing.T) { - var testLdapLink gitlab.LDAPGroupLink - var ldapLink gitlab.LDAPGroupLink rInt := acctest.RandInt() - testDataFile := "testdata/resource_gitlab_group_ldap_link.json" // PreCheck runs after Config so load test data here - err := testAccLoadTestData(testDataFile, &testLdapLink) - if err != nil { - t.Fatalf("[ERROR] Failed to load test data: %s", err.Error()) + var ldapLink gitlab.LDAPGroupLink + testLdapLink := gitlab.LDAPGroupLink{ + CN: "default", + Provider: "default", } resource.Test(t, resource.TestCase{ @@ -191,20 +187,6 @@ func testAccGetGitlabGroupLdapLink(ldapLink *gitlab.LDAPGroupLink, resourceState return nil } -func testAccLoadTestData(testdatafile string, ldapLink *gitlab.LDAPGroupLink) error { - testLdapLinkBytes, err := ioutil.ReadFile(testdatafile) - if err != nil { - return err - } - - err = json.Unmarshal(testLdapLinkBytes, ldapLink) - if err != nil { - return err - } - - return nil -} - func testAccGitlabGroupLdapLinkCreateConfig(rInt int, testLdapLink *gitlab.LDAPGroupLink) string { return fmt.Sprintf(` resource "gitlab_group" "foo" { diff --git a/internal/provider/resource_gitlab_group_test.go b/internal/provider/resource_gitlab_group_test.go index d39d1dcab..97dc6ac97 100644 --- a/internal/provider/resource_gitlab_group_test.go +++ b/internal/provider/resource_gitlab_group_test.go @@ -41,7 +41,7 @@ func TestAccGitlabGroup_basic(t *testing.T) { }, // Update the group to change the description { - Config: testAccGitlabGroupUpdateConfig(rInt), + Config: testAccGitlabGroupUpdateConfig(rInt, 1), Check: resource.ComposeTestCheckFunc( testAccCheckGitlabGroupExists("gitlab_group.foo", &group), testAccCheckGitlabGroupAttributes(&group, &testAccGitlabGroupExpectedAttributes{ @@ -63,6 +63,30 @@ func TestAccGitlabGroup_basic(t *testing.T) { }), ), }, + // Update the group to use zero-value `default_branch_protection` + { + Config: testAccGitlabGroupUpdateConfig(rInt, 0), + Check: resource.ComposeTestCheckFunc( + testAccCheckGitlabGroupExists("gitlab_group.foo", &group), + testAccCheckGitlabGroupAttributes(&group, &testAccGitlabGroupExpectedAttributes{ + Name: fmt.Sprintf("bar-name-%d", rInt), + Path: fmt.Sprintf("bar-path-%d", rInt), + Description: "Terraform acceptance tests! Updated description", + LFSEnabled: false, + Visibility: "public", // default value + RequestAccessEnabled: true, + ProjectCreationLevel: "developer", + SubGroupCreationLevel: "maintainer", + RequireTwoFactorAuth: true, + TwoFactorGracePeriod: 56, + AutoDevopsEnabled: true, + EmailsDisabled: true, + MentionsDisabled: true, + ShareWithGroupLock: true, + DefaultBranchProtection: 0, + }), + ), + }, // Update the group to put the name and description back { Config: testAccGitlabGroupConfig(rInt), @@ -398,7 +422,7 @@ resource "gitlab_group" "foo" { `, rInt, rInt) } -func testAccGitlabGroupUpdateConfig(rInt int) string { +func testAccGitlabGroupUpdateConfig(rInt int, defaultBranchProtection int) string { return fmt.Sprintf(` resource "gitlab_group" "foo" { name = "bar-name-%d" @@ -414,13 +438,13 @@ resource "gitlab_group" "foo" { emails_disabled = true mentions_disabled = true share_with_group_lock = true - default_branch_protection = 1 + default_branch_protection = %d # So that acceptance tests can be run in a gitlab organization # with no billing visibility_level = "public" } - `, rInt, rInt) + `, rInt, rInt, defaultBranchProtection) } func testAccGitlabNestedGroupConfig(rInt int) string { diff --git a/internal/provider/resource_gitlab_project.go b/internal/provider/resource_gitlab_project.go index 3583cb399..67203a2da 100644 --- a/internal/provider/resource_gitlab_project.go +++ b/internal/provider/resource_gitlab_project.go @@ -369,6 +369,12 @@ var resourceGitLabProjectSchema = map[string]*schema.Schema{ Type: schema.TypeBool, Optional: true, }, + "ci_forward_deployment_enabled": { + Description: "When a new deployment job starts, skip older deployment jobs that are still pending.", + Type: schema.TypeBool, + Optional: true, + Default: true, + }, } var _ = registerResource("gitlab_project", func() *schema.Resource { @@ -430,6 +436,7 @@ func resourceGitlabProjectSetToState(d *schema.ResourceData, project *gitlab.Pro d.Set("issues_template", project.IssuesTemplate) d.Set("merge_requests_template", project.MergeRequestsTemplate) d.Set("ci_config_path", project.CIConfigPath) + d.Set("ci_forward_deployment_enabled", project.CIForwardDeploymentEnabled) return nil } @@ -461,6 +468,7 @@ func resourceGitlabProjectCreate(ctx context.Context, d *schema.ResourceData, me MirrorTriggerBuilds: gitlab.Bool(d.Get("mirror_trigger_builds").(bool)), BuildCoverageRegex: gitlab.String(d.Get("build_coverage_regex").(string)), CIConfigPath: gitlab.String(d.Get("ci_config_path").(string)), + CIForwardDeploymentEnabled: gitlab.Bool(d.Get("ci_forward_deployment_enabled").(bool)), } if v, ok := d.GetOk("path"); ok { @@ -607,10 +615,19 @@ func resourceGitlabProjectCreate(ctx context.Context, d *schema.ResourceData, me return diag.Errorf("Failed to protect default branch %q for project %q: %s", newDefaultBranch, d.Id(), err) } - log.Printf("[DEBUG] unprotect old default branch %q for project %q", oldDefaultBranch, d.Id()) - _, err = client.ProtectedBranches.UnprotectRepositoryBranches(project.ID, oldDefaultBranch, gitlab.WithContext(ctx)) - if err != nil { - return diag.Errorf("Failed to unprotect undesired default branch %q for project %q: %s", oldDefaultBranch, d.Id(), err) + log.Printf("[DEBUG] check for protection on old default branch %q for project %q", oldDefaultBranch, d.Id()) + branch, _, err := client.ProtectedBranches.GetProtectedBranch(project.ID, oldDefaultBranch, gitlab.WithContext(ctx)) + if err != nil && !is404(err) { + return diag.Errorf("Failed to check for protected default branch %q for project %q: %v", oldDefaultBranch, d.Id(), err) + } + if branch == nil { + log.Printf("[DEBUG] Default protected branch %q for project %q does not exist", oldDefaultBranch, d.Id()) + } else { + log.Printf("[DEBUG] unprotect old default branch %q for project %q", oldDefaultBranch, d.Id()) + _, err = client.ProtectedBranches.UnprotectRepositoryBranches(project.ID, oldDefaultBranch, gitlab.WithContext(ctx)) + if err != nil { + return diag.Errorf("Failed to unprotect undesired default branch %q for project %q: %v", oldDefaultBranch, d.Id(), err) + } } log.Printf("[DEBUG] delete old default branch %q for project %q", oldDefaultBranch, d.Id()) @@ -829,6 +846,10 @@ func resourceGitlabProjectUpdate(ctx context.Context, d *schema.ResourceData, me options.CIConfigPath = gitlab.String(d.Get("ci_config_path").(string)) } + if d.HasChange("ci_forward_deployment_enabled") { + options.CIForwardDeploymentEnabled = gitlab.Bool(d.Get("ci_forward_deployment_enabled").(bool)) + } + if *options != (gitlab.EditProjectOptions{}) { log.Printf("[DEBUG] update gitlab project %s", d.Id()) _, _, err := client.Projects.EditProject(d.Id(), options, gitlab.WithContext(ctx)) diff --git a/internal/provider/resource_gitlab_project_test.go b/internal/provider/resource_gitlab_project_test.go index 50068df34..d9661a892 100644 --- a/internal/provider/resource_gitlab_project_test.go +++ b/internal/provider/resource_gitlab_project_test.go @@ -44,16 +44,17 @@ func TestAccGitlabProject_basic(t *testing.T) { MergeMethod: gitlab.FastForwardMerge, OnlyAllowMergeIfPipelineSucceeds: true, OnlyAllowMergeIfAllDiscussionsAreResolved: true, - SquashOption: gitlab.SquashOptionDefaultOff, - AllowMergeOnSkippedPipeline: false, - Archived: false, // needless, but let's make this explicit - PackagesEnabled: true, - PagesAccessLevel: gitlab.PublicAccessControl, - PrintingMergeRequestLinkEnabled: true, - BuildCoverageRegex: "foo", - IssuesTemplate: "", - MergeRequestsTemplate: "", - CIConfigPath: ".gitlab-ci.yml@mynamespace/myproject", + SquashOption: gitlab.SquashOptionDefaultOff, + AllowMergeOnSkippedPipeline: false, + Archived: false, // needless, but let's make this explicit + PackagesEnabled: true, + PrintingMergeRequestLinkEnabled: true, + PagesAccessLevel: gitlab.PublicAccessControl, + BuildCoverageRegex: "foo", + IssuesTemplate: "", + MergeRequestsTemplate: "", + CIConfigPath: ".gitlab-ci.yml@mynamespace/myproject", + CIForwardDeploymentEnabled: true, } defaultsMainBranch = defaults @@ -100,6 +101,7 @@ func TestAccGitlabProject_basic(t *testing.T) { PackagesEnabled: false, PagesAccessLevel: gitlab.DisabledAccessControl, BuildCoverageRegex: "bar", + CIForwardDeploymentEnabled: false, }, &received), ), }, @@ -413,6 +415,23 @@ member_check = false }) } +func TestAccGitlabProject_groupWithoutDefaultBranchProtection(t *testing.T) { + var project gitlab.Project + rInt := acctest.RandInt() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: providerFactories, + CheckDestroy: testAccCheckGitlabProjectDestroy, + Steps: []resource.TestStep{ + { + Config: testAccGitlabProjectConfigWithoutDefaultBranchProtection(rInt), + Check: testAccCheckGitlabProjectExists("gitlab_project.foo", &project), + }, + }, + }) +} + func TestAccGitlabProject_IssueMergeRequestTemplates(t *testing.T) { var project gitlab.Project rInt := acctest.RandInt() @@ -468,11 +487,12 @@ func TestAccGitlabProject_willError(t *testing.T) { PrintingMergeRequestLinkEnabled: true, OnlyAllowMergeIfPipelineSucceeds: true, OnlyAllowMergeIfAllDiscussionsAreResolved: true, - SquashOption: gitlab.SquashOptionDefaultOff, - PackagesEnabled: true, - PagesAccessLevel: gitlab.PublicAccessControl, - BuildCoverageRegex: "foo", - CIConfigPath: ".gitlab-ci.yml@mynamespace/myproject", + SquashOption: gitlab.SquashOptionDefaultOff, + PackagesEnabled: true, + PagesAccessLevel: gitlab.PublicAccessControl, + BuildCoverageRegex: "foo", + CIConfigPath: ".gitlab-ci.yml@mynamespace/myproject", + CIForwardDeploymentEnabled: true, } willError := defaults willError.TagList = []string{"notatag"} @@ -572,11 +592,12 @@ func TestAccGitlabProject_transfer(t *testing.T) { MergeMethod: gitlab.NoFastForwardMerge, OnlyAllowMergeIfPipelineSucceeds: false, OnlyAllowMergeIfAllDiscussionsAreResolved: false, - SquashOption: gitlab.SquashOptionDefaultOff, - PackagesEnabled: true, - PrintingMergeRequestLinkEnabled: true, - PagesAccessLevel: gitlab.PrivateAccessControl, - BuildCoverageRegex: "foo", + SquashOption: gitlab.SquashOptionDefaultOff, + PackagesEnabled: true, + PrintingMergeRequestLinkEnabled: true, + PagesAccessLevel: gitlab.PrivateAccessControl, + BuildCoverageRegex: "foo", + CIForwardDeploymentEnabled: true, } resource.Test(t, resource.TestCase{ @@ -1079,6 +1100,23 @@ resource "gitlab_project" "foo" { `, rInt, rInt, rInt) } +func testAccGitlabProjectConfigWithoutDefaultBranchProtection(rInt int) string { + return fmt.Sprintf(` +resource "gitlab_group" "foo" { + name = "foogroup-%d" + path = "foogroup-%d" + default_branch_protection = 0 + visibility_level = "public" +} + +resource "gitlab_project" "foo" { + name = "foo-%d" + description = "Terraform acceptance tests" + namespace_id = "${gitlab_group.foo.id}" +} + `, rInt, rInt, rInt) +} + func testAccGitlabProjectTransferBetweenGroups(rInt int) string { return fmt.Sprintf(` resource "gitlab_group" "foo" { @@ -1204,6 +1242,7 @@ resource "gitlab_project" "foo" { packages_enabled = false pages_access_level = "disabled" build_coverage_regex = "bar" + ci_forward_deployment_enabled = false } `, rInt, rInt) } diff --git a/internal/provider/resource_gitlab_project_variable.go b/internal/provider/resource_gitlab_project_variable.go index bcaab7d53..1096cdf62 100644 --- a/internal/provider/resource_gitlab_project_variable.go +++ b/internal/provider/resource_gitlab_project_variable.go @@ -203,7 +203,7 @@ func resourceGitlabProjectVariableDelete(ctx context.Context, d *schema.Resource // but it will be ignored in prior versions, causing nondeterministic destroy behavior when // destroying or updating scoped variables. // ref: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39209 - _, err := client.ProjectVariables.RemoveVariable(project, key, withEnvironmentScopeFilter(ctx, environmentScope)) + _, err := client.ProjectVariables.RemoveVariable(project, key, nil, withEnvironmentScopeFilter(ctx, environmentScope)) return augmentProjectVariableClientError(d, err) } diff --git a/internal/provider/resource_gitlab_project_variable_test.go b/internal/provider/resource_gitlab_project_variable_test.go index eff6dd854..6cb98eec9 100644 --- a/internal/provider/resource_gitlab_project_variable_test.go +++ b/internal/provider/resource_gitlab_project_variable_test.go @@ -106,7 +106,7 @@ resource "gitlab_project_variable" "foo" { // Check that the variable is recreated if deleted out-of-band. { PreConfig: func() { - if _, err := testGitlabClient.ProjectVariables.RemoveVariable(ctx.project.ID, "my_key"); err != nil { + if _, err := testGitlabClient.ProjectVariables.RemoveVariable(ctx.project.ID, "my_key", nil); err != nil { t.Fatalf("failed to remove variable: %v", err) } }, diff --git a/internal/provider/resource_gitlab_user.go b/internal/provider/resource_gitlab_user.go index 3b15376dc..3688b5e88 100644 --- a/internal/provider/resource_gitlab_user.go +++ b/internal/provider/resource_gitlab_user.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" gitlab "github.com/xanzy/go-gitlab" ) @@ -92,6 +93,13 @@ var _ = registerResource("gitlab_user", func() *schema.Resource { Type: schema.TypeString, Optional: true, }, + "state": { + Description: "String, defaults to 'active'. The state of the user account. Valid values are either 'active' or 'blocked'", + Type: schema.TypeString, + Optional: true, + Default: "active", + ValidateDiagFunc: validation.ToDiagFunc(validation.StringInSlice([]string{"active", "blocked"}, false)), + }, }, } }) @@ -105,6 +113,7 @@ func resourceGitlabUserSetToState(d *schema.ResourceData, user *gitlab.User) { d.Set("is_admin", user.IsAdmin) d.Set("is_external", user.External) d.Set("note", user.Note) + d.Set("state", user.State) } func resourceGitlabUserCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { @@ -136,6 +145,14 @@ func resourceGitlabUserCreate(ctx context.Context, d *schema.ResourceData, meta d.SetId(fmt.Sprintf("%d", user.ID)) + if d.Get("state") == "blocked" { + err := client.Users.BlockUser(user.ID) + + if err != nil { + return diag.FromErr(err) + } + } + return resourceGitlabUserRead(ctx, d, meta) } @@ -206,6 +223,22 @@ func resourceGitlabUserUpdate(ctx context.Context, d *schema.ResourceData, meta return diag.FromErr(err) } + if d.HasChange("state") { + if d.Get("state") == "active" { + err := client.Users.UnblockUser(id) + + if err != nil { + return diag.FromErr(err) + } + } else if d.Get("state") == "blocked" { + err := client.Users.BlockUser(id) + + if err != nil { + return diag.FromErr(err) + } + } + } + return resourceGitlabUserRead(ctx, d, meta) } diff --git a/internal/provider/resource_gitlab_user_test.go b/internal/provider/resource_gitlab_user_test.go index 68e8716ac..0d210a7ba 100644 --- a/internal/provider/resource_gitlab_user_test.go +++ b/internal/provider/resource_gitlab_user_test.go @@ -34,6 +34,33 @@ func TestAccGitlabUser_basic(t *testing.T) { Admin: false, CanCreateGroup: false, External: false, + State: "active", + }), + ), + }, + { + ResourceName: "gitlab_user.foo", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "password", + "skip_confirmation", + }, + }, + // Create a user with blocked state + { + Config: testAccGitlabUserConfigBlocked(rInt), + Check: resource.ComposeTestCheckFunc( + testAccCheckGitlabUserExists("gitlab_user.foo", &user), + testAccCheckGitlabUserAttributes(&user, &testAccGitlabUserExpectedAttributes{ + Email: fmt.Sprintf("listest%d@ssss.com", rInt), + Username: fmt.Sprintf("listest%d", rInt), + Name: fmt.Sprintf("foo %d", rInt), + ProjectsLimit: 0, + Admin: false, + CanCreateGroup: false, + External: false, + State: "blocked", }), ), }, @@ -60,6 +87,34 @@ func TestAccGitlabUser_basic(t *testing.T) { CanCreateGroup: true, External: false, Note: fmt.Sprintf("note%d", rInt), + State: "active", + }), + ), + }, + { + ResourceName: "gitlab_user.foo", + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "password", + "skip_confirmation", + }, + }, + // Update the user to change the state to blocked + { + Config: testAccGitlabUserUpdateConfigBlocked(rInt), + Check: resource.ComposeTestCheckFunc( + testAccCheckGitlabUserExists("gitlab_user.foo", &user), + testAccCheckGitlabUserAttributes(&user, &testAccGitlabUserExpectedAttributes{ + Email: fmt.Sprintf("listest%d@tttt.com", rInt), + Username: fmt.Sprintf("listest%d", rInt), + Name: fmt.Sprintf("bar %d", rInt), + ProjectsLimit: 10, + Admin: true, + CanCreateGroup: true, + External: false, + Note: fmt.Sprintf("note%d", rInt), + State: "blocked", }), ), }, @@ -85,6 +140,7 @@ func TestAccGitlabUser_basic(t *testing.T) { Admin: false, CanCreateGroup: false, External: false, + State: "active", }), ), }, @@ -110,6 +166,7 @@ func TestAccGitlabUser_basic(t *testing.T) { Admin: false, CanCreateGroup: false, External: false, + State: "active", }), ), }, @@ -135,6 +192,7 @@ func TestAccGitlabUser_basic(t *testing.T) { Admin: false, CanCreateGroup: false, External: false, + State: "active", }), ), }, @@ -215,6 +273,7 @@ type testAccGitlabUserExpectedAttributes struct { CanCreateGroup bool External bool Note string + State string } func testAccCheckGitlabUserAttributes(user *gitlab.User, want *testAccGitlabUserExpectedAttributes) resource.TestCheckFunc { @@ -251,6 +310,10 @@ func testAccCheckGitlabUserAttributes(user *gitlab.User, want *testAccGitlabUser return fmt.Errorf("got projects_limit %d; want %d", user.ProjectsLimit, want.ProjectsLimit) } + if user.State != want.State { + return fmt.Errorf("got state %q; want %q", user.State, want.State) + } + return nil } } @@ -292,6 +355,22 @@ resource "gitlab_user" "foo" { `, rInt, rInt, rInt, rInt) } +func testAccGitlabUserConfigBlocked(rInt int) string { + return fmt.Sprintf(` +resource "gitlab_user" "foo" { + name = "foo %d" + username = "listest%d" + password = "test%dtt" + email = "listest%d@ssss.com" + is_admin = false + projects_limit = 0 + can_create_group = false + is_external = false + state = "blocked" +} + `, rInt, rInt, rInt, rInt) +} + func testAccGitlabUserUpdateConfig(rInt int) string { return fmt.Sprintf(` resource "gitlab_user" "foo" { @@ -308,6 +387,23 @@ resource "gitlab_user" "foo" { `, rInt, rInt, rInt, rInt, rInt) } +func testAccGitlabUserUpdateConfigBlocked(rInt int) string { + return fmt.Sprintf(` +resource "gitlab_user" "foo" { + name = "bar %d" + username = "listest%d" + password = "test%dtt" + email = "listest%d@tttt.com" + is_admin = true + projects_limit = 10 + can_create_group = true + is_external = false + note = "note%d" + state = "blocked" +} + `, rInt, rInt, rInt, rInt, rInt) +} + func testAccGitlabUserUpdateConfigNoSkipConfirmation(rInt int) string { return fmt.Sprintf(` resource "gitlab_user" "foo" { diff --git a/internal/provider/testdata/resource_gitlab_group_ldap_link.json b/internal/provider/testdata/resource_gitlab_group_ldap_link.json deleted file mode 100644 index 8f7d69b9c..000000000 --- a/internal/provider/testdata/resource_gitlab_group_ldap_link.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "cn": "default", - "provider": "default" -} \ No newline at end of file