Skip to content
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.
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
37 changes: 25 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 Down Expand Up @@ -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
Expand Down
45 changes: 32 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,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)
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
68 changes: 44 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,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
}
}
}
}
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading