Skip to content

Commit ccdaa9e

Browse files
authored
Support changing property types of port_blueprint (#255)
* Support changing property types of port_blueprint Added a flag that represents our current protection against changing the property type. When the flag is set to false, we delete the property before recreating it with the new type. * update docs * split cover report to a different job * Add support for changing property type of system blueprints * change needs of public_cover_report from public_report to acctest * add checkout step to publish_cover_report * clean state after test * Remove unused PatchBlueprint method
1 parent 45c4663 commit ccdaa9e

File tree

11 files changed

+363
-42
lines changed

11 files changed

+363
-42
lines changed

.github/workflows/ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,14 @@ jobs:
152152
flaky_summary: 'true'
153153
include_time_in_summary: 'true'
154154
simplified_summary: 'true'
155+
156+
publish_cover_report:
157+
name: Publish Coverage Report
158+
if: ${{ always() }}
159+
needs: acctest
160+
runs-on: ubuntu-latest
161+
steps:
162+
- uses: actions/checkout@v4
155163
- name: Download all coverage reports
156164
uses: actions/download-artifact@v4
157165
with:

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Interact with Port-labs
1818
### Optional
1919

2020
- `base_url` (String)
21+
- `blueprint_property_type_change_protection` (Boolean) Protects you from accidentally changing the property type of blueprints which will delete the property before recreating it with the new type. Defaults to `true`
2122
- `client_id` (String) Client ID for Port-labs
2223
- `json_escape_html` (Boolean) When set to `false` disables the default HTML escaping of json.Marshal when reading data from Port. Defaults to `true`
2324
- `secret` (String, Sensitive) Client Secret for Port-labs

internal/acctest/acctest.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ var ProviderConfig = fmt.Sprintf(`provider "port" {
2828
}
2929
`, os.Getenv("PORT_CLIENT_ID"), os.Getenv("PORT_CLIENT_SECRET"), os.Getenv("PORT_BASE_URL"))
3030

31+
var ProviderConfigNoPropertyTypeProtection = fmt.Sprintf(`provider "port" {
32+
client_id = "%s"
33+
secret = "%s"
34+
base_url = "%s"
35+
blueprint_property_type_change_protection = false
36+
}
37+
`, os.Getenv("PORT_CLIENT_ID"), os.Getenv("PORT_CLIENT_SECRET"), os.Getenv("PORT_BASE_URL"))
38+
3139
var ProviderConfigNoEscapeHTML = fmt.Sprintf(`provider "port" {
3240
client_id = "%s"
3341
secret = "%s"

internal/cli/blueprint.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import (
88

99
func (c *PortClient) ReadBlueprint(ctx context.Context, id string) (*Blueprint, int, error) {
1010
pb := &PortBody{}
11-
url := "v1/blueprints/{identifier}"
11+
const url = "v1/blueprints/{identifier}"
1212
resp, err := c.Client.R().
1313
SetContext(ctx).
1414
SetHeader("Accept", "application/json").
@@ -27,7 +27,7 @@ func (c *PortClient) ReadBlueprint(ctx context.Context, id string) (*Blueprint,
2727

2828
func (c *PortClient) ReadSystemBlueprintStructure(ctx context.Context, id string) (*Blueprint, int, error) {
2929
pb := &PortBody{}
30-
url := "v1/blueprints/system/{identifier}/structure"
30+
const url = "v1/blueprints/system/{identifier}/structure"
3131
resp, err := c.Client.R().
3232
SetContext(ctx).
3333
SetHeader("Accept", "application/json").
@@ -44,7 +44,7 @@ func (c *PortClient) ReadSystemBlueprintStructure(ctx context.Context, id string
4444
}
4545

4646
func (c *PortClient) CreateBlueprint(ctx context.Context, b *Blueprint, createCatalogPage *bool) (*Blueprint, error) {
47-
url := "v1/blueprints"
47+
const url = "v1/blueprints"
4848
request := c.Client.R().
4949
SetBody(b).
5050
SetContext(ctx)
@@ -67,7 +67,7 @@ func (c *PortClient) CreateBlueprint(ctx context.Context, b *Blueprint, createCa
6767
}
6868

6969
func (c *PortClient) UpdateBlueprint(ctx context.Context, b *Blueprint, id string) (*Blueprint, error) {
70-
url := "v1/blueprints/{identifier}"
70+
const url = "v1/blueprints/{identifier}"
7171
resp, err := c.Client.R().
7272
SetBody(b).
7373
SetContext(ctx).
@@ -88,7 +88,7 @@ func (c *PortClient) UpdateBlueprint(ctx context.Context, b *Blueprint, id strin
8888
}
8989

9090
func (c *PortClient) DeleteBlueprint(ctx context.Context, id string) error {
91-
url := "v1/blueprints/{identifier}"
91+
const url = "v1/blueprints/{identifier}"
9292
resp, err := c.Client.R().
9393
SetContext(ctx).
9494
SetHeader("Accept", "application/json").
@@ -109,7 +109,7 @@ func (c *PortClient) DeleteBlueprint(ctx context.Context, id string) error {
109109
}
110110

111111
func (c *PortClient) DeleteBlueprintWithAllEntities(ctx context.Context, id string) (*string, error) {
112-
url := "v1/blueprints/{identifier}/all-entities?delete_blueprint=true"
112+
const url = "v1/blueprints/{identifier}/all-entities?delete_blueprint=true"
113113
resp, err := c.Client.R().
114114
SetContext(ctx).
115115
SetHeader("Accept", "application/json").
@@ -128,5 +128,4 @@ func (c *PortClient) DeleteBlueprintWithAllEntities(ctx context.Context, id stri
128128
}
129129

130130
return &pb.MigrationId, nil
131-
132131
}

internal/cli/client.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import (
1313
type Option func(*PortClient)
1414

1515
type PortClient struct {
16-
Client *resty.Client
17-
ClientID string
18-
Token string
19-
featureFlags []string
20-
JSONEscapeHTML bool
16+
Client *resty.Client
17+
ClientID string
18+
Token string
19+
featureFlags []string
20+
JSONEscapeHTML bool
21+
BlueprintPropertyTypeChangeProtection bool
2122
}
2223

2324
func New(baseURL string, opts ...Option) (*PortClient, error) {

internal/cli/models.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -548,11 +548,12 @@ type PortTeamBody struct {
548548
}
549549

550550
type PortProviderModel struct {
551-
ClientId types.String `tfsdk:"client_id"`
552-
Secret types.String `tfsdk:"secret"`
553-
Token types.String `tfsdk:"token"`
554-
BaseUrl types.String `tfsdk:"base_url"`
555-
JSONEscapeHTML types.Bool `tfsdk:"json_escape_html"`
551+
ClientId types.String `tfsdk:"client_id"`
552+
Secret types.String `tfsdk:"secret"`
553+
Token types.String `tfsdk:"token"`
554+
BaseUrl types.String `tfsdk:"base_url"`
555+
JSONEscapeHTML types.Bool `tfsdk:"json_escape_html"`
556+
BlueprintPropertyTypeChangeProtection types.Bool `tfsdk:"blueprint_property_type_change_protection"`
556557
}
557558

558559
type PortBodyDelete struct {

port/blueprint/resource.go

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -188,27 +188,33 @@ func (r *BlueprintResource) Update(ctx context.Context, req resource.UpdateReque
188188

189189
resp.Diagnostics.Append(req.Plan.Get(ctx, &state)...)
190190
resp.Diagnostics.Append(req.State.Get(ctx, &previousState)...)
191-
192191
if resp.Diagnostics.HasError() {
193192
return
194193
}
195-
b, err := blueprintResourceToPortRequest(ctx, state)
196194

195+
prevB, err := blueprintResourceToPortRequest(ctx, previousState)
196+
if err != nil {
197+
resp.Diagnostics.AddError("failed to transform previous state into a blueprint", err.Error())
198+
return
199+
}
200+
201+
b, err := blueprintResourceToPortRequest(ctx, state)
197202
if err != nil {
198203
resp.Diagnostics.AddError("failed to transform blueprint", err.Error())
199204
return
200205
}
201206

202207
var bp *cli.Blueprint
203-
createCatalogPage := state.CreateCatalogPage.ValueBoolPointer()
204208
if previousState.Identifier.IsNull() {
205-
bp, err = r.portClient.CreateBlueprint(ctx, b, createCatalogPage)
209+
bp, err = r.portClient.CreateBlueprint(ctx, b, state.CreateCatalogPage.ValueBoolPointer())
206210
if err != nil {
207211
resp.Diagnostics.AddError("failed to create blueprint", err.Error())
208212
return
209213
}
210214
} else {
211-
existingBp, statusCode, err := r.portClient.ReadBlueprint(ctx, previousState.Identifier.ValueString())
215+
var existingBp *cli.Blueprint
216+
var statusCode int
217+
existingBp, statusCode, err = r.portClient.ReadBlueprint(ctx, previousState.Identifier.ValueString())
212218
if err != nil {
213219
if statusCode == 404 {
214220
resp.Diagnostics.AddError("Blueprint doesn't exists, it is required to update the blueprint", err.Error())
@@ -220,6 +226,38 @@ func (r *BlueprintResource) Update(ctx context.Context, req resource.UpdateReque
220226
// aggregation properties are managed in a different resource, so we need to keep them in the update
221227
// to avoid losing them
222228
b.AggregationProperties = existingBp.AggregationProperties
229+
prevB.AggregationProperties = existingBp.AggregationProperties
230+
231+
propsWithChangedTypes := make(map[string]string, 0)
232+
for propKey, prop := range b.Schema.Properties {
233+
if prevProp, prevHasProp := prevB.Schema.Properties[propKey]; prevHasProp && prop.Type != prevProp.Type {
234+
propsWithChangedTypes[propKey] = prevProp.Type
235+
delete(prevB.Schema.Properties, propKey)
236+
}
237+
}
238+
if len(propsWithChangedTypes) > 0 && r.portClient.BlueprintPropertyTypeChangeProtection {
239+
for propKey, prevPropType := range propsWithChangedTypes {
240+
currentPropType := b.Schema.Properties[propKey].Type
241+
resp.Diagnostics.AddAttributeError(
242+
path.Root("properties").AtName(fmt.Sprintf("%s_props", currentPropType)).
243+
AtName(propKey).AtName("type"),
244+
"Property type changed while protection is enabled",
245+
fmt.Sprintf("The type of property %q changed from %q to %q. Applying this change will cause "+
246+
"you to lose the data for that property. If you wish to continue disable the protection in the "+
247+
"provider configuration by setting %q to false", propKey, prevPropType, currentPropType,
248+
"blueprint_property_type_change_protection"),
249+
)
250+
}
251+
return
252+
}
253+
if len(propsWithChangedTypes) > 0 {
254+
_, err = r.portClient.UpdateBlueprint(ctx, prevB, previousState.ID.ValueString())
255+
if err != nil {
256+
resp.Diagnostics.AddError("failed to pre-delete properties that changed their type", err.Error())
257+
return
258+
}
259+
}
260+
223261
bp, err = r.portClient.UpdateBlueprint(ctx, b, previousState.ID.ValueString())
224262
if err != nil {
225263
resp.Diagnostics.AddError("failed to update blueprint", err.Error())

port/blueprint/resource_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@ package blueprint_test
33
import (
44
"context"
55
"fmt"
6+
"github.com/stretchr/testify/require"
7+
"math/rand"
68
"net/http"
79
"os"
810
"regexp"
911
"strconv"
12+
"strings"
1013
"testing"
14+
"text/template"
1115
"time"
1216

1317
"github.com/port-labs/terraform-provider-port-labs/v2/internal/cli"
@@ -324,6 +328,95 @@ func TestAccPortBlueprintObjectProperty(t *testing.T) {
324328
})
325329
}
326330

331+
func TestAccPortBlueprintChangePropertyType(t *testing.T) {
332+
type data struct{ Identifier, PropType string }
333+
identifier := utils.GenID()
334+
tmpl, err := template.New("resource").Parse(`
335+
resource "port_blueprint" "{{.Identifier}}" {
336+
title = "test: {{.Identifier}}"
337+
icon = "Terraform"
338+
identifier = "{{.Identifier}}"
339+
properties = {
340+
{{.PropType}}_props = {
341+
myProperty = {
342+
title = "My Property"
343+
description = "This is a {{.PropType}} property"
344+
}
345+
}
346+
}
347+
}`)
348+
require.NoErrorf(t, err, "failed to parse test template")
349+
350+
var propTypes = [...]string{"string", "number", "boolean", "array", "object"}
351+
352+
// Shuffle the prop types to make sure we don't have an issue transitioning from one type to the next.
353+
rand.Shuffle(len(propTypes), func(i, j int) { propTypes[i], propTypes[j] = propTypes[j], propTypes[i] })
354+
355+
steps := make([]resource.TestStep, len(propTypes))
356+
for idx, propType := range propTypes {
357+
var txt strings.Builder
358+
err = tmpl.Execute(&txt, data{Identifier: identifier, PropType: propType})
359+
require.NoErrorf(t, err, "failed to execute template for propType: %s", propType)
360+
steps[idx] = resource.TestStep{
361+
Config: acctest.ProviderConfigNoPropertyTypeProtection + txt.String(),
362+
Check: resource.ComposeTestCheckFunc(resource.TestCheckResourceAttr(
363+
fmt.Sprintf("port_blueprint.%s", identifier),
364+
fmt.Sprintf("properties.%s_props.myProperty.title", propType),
365+
"My Property",
366+
)),
367+
}
368+
}
369+
370+
resource.Test(t, resource.TestCase{
371+
PreCheck: func() { acctest.TestAccPreCheck(t) },
372+
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
373+
Steps: steps,
374+
})
375+
}
376+
377+
func TestAccPortBlueprintChangePropertyTypeProtection(t *testing.T) {
378+
type data struct{ Identifier, PropType string }
379+
identifier := utils.GenID()
380+
tmpl, err := template.New("resource").Parse(`
381+
resource "port_blueprint" "{{.Identifier}}" {
382+
title = "test: {{.Identifier}}"
383+
icon = "Terraform"
384+
identifier = "{{.Identifier}}"
385+
properties = {
386+
{{.PropType}}_props = {
387+
myProperty = {
388+
title = "My Property"
389+
description = "This is a {{.PropType}} property"
390+
}
391+
}
392+
}
393+
}`)
394+
require.NoErrorf(t, err, "failed to parse test template")
395+
396+
var step1Text, step2Text strings.Builder
397+
require.NoError(t, tmpl.Execute(&step1Text, data{Identifier: identifier, PropType: "string"}))
398+
require.NoError(t, tmpl.Execute(&step2Text, data{Identifier: identifier, PropType: "number"}))
399+
400+
resource.Test(t, resource.TestCase{
401+
PreCheck: func() { acctest.TestAccPreCheck(t) },
402+
ProtoV6ProviderFactories: acctest.TestAccProtoV6ProviderFactories,
403+
Steps: []resource.TestStep{
404+
{
405+
Config: acctest.ProviderConfig + step1Text.String(),
406+
Check: resource.ComposeTestCheckFunc(resource.TestCheckResourceAttr(
407+
fmt.Sprintf("port_blueprint.%s", identifier),
408+
"properties.string_props.myProperty.title",
409+
"My Property",
410+
)),
411+
},
412+
{
413+
Config: acctest.ProviderConfig + step2Text.String(),
414+
ExpectError: regexp.MustCompile(`The type of property "myProperty" changed from "string" to "number"`),
415+
},
416+
},
417+
})
418+
}
419+
327420
func TestAccPortBlueprintWithChangelogDestination(t *testing.T) {
328421
identifier := utils.GenID()
329422
identifier2 := utils.GenID()

0 commit comments

Comments
 (0)