Skip to content

Commit 3c3ec49

Browse files
authored
Merge pull request #28 from xMoelletschi/10-variables
feat: add support for gitlab_project_variable and gitlab_group_variable
2 parents 396bf66 + 09dc9da commit 3c3ec49

File tree

10 files changed

+520
-0
lines changed

10 files changed

+520
-0
lines changed

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ terraform/
8282
├── project_membership.tf # generated: variable with project → shared groups
8383
├── group_labels.tf # generated: variable with group → labels
8484
├── project_labels.tf # generated: variable with project → labels
85+
├── ci_variables.tf # generated: group and project CI/CD variables
8586
├── pipeline_schedules.tf # generated: variable with project → pipeline schedules
8687
├── hooks.tf # generated: project and group webhooks
8788
└── ...
@@ -104,10 +105,19 @@ terraform/
104105
- ✅ GitLab Project Labels ([`gitlab_project_label`](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/project_label))
105106
- ✅ GitLab Pipeline Schedules ([`gitlab_pipeline_schedule`](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/pipeline_schedule))
106107
- ✅ GitLab Pipeline Schedule Variables ([`gitlab_pipeline_schedule_variable`](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/pipeline_schedule_variable))
108+
- ✅ GitLab Group Variables ([`gitlab_group_variable`](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/group_variable)) *
109+
- ✅ GitLab Project Variables ([`gitlab_project_variable`](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/project_variable)) *
107110
- ✅ GitLab Project Hooks ([`gitlab_project_hook`](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/project_hook))
108111
- ✅ GitLab Group Hooks ([`gitlab_group_hook`](https://registry.terraform.io/providers/gitlabhq/gitlab/latest/docs/resources/group_hook)) *(requires Premium/Ultimate)*
109112
- 🚧 More resources coming soon
110113

114+
> **\* CI/CD Variable Filtering:** Masked variables and file-type variables are automatically skipped.
115+
> Masked variables are excluded because the GitLab API returns redacted values (`[MASKED]`), which would produce invalid Terraform state.
116+
> File-type variables are excluded because they typically contain sensitive data such as SSH private keys or certificates.
117+
>
118+
> **Important:** If you store secrets (SSH keys, API tokens, etc.) as `env_var`-type CI/CD variables, they will be written to `.tf` files in plaintext.
119+
> To prevent this, store sensitive values as **file-type** variables — this is also [GitLab's recommended approach](https://docs.gitlab.com/ci/variables/#use-file-type-cicd-variables) for multi-line secrets like SSH keys, since they cannot be masked.
120+
111121
## Contributing
112122

113123
Contributions are welcome! Please:

internal/gitlab/client.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ type Resources struct {
2323
PipelineSchedules PipelineSchedules
2424
ProjectHooks ProjectHooks
2525
GroupHooks GroupHooks
26+
ProjectVariables ProjectVariables
27+
GroupVariables GroupVariables
2628
}
2729

2830
func NewClientFromAPI(api *gl.Client, group string) *Client {
@@ -84,6 +86,22 @@ func (c *Client) FetchAll(ctx context.Context, skipSet skip.Set) (*Resources, er
8486
slog.Info("fetched pipeline schedules", "count", len(pipelineSchedules))
8587
}
8688

89+
var projectVariables ProjectVariables
90+
var groupVariables GroupVariables
91+
if !skipSet.Has("variables") {
92+
groupVariables, err = c.ListGroupVariables(ctx, groups)
93+
if err != nil {
94+
return nil, fmt.Errorf("listing group variables: %w", err)
95+
}
96+
slog.Info("fetched group variables", "count", len(groupVariables))
97+
98+
projectVariables, err = c.ListProjectVariables(ctx, projects)
99+
if err != nil {
100+
return nil, fmt.Errorf("listing project variables: %w", err)
101+
}
102+
slog.Info("fetched project variables", "count", len(projectVariables))
103+
}
104+
87105
var projectHooks ProjectHooks
88106
var groupHooks GroupHooks
89107
if !skipSet.Has("hooks") {
@@ -109,5 +127,7 @@ func (c *Client) FetchAll(ctx context.Context, skipSet skip.Set) (*Resources, er
109127
PipelineSchedules: pipelineSchedules,
110128
ProjectHooks: projectHooks,
111129
GroupHooks: groupHooks,
130+
ProjectVariables: projectVariables,
131+
GroupVariables: groupVariables,
112132
}, nil
113133
}

internal/gitlab/variables.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package gitlab
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log/slog"
7+
8+
gl "gitlab.com/gitlab-org/api/client-go"
9+
)
10+
11+
// ProjectVariables maps project IDs to their CI/CD variables.
12+
type ProjectVariables = map[int64][]*gl.ProjectVariable
13+
14+
// GroupVariables maps group IDs to their CI/CD variables.
15+
type GroupVariables = map[int64][]*gl.GroupVariable
16+
17+
func (c *Client) ListProjectVariables(ctx context.Context, projects []*gl.Project) (ProjectVariables, error) {
18+
result := make(ProjectVariables, len(projects))
19+
20+
for _, p := range projects {
21+
if p == nil {
22+
continue
23+
}
24+
slog.Debug("fetching project variables", "project", p.PathWithNamespace)
25+
opts := &gl.ListProjectVariablesOptions{
26+
ListOptions: gl.ListOptions{
27+
Page: 1,
28+
PerPage: 100,
29+
},
30+
}
31+
var vars []*gl.ProjectVariable
32+
for {
33+
page, resp, err := c.api.ProjectVariables.ListVariables(p.ID, opts, gl.WithContext(ctx))
34+
if err != nil {
35+
return nil, fmt.Errorf("listing variables for project %d: %w", p.ID, err)
36+
}
37+
for _, v := range page {
38+
if v.Masked {
39+
slog.Warn("skipping masked variable", "key", v.Key, "project", p.PathWithNamespace)
40+
continue
41+
}
42+
if v.Hidden {
43+
slog.Warn("skipping hidden variable", "key", v.Key, "project", p.PathWithNamespace)
44+
continue
45+
}
46+
if v.VariableType == gl.FileVariableType {
47+
slog.Warn("skipping file variable", "key", v.Key, "project", p.PathWithNamespace)
48+
continue
49+
}
50+
vars = append(vars, v)
51+
}
52+
if resp.NextPage == 0 {
53+
break
54+
}
55+
opts.Page = resp.NextPage
56+
}
57+
if len(vars) > 0 {
58+
result[p.ID] = vars
59+
}
60+
}
61+
62+
return result, nil
63+
}
64+
65+
func (c *Client) ListGroupVariables(ctx context.Context, groups []*gl.Group) (GroupVariables, error) {
66+
result := make(GroupVariables, len(groups))
67+
68+
for _, g := range groups {
69+
if g == nil {
70+
continue
71+
}
72+
slog.Debug("fetching group variables", "group", g.FullPath)
73+
opts := &gl.ListGroupVariablesOptions{
74+
ListOptions: gl.ListOptions{
75+
Page: 1,
76+
PerPage: 100,
77+
},
78+
}
79+
var vars []*gl.GroupVariable
80+
for {
81+
page, resp, err := c.api.GroupVariables.ListVariables(g.ID, opts, gl.WithContext(ctx))
82+
if err != nil {
83+
return nil, fmt.Errorf("listing variables for group %d: %w", g.ID, err)
84+
}
85+
for _, v := range page {
86+
if v.Masked {
87+
slog.Warn("skipping masked variable", "key", v.Key, "group", g.FullPath)
88+
continue
89+
}
90+
if v.Hidden {
91+
slog.Warn("skipping hidden variable", "key", v.Key, "group", g.FullPath)
92+
continue
93+
}
94+
if v.VariableType == gl.FileVariableType {
95+
slog.Warn("skipping file variable", "key", v.Key, "group", g.FullPath)
96+
continue
97+
}
98+
vars = append(vars, v)
99+
}
100+
if resp.NextPage == 0 {
101+
break
102+
}
103+
opts.Page = resp.NextPage
104+
}
105+
if len(vars) > 0 {
106+
result[g.ID] = vars
107+
}
108+
}
109+
110+
return result, nil
111+
}

internal/terraform/import.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,40 @@ func GenerateImportCommands(resources *gitlab.Resources, existingResources map[s
121121
}
122122
}
123123

124+
if !skipSet.Has("variables") {
125+
for _, g := range resources.Groups {
126+
if g == nil {
127+
continue
128+
}
129+
for _, v := range resources.GroupVariables[g.ID] {
130+
name := groupVariableResourceName(g, v)
131+
key := "gitlab_group_variable." + name
132+
if !existingResources[key] {
133+
cmds = append(cmds, ImportCommand{
134+
Address: key,
135+
ID: fmt.Sprintf("%d:%s:%s", g.ID, v.Key, v.EnvironmentScope),
136+
})
137+
}
138+
}
139+
}
140+
141+
for _, p := range resources.Projects {
142+
if p == nil {
143+
continue
144+
}
145+
for _, v := range resources.ProjectVariables[p.ID] {
146+
name := projectVariableResourceName(p, v)
147+
key := "gitlab_project_variable." + name
148+
if !existingResources[key] {
149+
cmds = append(cmds, ImportCommand{
150+
Address: key,
151+
ID: fmt.Sprintf("%d:%s:%s", p.ID, v.Key, v.EnvironmentScope),
152+
})
153+
}
154+
}
155+
}
156+
}
157+
124158
if !skipSet.Has("hooks") {
125159
for _, g := range resources.Groups {
126160
if g == nil {

internal/terraform/import_test.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,107 @@ func TestGenerateImportCommandsSkipHooks(t *testing.T) {
490490
}
491491
}
492492

493+
func TestGenerateImportCommandsNewGroupVariable(t *testing.T) {
494+
resources := &gitlab.Resources{
495+
Groups: []*gl.Group{
496+
{ID: 10, Path: "my-group", FullPath: "my-group"},
497+
},
498+
GroupVariables: map[int64][]*gl.GroupVariable{
499+
10: {
500+
{Key: "API_URL", EnvironmentScope: "*"},
501+
{Key: "DB_HOST", EnvironmentScope: "production"},
502+
},
503+
},
504+
}
505+
506+
existing := map[string]bool{
507+
"gitlab_group.my_group": true,
508+
}
509+
510+
cmds := GenerateImportCommands(resources, existing, "my-group", nil)
511+
512+
if len(cmds) != 2 {
513+
t.Fatalf("expected 2 commands, got %d", len(cmds))
514+
}
515+
if cmds[0].Address != "gitlab_group_variable.my_group_api_url" {
516+
t.Errorf("address = %q, want %q", cmds[0].Address, "gitlab_group_variable.my_group_api_url")
517+
}
518+
if cmds[0].ID != "10:API_URL:*" {
519+
t.Errorf("id = %q, want %q", cmds[0].ID, "10:API_URL:*")
520+
}
521+
if cmds[1].Address != "gitlab_group_variable.my_group_db_host_production" {
522+
t.Errorf("address = %q, want %q", cmds[1].Address, "gitlab_group_variable.my_group_db_host_production")
523+
}
524+
if cmds[1].ID != "10:DB_HOST:production" {
525+
t.Errorf("id = %q, want %q", cmds[1].ID, "10:DB_HOST:production")
526+
}
527+
}
528+
529+
func TestGenerateImportCommandsNewProjectVariable(t *testing.T) {
530+
resources := &gitlab.Resources{
531+
Projects: []*gl.Project{
532+
{
533+
ID: 1,
534+
Path: "my-project",
535+
Namespace: &gl.ProjectNamespace{
536+
FullPath: "parent",
537+
},
538+
},
539+
},
540+
ProjectVariables: map[int64][]*gl.ProjectVariable{
541+
1: {
542+
{Key: "SECRET_KEY", EnvironmentScope: "*"},
543+
},
544+
},
545+
}
546+
547+
existing := map[string]bool{
548+
"gitlab_project.parent_my_project": true,
549+
}
550+
551+
cmds := GenerateImportCommands(resources, existing, "parent", nil)
552+
553+
if len(cmds) != 1 {
554+
t.Fatalf("expected 1 command, got %d", len(cmds))
555+
}
556+
if cmds[0].Address != "gitlab_project_variable.parent_my_project_secret_key" {
557+
t.Errorf("address = %q, want %q", cmds[0].Address, "gitlab_project_variable.parent_my_project_secret_key")
558+
}
559+
if cmds[0].ID != "1:SECRET_KEY:*" {
560+
t.Errorf("id = %q, want %q", cmds[0].ID, "1:SECRET_KEY:*")
561+
}
562+
}
563+
564+
func TestGenerateImportCommandsSkipVariables(t *testing.T) {
565+
resources := &gitlab.Resources{
566+
Groups: []*gl.Group{
567+
{ID: 10, Path: "grp", FullPath: "grp"},
568+
},
569+
Projects: []*gl.Project{
570+
{
571+
ID: 1,
572+
Path: "proj",
573+
Namespace: &gl.ProjectNamespace{FullPath: "grp"},
574+
},
575+
},
576+
GroupVariables: map[int64][]*gl.GroupVariable{
577+
10: {{Key: "VAR1", EnvironmentScope: "*"}},
578+
},
579+
ProjectVariables: map[int64][]*gl.ProjectVariable{
580+
1: {{Key: "VAR2", EnvironmentScope: "*"}},
581+
},
582+
}
583+
584+
skipSet := skip.Set{"variables": true}
585+
cmds := GenerateImportCommands(resources, nil, "grp", skipSet)
586+
587+
for _, cmd := range cmds {
588+
if strings.Contains(cmd.Address, "_variable.") {
589+
t.Errorf("should not generate variable import when skipped: %s", cmd.Address)
590+
}
591+
}
592+
}
593+
493594
func TestGenerateImportCommandsSkipPipelineSchedules(t *testing.T) {
494595
resources := &gitlab.Resources{
495596
Groups: []*gl.Group{
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
resource "gitlab_group_variable" "my_group_api_url" {
2+
group = gitlab_group.my_group.id
3+
key = "API_URL"
4+
value = "https://api.example.com"
5+
variable_type = "env_var"
6+
protected = false
7+
raw = true
8+
description = "API base URL"
9+
}
10+
11+
resource "gitlab_group_variable" "my_group_db_host_production" {
12+
group = gitlab_group.my_group.id
13+
key = "DB_HOST"
14+
value = "db.example.com"
15+
variable_type = "env_var"
16+
protected = true
17+
raw = false
18+
environment_scope = "production"
19+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
resource "gitlab_project_variable" "my_group_my_project_secret_key" {
2+
project = gitlab_project.my_group_my_project.id
3+
key = "SECRET_KEY"
4+
value = "abc123"
5+
variable_type = "env_var"
6+
protected = false
7+
raw = false
8+
description = "Secret key"
9+
}

0 commit comments

Comments
 (0)