diff --git a/internal/api/events/client.go b/internal/api/events/client.go index 4691350..cf19ee2 100644 --- a/internal/api/events/client.go +++ b/internal/api/events/client.go @@ -6,14 +6,22 @@ import ( "encoding/json" "fmt" "net/http" + "time" "github.com/infracost/cli/pkg/logging" ) var ( _ Client = (*client)(nil) + _ Client = noopClient{} ) +// noopClient drops every event. Returned by Config.Client when telemetry is +// disabled (self-hosted pricing mode). +type noopClient struct{} + +func (noopClient) Push(context.Context, string, ...interface{}) {} + type Client interface { Push(ctx context.Context, event string, extra ...interface{}) } @@ -58,6 +66,12 @@ func (c *client) Push(ctx context.Context, event string, extra ...interface{}) { return } + // Telemetry is fire-and-forget (errors are only logged), and the shared + // http.Client has no timeout — bound the request so a blocked or blackholed + // network fails fast instead of hanging the command. + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/event", c.config.Endpoint), bytes.NewReader(buf)) if err != nil { logging.WithError(err).Msg("events: failed to create request") diff --git a/internal/api/events/config.go b/internal/api/events/config.go index 737a81c..2590618 100644 --- a/internal/api/events/config.go +++ b/internal/api/events/config.go @@ -11,10 +11,18 @@ var ( type Config struct { Endpoint string `env:"INFRACOST_CLI_EVENTS_ENDPOINT" flag:"events-endpoint;hidden" usage:"The endpoint for the Infracost events service" default:"https://pricing.api.infracost.io"` + // Disabled turns every Push into a no-op so no telemetry leaves the + // machine. Set in self-hosted pricing mode (INFRACOST_CLI_PRICING_API_KEY), + // where the events endpoint is typically unreachable anyway. + Disabled bool + ClientFn func(httpClient *http.Client) Client } func (c *Config) Client(httpClient *http.Client) Client { + if c.Disabled { + return noopClient{} + } if c.ClientFn == nil { // The events client is kind of special in that it can be called anywhere in the CLI process (including outside // the context of commands). This means that it may not have been initialized, so we have this lazy evaluation / diff --git a/internal/api/events/config_test.go b/internal/api/events/config_test.go new file mode 100644 index 0000000..1a6b1da --- /dev/null +++ b/internal/api/events/config_test.go @@ -0,0 +1,25 @@ +package events + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDisabledClientDropsEvents(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests++ + })) + defer server.Close() + + cfg := &Config{Endpoint: server.URL, Disabled: true} + client := cfg.Client(server.Client()) + require.IsType(t, noopClient{}, client) + + client.Push(context.Background(), "infracost-run", "key", "value") + require.Zero(t, requests, "disabled events client must not send anything") +} diff --git a/internal/cmds/mcp.go b/internal/cmds/mcp.go index ef6026d..fcddfeb 100644 --- a/internal/cmds/mcp.go +++ b/internal/cmds/mcp.go @@ -143,6 +143,15 @@ func requireSessionReadyMiddleware(cfg *config.Config, source oauth2.TokenSource return next(ctx, method, req) } + // Self-hosted pricing mode: there is no Infracost Cloud login or + // org to resolve — the static pricing API key is the only + // credential. Tools that do require the Cloud fail downstream + // with an actionable "disabled because INFRACOST_CLI_PRICING_API_KEY + // is set" error. + if cfg.SelfHostedPricing() { + return next(ctx, method, req) + } + // Auth gate: every tool needs a token. Force resolution now // so the readable "run infracost auth login" guidance is // returned instead of an opaque downstream API failure. diff --git a/internal/cmds/mcp_test.go b/internal/cmds/mcp_test.go index 379477b..4057cb8 100644 --- a/internal/cmds/mcp_test.go +++ b/internal/cmds/mcp_test.go @@ -99,6 +99,21 @@ func TestRequireSessionReady_RecoveryToolsSkipOrgGate(t *testing.T) { } } +func TestRequireSessionReady_SelfHostedPricingSkipsGates(t *testing.T) { + // Self-hosted pricing mode has no Infracost Cloud login or org: even a + // failing token source and an empty OrgID must not block tool calls. + cfg := &config.Config{PricingAPIKey: "self-hosted-key"} + source := stubTokenSource{err: errors.New("authentication disabled")} + + res, called := runGate(t, cfg, source, "tools/call", "scan") + if !called { + t.Fatal("next handler did not run in self-hosted pricing mode") + } + if isErrorResult(res) { + t.Fatalf("unexpected error result: %#v", res) + } +} + func TestRequireSessionReady_IgnoresNonToolCalls(t *testing.T) { // A failing token source would block a tools/call, but other methods // (e.g. the initialize handshake) must pass through untouched so the diff --git a/internal/cmds/policies.go b/internal/cmds/policies.go index 1246d2e..af8c5c3 100644 --- a/internal/cmds/policies.go +++ b/internal/cmds/policies.go @@ -157,15 +157,22 @@ func Policies(ctx context.Context, cfg *config.Config, source oauth2.TokenSource repositoryURL := vcs.GetRemoteURL(absoluteDirectory) branchName := vcs.GetCurrentBranch(absoluteDirectory) - client := cfg.Dashboard.Client(api.Client(ctx, source, cfg.OrgID)) + // Self-hosted pricing mode never contacts Infracost Cloud; with nil run + // parameters the provider plugins list their built-in policies locally + // (none of which are evaluated at scan time in this mode). var runParameters *dashboard.RunParameters - if rp, err := client.RunParameters(ctx, repositoryURL, branchName); err != nil { - logging.Warnf("Failed to fetch runParameters, gathering policies without them: %s", err.Error()) + if cfg.SelfHostedPricing() { + logging.Infof("INFRACOST_CLI_PRICING_API_KEY is set: listing locally-available policies without Infracost Cloud") } else { - if cfg.Org == "" { - cfg.OrgID = rp.OrganizationID + client := cfg.Dashboard.Client(api.Client(ctx, source, cfg.OrgID)) + if rp, err := client.RunParameters(ctx, repositoryURL, branchName); err != nil { + logging.Warnf("Failed to fetch runParameters, gathering policies without them: %s", err.Error()) + } else { + if cfg.Org == "" { + cfg.OrgID = rp.OrganizationID + } + runParameters = &rp } - runParameters = &rp } events.RegisterMetadata("orgId", cfg.OrgID) @@ -216,12 +223,18 @@ func PoliciesCmd(cfg *config.Config) *cobra.Command { in.Path = args[0] } - source, err := cfg.Auth.Token(cmd.Context()) - if err != nil { - return fmt.Errorf("failed to log in: %w", err) - } - if err := resolveOrg(cmd.Context(), cfg, source); err != nil { - return err + // Self-hosted pricing mode needs no login or org: no Infracost + // Cloud API is contacted. + var source oauth2.TokenSource + if !cfg.SelfHostedPricing() { + var err error + source, err = cfg.Auth.Token(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to log in: %w", err) + } + if err := resolveOrg(cmd.Context(), cfg, source); err != nil { + return err + } } var result PoliciesResult diff --git a/internal/cmds/price.go b/internal/cmds/price.go index 17012b1..be6ba69 100644 --- a/internal/cmds/price.go +++ b/internal/cmds/price.go @@ -9,6 +9,7 @@ import ( "time" "github.com/infracost/cli/internal/api" + "github.com/infracost/cli/internal/api/dashboard" "github.com/infracost/cli/internal/api/events" "github.com/infracost/cli/internal/cache" "github.com/infracost/cli/internal/config" @@ -72,13 +73,24 @@ func Price(ctx context.Context, cfg *config.Config, source oauth2.TokenSource, s repositoryURL := vcs.GetRemoteURL(dir) branchName := vcs.GetCurrentBranch(dir) - client := cfg.Dashboard.Client(api.Client(ctx, source, cfg.OrgID)) - runParameters, err := client.RunParameters(ctx, repositoryURL, branchName) - if err != nil { - return nil, fmt.Errorf("failed to retrieve run parameters: %w", err) - } - if cfg.Org == "" { - cfg.OrgID = runParameters.OrganizationID + // Self-hosted pricing mode prices without Infracost Cloud: run parameters + // stay zero-valued, so no policies or usage defaults apply. + var runParameters dashboard.RunParameters + if cfg.SelfHostedPricing() { + if err := cfg.ValidateSelfHostedPricing(); err != nil { + return nil, err + } + logging.Infof("INFRACOST_CLI_PRICING_API_KEY is set: pricing against the self-hosted pricing API only; Infracost Cloud features are disabled") + } else { + client := cfg.Dashboard.Client(api.Client(ctx, source, cfg.OrgID)) + var rpErr error + runParameters, rpErr = client.RunParameters(ctx, repositoryURL, branchName) + if rpErr != nil { + return nil, fmt.Errorf("failed to retrieve run parameters: %w", rpErr) + } + if cfg.Org == "" { + cfg.OrgID = runParameters.OrganizationID + } } events.RegisterMetadata("orgId", cfg.OrgID) @@ -91,6 +103,7 @@ func Price(ctx context.Context, cfg *config.Config, source oauth2.TokenSource, s Dashboard: cfg.Dashboard, Currency: in.Currency, PricingEndpoint: cfg.PricingEndpoint, + PricingAPIKey: cfg.PricingAPIKey, FetchAuth: scanner.SSHFetchAuthFromValue(cfg.SSHKeyFile), } startTime := time.Now() @@ -148,12 +161,18 @@ func PriceCmd(cfg *config.Config) *cobra.Command { outputFormat = "json" } - source, err := cfg.Auth.Token(cmd.Context()) - if err != nil { - return fmt.Errorf("failed to log in: %w", err) - } - if err := resolveOrg(cmd.Context(), cfg, source); err != nil { - return err + // Self-hosted pricing mode needs no login or org: the static + // pricing API key is the only credential used, and no Infracost + // Cloud API is contacted. + var source oauth2.TokenSource + if !cfg.SelfHostedPricing() { + source, err = cfg.Auth.Token(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to log in: %w", err) + } + if err := resolveOrg(cmd.Context(), cfg, source); err != nil { + return err + } } var result PriceResult diff --git a/internal/cmds/scan.go b/internal/cmds/scan.go index d30d73c..9981ddf 100644 --- a/internal/cmds/scan.go +++ b/internal/cmds/scan.go @@ -10,6 +10,7 @@ import ( "time" "github.com/infracost/cli/internal/api" + "github.com/infracost/cli/internal/api/dashboard" "github.com/infracost/cli/internal/api/events" "github.com/infracost/cli/internal/cache" "github.com/infracost/cli/internal/config" @@ -81,26 +82,38 @@ func Scan(ctx context.Context, cfg *config.Config, source oauth2.TokenSource, st repositoryURL := vcs.GetRemoteURL(absoluteDirectory) branchName := vcs.GetCurrentBranch(absoluteDirectory) - client := cfg.Dashboard.Client(api.Client(ctx, source, cfg.OrgID)) + // Self-hosted pricing mode runs the scan without Infracost Cloud: run + // parameters stay zero-valued, so no policies, guardrails, budgets, usage + // defaults or config templates apply. + var runParameters dashboard.RunParameters + if cfg.SelfHostedPricing() { + if err := cfg.ValidateSelfHostedPricing(); err != nil { + return nil, err + } + logging.Infof("INFRACOST_CLI_PRICING_API_KEY is set: scanning against the self-hosted pricing API only; Infracost Cloud features (policies, guardrails, budgets, usage defaults) are disabled") + } else { + client := cfg.Dashboard.Client(api.Client(ctx, source, cfg.OrgID)) - runParameters, err := client.RunParameters(ctx, repositoryURL, branchName) - if err != nil { - return nil, fmt.Errorf("failed to retrieve run parameters: %w", err) - } + var err error + runParameters, err = client.RunParameters(ctx, repositoryURL, branchName) + if err != nil { + return nil, fmt.Errorf("failed to retrieve run parameters: %w", err) + } - // If --org was not provided, use the org from RunParameters. - // If --org was provided, log when it overrides what the API reports - // as the repo's default org. - if cfg.Org == "" { - cfg.OrgID = runParameters.OrganizationID - } else if runParameters.OrganizationID != "" && cfg.OrgID != runParameters.OrganizationID { - if uc, ucErr := cfg.Auth.LoadUserCache(); ucErr != nil { - logging.WithError(ucErr).Msg("failed to load user cache for override message") - } else if uc != nil { - for _, org := range uc.Organizations { - if org.ID == cfg.OrgID { - logging.Infof("using --org %s; overriding repository default", org.Slug) - break + // If --org was not provided, use the org from RunParameters. + // If --org was provided, log when it overrides what the API reports + // as the repo's default org. + if cfg.Org == "" { + cfg.OrgID = runParameters.OrganizationID + } else if runParameters.OrganizationID != "" && cfg.OrgID != runParameters.OrganizationID { + if uc, ucErr := cfg.Auth.LoadUserCache(); ucErr != nil { + logging.WithError(ucErr).Msg("failed to load user cache for override message") + } else if uc != nil { + for _, org := range uc.Organizations { + if org.ID == cfg.OrgID { + logging.Infof("using --org %s; overriding repository default", org.Slug) + break + } } } } @@ -116,6 +129,7 @@ func Scan(ctx context.Context, cfg *config.Config, source oauth2.TokenSource, st Dashboard: cfg.Dashboard, Currency: in.Currency, PricingEndpoint: cfg.PricingEndpoint, + PricingAPIKey: cfg.PricingAPIKey, FetchAuth: scanner.SSHFetchAuthFromValue(cfg.SSHKeyFile), } startTime := time.Now() @@ -218,12 +232,18 @@ func ScanCmd(cfg *config.Config) *cobra.Command { } }() - source, err := cfg.Auth.Token(cmd.Context()) - if err != nil { - return fmt.Errorf("failed to log in: %w", err) - } - if err := resolveOrg(cmd.Context(), cfg, source); err != nil { - return err + // Self-hosted pricing mode needs no login or org: the static + // pricing API key is the only credential used, and no Infracost + // Cloud API is contacted. + var source oauth2.TokenSource + if !cfg.SelfHostedPricing() { + source, err = cfg.Auth.Token(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to log in: %w", err) + } + if err := resolveOrg(cmd.Context(), cfg, source); err != nil { + return err + } } // Ensure plugins are present/updated as a distinct phase so the diff --git a/internal/config/config.go b/internal/config/config.go index b8a93ec..5d9e427 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "github.com/infracost/cli/internal/api/agents" @@ -18,6 +19,10 @@ var ( _ process.Processor = (*Config)(nil) ) +// defaultPricingEndpoint is Infracost's SaaS pricing API — must match the +// `default` tag on Config.PricingEndpoint. +const defaultPricingEndpoint = "https://pricing.api.infracost.io" + // Config contains the configuration for the CLI. type Config struct { // Environment is the environment to target for operations / authentication (development or production). Defaults to @@ -35,6 +40,17 @@ type Config struct { // PricingEndpoint is the endpoint to use for prices. Defaults to https://pricing.api.infracost.io. PricingEndpoint string `env:"INFRACOST_CLI_PRICING_ENDPOINT" flag:"pricing-endpoint;hidden" usage:"The pricing endpoint to use for prices" default:"https://pricing.api.infracost.io"` + // PricingAPIKey is a static API key for a self-hosted Cloud Pricing API (the + // value of its SELF_HOSTED_INFRACOST_API_KEY). Setting it switches the CLI + // into self-hosted pricing mode: the key is sent to the pricing API instead + // of an OAuth access token, and all other communication with Infracost Cloud + // (login, dashboard, telemetry) is disabled — policies, guardrails, budgets, + // usage defaults and config templates are skipped. Pair with + // PricingEndpoint to point at the self-hosted instance. Deliberately + // env-only (like Auth.AuthenticationToken): a flag would put the credential + // in argv, visible to `ps` and shell history. + PricingAPIKey string `env:"INFRACOST_CLI_PRICING_API_KEY"` + // Org is the organization slug or ID to use. Resolved to an ID before API calls. Org string `env:"INFRACOST_CLI_ORG" flag:"org" usage:"The organization slug or ID to use"` @@ -93,8 +109,63 @@ type Config struct { } func (config *Config) Process() { + // The pricing endpoint env var was renamed from the legacy CLI's + // INFRACOST_PRICING_API_ENDPOINT to INFRACOST_CLI_PRICING_ENDPOINT. + // Honor the legacy name only when the new var wasn't set at all (checking + // the env directly, not the resolved value: someone explicitly exporting + // the new var as the default endpoint must not be repointed) and no flag + // changed the endpoint, so migrating self-hosted users don't silently + // price against pricing.api.infracost.io. + if legacy := os.Getenv("INFRACOST_PRICING_API_ENDPOINT"); legacy != "" && + os.Getenv("INFRACOST_CLI_PRICING_ENDPOINT") == "" && + config.PricingEndpoint == defaultPricingEndpoint { + logging.Warnf("INFRACOST_PRICING_API_ENDPOINT was renamed to INFRACOST_CLI_PRICING_ENDPOINT; using the legacy value %s — please update your configuration", legacy) + config.PricingEndpoint = legacy + } + + // The legacy CLI used a single INFRACOST_API_KEY for everything, including + // self-hosted pricing. Adopt it as the pricing API key only when the + // legacy pricing endpoint var is also set — that combination is + // unambiguously a legacy self-hosted setup. INFRACOST_API_KEY on its own + // is too ambiguous to act on: it still exists in many CI environments for + // the runner flow, and switching those into self-hosted mode would + // silently disable Infracost Cloud. + if legacyKey := os.Getenv("INFRACOST_API_KEY"); legacyKey != "" && + config.PricingAPIKey == "" && + os.Getenv("INFRACOST_PRICING_API_ENDPOINT") != "" { + logging.Warnf("using INFRACOST_API_KEY as the self-hosted pricing API key because INFRACOST_PRICING_API_ENDPOINT is also set; please rename these to INFRACOST_CLI_PRICING_API_KEY and INFRACOST_CLI_PRICING_ENDPOINT") + config.PricingAPIKey = legacyKey + } + events.RegisterMetadata("cloudEnabled", os.Getenv("INFRACOST_ENABLE_CLOUD") == "true") events.RegisterMetadata("dashboardEnabled", os.Getenv("INFRACOST_ENABLE_DASHBOARD") == "true") events.RegisterMetadata("environment", config.Environment.String()) - events.RegisterMetadata("isDefaultPricingApiEndpoint", config.PricingEndpoint == "https://pricing.api.infracost.io") + events.RegisterMetadata("isDefaultPricingApiEndpoint", config.PricingEndpoint == defaultPricingEndpoint) + + // Self-hosted pricing mode: a static pricing API key means the user runs + // their own Cloud Pricing API and typically has no route to Infracost + // Cloud at all, so disable login (which would otherwise trigger an OAuth + // flow / JWKS fetch) and telemetry. This runs after the sub-configs' + // Process methods (process.Process is depth-first), so nothing below + // overwrites it. + config.Auth.Disabled = config.SelfHostedPricing() + config.Events.Disabled = config.SelfHostedPricing() +} + +// SelfHostedPricing reports whether the CLI is in self-hosted pricing mode: +// pricing calls authenticate with the static PricingAPIKey and every other +// Infracost Cloud integration is disabled. +func (config *Config) SelfHostedPricing() bool { + return config.PricingAPIKey != "" +} + +// ValidateSelfHostedPricing errors when the self-hosted pricing key is set +// but the endpoint still points at Infracost's SaaS pricing API — the SaaS +// only accepts OAuth tokens, so every price lookup with a static key would +// 403. Failing fast with the fix beats a confusing provider-plugin error. +func (config *Config) ValidateSelfHostedPricing() error { + if config.SelfHostedPricing() && config.PricingEndpoint == defaultPricingEndpoint { + return fmt.Errorf("INFRACOST_CLI_PRICING_API_KEY is set but the pricing endpoint still points at %s, which does not accept static API keys — set INFRACOST_CLI_PRICING_ENDPOINT to your self-hosted Cloud Pricing API", defaultPricingEndpoint) + } + return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 306821c..f918ee2 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -26,6 +26,137 @@ func TestConfig_Process(t *testing.T) { require.Equal(t, "prod", cfg.Dashboard.Environment) } +func TestConfig_SelfHostedPricing(t *testing.T) { + // Env-only on purpose: a flag would put the credential in argv. + t.Setenv("INFRACOST_CLI_PRICING_API_KEY", "self-hosted-key") + + var cfg Config + flags := pflag.NewFlagSet("", pflag.ContinueOnError) + if diags := process.PreProcess(&cfg, flags); diags.Len() != 0 { + t.Fatal(diags) + } + require.NoError(t, flags.Parse(nil)) + process.Process(&cfg) + + require.True(t, cfg.SelfHostedPricing()) + require.True(t, cfg.Auth.Disabled, "self-hosted pricing mode must disable Infracost Cloud auth") + require.True(t, cfg.Events.Disabled, "self-hosted pricing mode must disable telemetry") +} + +func TestConfig_ValidateSelfHostedPricing(t *testing.T) { + // Key + default endpoint can never work (the SaaS pricing API only + // accepts OAuth tokens), so it must fail fast with the fix. + cfg := Config{PricingAPIKey: "key", PricingEndpoint: "https://pricing.api.infracost.io"} + require.ErrorContains(t, cfg.ValidateSelfHostedPricing(), "INFRACOST_CLI_PRICING_ENDPOINT") + + cfg.PricingEndpoint = "http://pricing.internal:4000" + require.NoError(t, cfg.ValidateSelfHostedPricing()) + + // Not in self-hosted mode: never an error. + require.NoError(t, (&Config{PricingEndpoint: "https://pricing.api.infracost.io"}).ValidateSelfHostedPricing()) +} + +func TestConfig_SelfHostedPricingOffByDefault(t *testing.T) { + var cfg Config + + flags := pflag.NewFlagSet("", pflag.ContinueOnError) + if diags := process.PreProcess(&cfg, flags); diags.Len() != 0 { + t.Fatal(diags) + } + require.NoError(t, flags.Parse(nil)) + process.Process(&cfg) + + require.False(t, cfg.SelfHostedPricing()) + require.False(t, cfg.Auth.Disabled) + require.False(t, cfg.Events.Disabled) +} + +func TestConfig_LegacyPricingEndpointEnvVar(t *testing.T) { + // The legacy CLI read INFRACOST_PRICING_API_ENDPOINT; v2 renamed it to + // INFRACOST_CLI_PRICING_ENDPOINT. The legacy name must still be honored + // when nothing else sets an endpoint, so migrating self-hosted users + // don't silently price against pricing.api.infracost.io. + t.Setenv("INFRACOST_PRICING_API_ENDPOINT", "http://pricing.internal:4000") + + var cfg Config + flags := pflag.NewFlagSet("", pflag.ContinueOnError) + if diags := process.PreProcess(&cfg, flags); diags.Len() != 0 { + t.Fatal(diags) + } + require.NoError(t, flags.Parse(nil)) + process.Process(&cfg) + + require.Equal(t, "http://pricing.internal:4000", cfg.PricingEndpoint) +} + +func TestConfig_NewPricingEndpointWinsOverLegacy(t *testing.T) { + t.Setenv("INFRACOST_PRICING_API_ENDPOINT", "http://legacy.internal:4000") + t.Setenv("INFRACOST_CLI_PRICING_ENDPOINT", "http://new.internal:4000") + + var cfg Config + flags := pflag.NewFlagSet("", pflag.ContinueOnError) + if diags := process.PreProcess(&cfg, flags); diags.Len() != 0 { + t.Fatal(diags) + } + require.NoError(t, flags.Parse(nil)) + process.Process(&cfg) + + require.Equal(t, "http://new.internal:4000", cfg.PricingEndpoint) +} + +func TestConfig_ExplicitDefaultEndpointNotRepointedToLegacy(t *testing.T) { + // Someone explicitly exporting the new var as the default endpoint must + // not be silently repointed at a stale legacy var in their shell. + t.Setenv("INFRACOST_PRICING_API_ENDPOINT", "http://legacy.internal:4000") + t.Setenv("INFRACOST_CLI_PRICING_ENDPOINT", "https://pricing.api.infracost.io") + + var cfg Config + flags := pflag.NewFlagSet("", pflag.ContinueOnError) + if diags := process.PreProcess(&cfg, flags); diags.Len() != 0 { + t.Fatal(diags) + } + require.NoError(t, flags.Parse(nil)) + process.Process(&cfg) + + require.Equal(t, "https://pricing.api.infracost.io", cfg.PricingEndpoint) +} + +func TestConfig_LegacyAPIKeyAdoptedOnlyWithLegacyEndpoint(t *testing.T) { + // INFRACOST_API_KEY + INFRACOST_PRICING_API_ENDPOINT is unambiguously a + // legacy self-hosted setup, so the key is adopted. + t.Setenv("INFRACOST_API_KEY", "legacy-key") + t.Setenv("INFRACOST_PRICING_API_ENDPOINT", "http://legacy.internal:4000") + + var cfg Config + flags := pflag.NewFlagSet("", pflag.ContinueOnError) + if diags := process.PreProcess(&cfg, flags); diags.Len() != 0 { + t.Fatal(diags) + } + require.NoError(t, flags.Parse(nil)) + process.Process(&cfg) + + require.Equal(t, "legacy-key", cfg.PricingAPIKey) + require.Equal(t, "http://legacy.internal:4000", cfg.PricingEndpoint) + require.True(t, cfg.SelfHostedPricing()) +} + +func TestConfig_LegacyAPIKeyAloneIsIgnored(t *testing.T) { + // INFRACOST_API_KEY on its own is ambiguous (it lingers in CI envs for + // the runner flow) — it must not flip the CLI into self-hosted mode. + t.Setenv("INFRACOST_API_KEY", "legacy-key") + + var cfg Config + flags := pflag.NewFlagSet("", pflag.ContinueOnError) + if diags := process.PreProcess(&cfg, flags); diags.Len() != 0 { + t.Fatal(diags) + } + require.NoError(t, flags.Parse(nil)) + process.Process(&cfg) + + require.Empty(t, cfg.PricingAPIKey) + require.False(t, cfg.SelfHostedPricing()) +} + func TestConfig_DebugFlag(t *testing.T) { var cfg Config diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 3d18011..e1c2402 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -39,6 +39,11 @@ type Scanner struct { Dashboard dashboard.Config Currency string PricingEndpoint string + // PricingAPIKey enables self-hosted pricing mode: the static key is sent to + // providers to authenticate pricing calls (instead of an OAuth access + // token), and policy evaluation is disabled — providers receive empty + // (non-nil) policy slices so they evaluate none. + PricingAPIKey string // FetchAuth carries transport-level auth for remote fetches (the developer's // on-disk SSH keys). Passed through to every project's GenericOptions. FetchAuth *options.FetchAuth @@ -193,9 +198,13 @@ func (s *Scanner) Scan(ctx context.Context, runParameters dashboard.RunParameter repositoryName := runParameters.RepositoryName + // UsageDefaults is empty when the scan runs without Infracost Cloud + // (self-hosted pricing mode passes a zero-value RunParameters). usageDefaults := new(event.UsageDefaults) - if err := pj.Unmarshal(runParameters.UsageDefaults, usageDefaults); err != nil { - return nil, fmt.Errorf("failed to unmarshal usage defaults: %w", err) + if len(runParameters.UsageDefaults) > 0 { + if err := pj.Unmarshal(runParameters.UsageDefaults, usageDefaults); err != nil { + return nil, fmt.Errorf("failed to unmarshal usage defaults: %w", err) + } } var repoConfigOpts []repoconfig.GenerationOption @@ -311,6 +320,16 @@ func (s *Scanner) Scan(ctx context.Context, runParameters dashboard.RunParameter } } + // Self-hosted pricing mode never evaluates policies: the slices must be + // empty but non-nil — a nil FinopsPolicies tells the provider to evaluate + // every built-in policy (see ScanProjectOptions), which is exactly what we + // don't want here. This also overrides INFRACOST_CLI_USE_ALL_LOCAL_POLICIES. + if s.PricingAPIKey != "" { + productionFilters = []*event.ProductionFilter{} + tagPolicies = []*event.TagPolicy{} + finopsPolicies = []*event.FinopsPolicySettings{} + } + cacheDir := cache.ParserDir() if err := os.MkdirAll(cacheDir, 0o700); err != nil { return nil, fmt.Errorf("failed to create parser cache directory: %w", err) @@ -329,6 +348,7 @@ func (s *Scanner) Scan(ctx context.Context, runParameters dashboard.RunParameter RepositoryName: repositoryName, OrgID: runParameters.OrganizationID, PricingEndpoint: s.PricingEndpoint, + PricingAPIKey: s.PricingAPIKey, Currency: result.Config.Currency, TraceID: trace.ID, ProductionFilters: productionFilters, diff --git a/internal/scanner/scanner_component_test.go b/internal/scanner/scanner_component_test.go index 884f479..a59d19d 100644 --- a/internal/scanner/scanner_component_test.go +++ b/internal/scanner/scanner_component_test.go @@ -447,6 +447,41 @@ projects: require.Equal(t, 2, tokenSource.calls) }) + t.Run("self-hosted pricing key is sent to providers and disables policies", func(t *testing.T) { + dir := writeTestProject(t) + // Opting into local policies must not leak policy evaluation into + // self-hosted mode either — the empty policy config wins. + t.Setenv("INFRACOST_CLI_USE_ALL_LOCAL_POLICIES", "true") + + var inputs []*provider.TreeInput + s := newTestScanner(t, testScannerOpts{ + parseResponse: awsTerraformParseResponse("aws_instance"), + awsResources: []*provider.Resource{{Name: "aws_instance.web", Type: "aws_instance"}}, + processValidator: func(_ *testing.T, input *provider.TreeInput) { + inputs = append(inputs, input) + }, + }) + s.PricingAPIKey = "self-hosted-static-key" + + tokenSource := &sequenceTokenSource{tokens: []string{"oauth-token"}} + // Zero-value run parameters: self-hosted mode never fetches them, and + // the scan must cope with the empty usage defaults / policy fields. + result, err := s.Scan(context.Background(), dashboard.RunParameters{}, dir, "main", tokenSource, nil) + require.NoError(t, err) + require.Len(t, result.Projects, 1) + require.Len(t, result.Projects[0].Resources, 1) + + require.NotEmpty(t, inputs) + for _, input := range inputs { + require.Equal(t, "self-hosted-static-key", input.Infracost.ApiKey, + "providers must receive the static pricing API key, not the OAuth token") + require.NotNil(t, input.FinopsPolicyConfig, + "self-hosted mode must send an empty policy config — nil means 'evaluate all built-in policies'") + require.Empty(t, input.FinopsPolicyConfig.Policies) + } + require.Equal(t, 0, tokenSource.calls, "self-hosted mode must never consult the OAuth token source") + }) + t.Run("repo usage file is loaded", func(t *testing.T) { dir := writeTestProject(t, `version: "0.3" usage_file: usage.yml diff --git a/internal/update/check.go b/internal/update/check.go index 8acbc3c..7dc1b23 100644 --- a/internal/update/check.go +++ b/internal/update/check.go @@ -93,10 +93,15 @@ func compareVersions(current *semver.Version, latestStr string, method InstallMe } // skipUpdateCheck mirrors v0.10's logic: opt-out env, test runs, dev builds. +// Self-hosted pricing mode also skips: those environments typically can't +// reach releases.infracost.io, and an unanswered check would block exit. func skipUpdateCheck() bool { if os.Getenv("INFRACOST_SKIP_UPDATE_CHECK") != "" { return true } + if os.Getenv("INFRACOST_CLI_PRICING_API_KEY") != "" { + return true + } if isTestBinaryFn() { return true } diff --git a/pkg/auth/config.go b/pkg/auth/config.go index 47c8781..111951a 100644 --- a/pkg/auth/config.go +++ b/pkg/auth/config.go @@ -44,6 +44,13 @@ type Config struct { Environment string `flagvalue:"environment"` + // Disabled disables all authentication against Infracost Cloud. Set in + // self-hosted pricing mode (INFRACOST_CLI_PRICING_API_KEY): commands that + // require Infracost Cloud fail with an actionable error instead of + // triggering a login, and cached tokens are never validated (validation + // fetches JWKS over the network, which such environments may not reach). + Disabled bool + source oauth2.TokenSource } @@ -145,6 +152,10 @@ func (c *Config) Token(ctx context.Context) (oauth2.TokenSource, error) { // This function allows reuse of an earlier login attempt without risking the log in flow launching when the caller // requires a non-interactive prompt (such as when logging errors). func (c *Config) TokenFromCache(ctx context.Context) oauth2.TokenSource { + if c.Disabled { + return nil + } + if c.source != nil { return c.source } @@ -177,6 +188,10 @@ func isInteractive() bool { // Login performs the authentication flow (PKCE or Device Flow) and saves the token to the cache. // It first tries to load the token from the cache and validate it. func (c *Config) login(ctx context.Context) (oauth2.TokenSource, error) { + if c.Disabled { + return nil, fmt.Errorf("this command requires Infracost Cloud, which is disabled because INFRACOST_CLI_PRICING_API_KEY is set (self-hosted pricing mode)") + } + if len(c.AuthenticationToken) > 0 { return c.AuthenticationToken, nil } diff --git a/pkg/auth/config_test.go b/pkg/auth/config_test.go new file mode 100644 index 0000000..01c69a2 --- /dev/null +++ b/pkg/auth/config_test.go @@ -0,0 +1,35 @@ +package auth + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDisabledAuth(t *testing.T) { + // Self-hosted pricing mode disables all Infracost Cloud authentication: + // Token must fail with an actionable error instead of starting a login + // flow, and TokenFromCache must return nil without validating any cached + // token (validation fetches JWKS over the network). + cfg := &Config{Disabled: true} + + _, err := cfg.Token(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "INFRACOST_CLI_PRICING_API_KEY") + + require.Nil(t, cfg.TokenFromCache(context.Background())) +} + +func TestDisabledAuthWinsOverAuthenticationToken(t *testing.T) { + // Disabled short-circuits even an explicitly configured static + // authentication token — in self-hosted pricing mode nothing should be + // sent to Infracost Cloud at all. + cfg := &Config{ + Disabled: true, + ExternalConfig: ExternalConfig{AuthenticationToken: "static-token"}, + } + + _, err := cfg.Token(context.Background()) + require.Error(t, err) +} diff --git a/pkg/scanner/scan.go b/pkg/scanner/scan.go index e5f4ddc..5133ada 100644 --- a/pkg/scanner/scan.go +++ b/pkg/scanner/scan.go @@ -60,7 +60,12 @@ type ScanProjectOptions struct { // long multi-project scans don't reuse an expired access token. TokenSource oauth2.TokenSource // AccessToken is a fallback for callers that don't have a refresh-capable token source. - AccessToken string // nolint:gosec // G117: passed to providers, and not exposed + AccessToken string // nolint:gosec // G117: passed to providers, and not exposed + // PricingAPIKey is a static key for a self-hosted Cloud Pricing API. When + // set it is sent to providers as the pricing API key instead of the OAuth + // access token (which a self-hosted pricing API cannot validate), and the + // TokenSource is never consulted. + PricingAPIKey string // nolint:gosec // G117: passed to providers, and not exposed BranchName string RepositoryName string OrgID string @@ -261,10 +266,15 @@ func ScanProject(ctx context.Context, opts *ScanProjectOptions) (*ProjectResult, return nil, fmt.Errorf("failed to load provider plugins: %w", err) } - // Refresh the access token once, immediately before the first provider - // call — skipped entirely when no provider plugins are loaded so we - // don't burn a refresh for a no-op scan. - if len(providerPlugins) > 0 && opts.TokenSource != nil { + // A static self-hosted pricing API key always wins: it is the only + // credential a self-hosted Cloud Pricing API accepts, so it must never be + // overwritten by an OAuth access token. Otherwise refresh the access token + // once, immediately before the first provider call — skipped entirely when + // no provider plugins are loaded so we don't burn a refresh for a no-op + // scan. + if opts.PricingAPIKey != "" { + input.Infracost.ApiKey = opts.PricingAPIKey + } else if len(providerPlugins) > 0 && opts.TokenSource != nil { token, err := opts.TokenSource.Token() if err != nil { return nil, fmt.Errorf("failed to retrieve access token: %w", err)