Skip to content

Commit 8021cf9

Browse files
Config Generation: Handle read failures (#1690)
Sometimes, Grafana allows list operations but not read operations on limited permissions What happens then is that the import blocks are generated, but the process crashes on the resource generation step (tf plan) With this PR, this is turned into a "non-critical" error which can be displayed and ignored The superfluous import blocks are removed
1 parent 9d29e94 commit 8021cf9

File tree

8 files changed

+233
-15
lines changed

8 files changed

+233
-15
lines changed

pkg/generate/generate.go

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"github.com/grafana/terraform-provider-grafana/v3/internal/common"
1515
"github.com/grafana/terraform-provider-grafana/v3/pkg/generate/postprocessing"
16+
"github.com/grafana/terraform-provider-grafana/v3/pkg/generate/utils"
1617
"github.com/hashicorp/hcl/v2/hclwrite"
1718
"github.com/hashicorp/terraform-exec/tfexec"
1819
"github.com/zclconf/go-cty/cty"
@@ -22,8 +23,13 @@ var (
2223
allowedTerraformChars = regexp.MustCompile(`[^a-zA-Z0-9_-]`)
2324
)
2425

26+
// NonCriticalError is an error that is not critical to the generation process.
27+
// It can be handled differently by the caller.
28+
type NonCriticalError interface {
29+
NonCriticalError()
30+
}
31+
2532
// ResourceError is an error that occurred while generating a resource.
26-
// It can be filtered out by the caller if it is not critical that a single resource failed.
2733
type ResourceError struct {
2834
Resource *common.Resource
2935
Err error
@@ -33,6 +39,12 @@ func (e ResourceError) Error() string {
3339
return fmt.Sprintf("resource %s: %v", e.Resource.Name, e.Err)
3440
}
3541

42+
func (ResourceError) NonCriticalError() {}
43+
44+
type NonCriticalGenerationFailure struct{ error }
45+
46+
func (f NonCriticalGenerationFailure) NonCriticalError() {}
47+
3648
type GenerationSuccess struct {
3749
Resource *common.Resource
3850
Blocks int
@@ -293,15 +305,61 @@ func generateImportBlocks(ctx context.Context, client *common.Client, listerData
293305
}
294306
_, err = cfg.Terraform.Plan(ctx, tfexec.GenerateConfigOut(generatedFilename("resources.tf")))
295307
if err != nil {
296-
return failuref("failed to generate resources: %w", err)
308+
// If resources.tf was created and is not empty, return the error as a "non-critical" error
309+
if stat, statErr := os.Stat(generatedFilename("resources.tf")); statErr == nil && stat.Size() > 0 {
310+
returnResult.Errors = append(returnResult.Errors, NonCriticalGenerationFailure{err})
311+
} else {
312+
return failuref("failed to generate resources: %w", err)
313+
}
297314
}
315+
316+
if err := removeOrphanedImports(generatedFilename("imports.tf"), generatedFilename("resources.tf")); err != nil {
317+
return failure(err)
318+
}
319+
298320
if err := sortResourcesFile(generatedFilename("resources.tf")); err != nil {
299321
return failure(err)
300322
}
301323

302324
return returnResult
303325
}
304326

327+
// removeOrphanedImports removes import blocks that do not have a corresponding resource block in the resources file.
328+
// These happen when the Terraform plan command has failed for some resources.
329+
func removeOrphanedImports(importsFile, resourcesFile string) error {
330+
imports, err := utils.ReadHCLFile(importsFile)
331+
if err != nil {
332+
return err
333+
}
334+
335+
resources, err := utils.ReadHCLFile(resourcesFile)
336+
if err != nil {
337+
return err
338+
}
339+
340+
resourcesMap := map[string]struct{}{}
341+
for _, block := range resources.Body().Blocks() {
342+
if block.Type() != "resource" {
343+
continue
344+
}
345+
346+
resourcesMap[strings.Join(block.Labels(), ".")] = struct{}{}
347+
}
348+
349+
for _, block := range imports.Body().Blocks() {
350+
if block.Type() != "import" {
351+
continue
352+
}
353+
354+
importTo := strings.TrimSpace(string(block.Body().GetAttribute("to").Expr().BuildTokens(nil).Bytes()))
355+
if _, ok := resourcesMap[importTo]; !ok {
356+
imports.Body().RemoveBlock(block)
357+
}
358+
}
359+
360+
return writeBlocksFile(importsFile, true, imports.Body().Blocks()...)
361+
}
362+
305363
func filterResources(resources []*common.Resource, includedResources []string) ([]*common.Resource, error) {
306364
if len(includedResources) == 0 {
307365
return resources, nil

pkg/generate/generate_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ import (
77
"strings"
88
"testing"
99

10+
"github.com/grafana/grafana-openapi-client-go/client/access_control"
11+
"github.com/grafana/grafana-openapi-client-go/client/service_accounts"
12+
"github.com/grafana/grafana-openapi-client-go/models"
13+
"github.com/grafana/terraform-provider-grafana/v3/internal/common"
1014
"github.com/grafana/terraform-provider-grafana/v3/internal/testutils"
1115
"github.com/grafana/terraform-provider-grafana/v3/pkg/generate"
16+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest"
1217
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
1318
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
1419
"github.com/stretchr/testify/assert"
@@ -228,6 +233,99 @@ func TestAccGenerate(t *testing.T) {
228233
}
229234
}
230235

236+
func TestAccGenerate_RestrictedPermissions(t *testing.T) {
237+
if testing.Short() {
238+
t.Skip("skipping long test")
239+
}
240+
testutils.CheckEnterpriseTestsEnabled(t, ">=10.0.0")
241+
242+
// Create SA with no permissions
243+
randString := acctest.RandString(10)
244+
client := testutils.Provider.Meta().(*common.Client).GrafanaAPI.Clone().WithOrgID(0)
245+
sa, err := client.ServiceAccounts.CreateServiceAccount(
246+
service_accounts.NewCreateServiceAccountParams().WithBody(&models.CreateServiceAccountForm{
247+
Name: "test-no-permissions-" + randString,
248+
Role: "None",
249+
},
250+
))
251+
require.NoError(t, err)
252+
t.Cleanup(func() {
253+
client.ServiceAccounts.DeleteServiceAccount(sa.Payload.ID)
254+
})
255+
256+
saToken, err := client.ServiceAccounts.CreateToken(
257+
service_accounts.NewCreateTokenParams().WithBody(&models.AddServiceAccountTokenCommand{
258+
Name: "test-no-permissions-" + randString,
259+
},
260+
).WithServiceAccountID(sa.Payload.ID),
261+
)
262+
require.NoError(t, err)
263+
264+
// Allow the SA to read dashboards
265+
if _, err := client.AccessControl.CreateRole(&models.CreateRoleForm{
266+
Name: randString,
267+
Permissions: []*models.Permission{
268+
{
269+
Action: "dashboards:read",
270+
Scope: "dashboards:*",
271+
},
272+
{
273+
Action: "folders:read",
274+
Scope: "folders:*",
275+
},
276+
},
277+
UID: randString,
278+
}); err != nil {
279+
t.Fatal(err)
280+
}
281+
t.Cleanup(func() {
282+
client.AccessControl.DeleteRole(access_control.NewDeleteRoleParams().WithRoleUID(randString))
283+
})
284+
if _, err := client.AccessControl.SetUserRoles(sa.Payload.ID, &models.SetUserRolesCommand{
285+
RoleUids: []string{randString},
286+
Global: false,
287+
}); err != nil {
288+
t.Fatal(err)
289+
}
290+
291+
resource.Test(t, resource.TestCase{
292+
ProtoV5ProviderFactories: testutils.ProtoV5ProviderFactories,
293+
Steps: []resource.TestStep{
294+
{
295+
Config: testutils.TestAccExample(t, "resources/grafana_dashboard/resource.tf"),
296+
Check: func(s *terraform.State) error {
297+
tempDir := t.TempDir()
298+
config := generate.Config{
299+
OutputDir: tempDir,
300+
Clobber: true,
301+
Format: generate.OutputFormatHCL,
302+
ProviderVersion: "v3.0.0",
303+
Grafana: &generate.GrafanaConfig{
304+
URL: "http://localhost:3000",
305+
Auth: saToken.Payload.Key,
306+
},
307+
}
308+
309+
result := generate.Generate(context.Background(), &config)
310+
assert.NotEmpty(t, result.Errors, "expected errors, got: %+v", result)
311+
for _, err := range result.Errors {
312+
// Check that all errors are non critical
313+
_, ok := err.(generate.NonCriticalError)
314+
assert.True(t, ok, "expected NonCriticalError, got: %v (Type: %T)", err, err)
315+
}
316+
317+
assertFiles(t, tempDir, "testdata/generate/dashboard-restricted-permissions", []string{
318+
".terraform",
319+
".terraform.lock.hcl",
320+
})
321+
322+
return nil
323+
},
324+
},
325+
},
326+
})
327+
}
328+
231329
// assertFiles checks that all files in the "expectedFilesDir" directory match the files in the "gotFilesDir" directory.
232330
func assertFiles(t *testing.T, gotFilesDir, expectedFilesDir string, ignoreDirEntries []string) {
233331
t.Helper()

pkg/generate/postprocessing/postprocessing.go

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,19 @@
11
package postprocessing
22

33
import (
4-
"errors"
54
"os"
65

7-
"github.com/hashicorp/hcl/v2"
6+
"github.com/grafana/terraform-provider-grafana/v3/pkg/generate/utils"
87
"github.com/hashicorp/hcl/v2/hclwrite"
98
)
109

1110
type postprocessingFunc func(*hclwrite.File) error
1211

1312
func postprocessFile(fpath string, fn postprocessingFunc) error {
14-
src, err := os.ReadFile(fpath)
13+
file, err := utils.ReadHCLFile(fpath)
1514
if err != nil {
1615
return err
1716
}
18-
19-
file, diags := hclwrite.ParseConfig(src, fpath, hcl.Pos{Line: 1, Column: 1})
20-
if diags.HasErrors() {
21-
return errors.New(diags.Error())
22-
}
2317
initialBytes := file.Bytes()
2418

2519
if err := fn(file); err != nil {

pkg/generate/terraform.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,18 @@ func setupTerraform(cfg *Config) (*tfexec.Terraform, error) {
7474
}
7575

7676
func writeBlocks(filepath string, blocks ...*hclwrite.Block) error {
77+
return writeBlocksFile(filepath, false, blocks...)
78+
}
79+
80+
func writeBlocksFile(filepath string, new bool, blocks ...*hclwrite.Block) error {
7781
contents := hclwrite.NewFile()
78-
if fileBytes, err := os.ReadFile(filepath); err == nil {
79-
var diags hcl.Diagnostics
80-
contents, diags = hclwrite.ParseConfig(fileBytes, filepath, hcl.InitialPos)
81-
if diags.HasErrors() {
82-
return errors.Join(diags.Errs()...)
82+
if !new {
83+
if fileBytes, err := os.ReadFile(filepath); err == nil {
84+
var diags hcl.Diagnostics
85+
contents, diags = hclwrite.ParseConfig(fileBytes, filepath, hcl.InitialPos)
86+
if diags.HasErrors() {
87+
return errors.Join(diags.Errs()...)
88+
}
8389
}
8490
}
8591

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import {
2+
to = grafana_dashboard._0_my-dashboard-uid
3+
id = "0:my-dashboard-uid"
4+
}
5+
6+
import {
7+
to = grafana_folder._0_my-folder-uid
8+
id = "0:my-folder-uid"
9+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
terraform {
2+
required_providers {
3+
grafana = {
4+
source = "grafana/grafana"
5+
version = "3.0.0"
6+
}
7+
}
8+
}
9+
10+
provider "grafana" {
11+
url = "http://localhost:3000"
12+
auth = "REDACTED"
13+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# __generated__ by Terraform
2+
# Please review these resources and move them into your main configuration files.
3+
4+
# __generated__ by Terraform from "0:my-dashboard-uid"
5+
resource "grafana_dashboard" "_0_my-dashboard-uid" {
6+
config_json = jsonencode({
7+
title = "My Dashboard"
8+
uid = "my-dashboard-uid"
9+
})
10+
folder = grafana_folder._0_my-folder-uid.uid
11+
}
12+
13+
# __generated__ by Terraform from "0:my-folder-uid"
14+
resource "grafana_folder" "_0_my-folder-uid" {
15+
title = "My Folder"
16+
uid = "my-folder-uid"
17+
}

pkg/generate/utils/hcl.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package utils
2+
3+
import (
4+
"errors"
5+
"os"
6+
7+
"github.com/hashicorp/hcl/v2"
8+
"github.com/hashicorp/hcl/v2/hclwrite"
9+
)
10+
11+
func ReadHCLFile(fpath string) (*hclwrite.File, error) {
12+
src, err := os.ReadFile(fpath)
13+
if err != nil {
14+
return nil, err
15+
}
16+
17+
file, diags := hclwrite.ParseConfig(src, fpath, hcl.Pos{Line: 1, Column: 1})
18+
if diags.HasErrors() {
19+
return nil, errors.New(diags.Error())
20+
}
21+
22+
return file, nil
23+
}

0 commit comments

Comments
 (0)