Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions internal/api/events/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
}
Expand Down Expand Up @@ -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")
Expand Down
8 changes: 8 additions & 0 deletions internal/api/events/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down
25 changes: 25 additions & 0 deletions internal/api/events/config_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
9 changes: 9 additions & 0 deletions internal/cmds/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions internal/cmds/mcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 26 additions & 12 deletions internal/cmds/policies.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -178,6 +185,7 @@ func Policies(ctx context.Context, cfg *config.Config, source oauth2.TokenSource
Dashboard: cfg.Dashboard,
Currency: cfg.Currency,
PricingEndpoint: cfg.PricingEndpoint,
PricingAPIKey: cfg.PricingAPIKey,
FetchAuth: scanner.SSHFetchAuthFromValue(cfg.SSHKeyFile),
}
finops, tagging, err := s.ListPolicies(ctx, runParameters, providers)
Expand Down Expand Up @@ -216,12 +224,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
Expand Down
42 changes: 29 additions & 13 deletions internal/cmds/price.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -72,13 +73,21 @@ 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() {
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)
Expand All @@ -91,6 +100,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()
Expand Down Expand Up @@ -148,12 +158,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
Expand Down
65 changes: 41 additions & 24 deletions internal/cmds/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -81,26 +82,35 @@ 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() {
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
}
}
}
}
Expand All @@ -116,6 +126,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()
Expand Down Expand Up @@ -218,12 +229,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
Expand Down
35 changes: 35 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ 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.
PricingAPIKey string `env:"INFRACOST_CLI_PRICING_API_KEY" flag:"pricing-api-key;hidden" usage:"API key for a self-hosted Cloud Pricing API; setting it disables Infracost Cloud features (policies, guardrails, budgets)"`

// 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"`

Expand Down Expand Up @@ -93,8 +102,34 @@ 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 when nothing else set an endpoint so self-hosted
// users migrating from the legacy CLI don't silently price against
// pricing.api.infracost.io.
if legacy := os.Getenv("INFRACOST_PRICING_API_ENDPOINT"); legacy != "" && config.PricingEndpoint == "https://pricing.api.infracost.io" {
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
}

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")

// 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 != ""
}
Loading
Loading