Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: exit early if Infracost API Key is invalid #2927

Merged
merged 8 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ var (
versionRegex = regexp.MustCompile(`Infracost v.*`)
panicRegex = regexp.MustCompile(`runtime\serror:([\w\d\n\r\[\]\:\/\.\\(\)\+\,\{\}\*\@\s\?]*)Environment`)
pathRegex = regexp.MustCompile(`(/.*/)(infracost/infracost/cmd/infracost/testdata/.*)`)
credsRegex = regexp.MustCompile(`/.*/credentials\.yml`)
)

type GoldenFileOptions = struct {
Expand Down Expand Up @@ -234,6 +235,7 @@ func stripDynamicValues(actual []byte) []byte {
actual = versionRegex.ReplaceAll(actual, []byte("Infracost vREPLACED_VERSION"))
actual = panicRegex.ReplaceAll(actual, []byte("runtime error: REPLACED ERROR\nEnvironment"))
actual = pathRegex.ReplaceAll(actual, []byte("REPLACED_PROJECT_PATH/$2"))
actual = credsRegex.ReplaceAll(actual, []byte("REPLACED_CREDENTIALS_PATH"))

return actual
}
Expand Down
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
58 changes: 58 additions & 0 deletions cmd/infracost/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -912,3 +912,61 @@ 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.NewPricingAPIClient(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 REPLACED_CREDENTIALS_PATH 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 REPLACED_CREDENTIALS_PATH 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
7 changes: 6 additions & 1 deletion cmd/infracost/upload_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,17 @@ func TestUploadHelp(t *testing.T) {
}

func TestUploadSelfHosted(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{}`))
}))
defer s.Close()

GoldenFileCommandTest(t,
testutil.CalcGoldenFileTestdataDirName(),
[]string{"upload", "--path", "./testdata/example_out.json"},
&GoldenFileOptions{CaptureLogs: true},
func(c *config.RunContext) {
c.Config.PricingAPIEndpoint = "https://fake.url"
c.Config.PricingAPIEndpoint = s.URL
},
)
}
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
20 changes: 17 additions & 3 deletions internal/apiclient/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ func GetPricingAPIClient(ctx *config.RunContext) *PricingAPIClient {
return pricingClient
}

c := NewPricingAPIClient(ctx)
if c == nil {
return nil
}

initCache(ctx, c)
pricingClient = c
return c
}

// NewPricingAPIClient creates a new instance of PricingAPIClient using the given
// RunContext configuration. Most callers should use GetPricingAPIClient instead
// of this function to ensure that the client cache is global across the
// application. This function is useful for creating isolated pricing clients
// which do not share the global cache.
func NewPricingAPIClient(ctx *config.RunContext) *PricingAPIClient {
if ctx == nil {
return nil
}
Expand Down Expand Up @@ -129,8 +145,6 @@ func GetPricingAPIClient(ctx *config.RunContext) *PricingAPIClient {
EventsDisabled: ctx.Config.EventsDisabled,
}

initCache(ctx, c)
pricingClient = c
return c
}

Expand Down Expand Up @@ -364,7 +378,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