Skip to content

Commit e917ad5

Browse files
toppercodesampagent
andcommitted
Harden dashboard UID handling and JSON normalization
Amp-Thread-ID: https://ampcode.com/threads/T-019c9bc7-a165-774f-b025-2e92d6e0b9d5 Co-authored-by: Amp <amp@ampcode.com>
1 parent d53d729 commit e917ad5

4 files changed

Lines changed: 207 additions & 20 deletions

File tree

axiom/data_source_dashboard.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func (d *DashboardDataSource) Schema(_ context.Context, _ datasource.SchemaReque
6262
},
6363
"id": schema.StringAttribute{
6464
Computed: true,
65-
MarkdownDescription: "Dashboard identifier (same value as `uid`).",
65+
MarkdownDescription: "Internal dashboard identifier returned by the API.",
6666
},
6767
"dashboard": schema.StringAttribute{
6868
Computed: true,
@@ -116,7 +116,7 @@ func (d *DashboardDataSource) Read(ctx context.Context, req datasource.ReadReque
116116
return
117117
}
118118

119-
dashboardJSON, err := normalizeDashboardRaw(dashboard.Dashboard)
119+
dashboardJSON, err := normalizeDashboardRaw(dashboard.Dashboard, types.StringValue(string(dashboard.Dashboard)))
120120
if err != nil {
121121
resp.Diagnostics.AddError("Failed to read dashboard", fmt.Sprintf("Unable to normalize dashboard payload: %s", err))
122122
return

axiom/resource_dashboard.go

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ import (
1919
"github.com/axiomhq/axiom-go/axiom"
2020
)
2121

22+
var dashboardServerManagedFields = map[string]struct{}{
23+
"id": {},
24+
"version": {},
25+
"createdAt": {},
26+
"updatedAt": {},
27+
"createdBy": {},
28+
"updatedBy": {},
29+
}
30+
2231
var (
2332
_ resource.Resource = &DashboardResource{}
2433
_ resource.ResourceWithImportState = &DashboardResource{}
@@ -75,7 +84,7 @@ func (r *DashboardResource) Schema(_ context.Context, _ resource.SchemaRequest,
7584
Attributes: map[string]schema.Attribute{
7685
"id": schema.StringAttribute{
7786
Computed: true,
78-
MarkdownDescription: "Dashboard identifier (same value as `uid`).",
87+
MarkdownDescription: "Internal dashboard identifier returned by the API.",
7988
PlanModifiers: []planmodifier.String{
8089
stringplanmodifier.UseStateForUnknown(),
8190
},
@@ -179,7 +188,7 @@ func (r *DashboardResource) Create(ctx context.Context, req resource.CreateReque
179188
return
180189
}
181190

182-
state, err := flattenDashboardResource(created.Dashboard, plan.Overwrite)
191+
state, err := flattenDashboardResource(created.Dashboard, plan.Overwrite, plan.Dashboard)
183192
if err != nil {
184193
resp.Diagnostics.AddError("Failed to create dashboard", err.Error())
185194
return
@@ -222,7 +231,7 @@ func (r *DashboardResource) Read(ctx context.Context, req resource.ReadRequest,
222231
return
223232
}
224233

225-
flattened, err := flattenDashboardResource(*dashboard, state.Overwrite)
234+
flattened, err := flattenDashboardResource(*dashboard, state.Overwrite, state.Dashboard)
226235
if err != nil {
227236
resp.Diagnostics.AddError("Failed to read dashboard", err.Error())
228237
return
@@ -271,7 +280,7 @@ func (r *DashboardResource) Update(ctx context.Context, req resource.UpdateReque
271280
return
272281
}
273282

274-
flattened, err := flattenDashboardResource(updated.Dashboard, plan.Overwrite)
283+
flattened, err := flattenDashboardResource(updated.Dashboard, plan.Overwrite, plan.Dashboard)
275284
if err != nil {
276285
resp.Diagnostics.AddError("Failed to update dashboard", err.Error())
277286
return
@@ -310,7 +319,7 @@ func (r *DashboardResource) ImportState(ctx context.Context, req resource.Import
310319
func dashboardUpsertPayloadFromModel(plan DashboardResourceModel, fallbackUID string, currentVersion int64, isCreate bool) (dashboardUpsertRequest, string, diag.Diagnostics) {
311320
var diags diag.Diagnostics
312321

313-
normalizedDashboard, err := normalizeDashboardString(plan.Dashboard.ValueString())
322+
normalizedDashboard, dashboardMap, err := normalizeDashboardString(plan.Dashboard.ValueString())
314323
if err != nil {
315324
diags.AddError("Invalid dashboard JSON", fmt.Sprintf("`dashboard` must be valid JSON: %s", err))
316325
return dashboardUpsertRequest{}, "", diags
@@ -321,6 +330,15 @@ func dashboardUpsertPayloadFromModel(plan DashboardResourceModel, fallbackUID st
321330
uid = plan.UID.ValueString()
322331
}
323332

333+
dashboardUID, hasDashboardUID := stringValueFromMap(dashboardMap, "uid")
334+
if hasDashboardUID {
335+
if uid != "" && uid != dashboardUID {
336+
diags.AddError("UID mismatch", "`uid` must match `dashboard.uid` when both are set.")
337+
return dashboardUpsertRequest{}, "", diags
338+
}
339+
uid = dashboardUID
340+
}
341+
324342
payload := dashboardUpsertRequest{
325343
Dashboard: normalizedDashboard,
326344
UID: uid,
@@ -356,8 +374,8 @@ func dashboardUIDFromState(state DashboardResourceModel) string {
356374
return ""
357375
}
358376

359-
func flattenDashboardResource(in dashboardResourcePayload, overwrite types.Bool) (DashboardResourceModel, error) {
360-
normalizedDashboard, err := normalizeDashboardRaw(in.Dashboard)
377+
func flattenDashboardResource(in dashboardResourcePayload, overwrite types.Bool, configuredDashboard types.String) (DashboardResourceModel, error) {
378+
normalizedDashboard, err := normalizeDashboardRaw(in.Dashboard, configuredDashboard)
361379
if err != nil {
362380
return DashboardResourceModel{}, fmt.Errorf("unable to normalize dashboard document: %w", err)
363381
}
@@ -396,21 +414,21 @@ func decodeDashboardResource(raw []byte) (*dashboardResourcePayload, error) {
396414
return out, nil
397415
}
398416

399-
func normalizeDashboardString(raw string) (json.RawMessage, error) {
417+
func normalizeDashboardString(raw string) (json.RawMessage, map[string]any, error) {
400418
parsed := make(map[string]any)
401419
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
402-
return nil, err
420+
return nil, nil, err
403421
}
404422

405423
normalized, err := json.Marshal(parsed)
406424
if err != nil {
407-
return nil, err
425+
return nil, nil, err
408426
}
409427

410-
return normalized, nil
428+
return normalized, parsed, nil
411429
}
412430

413-
func normalizeDashboardRaw(raw json.RawMessage) (string, error) {
431+
func normalizeDashboardRaw(raw json.RawMessage, configured types.String) (string, error) {
414432
if len(raw) == 0 {
415433
return "", errors.New("dashboard payload is empty")
416434
}
@@ -420,6 +438,14 @@ func normalizeDashboardRaw(raw json.RawMessage) (string, error) {
420438
return "", err
421439
}
422440

441+
for field := range dashboardServerManagedFields {
442+
delete(parsed, field)
443+
}
444+
445+
if !dashboardConfigHasUID(configured) {
446+
delete(parsed, "uid")
447+
}
448+
423449
normalized, err := json.Marshal(parsed)
424450
if err != nil {
425451
return "", err
@@ -428,6 +454,34 @@ func normalizeDashboardRaw(raw json.RawMessage) (string, error) {
428454
return string(normalized), nil
429455
}
430456

457+
func dashboardConfigHasUID(configured types.String) bool {
458+
if configured.IsNull() || configured.IsUnknown() || configured.ValueString() == "" {
459+
return false
460+
}
461+
462+
parsed := make(map[string]any)
463+
if err := json.Unmarshal([]byte(configured.ValueString()), &parsed); err != nil {
464+
return false
465+
}
466+
467+
_, ok := stringValueFromMap(parsed, "uid")
468+
return ok
469+
}
470+
471+
func stringValueFromMap(in map[string]any, key string) (string, bool) {
472+
v, ok := in[key]
473+
if !ok {
474+
return "", false
475+
}
476+
477+
s, ok := v.(string)
478+
if !ok || s == "" {
479+
return "", false
480+
}
481+
482+
return s, true
483+
}
484+
431485
func addDashboardUpdateError(resp *resource.UpdateResponse, err error, uid string, localVersion int64) {
432486
addDashboardWriteErrorDiagnostics(&resp.Diagnostics, err, uid, localVersion)
433487
}

axiom/resource_dashboard_integration_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,48 @@ func TestAccAxiomDashboardResource_ServerGeneratedUID(t *testing.T) {
136136
})
137137
}
138138

139+
func TestAccAxiomDashboardResource_UIDInDashboardDocument(t *testing.T) {
140+
if os.Getenv("TF_ACC") == "" {
141+
t.Skip("acceptance tests skipped unless TF_ACC is set")
142+
}
143+
testAccPreCheck(t)
144+
145+
client, err := ax.NewClient()
146+
assert.NoError(t, err)
147+
148+
uid := "tf-dashboard-doc-uid-" + strings.ReplaceAll(uuid.NewString(), "_", "-")
149+
resourceName := "axiom_dashboard.test"
150+
151+
resource.Test(t, resource.TestCase{
152+
ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){
153+
"axiom": providerserver.NewProtocol6WithError(NewAxiomProvider()),
154+
},
155+
CheckDestroy: testAccCheckAxiomResourcesDestroyed(client),
156+
Steps: []resource.TestStep{
157+
{
158+
Config: testAccAxiomDashboardConfigDocumentUID(uid, "doc-uid-create", false, true),
159+
Check: resource.ComposeTestCheckFunc(
160+
testAccCheckAxiomResourcesExist(client, resourceName),
161+
resource.TestCheckResourceAttr(resourceName, "uid", uid),
162+
),
163+
},
164+
{
165+
Config: testAccAxiomDashboardConfigDocumentUID(uid, "doc-uid-update", false, false),
166+
Check: resource.ComposeTestCheckFunc(
167+
testAccCheckAxiomResourcesExist(client, resourceName),
168+
resource.TestCheckResourceAttr(resourceName, "uid", uid),
169+
resource.TestCheckResourceAttrWith(resourceName, "dashboard", func(v string) error {
170+
if strings.Contains(v, `"uid"`) {
171+
return fmt.Errorf("expected dashboard JSON in state to omit uid when not configured, got %s", v)
172+
}
173+
return nil
174+
}),
175+
),
176+
},
177+
},
178+
})
179+
}
180+
139181
func testAccAxiomDashboardConfig(uid, name string, overwrite bool) string {
140182
return fmt.Sprintf(`
141183
provider "axiom" {
@@ -183,6 +225,34 @@ resource "axiom_dashboard" "test" {
183225
`, os.Getenv("AXIOM_TOKEN"), os.Getenv("AXIOM_URL"), overwrite, name)
184226
}
185227

228+
func testAccAxiomDashboardConfigDocumentUID(uid, name string, overwrite, includeUIDInDocument bool) string {
229+
uidField := ""
230+
if includeUIDInDocument {
231+
uidField = fmt.Sprintf("uid = %q\n", uid)
232+
}
233+
234+
return fmt.Sprintf(`
235+
provider "axiom" {
236+
api_token = %q
237+
base_url = %q
238+
}
239+
240+
resource "axiom_dashboard" "test" {
241+
overwrite = %t
242+
dashboard = jsonencode({
243+
%sname = %q
244+
description = "terraform acceptance dashboard"
245+
refreshTime = 60
246+
schemaVersion = 2
247+
timeWindowStart = "qr-now-1h"
248+
timeWindowEnd = "qr-now"
249+
charts = []
250+
layout = []
251+
})
252+
}
253+
`, os.Getenv("AXIOM_TOKEN"), os.Getenv("AXIOM_URL"), overwrite, uidField, name)
254+
}
255+
186256
func testAccCaptureDashboardUID(resourceName string, out *string) resource.TestCheckFunc {
187257
return func(s *terraform.State) error {
188258
rs, ok := s.RootModule().Resources[resourceName]

0 commit comments

Comments
 (0)