diff --git a/cmd/generate/main.go b/cmd/generate/main.go index ee9979b1a..2eb2758d8 100644 --- a/cmd/generate/main.go +++ b/cmd/generate/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "fmt" "log" "os" @@ -167,7 +168,8 @@ This supports a glob format. Examples: if err != nil { return fmt.Errorf("failed to parse flags: %w", err) } - return generate.Generate(ctx.Context, cfg) + result := generate.Generate(ctx.Context, cfg) + return errors.Join(result.Errors...) }, } diff --git a/internal/resources/slo/resource_slo.go b/internal/resources/slo/resource_slo.go index 75678fdc3..b28b0c0fb 100644 --- a/internal/resources/slo/resource_slo.go +++ b/internal/resources/slo/resource_slo.go @@ -272,13 +272,7 @@ func listSlos(ctx context.Context, client *common.Client, data any) ([]string, e slolist, _, err := sloClient.DefaultAPI.V1SloGet(ctx).Execute() if err != nil { - // // TODO: Uninitialized SLO plugin. This should be handled better - // cast, ok := err.(*slo.GenericOpenAPIError) - // if ok && strings.Contains(cast.Error(), "status: 500") { - // return nil, nil - // } - - return nil, nil + return nil, err } var ids []string diff --git a/pkg/generate/cloud.go b/pkg/generate/cloud.go index 9c34d085f..62a434d5a 100644 --- a/pkg/generate/cloud.go +++ b/pkg/generate/cloud.go @@ -33,13 +33,13 @@ type stack struct { onCallToken string } -func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, error) { +func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, GenerationResult) { // Gen provider providerBlock := hclwrite.NewBlock("provider", []string{"grafana"}) providerBlock.Body().SetAttributeValue("alias", cty.StringVal("cloud")) providerBlock.Body().SetAttributeValue("cloud_access_policy_token", cty.StringVal(cfg.Cloud.AccessPolicyToken)) if err := writeBlocks(filepath.Join(cfg.OutputDir, "cloud-provider.tf"), providerBlock); err != nil { - return nil, err + return nil, failure(err) } // Generate imports @@ -47,18 +47,18 @@ func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, error) { CloudAccessPolicyToken: types.StringValue(cfg.Cloud.AccessPolicyToken), } if err := config.SetDefaults(); err != nil { - return nil, err + return nil, failure(err) } client, err := provider.CreateClients(config) if err != nil { - return nil, err + return nil, failure(err) } cloudClient := client.GrafanaCloudAPI stacks, _, err := cloudClient.InstancesAPI.GetInstances(ctx).Execute() if err != nil { - return nil, err + return nil, failure(err) } // Cleanup SAs @@ -67,32 +67,33 @@ func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, error) { if cfg.Cloud.CreateStackServiceAccount { for _, stack := range stacks.Items { if err := createManagementStackServiceAccount(ctx, cloudClient, stack, managementServiceAccountName); err != nil { - return nil, err + return nil, failure(err) } } } data := cloud.NewListerData(cfg.Cloud.Org) - if err := generateImportBlocks(ctx, client, data, cloud.Resources, cfg, "cloud"); err != nil { - return nil, err + returnResult := generateImportBlocks(ctx, client, data, cloud.Resources, cfg, "cloud") + if returnResult.Blocks() == 0 { // Skip if no resources were found + return nil, returnResult } plannedState, err := getPlannedState(ctx, cfg) if err != nil { - return nil, err + return nil, failure(err) } if err := postprocessing.StripDefaults(filepath.Join(cfg.OutputDir, "cloud-resources.tf"), nil); err != nil { - return nil, err + return nil, failure(err) } if err := postprocessing.WrapJSONFieldsInFunction(filepath.Join(cfg.OutputDir, "cloud-resources.tf")); err != nil { - return nil, err + return nil, failure(err) } if err := postprocessing.ReplaceReferences(filepath.Join(cfg.OutputDir, "cloud-resources.tf"), plannedState, nil); err != nil { - return nil, err + return nil, failure(err) } if !cfg.Cloud.CreateStackServiceAccount { - return nil, nil + return nil, returnResult } // Add management service account (grafana_cloud_stack_service_account) @@ -149,7 +150,7 @@ func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, error) { providerBlock.Body().SetAttributeTraversal("sm_url", traversal("grafana_synthetic_monitoring_installation", stack.Slug, "stack_sm_api_url")) if err := writeBlocks(filepath.Join(cfg.OutputDir, fmt.Sprintf("stack-%s-provider.tf", stack.Slug)), saBlock, saTokenBlock, smInstallationMetricsPublishBlock, smInstallationTokenBlock, smInstallationBlock, providerBlock); err != nil { - return nil, fmt.Errorf("failed to write management service account blocks for stack %q: %w", stack.Slug, err) + return nil, failuref("failed to write management service account blocks for stack %q: %w", stack.Slug, err) } // Apply then go into the state and find the management key @@ -161,14 +162,14 @@ func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, error) { tfexec.Target("grafana_synthetic_monitoring_installation."+stack.Slug), ) if err != nil { - return nil, fmt.Errorf("failed to apply management service account blocks for stack %q: %w", stack.Slug, err) + return nil, failuref("failed to apply management service account blocks for stack %q: %w", stack.Slug, err) } } managedStacks := []stack{} state, err := getState(ctx, cfg) if err != nil { - return nil, err + return nil, failure(err) } stacksMap := map[string]stack{} for _, resource := range state.Values.RootModule.Resources { @@ -198,7 +199,7 @@ func generateCloudResources(ctx context.Context, cfg *Config) ([]stack, error) { managedStacks = append(managedStacks, stack) } - return managedStacks, nil + return managedStacks, returnResult } func createManagementStackServiceAccount(ctx context.Context, cloudClient *gcom.APIClient, stack gcom.FormattedApiInstance, saName string) error { diff --git a/pkg/generate/generate.go b/pkg/generate/generate.go index 263f37ef9..4b0830255 100644 --- a/pkg/generate/generate.go +++ b/pkg/generate/generate.go @@ -22,26 +22,65 @@ var ( allowedTerraformChars = regexp.MustCompile(`[^a-zA-Z0-9_-]`) ) -func Generate(ctx context.Context, cfg *Config) error { +// ResourceError is an error that occurred while generating a resource. +// It can be filtered out by the caller if it is not critical that a single resource failed. +type ResourceError struct { + Resource *common.Resource + Err error +} + +func (e ResourceError) Error() string { + return fmt.Sprintf("resource %s: %v", e.Resource.Name, e.Err) +} + +type GenerationSuccess struct { + Resource *common.Resource + Blocks int +} + +type GenerationResult struct { + Success []GenerationSuccess + Errors []error +} + +func (r GenerationResult) Blocks() int { + blocks := 0 + for _, s := range r.Success { + blocks += s.Blocks + } + return blocks +} + +func failure(err error) GenerationResult { + return GenerationResult{ + Errors: []error{err}, + } +} + +func failuref(format string, args ...any) GenerationResult { + return failure(fmt.Errorf(format, args...)) +} + +func Generate(ctx context.Context, cfg *Config) GenerationResult { var err error if !filepath.IsAbs(cfg.OutputDir) { if cfg.OutputDir, err = filepath.Abs(cfg.OutputDir); err != nil { - return fmt.Errorf("failed to get absolute path for %s: %w", cfg.OutputDir, err) + return failuref("failed to get absolute path for %s: %w", cfg.OutputDir, err) } } if _, err := os.Stat(cfg.OutputDir); err == nil && cfg.Clobber { log.Printf("Deleting all files in %s", cfg.OutputDir) if err := os.RemoveAll(cfg.OutputDir); err != nil { - return fmt.Errorf("failed to delete %s: %s", cfg.OutputDir, err) + return failuref("failed to delete %s: %s", cfg.OutputDir, err) } } else if err == nil && !cfg.Clobber { - return fmt.Errorf("output dir %q already exists. Use the clobber option to delete it", cfg.OutputDir) + return failuref("output dir %q already exists. Use the clobber option to delete it", cfg.OutputDir) } log.Printf("Generating resources to %s", cfg.OutputDir) if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { - return fmt.Errorf("failed to create output directory %s: %s", cfg.OutputDir, err) + return failuref("failed to create output directory %s: %s", cfg.OutputDir, err) } // Generate provider installation block @@ -59,22 +98,21 @@ func Generate(ctx context.Context, cfg *Config) error { tf, err := setupTerraform(cfg) // Terraform init to download the provider if err != nil { - return fmt.Errorf("failed to run terraform init: %w", err) + return failuref("failed to run terraform init: %w", err) } cfg.Terraform = tf + var returnResult GenerationResult if cfg.Cloud != nil { log.Printf("Generating cloud resources") - stacks, err := generateCloudResources(ctx, cfg) - if err != nil { - return err - } + var stacks []stack + stacks, returnResult = generateCloudResources(ctx, cfg) for _, stack := range stacks { stack.name = "stack-" + stack.slug - if err := generateGrafanaResources(ctx, cfg, stack, false); err != nil { - return err - } + stackResult := generateGrafanaResources(ctx, cfg, stack, false) + returnResult.Success = append(returnResult.Success, stackResult.Success...) + returnResult.Errors = append(returnResult.Errors, stackResult.Errors...) } } @@ -89,29 +127,42 @@ func Generate(ctx context.Context, cfg *Config) error { onCallURL: cfg.Grafana.OnCallURL, } log.Printf("Generating Grafana resources") - if err := generateGrafanaResources(ctx, cfg, stack, true); err != nil { - return err + returnResult = generateGrafanaResources(ctx, cfg, stack, true) + } + + if !cfg.OutputCredentials && cfg.Format != OutputFormatCrossplane { + if err := postprocessing.RedactCredentials(cfg.OutputDir); err != nil { + return failuref("failed to redact credentials: %w", err) } } - if cfg.Format == OutputFormatCrossplane { - return convertToCrossplane(cfg) + if returnResult.Blocks() == 0 { + if err := os.WriteFile(filepath.Join(cfg.OutputDir, "resources.tf"), []byte("# No resources were found\n"), 0600); err != nil { + return failure(err) + } + if err := os.WriteFile(filepath.Join(cfg.OutputDir, "imports.tf"), []byte("# No resources were found\n"), 0600); err != nil { + return failure(err) + } + return returnResult } - if !cfg.OutputCredentials { - if err := postprocessing.RedactCredentials(cfg.OutputDir); err != nil { - return fmt.Errorf("failed to redact credentials: %w", err) + if cfg.Format == OutputFormatCrossplane { + if err := convertToCrossplane(cfg); err != nil { + return failure(err) } + return returnResult } if cfg.Format == OutputFormatJSON { - return convertToTFJSON(cfg.OutputDir) + if err := convertToTFJSON(cfg.OutputDir); err != nil { + return failure(err) + } } - return nil + return returnResult } -func generateImportBlocks(ctx context.Context, client *common.Client, listerData any, resources []*common.Resource, cfg *Config, provider string) error { +func generateImportBlocks(ctx context.Context, client *common.Client, listerData any, resources []*common.Resource, cfg *Config, provider string) GenerationResult { generatedFilename := func(suffix string) string { if provider == "" { return filepath.Join(cfg.OutputDir, suffix) @@ -122,7 +173,7 @@ func generateImportBlocks(ctx context.Context, client *common.Client, listerData resources, err := filterResources(resources, cfg.IncludeResources) if err != nil { - return err + return failure(err) } // Generate HCL blocks in parallel with a wait group @@ -207,12 +258,21 @@ func generateImportBlocks(ctx context.Context, client *common.Client, listerData wg.Wait() close(results) + returnResult := GenerationResult{} resultsSlice := []result{} for r := range results { if r.err != nil { - return fmt.Errorf("failed to generate %s resources: %w", r.resource.Name, r.err) + returnResult.Errors = append(returnResult.Errors, ResourceError{ + Resource: r.resource, + Err: r.err, + }) + } else { + resultsSlice = append(resultsSlice, r) + returnResult.Success = append(returnResult.Success, GenerationSuccess{ + Resource: r.resource, + Blocks: len(r.blocks), + }) } - resultsSlice = append(resultsSlice, r) } sort.Slice(resultsSlice, func(i, j int) bool { return resultsSlice[i].resource.Name < resultsSlice[j].resource.Name @@ -225,23 +285,21 @@ func generateImportBlocks(ctx context.Context, client *common.Client, listerData } if len(allBlocks) == 0 { - if err := os.WriteFile(generatedFilename("resources.tf"), []byte("# No resources were found\n"), 0600); err != nil { - return err - } - if err := os.WriteFile(generatedFilename("imports.tf"), []byte("# No resources were found\n"), 0600); err != nil { - return err - } - return nil + return returnResult } if err := writeBlocks(generatedFilename("imports.tf"), allBlocks...); err != nil { - return err + return failure(err) } _, err = cfg.Terraform.Plan(ctx, tfexec.GenerateConfigOut(generatedFilename("resources.tf"))) if err != nil { - return fmt.Errorf("failed to generate resources: %w", err) + return failuref("failed to generate resources: %w", err) } - return sortResourcesFile(generatedFilename("resources.tf")) + if err := sortResourcesFile(generatedFilename("resources.tf")); err != nil { + return failure(err) + } + + return returnResult } func filterResources(resources []*common.Resource, includedResources []string) ([]*common.Resource, error) { diff --git a/pkg/generate/generate_test.go b/pkg/generate/generate_test.go index f7d24a1f4..42c42d2dc 100644 --- a/pkg/generate/generate_test.go +++ b/pkg/generate/generate_test.go @@ -30,6 +30,7 @@ func TestAccGenerate(t *testing.T) { config string generateConfig func(cfg *generate.Config) check func(t *testing.T, tempDir string) + resultCheck func(t *testing.T, result generate.GenerationResult) }{ { name: "dashboard", @@ -158,6 +159,27 @@ func TestAccGenerate(t *testing.T) { }) }, }, + { + name: "fail-to-generate", + config: " ", + generateConfig: func(cfg *generate.Config) { + cfg.Grafana.IsGrafanaCloudStack = true // Querying Grafana Cloud stuff will fail (this is a local instance) + }, + resultCheck: func(t *testing.T, result generate.GenerationResult) { + require.Greater(t, len(result.Success), 0, "expected successes, got: %+v", result) + require.Greater(t, len(result.Errors), 1, "expected more than one error, got: %+v", result) + gotCloudErrors := false + for _, err := range result.Errors { + resourceError, ok := err.(generate.ResourceError) + require.True(t, ok, "expected ResourceError, got: %v", err) + if strings.HasPrefix(resourceError.Resource.Name, "grafana_machine_learning") || strings.HasPrefix(resourceError.Resource.Name, "grafana_slo") { + gotCloudErrors = true + break + } + } + require.True(t, gotCloudErrors, "expected errors related to Grafana Cloud resources, got: %v", result.Errors) + }, + }, } for _, tc := range cases { @@ -186,8 +208,16 @@ func TestAccGenerate(t *testing.T) { tc.generateConfig(&config) } - require.NoError(t, generate.Generate(context.Background(), &config)) - tc.check(t, tempDir) + result := generate.Generate(context.Background(), &config) + if tc.resultCheck != nil { + tc.resultCheck(t, result) + } else { + require.Len(t, result.Errors, 0, "expected no errors, got: %v", result.Errors) + } + + if tc.check != nil { + tc.check(t, tempDir) + } return nil }, diff --git a/pkg/generate/grafana.go b/pkg/generate/grafana.go index a97d27527..55aac2ce3 100644 --- a/pkg/generate/grafana.go +++ b/pkg/generate/grafana.go @@ -17,7 +17,7 @@ import ( "github.com/zclconf/go-cty/cty" ) -func generateGrafanaResources(ctx context.Context, cfg *Config, stack stack, genProvider bool) error { +func generateGrafanaResources(ctx context.Context, cfg *Config, stack stack, genProvider bool) GenerationResult { generatedFilename := func(suffix string) string { if stack.name == "" { return filepath.Join(cfg.OutputDir, suffix) @@ -42,7 +42,7 @@ func generateGrafanaResources(ctx context.Context, cfg *Config, stack stack, gen providerBlock.Body().SetAttributeValue("alias", cty.StringVal(stack.name)) } if err := writeBlocks(generatedFilename("provider.tf"), providerBlock); err != nil { - return err + return failure(err) } } @@ -66,20 +66,22 @@ func generateGrafanaResources(ctx context.Context, cfg *Config, stack stack, gen config.OncallURL = types.StringValue(stack.onCallURL) } if err := config.SetDefaults(); err != nil { - return err + return failure(err) } client, err := provider.CreateClients(config) if err != nil { - return err + return failure(err) } if stack.isCloud { resources = append(resources, slo.Resources...) resources = append(resources, machinelearning.Resources...) } - if err := generateImportBlocks(ctx, client, listerData, resources, cfg, stack.name); err != nil { - return err + + returnResult := generateImportBlocks(ctx, client, listerData, resources, cfg, stack.name) + if returnResult.Blocks() == 0 { // Skip if no resources were found + return returnResult } stripDefaultsExtraFields := map[string]any{} @@ -91,22 +93,22 @@ func generateGrafanaResources(ctx context.Context, cfg *Config, stack stack, gen plannedState, err := getPlannedState(ctx, cfg) if err != nil { - return err + return failure(err) } if err := postprocessing.StripDefaults(generatedFilename("resources.tf"), stripDefaultsExtraFields); err != nil { - return err + return failure(err) } if err := postprocessing.AbstractDashboards(generatedFilename("resources.tf")); err != nil { - return err + return failure(err) } if err := postprocessing.WrapJSONFieldsInFunction(generatedFilename("resources.tf")); err != nil { - return err + return failure(err) } if err := postprocessing.ReplaceReferences(generatedFilename("resources.tf"), plannedState, []string{ "*.org_id=grafana_organization.id", }); err != nil { - return err + return failure(err) } - return nil + return returnResult }