Skip to content

Commit e8f4d47

Browse files
authored
Merge pull request #964 from timofurrer/feature/repository-file-parallelism-940
resource/gitlab_repository_file: Mitigate parallelism limitation
2 parents adac2bc + e189068 commit e8f4d47

File tree

4 files changed

+214
-27
lines changed

4 files changed

+214
-27
lines changed

docs/resources/repository_file.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,24 @@ page_title: "gitlab_repository_file Resource - terraform-provider-gitlab"
44
subcategory: ""
55
description: |-
66
The gitlab_repository_file resource allows to manage the lifecycle of a file within a repository.
7-
~> Limitations: The GitLab Repository Files API https://docs.gitlab.com/ee/api/repository_files.html can only create, update or delete a single file at the time. The API will also fail with a 400 https://docs.gitlab.com/ee/api/repository_files.html#update-existing-file-in-repository response status code if the underlying repository is changed while the API tries to make changes. Therefore, it's recommended to make sure that you execute it with -parallelism=1 https://www.terraform.io/docs/cli/commands/apply.html#parallelism-n and that no other entity than the terraform at hand makes changes to the underlying repository while it's executing.
7+
-> Timeouts Default timeout for Create, Update and Delete is one minute and can be configured in the timeouts block.
8+
-> Implementation Detail GitLab is unable to handle concurrent calls to the GitLab repository files API for the same project.
9+
Therefore, this resource queues every call to the repository files API no matter of the project, which may slow down the terraform
10+
execution time for some configurations. In addition, retries are performed in case a refresh is required because another application
11+
changed the repository at the same time.
812
Upstream API: GitLab REST API docs https://docs.gitlab.com/ee/api/repository_files.html
913
---
1014

1115
# gitlab_repository_file (Resource)
1216

1317
The `gitlab_repository_file` resource allows to manage the lifecycle of a file within a repository.
1418

15-
~> **Limitations**: The [GitLab Repository Files API](https://docs.gitlab.com/ee/api/repository_files.html) can only create, update or delete a single file at the time. The API will also [fail with a 400](https://docs.gitlab.com/ee/api/repository_files.html#update-existing-file-in-repository) response status code if the underlying repository is changed while the API tries to make changes. Therefore, it's recommended to make sure that you execute it with [-parallelism=1](https://www.terraform.io/docs/cli/commands/apply.html#parallelism-n) and that no other entity than the terraform at hand makes changes to the underlying repository while it's executing.
19+
-> **Timeouts** Default timeout for *Create*, *Update* and *Delete* is one minute and can be configured in the `timeouts` block.
20+
21+
-> **Implementation Detail** GitLab is unable to handle concurrent calls to the GitLab repository files API for the same project.
22+
Therefore, this resource queues every call to the repository files API no matter of the project, which may slow down the terraform
23+
execution time for some configurations. In addition, retries are performed in case a refresh is required because another application
24+
changed the repository at the same time.
1625

1726
**Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/ee/api/repository_files.html)
1827

@@ -57,6 +66,7 @@ resource "gitlab_repository_file" "this" {
5766
- `author_name` (String) Name of the commit author.
5867
- `id` (String) The ID of this resource.
5968
- `start_branch` (String) Name of the branch to start the new commit from.
69+
- `timeouts` (Block, Optional) (see [below for nested schema](#nestedblock--timeouts))
6070

6171
### Read-Only
6272

@@ -69,6 +79,15 @@ resource "gitlab_repository_file" "this" {
6979
- `ref` (String) The name of branch, tag or commit.
7080
- `size` (Number) The file size.
7181

82+
<a id="nestedblock--timeouts"></a>
83+
### Nested Schema for `timeouts`
84+
85+
Optional:
86+
87+
- `create` (String)
88+
- `delete` (String)
89+
- `update` (String)
90+
7291
## Import
7392

7493
Import is supported using the following syntax:

internal/provider/resource_gitlab_repository_file.go

Lines changed: 114 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,44 @@ package provider
33
import (
44
"context"
55
"encoding/base64"
6+
"errors"
67
"fmt"
78
"log"
9+
"net/http"
810
"strings"
11+
"time"
912

1013
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
14+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
1115
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
1216
gitlab "github.com/xanzy/go-gitlab"
1317
)
1418

1519
const encoding = "base64"
1620

21+
// NOTE: this lock is a bit of a hack to prevent parallel calls to the GitLab Repository Files API.
22+
// If it is called concurrently, the API will return a 400 error along the lines of:
23+
// ```
24+
// (400 Bad Request) DELETE https://gitlab.com/api/v4/projects/30716/repository/files/somefile.yaml: 400
25+
// {message: 9:Could not update refs/heads/master. Please refresh and try again..}
26+
// ```
27+
//
28+
// This lock only solves half of the problem, where the provider is responsible for
29+
// the concurrency. The other half is if the API is called outside of terraform at the same time
30+
// this resource makes calls to the API.
31+
// To mitigate this, simple retries are used.
32+
var resourceGitlabRepositoryFileApiLock = newLock()
33+
1734
var _ = registerResource("gitlab_repository_file", func() *schema.Resource {
1835
return &schema.Resource{
1936
Description: `The ` + "`gitlab_repository_file`" + ` resource allows to manage the lifecycle of a file within a repository.
2037
21-
~> **Limitations**: The [GitLab Repository Files API](https://docs.gitlab.com/ee/api/repository_files.html) can only create, update or delete a single file at the time. The API will also [fail with a 400](https://docs.gitlab.com/ee/api/repository_files.html#update-existing-file-in-repository) response status code if the underlying repository is changed while the API tries to make changes. Therefore, it's recommended to make sure that you execute it with [-parallelism=1](https://www.terraform.io/docs/cli/commands/apply.html#parallelism-n) and that no other entity than the terraform at hand makes changes to the underlying repository while it's executing.
38+
-> **Timeouts** Default timeout for *Create*, *Update* and *Delete* is one minute and can be configured in the ` + "`timeouts`" + ` block.
39+
40+
-> **Implementation Detail** GitLab is unable to handle concurrent calls to the GitLab repository files API for the same project.
41+
Therefore, this resource queues every call to the repository files API no matter of the project, which may slow down the terraform
42+
execution time for some configurations. In addition, retries are performed in case a refresh is required because another application
43+
changed the repository at the same time.
2244
2345
**Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/ee/api/repository_files.html)`,
2446

@@ -29,6 +51,11 @@ var _ = registerResource("gitlab_repository_file", func() *schema.Resource {
2951
Importer: &schema.ResourceImporter{
3052
StateContext: schema.ImportStatePassthroughContext,
3153
},
54+
Timeouts: &schema.ResourceTimeout{
55+
Create: schema.DefaultTimeout(1 * time.Minute),
56+
Update: schema.DefaultTimeout(1 * time.Minute),
57+
Delete: schema.DefaultTimeout(1 * time.Minute),
58+
},
3259

3360
// the schema matches https://docs.gitlab.com/ee/api/repository_files.html#create-new-file-in-repository
3461
// However, we don't support the `encoding` parameter as it seems to be broken.
@@ -69,10 +96,18 @@ var _ = registerResource("gitlab_repository_file", func() *schema.Resource {
6996
})
7097

7198
func resourceGitlabRepositoryFileCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
72-
client := meta.(*gitlab.Client)
7399
project := d.Get("project").(string)
74100
filePath := d.Get("file_path").(string)
75101

102+
log.Printf("[DEBUG] gitlab_repository_file: waiting for lock to create %s/%s", project, filePath)
103+
if err := resourceGitlabRepositoryFileApiLock.lock(ctx); err != nil {
104+
return diag.FromErr(err)
105+
}
106+
defer resourceGitlabRepositoryFileApiLock.unlock()
107+
log.Printf("[DEBUG] gitlab_repository_file: got lock to create %s/%s", project, filePath)
108+
109+
client := meta.(*gitlab.Client)
110+
76111
options := &gitlab.CreateFileOptions{
77112
Branch: gitlab.String(d.Get("branch").(string)),
78113
Encoding: gitlab.String(encoding),
@@ -85,12 +120,22 @@ func resourceGitlabRepositoryFileCreate(ctx context.Context, d *schema.ResourceD
85120
options.StartBranch = gitlab.String(startBranch.(string))
86121
}
87122

88-
repositoryFile, _, err := client.RepositoryFiles.CreateFile(project, filePath, options, gitlab.WithContext(ctx))
123+
err := resource.RetryContext(ctx, d.Timeout(schema.TimeoutCreate), func() *resource.RetryError {
124+
repositoryFile, _, err := client.RepositoryFiles.CreateFile(project, filePath, options, gitlab.WithContext(ctx))
125+
if err != nil {
126+
if isRefreshError(err) {
127+
return resource.RetryableError(err)
128+
}
129+
return resource.NonRetryableError(err)
130+
}
131+
132+
d.SetId(resourceGitLabRepositoryFileBuildId(project, repositoryFile.Branch, repositoryFile.FilePath))
133+
return nil
134+
})
89135
if err != nil {
90136
return diag.FromErr(err)
91137
}
92138

93-
d.SetId(resourceGitLabRepositoryFileBuildId(project, repositoryFile.Branch, repositoryFile.FilePath))
94139
return resourceGitlabRepositoryFileRead(ctx, d, meta)
95140
}
96141

@@ -126,35 +171,54 @@ func resourceGitlabRepositoryFileRead(ctx context.Context, d *schema.ResourceDat
126171
}
127172

128173
func resourceGitlabRepositoryFileUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
129-
client := meta.(*gitlab.Client)
130174
project, branch, filePath, err := resourceGitLabRepositoryFileParseId(d.Id())
131175
if err != nil {
132176
return diag.FromErr(err)
133177
}
134178

135-
readOptions := &gitlab.GetFileOptions{
136-
Ref: gitlab.String(branch),
179+
log.Printf("[DEBUG] gitlab_repository_file: waiting for lock to update %s/%s", project, filePath)
180+
if err := resourceGitlabRepositoryFileApiLock.lock(ctx); err != nil {
181+
return diag.FromErr(err)
137182
}
183+
defer resourceGitlabRepositoryFileApiLock.unlock()
184+
log.Printf("[DEBUG] gitlab_repository_file: got lock to update %s/%s", project, filePath)
138185

139-
existingRepositoryFile, _, err := client.RepositoryFiles.GetFile(project, filePath, readOptions, gitlab.WithContext(ctx))
140-
if err != nil {
141-
return diag.FromErr(err)
186+
client := meta.(*gitlab.Client)
187+
188+
readOptions := &gitlab.GetFileOptions{
189+
Ref: gitlab.String(branch),
142190
}
143191

144-
options := &gitlab.UpdateFileOptions{
192+
updateOptions := &gitlab.UpdateFileOptions{
145193
Branch: gitlab.String(branch),
146194
Encoding: gitlab.String(encoding),
147195
AuthorEmail: gitlab.String(d.Get("author_email").(string)),
148196
AuthorName: gitlab.String(d.Get("author_name").(string)),
149197
Content: gitlab.String(d.Get("content").(string)),
150198
CommitMessage: gitlab.String(d.Get("commit_message").(string)),
151-
LastCommitID: gitlab.String(existingRepositoryFile.LastCommitID),
152199
}
153200
if startBranch, ok := d.GetOk("start_branch"); ok {
154-
options.StartBranch = gitlab.String(startBranch.(string))
201+
updateOptions.StartBranch = gitlab.String(startBranch.(string))
155202
}
156203

157-
_, _, err = client.RepositoryFiles.UpdateFile(project, filePath, options, gitlab.WithContext(ctx))
204+
err = resource.RetryContext(ctx, d.Timeout(schema.TimeoutUpdate), func() *resource.RetryError {
205+
// NOTE: we also re-read the file to obtain an eventually changed `LastCommitID` for which we needed the refresh
206+
existingRepositoryFile, _, err := client.RepositoryFiles.GetFile(project, filePath, readOptions, gitlab.WithContext(ctx))
207+
if err != nil {
208+
return resource.NonRetryableError(err)
209+
}
210+
211+
updateOptions.LastCommitID = gitlab.String(existingRepositoryFile.LastCommitID)
212+
_, _, err = client.RepositoryFiles.UpdateFile(project, filePath, updateOptions, gitlab.WithContext(ctx))
213+
if err != nil {
214+
if isRefreshError(err) {
215+
return resource.RetryableError(err)
216+
}
217+
return resource.NonRetryableError(err)
218+
}
219+
220+
return nil
221+
})
158222
if err != nil {
159223
return diag.FromErr(err)
160224
}
@@ -163,32 +227,50 @@ func resourceGitlabRepositoryFileUpdate(ctx context.Context, d *schema.ResourceD
163227
}
164228

165229
func resourceGitlabRepositoryFileDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
166-
client := meta.(*gitlab.Client)
167230
project, branch, filePath, err := resourceGitLabRepositoryFileParseId(d.Id())
168231
if err != nil {
169232
return diag.FromErr(err)
170233
}
171234

172-
readOptions := &gitlab.GetFileOptions{
173-
Ref: gitlab.String(branch),
174-
}
175-
176-
existingRepositoryFile, _, err := client.RepositoryFiles.GetFile(project, filePath, readOptions, gitlab.WithContext(ctx))
177-
if err != nil {
235+
log.Printf("[DEBUG] gitlab_repository_file: waiting for lock to delete %s/%s", project, filePath)
236+
if err := resourceGitlabRepositoryFileApiLock.lock(ctx); err != nil {
178237
return diag.FromErr(err)
179238
}
239+
defer resourceGitlabRepositoryFileApiLock.unlock()
240+
log.Printf("[DEBUG] gitlab_repository_file: got lock to delete %s/%s", project, filePath)
241+
242+
client := meta.(*gitlab.Client)
180243

181-
options := &gitlab.DeleteFileOptions{
244+
readOptions := &gitlab.GetFileOptions{
245+
Ref: gitlab.String(branch),
246+
}
247+
deleteOptions := &gitlab.DeleteFileOptions{
182248
Branch: gitlab.String(d.Get("branch").(string)),
183249
AuthorEmail: gitlab.String(d.Get("author_email").(string)),
184250
AuthorName: gitlab.String(d.Get("author_name").(string)),
185251
CommitMessage: gitlab.String(fmt.Sprintf("[DELETE]: %s", d.Get("commit_message").(string))),
186-
LastCommitID: gitlab.String(existingRepositoryFile.LastCommitID),
187252
}
188253

189-
resp, err := client.RepositoryFiles.DeleteFile(project, filePath, options)
254+
err = resource.RetryContext(ctx, d.Timeout(schema.TimeoutDelete), func() *resource.RetryError {
255+
// NOTE: we also re-read the file to obtain an eventually changed `LastCommitID` for which we needed the refresh
256+
257+
existingRepositoryFile, _, err := client.RepositoryFiles.GetFile(project, filePath, readOptions, gitlab.WithContext(ctx))
258+
if err != nil {
259+
return resource.NonRetryableError(err)
260+
}
261+
262+
deleteOptions.LastCommitID = gitlab.String(existingRepositoryFile.LastCommitID)
263+
resp, err := client.RepositoryFiles.DeleteFile(project, filePath, deleteOptions)
264+
if err != nil {
265+
if isRefreshError(err) {
266+
return resource.RetryableError(err)
267+
}
268+
return resource.NonRetryableError(fmt.Errorf("%s failed to delete repository file: (%s) %v", d.Id(), resp.Status, err))
269+
}
270+
return nil
271+
})
190272
if err != nil {
191-
return diag.Errorf("%s failed to delete repository file: (%s) %v", d.Id(), resp.Status, err)
273+
return diag.FromErr(err)
192274
}
193275

194276
return nil
@@ -214,3 +296,10 @@ func resourceGitLabRepositoryFileParseId(id string) (string, string, string, err
214296
func resourceGitLabRepositoryFileBuildId(project string, branch string, filePath string) string {
215297
return fmt.Sprintf("%s:%s:%s", project, branch, filePath)
216298
}
299+
300+
func isRefreshError(err error) bool {
301+
var httpErr *gitlab.ErrorResponse
302+
return errors.As(err, &httpErr) &&
303+
httpErr.Response.StatusCode == http.StatusBadRequest &&
304+
strings.Contains(httpErr.Message, "Please refresh and try again")
305+
}

internal/provider/resource_gitlab_repository_file_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,32 @@ func TestAccGitlabRepositoryFile_createSameFileDifferentRepository(t *testing.T)
6262
})
6363
}
6464

65+
func TestAccGitlabRepositoryFile_concurrentResources(t *testing.T) {
66+
testAccCheck(t)
67+
68+
testProject := testAccCreateProject(t)
69+
70+
resource.Test(t, resource.TestCase{
71+
PreCheck: func() { testAccPreCheck(t) },
72+
ProviderFactories: providerFactories,
73+
CheckDestroy: testAccCheckGitlabRepositoryFileDestroy,
74+
Steps: []resource.TestStep{
75+
// NOTE: we don't need to check anything here, just make sure no terraform errors are being raised,
76+
// the other test cases will do the actual testing :)
77+
{
78+
Config: testAccGitlabRepositoryFileConcurrentResourcesConfig(testProject.ID),
79+
},
80+
{
81+
Config: testAccGitlabRepositoryFileConcurrentResourcesConfigUpdate(testProject.ID),
82+
},
83+
{
84+
Config: testAccGitlabRepositoryFileConcurrentResourcesConfigUpdate(testProject.ID),
85+
Destroy: true,
86+
},
87+
},
88+
})
89+
}
90+
6591
func TestAccGitlabRepositoryFile_validationOfBase64Content(t *testing.T) {
6692
cases := []struct {
6793
givenContent string
@@ -366,3 +392,31 @@ resource "gitlab_repository_file" "bar_file" {
366392
}
367393
`, rInt, rInt)
368394
}
395+
396+
func testAccGitlabRepositoryFileConcurrentResourcesConfig(projectID int) string {
397+
return fmt.Sprintf(`
398+
resource "gitlab_repository_file" "this" {
399+
project = "%d"
400+
file_path = "file-${count.index}.txt"
401+
branch = "main"
402+
content = base64encode("content-${count.index}")
403+
commit_message = "Add file ${count.index}"
404+
405+
count = 50
406+
}
407+
`, projectID)
408+
}
409+
410+
func testAccGitlabRepositoryFileConcurrentResourcesConfigUpdate(projectID int) string {
411+
return fmt.Sprintf(`
412+
resource "gitlab_repository_file" "this" {
413+
project = "%d"
414+
file_path = "file-${count.index}.txt"
415+
branch = "main"
416+
content = base64encode("updated-content-${count.index}")
417+
commit_message = "Add file ${count.index}"
418+
419+
count = 50
420+
}
421+
`, projectID)
422+
}

internal/provider/util.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package provider
22

33
import (
4+
"context"
45
"fmt"
56
"net/url"
67
"regexp"
@@ -400,3 +401,27 @@ func setStateMapInResourceData(stateMap map[string]interface{}, d *schema.Resour
400401

401402
return nil
402403
}
404+
405+
// lock can be used to lock, but make it `context.Context` aware.
406+
// e.g. it'll respect cancelling and timeouts.
407+
type lock chan struct{}
408+
409+
func newLock() lock {
410+
return make(lock, 1)
411+
412+
}
413+
414+
func (c lock) lock(ctx context.Context) error {
415+
select {
416+
case c <- struct{}{}:
417+
// lock acquired
418+
return nil
419+
case <-ctx.Done():
420+
// Timeout
421+
return ctx.Err()
422+
}
423+
}
424+
425+
func (c lock) unlock() {
426+
<-c
427+
}

0 commit comments

Comments
 (0)