Skip to content

Commit d1a01f7

Browse files
committed
Convert k6 project IDs to string
1 parent b8365c8 commit d1a01f7

File tree

3 files changed

+163
-40
lines changed

3 files changed

+163
-40
lines changed

docs/resources/k6_project.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ resource "grafana_k6_project" "test_project" {
2929

3030
- `created` (String) The date when the project was created.
3131
- `grafana_folder_uid` (String) The Grafana folder uid.
32-
- `id` (Number) Numeric identifier of the project.
32+
- `id` (String) Numeric identifier of the project.
3333
- `is_default` (Boolean) Use this project as default for running tests when no explicit project identifier is provided.
3434
- `updated` (String) The date when the project was last updated.
3535

internal/resources/k6/resource_project.go

Lines changed: 121 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ import (
1919

2020
// Ensure the implementation satisfies the expected interfaces.
2121
var (
22-
_ resource.ResourceWithConfigure = (*projectResource)(nil)
23-
_ resource.ResourceWithImportState = (*projectResource)(nil)
22+
_ resource.ResourceWithConfigure = (*projectResource)(nil)
23+
_ resource.ResourceWithImportState = (*projectResource)(nil)
24+
_ resource.ResourceWithUpgradeState = (*projectResource)(nil)
2425
)
2526

2627
var (
2728
resourceProjectName = "grafana_k6_project"
28-
resourceProjectID = common.NewResourceID(common.IntIDField("id"))
29+
resourceProjectID = common.NewResourceID(common.StringIDField("id"))
2930
)
3031

3132
func resourceProject() *common.Resource {
@@ -38,7 +39,7 @@ func resourceProject() *common.Resource {
3839
}
3940

4041
// projectResourceModel maps the resource schema data.
41-
type projectResourceModel struct {
42+
type projectResourceModelV0 struct {
4243
ID types.Int32 `tfsdk:"id"`
4344
Name types.String `tfsdk:"name"`
4445
IsDefault types.Bool `tfsdk:"is_default"`
@@ -47,6 +48,15 @@ type projectResourceModel struct {
4748
Updated types.String `tfsdk:"updated"`
4849
}
4950

51+
type projectResourceModelV1 struct {
52+
ID types.String `tfsdk:"id"`
53+
Name types.String `tfsdk:"name"`
54+
IsDefault types.Bool `tfsdk:"is_default"`
55+
GrafanaFolderUID types.String `tfsdk:"grafana_folder_uid"`
56+
Created types.String `tfsdk:"created"`
57+
Updated types.String `tfsdk:"updated"`
58+
}
59+
5060
// projectResource is the resource implementation.
5161
type projectResource struct {
5262
basePluginFrameworkResource
@@ -62,7 +72,7 @@ func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, re
6272
resp.Schema = schema.Schema{
6373
Description: "Manages a k6 project.",
6474
Attributes: map[string]schema.Attribute{
65-
"id": schema.Int32Attribute{
75+
"id": schema.StringAttribute{
6676
Description: "Numeric identifier of the project.",
6777
Computed: true,
6878
},
@@ -87,13 +97,64 @@ func (r *projectResource) Schema(_ context.Context, _ resource.SchemaRequest, re
8797
Computed: true,
8898
},
8999
},
100+
Version: 1,
101+
}
102+
}
103+
104+
func (r *projectResource) UpgradeState(ctx context.Context) map[int64]resource.StateUpgrader {
105+
return map[int64]resource.StateUpgrader{
106+
0: {
107+
PriorSchema: &schema.Schema{
108+
Attributes: map[string]schema.Attribute{
109+
"id": schema.Int32Attribute{
110+
Computed: true,
111+
},
112+
"name": schema.StringAttribute{
113+
Required: true,
114+
},
115+
"is_default": schema.BoolAttribute{
116+
Computed: true,
117+
},
118+
"grafana_folder_uid": schema.StringAttribute{
119+
Computed: true,
120+
},
121+
"created": schema.StringAttribute{
122+
Computed: true,
123+
},
124+
"updated": schema.StringAttribute{
125+
Computed: true,
126+
},
127+
},
128+
},
129+
StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) {
130+
// Convert int32 ID to string ID
131+
var priorStateData projectResourceModelV0
132+
diags := req.State.Get(ctx, &priorStateData)
133+
resp.Diagnostics.Append(diags...)
134+
if resp.Diagnostics.HasError() {
135+
return
136+
}
137+
138+
upgradedStateData := projectResourceModelV1{
139+
ID: types.StringValue(strconv.Itoa(int(priorStateData.ID.ValueInt32()))),
140+
Name: priorStateData.Name,
141+
IsDefault: priorStateData.IsDefault,
142+
GrafanaFolderUID: priorStateData.GrafanaFolderUID,
143+
Created: priorStateData.Created,
144+
Updated: priorStateData.Updated,
145+
}
146+
147+
diags = resp.State.Set(ctx, upgradedStateData)
148+
resp.Diagnostics.Append(diags...)
149+
},
150+
},
90151
}
91152
}
92153

93154
// Create creates the resource and sets the Terraform state on success.
94155
func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
95156
// Retrieve values from plan
96-
var plan projectResourceModel
157+
var plan projectResourceModelV1
97158
diags := req.Plan.Get(ctx, &plan)
98159
resp.Diagnostics.Append(diags...)
99160
if resp.Diagnostics.HasError() {
@@ -119,7 +180,7 @@ func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest
119180
}
120181

121182
// Map response body to schema and populate Computed attribute values
122-
plan.ID = types.Int32Value(p.GetId())
183+
plan.ID = types.StringValue(strconv.Itoa(int(p.GetId())))
123184
plan.Name = types.StringValue(p.GetName())
124185
plan.IsDefault = types.BoolValue(p.GetIsDefault())
125186
plan.GrafanaFolderUID = handleGrafanaFolderUID(p.GrafanaFolderUid)
@@ -137,15 +198,31 @@ func (r *projectResource) Create(ctx context.Context, req resource.CreateRequest
137198
// Read retrieves the resource information.
138199
func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
139200
// Get current state
140-
var state projectResourceModel
201+
var state projectResourceModelV1
141202
diags := req.State.Get(ctx, &state)
142203
resp.Diagnostics.Append(diags...)
143204
if resp.Diagnostics.HasError() {
144205
return
145206
}
146207

208+
// If the ID is empty, we cannot read the resource.
209+
// This is required for crossplane to work, but it never happens in Terraform in practice.
210+
if state.ID.IsNull() || state.ID.IsUnknown() || state.ID.ValueString() == "" {
211+
resp.State.RemoveResource(ctx)
212+
return
213+
}
214+
147215
ctx = context.WithValue(ctx, k6.ContextAccessToken, r.config.Token)
148-
k6Req := r.client.ProjectsAPI.ProjectsRetrieve(ctx, state.ID.ValueInt32()).
216+
projectID, err := strconv.ParseInt(state.ID.ValueString(), 10, 32)
217+
if err != nil {
218+
resp.Diagnostics.AddError(
219+
"Error parsing project ID",
220+
"Could not parse project ID '"+state.ID.ValueString()+"': "+err.Error(),
221+
)
222+
return
223+
}
224+
225+
k6Req := r.client.ProjectsAPI.ProjectsRetrieve(ctx, int32(projectID)).
149226
XStackId(r.config.StackID)
150227

151228
p, httpResp, err := k6Req.Execute()
@@ -158,13 +235,13 @@ func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, re
158235
if err != nil {
159236
resp.Diagnostics.AddError(
160237
"Error reading k6 project",
161-
"Could not read k6 project with id "+strconv.Itoa(int(state.ID.ValueInt32()))+": "+err.Error(),
238+
"Could not read k6 project with id "+state.ID.ValueString()+": "+err.Error(),
162239
)
163240
return
164241
}
165242

166243
// Overwrite items with refreshed state
167-
state.ID = types.Int32Value(p.GetId())
244+
state.ID = types.StringValue(strconv.Itoa(int(p.GetId())))
168245
state.Name = types.StringValue(p.GetName())
169246
state.IsDefault = types.BoolValue(p.GetIsDefault())
170247
state.GrafanaFolderUID = handleGrafanaFolderUID(p.GrafanaFolderUid)
@@ -182,54 +259,64 @@ func (r *projectResource) Read(ctx context.Context, req resource.ReadRequest, re
182259
// Update updates the resource and sets the updated Terraform state on success.
183260
func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
184261
// Retrieve values from plan
185-
var plan projectResourceModel
262+
var plan projectResourceModelV1
186263
diags := req.Plan.Get(ctx, &plan)
187264
resp.Diagnostics.Append(diags...)
188265
if resp.Diagnostics.HasError() {
189266
return
190267
}
191268

192269
// Get current state to retrieve the ID
193-
var state projectResourceModel
270+
var state projectResourceModelV1
194271
diags = req.State.Get(ctx, &state)
195272
resp.Diagnostics.Append(diags...)
196273
if resp.Diagnostics.HasError() {
197274
return
198275
}
199276

277+
intID, err := strconv.ParseInt(state.ID.ValueString(), 10, 32)
278+
if err != nil {
279+
resp.Diagnostics.AddError(
280+
"Error parsing project ID",
281+
"Could not parse project ID '"+state.ID.ValueString()+"': "+err.Error(),
282+
)
283+
return
284+
}
285+
projectID := int32(intID)
286+
200287
// Generate API request body from plan
201288
toUpdate := k6.NewPatchProjectApiModel(plan.Name.ValueString())
202289

203290
ctx = context.WithValue(ctx, k6.ContextAccessToken, r.config.Token)
204-
updateReq := r.client.ProjectsAPI.ProjectsPartialUpdate(ctx, state.ID.ValueInt32()).
291+
updateReq := r.client.ProjectsAPI.ProjectsPartialUpdate(ctx, projectID).
205292
PatchProjectApiModel(toUpdate).
206293
XStackId(r.config.StackID)
207294

208295
// Update the project
209-
_, err := updateReq.Execute()
296+
_, err = updateReq.Execute()
210297
if err != nil {
211298
resp.Diagnostics.AddError(
212299
"Error updating k6 project",
213-
"Could not update k6 project with id "+strconv.Itoa(int(state.ID.ValueInt32()))+": "+err.Error(),
300+
"Could not update k6 project with id "+state.ID.ValueString()+": "+err.Error(),
214301
)
215302
return
216303
}
217304

218305
// Update resource state with updated items and timestamp
219-
fetchReq := r.client.ProjectsAPI.ProjectsRetrieve(ctx, state.ID.ValueInt32()).
306+
fetchReq := r.client.ProjectsAPI.ProjectsRetrieve(ctx, projectID).
220307
XStackId(r.config.StackID)
221308

222309
p, _, err := fetchReq.Execute()
223310
if err != nil {
224311
resp.Diagnostics.AddError(
225312
"Error reading k6 project",
226-
"Could not read k6 project with id "+strconv.Itoa(int(state.ID.ValueInt32()))+": "+err.Error(),
313+
"Could not read k6 project with id "+state.ID.ValueString()+": "+err.Error(),
227314
)
228315
return
229316
}
230317

231318
// Overwrite items with refreshed state
232-
plan.ID = types.Int32Value(p.GetId())
319+
plan.ID = types.StringValue(strconv.Itoa(int(p.GetId())))
233320
plan.Name = types.StringValue(p.GetName())
234321
plan.IsDefault = types.BoolValue(p.GetIsDefault())
235322
plan.GrafanaFolderUID = handleGrafanaFolderUID(p.GrafanaFolderUid)
@@ -246,44 +333,39 @@ func (r *projectResource) Update(ctx context.Context, req resource.UpdateRequest
246333
// Delete deletes the resource and removes the Terraform state on success.
247334
func (r *projectResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
248335
// Retrieve values from state
249-
var state projectResourceModel
336+
var state projectResourceModelV1
250337
diags := req.State.Get(ctx, &state)
251338
resp.Diagnostics.Append(diags...)
252339
if resp.Diagnostics.HasError() {
253340
return
254341
}
255342

343+
intID, err := strconv.ParseInt(state.ID.ValueString(), 10, 32)
344+
if err != nil {
345+
resp.Diagnostics.AddError(
346+
"Error parsing project ID",
347+
"Could not parse project ID '"+state.ID.ValueString()+"': "+err.Error(),
348+
)
349+
return
350+
}
351+
projectID := int32(intID)
352+
256353
// Delete existing project
257354
ctx = context.WithValue(ctx, k6.ContextAccessToken, r.config.Token)
258-
deleteReq := r.client.ProjectsAPI.ProjectsDestroy(ctx, state.ID.ValueInt32()).
355+
deleteReq := r.client.ProjectsAPI.ProjectsDestroy(ctx, projectID).
259356
XStackId(r.config.StackID)
260357

261-
_, err := deleteReq.Execute()
358+
_, err = deleteReq.Execute()
262359
if err != nil {
263360
resp.Diagnostics.AddError(
264361
"Error deleting k6 project",
265-
"Could not delete k6 project with id "+strconv.Itoa(int(state.ID.ValueInt32()))+": "+err.Error(),
362+
"Could not delete k6 project with id "+state.ID.ValueString()+": "+err.Error(),
266363
)
267364
}
268365
}
269366

270367
func (r *projectResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
271-
id, err := strconv.ParseInt(req.ID, 10, 32)
272-
if err != nil {
273-
resp.Diagnostics.AddError(
274-
"Error importing k6 project",
275-
"Could not parse k6 project id "+req.ID+": "+err.Error(),
276-
)
277-
return
278-
}
279-
280-
resp.State.SetAttribute(ctx, path.Root("id"), types.Int32Value(int32(id)))
281-
282-
readReq := resource.ReadRequest{State: resp.State}
283-
readResp := resource.ReadResponse{State: resp.State}
284-
285-
r.Read(ctx, readReq, &readResp)
286-
resp.Diagnostics.Append(readResp.Diagnostics...)
368+
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
287369
}
288370

289371
// listProjects retrieves the list ids of all the existing projects.

internal/resources/k6/resource_project_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,47 @@ func TestAccProject_basic(t *testing.T) {
9797
})
9898
}
9999

100+
func TestAccProject_StateUpgrade(t *testing.T) {
101+
testutils.CheckCloudInstanceTestsEnabled(t)
102+
103+
var project k6.ProjectApiModel
104+
105+
projectName := "Terraform Test Project " + acctest.RandString(8)
106+
107+
resource.ParallelTest(t, resource.TestCase{
108+
CheckDestroy: resource.ComposeTestCheckFunc(
109+
projectCheckExists.destroyed(&project),
110+
),
111+
Steps: []resource.TestStep{
112+
{
113+
Config: testutils.TestAccExampleWithReplace(t, "resources/grafana_k6_project/resource.tf", map[string]string{
114+
"Terraform Test Project": projectName,
115+
}),
116+
ExternalProviders: map[string]resource.ExternalProvider{
117+
"grafana": {
118+
Source: "grafana/grafana",
119+
VersionConstraint: "<=3.25.2",
120+
},
121+
},
122+
Check: projectCheckExists.exists("grafana_k6_project.test_project", &project),
123+
},
124+
// Test apply updates the TF state to the latest schema but the resource is unchanged
125+
{
126+
ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories,
127+
Config: testutils.TestAccExampleWithReplace(t, "resources/grafana_k6_project/resource.tf", map[string]string{
128+
"Terraform Test Project": projectName,
129+
}),
130+
Check: resource.ComposeTestCheckFunc(
131+
testAccProjectUnchangedAttr("grafana_k6_project.test_project", "id", func() string { return strconv.Itoa(int(project.GetId())) }),
132+
testAccProjectUnchangedAttr("grafana_k6_project.test_project", "name", func() string { return projectName }),
133+
testAccProjectUnchangedAttr("grafana_k6_project.test_project", "grafana_folder_uid", project.GetGrafanaFolderUid),
134+
testAccProjectUnchangedAttr("grafana_k6_project.test_project", "created", func() string { return project.GetCreated().Truncate(time.Microsecond).Format(time.RFC3339Nano) }),
135+
testAccProjectUnchangedAttr("grafana_k6_project.test_project", "updated", func() string { return project.GetUpdated().Truncate(time.Microsecond).Format(time.RFC3339Nano) }),
136+
),
137+
},
138+
},
139+
})
140+
}
100141
func testAccProjectUnchangedAttr(resName, attrName string, oldValueGetter func() string) resource.TestCheckFunc {
101142
return resource.TestCheckResourceAttrWith(resName, attrName, func(newVal string) error {
102143
if oldValue := oldValueGetter(); oldValue != newVal {

0 commit comments

Comments
 (0)