Skip to content

Commit 25d3b91

Browse files
feat: expose more enterprise settings for projects (#264)
1 parent 38fa522 commit 25d3b91

File tree

4 files changed

+361
-31
lines changed

4 files changed

+361
-31
lines changed

docs/resources/project.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,25 @@ resource "unleash_project" "test_project" {
2828
id = "my_project"
2929
name = "My Terraform project"
3030
description = "A project created through terraform"
31+
32+
mode = "protected"
33+
34+
feature_naming = {
35+
pattern = "^feature_[a-z0-9_-]+$"
36+
example = "feature_user_signup"
37+
description = "Feature keys must start with feature_ and use lowercase alphanumerics."
38+
}
39+
40+
link_templates = [
41+
{
42+
title = "Product Spec"
43+
url_template = "https://docs.example.com/projects/{{project}}/features/{{feature}}"
44+
},
45+
{
46+
title = "Issue Tracker"
47+
url_template = "https://issues.example.com/browse/{{feature}}"
48+
}
49+
]
3150
}
3251
```
3352

@@ -42,4 +61,30 @@ resource "unleash_project" "test_project" {
4261
### Optional
4362

4463
- `description` (String) A description of the project's purpose.
64+
- `feature_naming` (Attributes) Optional feature naming pattern applied to all features created in this project. (see [below for nested schema](#nestedatt--feature_naming))
65+
- `link_templates` (Attributes List) Optional list of link templates automatically added to new feature flags. (see [below for nested schema](#nestedatt--link_templates))
4566
- `mode` (String) The project's collaboration mode. Determines whether non project members can submit change requests and the projects visibility to non members. Valid values are 'open', 'protected' and 'private'. If a value is not set, the project will default to 'open'
67+
68+
<a id="nestedatt--feature_naming"></a>
69+
### Nested Schema for `feature_naming`
70+
71+
Required:
72+
73+
- `pattern` (String) A JavaScript regular expression pattern, without the start and end delimiters.
74+
75+
Optional:
76+
77+
- `description` (String) A human-readable description of the pattern.
78+
- `example` (String) An example feature name that matches the pattern.
79+
80+
81+
<a id="nestedatt--link_templates"></a>
82+
### Nested Schema for `link_templates`
83+
84+
Required:
85+
86+
- `url_template` (String) URL template that can contain {{project}} or {{feature}} placeholders.
87+
88+
Optional:
89+
90+
- `title` (String) Link title shown in the Unleash UI.

examples/resources/unleash_project/resource.tf

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,23 @@ resource "unleash_project" "test_project" {
1313
id = "my_project"
1414
name = "My Terraform project"
1515
description = "A project created through terraform"
16+
17+
mode = "protected"
18+
19+
feature_naming = {
20+
pattern = "^feature_[a-z0-9_-]+$"
21+
example = "feature_user_signup"
22+
description = "Feature keys must start with feature_ and use lowercase alphanumerics."
23+
}
24+
25+
link_templates = [
26+
{
27+
title = "Product Spec"
28+
url_template = "https://docs.example.com/projects/{{project}}/features/{{feature}}"
29+
},
30+
{
31+
title = "Issue Tracker"
32+
url_template = "https://issues.example.com/browse/{{feature}}"
33+
}
34+
]
1635
}

internal/provider/project_resource.go

Lines changed: 187 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66

77
unleash "github.com/Unleash/unleash-server-api-go/client"
8+
"github.com/hashicorp/terraform-plugin-framework/diag"
89
"github.com/hashicorp/terraform-plugin-framework/path"
910
"github.com/hashicorp/terraform-plugin-framework/resource"
1011
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
@@ -27,10 +28,23 @@ type projectResource struct {
2728
}
2829

2930
type projectResourceModel struct {
30-
Id types.String `tfsdk:"id"`
31-
Name types.String `tfsdk:"name"`
31+
Id types.String `tfsdk:"id"`
32+
Name types.String `tfsdk:"name"`
33+
Description types.String `tfsdk:"description"`
34+
Mode types.String `tfsdk:"mode"`
35+
FeatureNaming *featureNamingModel `tfsdk:"feature_naming"`
36+
LinkTemplates []projectLinkTemplateModel `tfsdk:"link_templates"`
37+
}
38+
39+
type featureNamingModel struct {
40+
Pattern types.String `tfsdk:"pattern"`
41+
Example types.String `tfsdk:"example"`
3242
Description types.String `tfsdk:"description"`
33-
Mode types.String `tfsdk:"mode"`
43+
}
44+
45+
type projectLinkTemplateModel struct {
46+
Title types.String `tfsdk:"title"`
47+
UrlTemplate types.String `tfsdk:"url_template"`
3448
}
3549

3650
// Configure adds the provider configured client to the data source.
@@ -77,6 +91,40 @@ func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, re
7791
Computed: true,
7892
Optional: true,
7993
},
94+
"feature_naming": schema.SingleNestedAttribute{
95+
Description: "Optional feature naming pattern applied to all features created in this project.",
96+
Optional: true,
97+
Attributes: map[string]schema.Attribute{
98+
"pattern": schema.StringAttribute{
99+
Description: "A JavaScript regular expression pattern, without the start and end delimiters.",
100+
Required: true,
101+
},
102+
"example": schema.StringAttribute{
103+
Description: "An example feature name that matches the pattern.",
104+
Optional: true,
105+
},
106+
"description": schema.StringAttribute{
107+
Description: "A human-readable description of the pattern.",
108+
Optional: true,
109+
},
110+
},
111+
},
112+
"link_templates": schema.ListNestedAttribute{
113+
Description: "Optional list of link templates automatically added to new feature flags.",
114+
Optional: true,
115+
NestedObject: schema.NestedAttributeObject{
116+
Attributes: map[string]schema.Attribute{
117+
"title": schema.StringAttribute{
118+
Description: "Link title shown in the Unleash UI.",
119+
Optional: true,
120+
},
121+
"url_template": schema.StringAttribute{
122+
Description: "URL template that can contain {{project}} or {{feature}} placeholders.",
123+
Required: true,
124+
},
125+
},
126+
},
127+
},
80128
},
81129
}
82130
}
@@ -122,6 +170,22 @@ func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest
122170
updateProjectSettingsRequest := *unleash.NewUpdateProjectEnterpriseSettingsSchemaWithDefaults()
123171
updateProjectSettingsRequest.SetMode(mode)
124172

173+
featureNaming := expandFeatureNaming(plan.FeatureNaming, &resp.Diagnostics)
174+
if resp.Diagnostics.HasError() {
175+
return
176+
}
177+
if featureNaming != nil {
178+
updateProjectSettingsRequest.SetFeatureNaming(*featureNaming)
179+
}
180+
181+
linkTemplates := expandLinkTemplates(plan.LinkTemplates, &resp.Diagnostics)
182+
if resp.Diagnostics.HasError() {
183+
return
184+
}
185+
if linkTemplates != nil {
186+
updateProjectSettingsRequest.SetLinkTemplates(linkTemplates)
187+
}
188+
125189
updateSettingsResponse, err := r.client.ProjectsAPI.UpdateProjectEnterpriseSettings(ctx, *plan.Id.ValueStringPointer()).UpdateProjectEnterpriseSettingsSchema(updateProjectSettingsRequest).Execute()
126190

127191
if !ValidateApiResponse(updateSettingsResponse, 200, &resp.Diagnostics, err) {
@@ -189,20 +253,29 @@ func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, re
189253

190254
func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
191255
tflog.Debug(ctx, "Preparing to update project resource")
192-
var state projectResourceModel
193-
resp.Diagnostics.Append(req.Plan.Get(ctx, &state)...)
256+
var plan projectResourceModel
257+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
194258

195259
if resp.Diagnostics.HasError() {
196260
return
197261
}
198262

199263
updateProjectSchema := *unleash.NewUpdateProjectSchemaWithDefaults()
200-
updateProjectSchema.Name = *state.Name.ValueStringPointer()
201-
if !state.Description.IsNull() {
202-
updateProjectSchema.Description = state.Description.ValueStringPointer()
264+
updateProjectSchema.Name = *plan.Name.ValueStringPointer()
265+
if !plan.Description.IsNull() {
266+
updateProjectSchema.Description = plan.Description.ValueStringPointer()
203267
}
204268

205-
mode, err := resolveRequestedMode(state)
269+
if plan.Id.IsNull() || plan.Id.IsUnknown() {
270+
var state projectResourceModel
271+
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
272+
if resp.Diagnostics.HasError() {
273+
return
274+
}
275+
plan.Id = state.Id
276+
}
277+
278+
mode, err := resolveRequestedMode(plan)
206279
if err != nil {
207280
resp.Diagnostics.AddError(err.Error(), "InvalidMode")
208281
return
@@ -211,15 +284,29 @@ func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest
211284
updateProjectSettingsRequest := *unleash.NewUpdateProjectEnterpriseSettingsSchemaWithDefaults()
212285
updateProjectSettingsRequest.SetMode(mode)
213286

214-
updateSettingsResponse, err := r.client.ProjectsAPI.UpdateProjectEnterpriseSettings(ctx, *state.Id.ValueStringPointer()).UpdateProjectEnterpriseSettingsSchema(updateProjectSettingsRequest).Execute()
287+
featureNaming := expandFeatureNaming(plan.FeatureNaming, &resp.Diagnostics)
288+
if resp.Diagnostics.HasError() {
289+
return
290+
}
291+
if featureNaming != nil {
292+
updateProjectSettingsRequest.SetFeatureNaming(*featureNaming)
293+
}
215294

216-
if !ValidateApiResponse(updateSettingsResponse, 200, &resp.Diagnostics, err) {
295+
linkTemplates := expandLinkTemplates(plan.LinkTemplates, &resp.Diagnostics)
296+
if resp.Diagnostics.HasError() {
217297
return
218298
}
299+
if linkTemplates != nil {
300+
updateProjectSettingsRequest.SetLinkTemplates(linkTemplates)
301+
}
219302

220-
req.State.Get(ctx, &state)
303+
updateSettingsResponse, err := r.client.ProjectsAPI.UpdateProjectEnterpriseSettings(ctx, *plan.Id.ValueStringPointer()).UpdateProjectEnterpriseSettingsSchema(updateProjectSettingsRequest).Execute()
304+
305+
if !ValidateApiResponse(updateSettingsResponse, 200, &resp.Diagnostics, err) {
306+
return
307+
}
221308

222-
api_response, err := r.client.ProjectsAPI.UpdateProject(ctx, state.Id.ValueString()).UpdateProjectSchema(updateProjectSchema).Execute()
309+
api_response, err := r.client.ProjectsAPI.UpdateProject(ctx, plan.Id.ValueString()).UpdateProjectSchema(updateProjectSchema).Execute()
223310

224311
if !ValidateApiResponse(api_response, 200, &resp.Diagnostics, err) {
225312
return
@@ -230,26 +317,26 @@ func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest
230317

231318
var project unleash.ProjectSchema
232319
for _, p := range projects.Projects {
233-
if p.Id == state.Id.ValueString() {
320+
if p.Id == plan.Id.ValueString() {
234321
project = p
235322
}
236323
}
237324
if !ValidateApiResponse(api_response, 200, &resp.Diagnostics, err) {
238325
return
239326
}
240327

241-
state.Id = types.StringValue(fmt.Sprintf("%v", project.Id))
242-
state.Name = types.StringValue(fmt.Sprintf("%v", project.Name))
328+
plan.Id = types.StringValue(fmt.Sprintf("%v", project.Id))
329+
plan.Name = types.StringValue(fmt.Sprintf("%v", project.Name))
243330

244-
setModelMode(project.Mode, &state)
331+
setModelMode(project.Mode, &plan)
245332

246333
if project.Description.IsSet() {
247-
state.Description = types.StringValue(*project.Description.Get())
334+
plan.Description = types.StringValue(*project.Description.Get())
248335
} else {
249-
state.Description = types.StringNull()
336+
plan.Description = types.StringNull()
250337
}
251338

252-
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
339+
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
253340
tflog.Debug(ctx, "Finished updating project data source", map[string]any{"success": true})
254341
}
255342

@@ -294,3 +381,83 @@ func resolveRequestedMode(plan projectResourceModel) (string, error) {
294381
return "open", nil
295382
}
296383
}
384+
385+
func expandFeatureNaming(model *featureNamingModel, diagnostics *diag.Diagnostics) *unleash.CreateFeatureNamingPatternSchema {
386+
if model == nil {
387+
return nil
388+
}
389+
390+
if model.Pattern.IsUnknown() {
391+
diagnostics.AddError("Invalid feature_naming.pattern", "feature_naming.pattern cannot be unknown")
392+
return nil
393+
}
394+
395+
if model.Pattern.IsNull() || model.Pattern.ValueString() == "" {
396+
diagnostics.AddError("Invalid feature_naming.pattern", "feature_naming.pattern must be provided and cannot be empty")
397+
return nil
398+
}
399+
400+
featureNaming := unleash.CreateFeatureNamingPatternSchema{}
401+
featureNaming.SetPattern(model.Pattern.ValueString())
402+
403+
if model.Example.IsUnknown() {
404+
diagnostics.AddError("Invalid feature_naming.example", "feature_naming.example cannot be unknown")
405+
return nil
406+
}
407+
408+
if model.Example.IsNull() {
409+
featureNaming.SetExampleNil()
410+
} else {
411+
featureNaming.SetExample(model.Example.ValueString())
412+
}
413+
414+
if model.Description.IsUnknown() {
415+
diagnostics.AddError("Invalid feature_naming.description", "feature_naming.description cannot be unknown")
416+
return nil
417+
}
418+
419+
if model.Description.IsNull() {
420+
featureNaming.SetDescriptionNil()
421+
} else {
422+
featureNaming.SetDescription(model.Description.ValueString())
423+
}
424+
425+
return &featureNaming
426+
}
427+
428+
func expandLinkTemplates(models []projectLinkTemplateModel, diagnostics *diag.Diagnostics) []unleash.ProjectLinkTemplateSchema {
429+
if models == nil {
430+
return nil
431+
}
432+
433+
templates := make([]unleash.ProjectLinkTemplateSchema, len(models))
434+
435+
for i, model := range models {
436+
if model.UrlTemplate.IsUnknown() {
437+
diagnostics.AddError("Invalid link_templates url", fmt.Sprintf("link_templates[%d].url_template cannot be unknown", i))
438+
return nil
439+
}
440+
441+
if model.UrlTemplate.IsNull() || model.UrlTemplate.ValueString() == "" {
442+
diagnostics.AddError("Invalid link_templates url", fmt.Sprintf("link_templates[%d].url_template must be provided and cannot be empty", i))
443+
return nil
444+
}
445+
446+
template := unleash.NewProjectLinkTemplateSchema(model.UrlTemplate.ValueString())
447+
448+
if model.Title.IsUnknown() {
449+
diagnostics.AddError("Invalid link_templates title", fmt.Sprintf("link_templates[%d].title cannot be unknown", i))
450+
return nil
451+
}
452+
453+
if model.Title.IsNull() {
454+
template.SetTitleNil()
455+
} else {
456+
template.SetTitle(model.Title.ValueString())
457+
}
458+
459+
templates[i] = *template
460+
}
461+
462+
return templates
463+
}

0 commit comments

Comments
 (0)