Skip to content

Commit

Permalink
feat: exit early if Infracost API is invalid
Browse files Browse the repository at this point in the history
Changes the `breakdown`, `diff`, `upload` and `comment` commands to exit early if
the INFRACOST_API_KEY is invalid. This is done so that users can better debug their
pipelines where an API key might be incorrectly set.

To accomodate this change i've introduced the idea of command middleware which will
wrap commands before their execution. This means we can easily chain on additional
functionality/checks in the future if required.
  • Loading branch information
hugorut committed Mar 5, 2024
1 parent fc7c294 commit da211d2
Show file tree
Hide file tree
Showing 13 changed files with 120 additions and 15 deletions.
4 changes: 2 additions & 2 deletions cmd/infracost/breakdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func breakdownCmd(ctx *config.RunContext) *cobra.Command {
terraform show -json tfplan.binary > plan.json
infracost breakdown --path plan.json`,
ValidArgs: []string{"--", "-"},
RunE: func(cmd *cobra.Command, args []string) error {
RunE: checkAPIKeyIsValid(ctx, func(cmd *cobra.Command, args []string) error {
if err := checkAPIKey(ctx.Config.APIKey, ctx.Config.PricingAPIEndpoint, ctx.Config.DefaultPricingAPIEndpoint); err != nil {
return err
}
Expand All @@ -41,7 +41,7 @@ func breakdownCmd(ctx *config.RunContext) *cobra.Command {
}

return runMain(cmd, ctx)
},
}),
}

addRunFlags(cmd)
Expand Down
36 changes: 36 additions & 0 deletions cmd/infracost/breakdown_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1235,3 +1235,39 @@ func TestBreakdownConfigFileWithSkipAutoDetect(t *testing.T) {
},
)
}

func TestBreakdownInvalidAPIKey(t *testing.T) {
testName := testutil.CalcGoldenFileTestdataDirName()
dir := path.Join("./testdata", testName)
GoldenFileCommandTest(
t,
testutil.CalcGoldenFileTestdataDirName(),
[]string{
"breakdown",
"--path", dir,
},
nil,
func(ctx *config.RunContext) {
ctx.Config.APIKey = "BAD_KEY"
ctx.Config.Credentials.APIKey = "BAD_KEY"
},
)
}

func TestBreakdownEmptyAPIKey(t *testing.T) {
testName := testutil.CalcGoldenFileTestdataDirName()
dir := path.Join("./testdata", testName)
GoldenFileCommandTest(
t,
testutil.CalcGoldenFileTestdataDirName(),
[]string{
"breakdown",
"--path", dir,
},
nil,
func(ctx *config.RunContext) {
ctx.Config.APIKey = ""
ctx.Config.Credentials.APIKey = ""
},
)
}
2 changes: 2 additions & 0 deletions cmd/infracost/comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ func commentCmd(ctx *config.RunContext) *cobra.Command {

cmds := []*cobra.Command{commentGitHubCmd(ctx), commentGitLabCmd(ctx), commentAzureReposCmd(ctx), commentBitbucketCmd(ctx)}
for _, subCmd := range cmds {
subCmd.RunE = checkAPIKeyIsValid(ctx, subCmd.RunE)

subCmd.Flags().StringArray("policy-path", nil, "Path to Infracost policy files, glob patterns need quotes (experimental)")
subCmd.Flags().Bool("show-all-projects", false, "Show all projects in the table of the comment output")
subCmd.Flags().Bool("show-changed", false, "Show only projects in the table that have code changes")
Expand Down
4 changes: 2 additions & 2 deletions cmd/infracost/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func diffCmd(ctx *config.RunContext) *cobra.Command {
terraform show -json tfplan.binary > plan.json
infracost diff --path plan.json`,
ValidArgs: []string{"--", "-"},
RunE: func(cmd *cobra.Command, args []string) error {
RunE: checkAPIKeyIsValid(ctx, func(cmd *cobra.Command, args []string) error {
if err := checkAPIKey(ctx.Config.APIKey, ctx.Config.PricingAPIEndpoint, ctx.Config.DefaultPricingAPIEndpoint); err != nil {
return err
}
Expand All @@ -55,7 +55,7 @@ func diffCmd(ctx *config.RunContext) *cobra.Command {
}

return runDiff(cmd, ctx)
},
}),
}

addRunFlags(cmd)
Expand Down
57 changes: 57 additions & 0 deletions cmd/infracost/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -912,3 +912,60 @@ func newErroredProject(ctx *config.ProjectContext, err error) *projectOutput {

return &projectOutput{projects: []*schema.Project{schema.NewProject(name, metadata)}}
}

// runCommandFunc is a function that runs a command and returns an error this is
// used by cobra.RunE.
type runCommandFunc func(cmd *cobra.Command, args []string) error

// runCommandMiddleware is a function that wraps a runCommandFunc and returns a
// new runCommandFunc. This is used to add functionality to a command without
// modifying the command itself. Middleware can be chained together to add
// multiple pieces of functionality.
//
//nolint:deadcode,unused
type runCommandMiddleware func(ctx *config.RunContext, next runCommandFunc) runCommandFunc

// checkAPIKeyIsValid is a runCommandMiddleware that checks if the API key is
// valid before running the command.
func checkAPIKeyIsValid(ctx *config.RunContext, next runCommandFunc) runCommandFunc {
return func(cmd *cobra.Command, args []string) error {
if ctx.Config.APIKey == "" {
return fmt.Errorf("%s %s %s %s %s\n%s %s.\n%s %s %s",
ui.PrimaryString("INFRACOST_API_KEY"),
"is not set but is required, check your",
"environment variable is named correctly or add your API key to your",
ui.PrimaryString(config.CredentialsFilePath()),
"credentials file.",
"If you recently regenerated your API key, you can retrieve it from",
ui.LinkString(ctx.Config.DashboardEndpoint),
"See",
ui.LinkString("https://infracost.io/support"),
"if you continue having issues.")
}

pricingClient := apiclient.GetPricingAPIClient(ctx)
_, err := pricingClient.DoQueries([]apiclient.GraphQLQuery{
{},
})
var apiError *apiclient.APIError
if errors.As(err, &apiError) {
if apiError.ErrorCode == apiclient.ErrorCodeAPIKeyInvalid {
return fmt.Errorf("%s %s %s %s %s\n%s %s.\n%s %s %s",
"Invalid API Key, please check your",
ui.PrimaryString("INFRACOST_API_KEY"),
"environment variable or",
ui.PrimaryString(config.CredentialsFilePath()),
"credentials file.",
"If you recently regenerated your API key, you can retrieve it from",
ui.LinkString(ctx.Config.DashboardEndpoint),
"See",
ui.LinkString("https://infracost.io/support"),
"if you continue having issues.",
)

}
}

return next(cmd, args)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

Err:
Error: INFRACOST_API_KEY is not set but is required, check your environment variable is named correctly or add your API key to your /Users/hugorut/.config/infracost/credentials.yml credentials file.
If you recently regenerated your API key, you can retrieve it from https://dashboard.infracost.io.
See https://infracost.io/support if you continue having issues.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

Err:
Error: Invalid API Key, please check your INFRACOST_API_KEY environment variable or /Users/hugorut/.config/infracost/credentials.yml credentials file.
If you recently regenerated your API key, you can retrieve it from https://dashboard.infracost.io.
See https://infracost.io/support if you continue having issues.
4 changes: 2 additions & 2 deletions cmd/infracost/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ See https://infracost.io/docs/features/cli_commands/#upload-runs`,
infracost upload --path infracost.json`,
ValidArgs: []string{"--", "-"},
RunE: func(cmd *cobra.Command, args []string) error {
RunE: checkAPIKeyIsValid(ctx, func(cmd *cobra.Command, args []string) error {
var err error

format, _ := cmd.Flags().GetString("format")
Expand Down Expand Up @@ -83,7 +83,7 @@ See https://infracost.io/docs/features/cli_commands/#upload-runs`,
}

return nil
},
}),
}

cmd.Flags().String("path", "p", "Path to Infracost JSON file.")
Expand Down
2 changes: 1 addition & 1 deletion internal/apiclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ type APIErrorResponse struct {
ErrorCode string `json:"error_code"`
}

func (c *APIClient) doQueries(queries []GraphQLQuery) ([]gjson.Result, error) {
func (c *APIClient) DoQueries(queries []GraphQLQuery) ([]gjson.Result, error) {
if len(queries) == 0 {
log.Debug().Msg("Skipping GraphQL request as no queries have been specified")
return []gjson.Result{}, nil
Expand Down
4 changes: 2 additions & 2 deletions internal/apiclient/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ func (c *DashboardAPIClient) AddRun(ctx *config.RunContext, out output.Root, com
}
}
`
results, err := c.doQueries([]GraphQLQuery{{q, v}})
results, err := c.DoQueries([]GraphQLQuery{{q, v}})
if err != nil {
return response, err
}
Expand Down Expand Up @@ -245,7 +245,7 @@ func (c *DashboardAPIClient) QueryCLISettings() (QueryCLISettingsResponse, error
}
}
`
results, err := c.doQueries([]GraphQLQuery{{q, map[string]interface{}{}}})
results, err := c.DoQueries([]GraphQLQuery{{q, map[string]interface{}{}}})
if err != nil {
return response, fmt.Errorf("query failed when requesting org settings %w", err)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/apiclient/policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ func (c *PolicyAPIClient) uploadProjectPolicyData(p2rs []policy2Resource) (strin
"policyResources": p2rs,
}

results, err := c.doQueries([]GraphQLQuery{{q, v}})
results, err := c.DoQueries([]GraphQLQuery{{q, v}})
if err != nil {
return "", fmt.Errorf("query storePolicyResources failed %w", err)
}
Expand Down Expand Up @@ -302,7 +302,7 @@ func (c *PolicyAPIClient) getPolicyResourceAllowList() (map[string]allowList, er
`
v := map[string]interface{}{}

results, err := c.doQueries([]GraphQLQuery{{q, v}})
results, err := c.DoQueries([]GraphQLQuery{{q, v}})
if err != nil {
return nil, fmt.Errorf("query policyResourceAllowList failed %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion internal/apiclient/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ func (c *PricingAPIClient) PerformRequest(req BatchRequest) ([]PriceQueryResult,
for i, query := range deduplicatedServerQueries {
rawQueries[i] = query.query
}
resultsFromServer, err := c.doQueries(rawQueries)
resultsFromServer, err := c.DoQueries(rawQueries)
if err != nil {
return []PriceQueryResult{}, err
}
Expand Down
6 changes: 3 additions & 3 deletions internal/apiclient/usage.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func (c *UsageAPIClient) ListActualCosts(vars ActualCostsQueryVariables) ([]Actu

logging.Logger.Debug().Msgf("Getting actual costs from %s for %s", c.endpoint, vars.Address)

results, err := c.doQueries([]GraphQLQuery{query})
results, err := c.DoQueries([]GraphQLQuery{query})
if err != nil {
return nil, err
} else if len(results) > 0 && results[0].Get("errors").Exists() {
Expand Down Expand Up @@ -181,7 +181,7 @@ func (c *UsageAPIClient) ListUsageQuantities(vars UsageQuantitiesQueryVariables)

logging.Logger.Debug().Msgf("Getting usage quantities from %s for %s %s %v", c.endpoint, vars.ResourceType, vars.Address, vars.UsageKeys)

results, err := c.doQueries([]GraphQLQuery{query})
results, err := c.DoQueries([]GraphQLQuery{query})
if err != nil {
return nil, err
} else if len(results) > 0 && results[0].Get("errors").Exists() {
Expand Down Expand Up @@ -306,7 +306,7 @@ func (c *UsageAPIClient) UploadCloudResourceIDs(vars CloudResourceIDVariables) e

logging.Logger.Debug().Msgf("Uploading cloud resource IDs to %s for %s %s", c.endpoint, vars.RepoURL, vars.ProjectWithWorkspace)

results, err := c.doQueries([]GraphQLQuery{query})
results, err := c.DoQueries([]GraphQLQuery{query})
if err != nil {
return err
} else if len(results) > 0 && results[0].Get("errors").Exists() {
Expand Down

0 comments on commit da211d2

Please sign in to comment.