Skip to content

Commit 0519c96

Browse files
authored
Merge pull request #724 from timofurrer/feature/gitlab_repository_file
feature: add gitlab_repository_file resource. Closes #720, #688, #350
2 parents 500361c + d43bf0f commit 0519c96

File tree

4 files changed

+670
-0
lines changed

4 files changed

+670
-0
lines changed

docs/resources/repository_file.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# gitlab\_repository\_file
2+
3+
This resource allows you to create and manage GitLab repository files.
4+
5+
**Limitations**:
6+
7+
The [GitLab Repository Files API](https://docs.gitlab.com/ee/api/repository_files.html)
8+
can only create, update or delete a single file at the time.
9+
The API will also
10+
[fail with a `400`](https://docs.gitlab.com/ee/api/repository_files.html#update-existing-file-in-repository)
11+
response status code if the underlying repository is changed while the API tries to make changes.
12+
Therefore, it's recommended to make sure that you execute it with
13+
[`-parallelism=1`](https://www.terraform.io/docs/cli/commands/apply.html#parallelism-n)
14+
and that no other entity than the terraform at hand makes changes to the
15+
underlying repository while it's executing.
16+
17+
## Example Usage
18+
19+
```hcl
20+
resource "gitlab_group" "this" {
21+
name = "example"
22+
path = "example"
23+
description = "An example group"
24+
}
25+
resource "gitlab_project" "this" {
26+
name = "example"
27+
namespace_id = gitlab_group.this.id
28+
initialize_with_readme = true
29+
}
30+
resource "gitlab_repository_file" "this" {
31+
project = gitlab_project.this.id
32+
file_path = "meow.txt"
33+
branch = "main"
34+
content = base64encode("Meow goes the cat")
35+
author_email = "[email protected]"
36+
author_name = "Terraform"
37+
commit_message = "feature: add meow file"
38+
}
39+
```
40+
41+
## Argument Reference
42+
43+
The following arguments are supported:
44+
45+
* `project` - (Required) The ID of the project.
46+
47+
* `file_path` - (Required) The full path of the file.
48+
It must be relative to the root of the project without a leading slash `/`.
49+
50+
* `branch` - (Required) Name of the branch to which to commit to.
51+
52+
* `content` - (Required) base64 encoded file content.
53+
No other encoding is currently supported,
54+
because of a [GitLab API bug](https://gitlab.com/gitlab-org/gitlab/-/issues/342430).
55+
56+
* `commit_message` - (Required) Commit message.
57+
58+
* `start_branch` - (Optional) Name of the branch to start the new commit from.
59+
60+
* `author_email` - (Optional) Email of the commit author.
61+
62+
* `author_name` - (Optional) Name of the commit author.
63+
64+
## Attribute Reference
65+
66+
The resource exports the following attributes:
67+
68+
* `id` - The unique ID assigned to the file.
69+
70+
## Import
71+
72+
A Repository File can be imported using the following id pattern, e.g.
73+
74+
```
75+
$ terraform import gitlab_repository_file.this <project-id>:<branch-name>:<file-path>
76+
```

gitlab/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ func Provider() *schema.Provider {
108108
"gitlab_group_share_group": resourceGitlabGroupShareGroup(),
109109
"gitlab_project_badge": resourceGitlabProjectBadge(),
110110
"gitlab_group_badge": resourceGitlabGroupBadge(),
111+
"gitlab_repository_file": resourceGitLabRepositoryFile(),
111112
},
112113
}
113114

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
package gitlab
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"fmt"
7+
"log"
8+
"strings"
9+
10+
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
11+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
12+
gitlab "github.com/xanzy/go-gitlab"
13+
)
14+
15+
const encoding = "base64"
16+
17+
func resourceGitLabRepositoryFile() *schema.Resource {
18+
return &schema.Resource{
19+
CreateContext: resourceGitlabRepositoryFileCreate,
20+
ReadContext: resourceGitlabRepositoryFileRead,
21+
UpdateContext: resourceGitlabRepositoryFileUpdate,
22+
DeleteContext: resourceGitlabRepositoryFileDelete,
23+
Importer: &schema.ResourceImporter{
24+
StateContext: schema.ImportStatePassthroughContext,
25+
},
26+
27+
// the schema matches https://docs.gitlab.com/ee/api/repository_files.html#create-new-file-in-repository
28+
// However, we don't support the `encoding` parameter as it seems to be broken.
29+
// Only a value of `base64` is supported, all others, including the documented default `text`, lead to
30+
// a `400 {error: encoding does not have a valid value}` error.
31+
Schema: map[string]*schema.Schema{
32+
"project": {
33+
Type: schema.TypeString,
34+
Required: true,
35+
ForceNew: true,
36+
},
37+
"file_path": {
38+
Type: schema.TypeString,
39+
Required: true,
40+
ForceNew: true,
41+
},
42+
"branch": {
43+
Type: schema.TypeString,
44+
Required: true,
45+
ForceNew: true,
46+
},
47+
"start_branch": {
48+
Type: schema.TypeString,
49+
Optional: true,
50+
},
51+
"author_email": {
52+
Type: schema.TypeString,
53+
Optional: true,
54+
},
55+
"author_name": {
56+
Type: schema.TypeString,
57+
Optional: true,
58+
},
59+
"content": {
60+
Type: schema.TypeString,
61+
Required: true,
62+
ValidateFunc: validateBase64Content,
63+
},
64+
"commit_message": {
65+
Type: schema.TypeString,
66+
Required: true,
67+
},
68+
"encoding": {
69+
Type: schema.TypeString,
70+
Computed: true,
71+
},
72+
},
73+
}
74+
}
75+
76+
func resourceGitlabRepositoryFileCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
77+
client := meta.(*gitlab.Client)
78+
project := d.Get("project").(string)
79+
filePath := d.Get("file_path").(string)
80+
81+
options := &gitlab.CreateFileOptions{
82+
Branch: gitlab.String(d.Get("branch").(string)),
83+
Encoding: gitlab.String(encoding),
84+
AuthorEmail: gitlab.String(d.Get("author_email").(string)),
85+
AuthorName: gitlab.String(d.Get("author_name").(string)),
86+
Content: gitlab.String(d.Get("content").(string)),
87+
CommitMessage: gitlab.String(d.Get("commit_message").(string)),
88+
}
89+
if startBranch, ok := d.GetOk("start_branch"); ok {
90+
options.StartBranch = gitlab.String(startBranch.(string))
91+
}
92+
93+
repositoryFile, _, err := client.RepositoryFiles.CreateFile(project, filePath, options, gitlab.WithContext(ctx))
94+
if err != nil {
95+
return diag.FromErr(err)
96+
}
97+
98+
d.SetId(resourceGitLabRepositoryFileBuildId(project, repositoryFile.Branch, repositoryFile.FilePath))
99+
return resourceGitlabRepositoryFileRead(ctx, d, meta)
100+
}
101+
102+
func resourceGitlabRepositoryFileRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
103+
client := meta.(*gitlab.Client)
104+
project, branch, filePath, err := resourceGitLabRepositoryFileParseId(d.Id())
105+
if err != nil {
106+
return diag.FromErr(err)
107+
}
108+
109+
options := &gitlab.GetFileOptions{
110+
Ref: gitlab.String(branch),
111+
}
112+
113+
repositoryFile, _, err := client.RepositoryFiles.GetFile(project, filePath, options, gitlab.WithContext(ctx))
114+
if err != nil {
115+
if strings.Contains(err.Error(), "404 File Not Found") {
116+
log.Printf("[WARN] file %s not found, removing from state", filePath)
117+
d.SetId("")
118+
return nil
119+
}
120+
return diag.FromErr(err)
121+
}
122+
123+
d.SetId(resourceGitLabRepositoryFileBuildId(project, branch, repositoryFile.FilePath))
124+
d.Set("project", project)
125+
d.Set("file_path", repositoryFile.FilePath)
126+
d.Set("branch", repositoryFile.Ref)
127+
d.Set("encoding", repositoryFile.Encoding)
128+
d.Set("content", repositoryFile.Content)
129+
130+
return nil
131+
}
132+
133+
func resourceGitlabRepositoryFileUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
134+
client := meta.(*gitlab.Client)
135+
project, branch, filePath, err := resourceGitLabRepositoryFileParseId(d.Id())
136+
if err != nil {
137+
return diag.FromErr(err)
138+
}
139+
140+
readOptions := &gitlab.GetFileOptions{
141+
Ref: gitlab.String(branch),
142+
}
143+
144+
existingRepositoryFile, _, err := client.RepositoryFiles.GetFile(project, filePath, readOptions, gitlab.WithContext(ctx))
145+
if err != nil {
146+
return diag.FromErr(err)
147+
}
148+
149+
options := &gitlab.UpdateFileOptions{
150+
Branch: gitlab.String(branch),
151+
Encoding: gitlab.String(encoding),
152+
AuthorEmail: gitlab.String(d.Get("author_email").(string)),
153+
AuthorName: gitlab.String(d.Get("author_name").(string)),
154+
Content: gitlab.String(d.Get("content").(string)),
155+
CommitMessage: gitlab.String(d.Get("commit_message").(string)),
156+
LastCommitID: gitlab.String(existingRepositoryFile.LastCommitID),
157+
}
158+
if startBranch, ok := d.GetOk("start_branch"); ok {
159+
options.StartBranch = gitlab.String(startBranch.(string))
160+
}
161+
162+
_, _, err = client.RepositoryFiles.UpdateFile(project, filePath, options, gitlab.WithContext(ctx))
163+
if err != nil {
164+
return diag.FromErr(err)
165+
}
166+
167+
return resourceGitlabRepositoryFileRead(ctx, d, meta)
168+
}
169+
170+
func resourceGitlabRepositoryFileDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
171+
client := meta.(*gitlab.Client)
172+
project, branch, filePath, err := resourceGitLabRepositoryFileParseId(d.Id())
173+
if err != nil {
174+
return diag.FromErr(err)
175+
}
176+
177+
readOptions := &gitlab.GetFileOptions{
178+
Ref: gitlab.String(branch),
179+
}
180+
181+
existingRepositoryFile, _, err := client.RepositoryFiles.GetFile(project, filePath, readOptions, gitlab.WithContext(ctx))
182+
if err != nil {
183+
return diag.FromErr(err)
184+
}
185+
186+
options := &gitlab.DeleteFileOptions{
187+
Branch: gitlab.String(d.Get("branch").(string)),
188+
AuthorEmail: gitlab.String(d.Get("author_email").(string)),
189+
AuthorName: gitlab.String(d.Get("author_name").(string)),
190+
CommitMessage: gitlab.String(fmt.Sprintf("[DELETE]: %s", d.Get("commit_message").(string))),
191+
LastCommitID: gitlab.String(existingRepositoryFile.LastCommitID),
192+
}
193+
194+
resp, err := client.RepositoryFiles.DeleteFile(project, filePath, options)
195+
if err != nil {
196+
return diag.Errorf("%s failed to delete repository file: (%s) %v", d.Id(), resp.Status, err)
197+
}
198+
199+
return nil
200+
}
201+
202+
func validateBase64Content(v interface{}, k string) (we []string, errors []error) {
203+
content := v.(string)
204+
if _, err := base64.StdEncoding.DecodeString(content); err != nil {
205+
errors = append(errors, fmt.Errorf("given repository file content '%s' is not base64 encoded, but must be", content))
206+
}
207+
return
208+
}
209+
210+
func resourceGitLabRepositoryFileParseId(id string) (string, string, string, error) {
211+
parts := strings.SplitN(id, ":", 3)
212+
if len(parts) != 3 {
213+
return "", "", "", fmt.Errorf("Unexpected ID format (%q). Expected project:branch:repository_file_path", id)
214+
}
215+
216+
return parts[0], parts[1], parts[2], nil
217+
}
218+
219+
func resourceGitLabRepositoryFileBuildId(project string, branch string, filePath string) string {
220+
return fmt.Sprintf("%s:%s:%s", project, branch, filePath)
221+
}

0 commit comments

Comments
 (0)