diff --git a/go.mod b/go.mod index ff50ea1..6e51ae3 100644 --- a/go.mod +++ b/go.mod @@ -96,3 +96,6 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +// DEV: local proto with provider raw_options (remove once proto is tagged/published) +replace github.com/infracost/proto => ../proto diff --git a/internal/cluster/cluster.go b/internal/cluster/cluster.go new file mode 100644 index 0000000..2e6f340 --- /dev/null +++ b/internal/cluster/cluster.go @@ -0,0 +1,70 @@ +// Package cluster resolves the underlying-cluster topology that Kubernetes +// workloads deploy onto, so the kubernetes provider plugin can price them. +// +// Kubernetes manifests carry no cluster info; the cluster's node pools live in +// separate infrastructure-as-code (e.g. an EKS module in another repo). This +// package derives a cluster Config from that IaC; the scanner serializes it to +// JSON and passes it to the kubernetes provider plugin via the generic +// per-plugin options channel (TreeInput.raw_options). The same resolver is +// intended for reuse by Infracost Cloud, which has access to every repo and can +// join app repos to their cluster repos. +package cluster + +import "encoding/json" + +// KubernetesProviderName is the provider plugin name (GetPluginInfo.Name) whose +// raw_options the resolved cluster Config is sent to. +const KubernetesProviderName = "infracost/kubernetes" + +// Config is the cluster topology. Its JSON shape is the contract with the +// kubernetes provider plugin (mirrors providers/pkg/kubernetes.ClusterConfig); +// keep the two in sync, or promote to a shared schema module. +type Config struct { + CloudProvider string `json:"cloud_provider"` + Region string `json:"region"` + ComputePools []ComputePool `json:"compute_pools"` + StorageBackends []StorageBackend `json:"storage_backends,omitempty"` + LoadBalancerDefaults *LoadBalancerDefaults `json:"load_balancer_defaults,omitempty"` +} + +type ComputePool struct { + Name string `json:"name"` + // InstanceTypes are the interchangeable instance families a (usually Spot) + // node group may launch. The provider prices all of them and uses the + // cheapest, since a capacity/price-optimized autoscaler rarely lands on the + // first/newest one. + InstanceTypes []string `json:"instance_types"` + Spot bool `json:"spot"` + NodeCount int64 `json:"node_count,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + // Taints the node group applies. Mirrors the provider schema so a + // supplied/derived spec can drive taint-aware pod->pool matching. Not yet + // populated by FromTerraformTree (the parsed tree drops node-group + // labels/taints — see docs/kubernetes-plugin.md). + Taints []Taint `json:"taints,omitempty"` +} + +// Taint is a node taint (key/value/effect). +type Taint struct { + Key string `json:"key"` + Value string `json:"value"` + Effect string `json:"effect"` +} + +type StorageBackend struct { + Name string `json:"name"` + StorageType string `json:"storage_type"` +} + +type LoadBalancerDefaults struct { + Type string `json:"type"` +} + +// JSON renders the config as the JSON the provider expects. +func (c *Config) JSON() (string, error) { + b, err := json.Marshal(c) + if err != nil { + return "", err + } + return string(b), nil +} diff --git a/internal/cluster/resolve.go b/internal/cluster/resolve.go new file mode 100644 index 0000000..60f77f2 --- /dev/null +++ b/internal/cluster/resolve.go @@ -0,0 +1,55 @@ +package cluster + +import ( + "context" + "os" + "path/filepath" + + "github.com/infracost/proto/gen/go/infracost/parser/options" + pb "github.com/infracost/proto/gen/go/infracost/plugin" + "google.golang.org/grpc" +) + +// TerraformParser is the subset of a parser plugin the resolver needs. +// *plugins.ParserPlugin satisfies it (it embeds the ParserServiceClient). +type TerraformParser interface { + Parse(ctx context.Context, in *pb.ParseRequest, opts ...grpc.CallOption) (*pb.ParseResponse, error) +} + +// ResolveFromDir parses the cluster IaC at dir with the given Terraform parser +// plugin and derives the cluster topology. Returns (nil, nil) when dir has no +// recognizable cluster. +// +// The repo root is set to dir's enclosing git repository (not dir itself) so +// that local modules referenced as siblings — e.g. a root at infra/prod that +// uses ../modules/base — are loaded; the parser rejects modules outside the +// repo root. +func ResolveFromDir(ctx context.Context, dir string, parser TerraformParser) (*Config, error) { + resp, err := parser.Parse(ctx, &pb.ParseRequest{ + Path: dir, + GenericOptions: &options.GenericOptions{ + RepoDirectory: repoRoot(dir), + WorkingDirectory: dir, + }, + }) + if err != nil { + return nil, err + } + return FromTerraformTree(resp.GetTree()) +} + +// repoRoot walks up from dir to the nearest ancestor containing a .git entry, +// returning that directory. Falls back to dir when no repository is found. +func repoRoot(dir string) string { + d := dir + for { + if _, err := os.Stat(filepath.Join(d, ".git")); err == nil { + return d + } + parent := filepath.Dir(d) + if parent == d { + return dir + } + d = parent + } +} diff --git a/internal/cluster/resolve_test.go b/internal/cluster/resolve_test.go new file mode 100644 index 0000000..0442700 --- /dev/null +++ b/internal/cluster/resolve_test.go @@ -0,0 +1,39 @@ +package cluster + +import ( + "context" + "testing" + + gptree "github.com/infracost/go-proto/pkg/tree" + "github.com/infracost/go-proto/pkg/tree/aws/eks" + pb "github.com/infracost/proto/gen/go/infracost/plugin" + "google.golang.org/grpc" +) + +type fakeParser struct { + resp *pb.ParseResponse + err error +} + +func (f fakeParser) Parse(_ context.Context, _ *pb.ParseRequest, _ ...grpc.CallOption) (*pb.ParseResponse, error) { + return f.resp, f.err +} + +func TestResolveFromDir(t *testing.T) { + var w gptree.Tree + w.AWS.EKS.NodeGroups = []eks.NodeGroup{ + nodeGroup("default_workers", "us-east-2", 3, true, "r7i.large"), + } + pt, err := w.ToProto() + if err != nil { + t.Fatalf("ToProto: %v", err) + } + + cfg, err := ResolveFromDir(context.Background(), "./infra", fakeParser{resp: &pb.ParseResponse{Tree: pt}}) + if err != nil { + t.Fatalf("ResolveFromDir: %v", err) + } + if cfg == nil || len(cfg.ComputePools) != 1 || len(cfg.ComputePools[0].InstanceTypes) != 1 || cfg.ComputePools[0].InstanceTypes[0] != "r7i.large" { + t.Fatalf("unexpected config: %+v", cfg) + } +} diff --git a/internal/cluster/terraform.go b/internal/cluster/terraform.go new file mode 100644 index 0000000..86cbbff --- /dev/null +++ b/internal/cluster/terraform.go @@ -0,0 +1,112 @@ +package cluster + +import ( + "encoding/json" + "strings" + + gptree "github.com/infracost/go-proto/pkg/tree" + "github.com/infracost/go-proto/pkg/tree/aws/eks" + prototree "github.com/infracost/proto/gen/go/infracost/tree" +) + +// FromTerraformTree derives a cluster Config from a parsed Terraform tree (the +// cluster's IaC), reading EKS node groups. Each node group becomes a compute +// pool, including its Kubernetes labels + taints (surfaced by the terraform +// parser in raw attributes) so the provider can match a pod to the pool it +// would actually schedule onto. +// +// Returns (nil, nil) when the tree defines no recognizable cluster, so callers +// can fall back to an explicitly-provided cluster spec. +func FromTerraformTree(protoTree *prototree.Tree) (*Config, error) { + if protoTree == nil { + return nil, nil + } + w, err := gptree.FromProto(protoTree) + if err != nil { + return nil, err + } + + cfg := &Config{CloudProvider: "aws"} + + for i := range w.AWS.EKS.NodeGroups { + ng := &w.AWS.EKS.NodeGroups[i] + + // A node group may list several interchangeable instance families + // (Spot diversification); carry them all so the provider can price the + // cheapest. + var instanceTypes []string + for _, it := range ng.InstanceTypes.Items() { + if v := it.Value(); v != "" { + instanceTypes = append(instanceTypes, v) + } + } + if len(instanceTypes) == 0 { + continue + } + + if cfg.Region == "" { + cfg.Region = ng.GetBase().Region + } + + ras := ng.GetBase().Definition.RawStringAttributes + cfg.ComputePools = append(cfg.ComputePools, ComputePool{ + Name: ng.Name.Value(), + InstanceTypes: instanceTypes, + NodeCount: ng.InstanceCount.Value(), + Spot: ng.PurchaseOption.Value() == eks.PurchaseOptionSpot, + Labels: parseLabels(ras["k8s_labels"]), + Taints: parseTaints(ras["k8s_taints"]), + }) + } + + if len(cfg.ComputePools) == 0 { + return nil, nil + } + return cfg, nil +} + +func parseLabels(raw string) map[string]string { + if raw == "" { + return nil + } + var m map[string]string + if json.Unmarshal([]byte(raw), &m) != nil { + return nil + } + return m +} + +func parseTaints(raw string) []Taint { + if raw == "" { + return nil + } + var entries []map[string]string + if json.Unmarshal([]byte(raw), &entries) != nil { + return nil + } + out := make([]Taint, 0, len(entries)) + for _, e := range entries { + out = append(out, Taint{ + Key: e["key"], + Value: e["value"], + Effect: normalizeTaintEffect(e["effect"]), + }) + } + return out +} + +// normalizeTaintEffect maps the Terraform/AWS taint effect spelling +// (NO_SCHEDULE) to the Kubernetes spelling (NoSchedule) the provider matches +// pod tolerations against. +func normalizeTaintEffect(effect string) string { + switch strings.ToUpper(strings.ReplaceAll(effect, "_", "")) { + case "NOSCHEDULE": + return "NoSchedule" + case "NOEXECUTE": + return "NoExecute" + case "PREFERNOSCHEDULE": + return "PreferNoSchedule" + default: + return effect + } +} diff --git a/internal/cluster/terraform_test.go b/internal/cluster/terraform_test.go new file mode 100644 index 0000000..ffc413e --- /dev/null +++ b/internal/cluster/terraform_test.go @@ -0,0 +1,89 @@ +package cluster + +import ( + "testing" + + gptree "github.com/infracost/go-proto/pkg/tree" + "github.com/infracost/go-proto/pkg/tree/aws/eks" + "github.com/infracost/go-proto/pkg/tree/resource" + "github.com/infracost/go-proto/pkg/tree/value" +) + +// nodeGroup builds an eks.NodeGroup mirroring the shape produced by parsing a +// terraform-aws-modules/eks managed node group. +func nodeGroup(name, region string, count int64, spot bool, instanceTypes ...string) eks.NodeGroup { + items := make([]value.Value[string], 0, len(instanceTypes)) + for _, it := range instanceTypes { + items = append(items, value.New(it, 0, "", nil)) + } + purchase := eks.PurchaseOptionOnDemand + if spot { + purchase = eks.PurchaseOptionSpot + } + return eks.NodeGroup{ + Resource: resource.Resource{Region: region}, + Name: value.New(name, 0, "", nil), + ClusterName: value.New("prod", 0, "", nil), + InstanceCount: value.New(count, 0, "", nil), + InstanceTypes: *value.NewList(items, 0, "", nil), + PurchaseOption: value.New(purchase, 0, "", nil), + } +} + +func TestFromTerraformTree_EKSNodeGroups(t *testing.T) { + // Mirrors coast's prod cluster in ./infra: SPOT worker pools on r7i.large, + // a t3.large data-export pool, region us-east-2. + var w gptree.Tree + w.AWS.EKS.NodeGroups = []eks.NodeGroup{ + nodeGroup("default_workers", "us-east-2", 3, true, "r7i.large", "r7a.large", "r6i.large"), + nodeGroup("data_export", "us-east-2", 1, true, "t3.large", "t3a.large"), + } + + pt, err := w.ToProto() + if err != nil { + t.Fatalf("ToProto: %v", err) + } + + cfg, err := FromTerraformTree(pt) + if err != nil { + t.Fatalf("FromTerraformTree: %v", err) + } + if cfg == nil { + t.Fatal("expected a cluster config, got nil") + } + if cfg.CloudProvider != "aws" || cfg.Region != "us-east-2" { + t.Fatalf("unexpected cloud/region: %s/%s", cfg.CloudProvider, cfg.Region) + } + if len(cfg.ComputePools) != 2 { + t.Fatalf("expected 2 pools, got %d", len(cfg.ComputePools)) + } + + p0 := cfg.ComputePools[0] + if p0.Name != "default_workers" || !p0.Spot || p0.NodeCount != 3 { + t.Fatalf("unexpected first pool: %+v", p0) + } + if len(p0.InstanceTypes) != 3 || p0.InstanceTypes[0] != "r7i.large" || p0.InstanceTypes[2] != "r6i.large" { + t.Fatalf("expected all 3 instance families carried, got %v", p0.InstanceTypes) + } + + // The derived config must serialize to the JSON the provider consumes. + js, err := cfg.JSON() + if err != nil || js == "" { + t.Fatalf("JSON(): %q err=%v", js, err) + } +} + +func TestFromTerraformTree_NoCluster(t *testing.T) { + var w gptree.Tree + pt, err := w.ToProto() + if err != nil { + t.Fatalf("ToProto: %v", err) + } + cfg, err := FromTerraformTree(pt) + if err != nil { + t.Fatalf("FromTerraformTree: %v", err) + } + if cfg != nil { + t.Fatalf("expected nil config for a tree with no node groups, got %+v", cfg) + } +} diff --git a/internal/cmds/scan.go b/internal/cmds/scan.go index ca9f7a9..2f7408e 100644 --- a/internal/cmds/scan.go +++ b/internal/cmds/scan.go @@ -36,6 +36,11 @@ type ScanInput struct { // --currency CLI flag); the MCP tool handler takes it from its // input args, falling back to cfg.Currency. Currency string + // KubernetesClusterFrom is an optional path to the Terraform that + // defines the cluster Kubernetes manifests deploy onto (e.g. an EKS + // module). When set, the scanner derives the cluster's node pools from + // it and prices K8s workloads against them. + KubernetesClusterFrom string } // ScanResult is the typed output of `scan`. Aliased to *format.Output so @@ -109,11 +114,12 @@ func Scan(ctx context.Context, cfg *config.Config, source oauth2.TokenSource, st events.RegisterMetadata("branchId", branchName) s := &scanner.Scanner{ - Plugins: &cfg.Plugins, - Logging: cfg.Logging, - Dashboard: cfg.Dashboard, - Currency: in.Currency, - PricingEndpoint: cfg.PricingEndpoint, + Plugins: &cfg.Plugins, + Logging: cfg.Logging, + Dashboard: cfg.Dashboard, + Currency: in.Currency, + PricingEndpoint: cfg.PricingEndpoint, + KubernetesClusterFrom: in.KubernetesClusterFrom, } startTime := time.Now() result, err := s.Scan(ctx, runParameters, absolutePath, branchName, source) @@ -229,6 +235,7 @@ func ScanCmd(cfg *config.Config) *cobra.Command { } cmd.Flags().StringVar(&cfg.Currency, "currency", "", "ISO 4217 currency code to use for prices (e.g. USD, EUR, GBP)") + cmd.Flags().StringVar(&in.KubernetesClusterFrom, "kubernetes-cluster-from", "", "Path to the Terraform that defines the cluster (e.g. an EKS module) that Kubernetes manifests deploy onto, used to price K8s workloads") cmd.Flags().BoolVar(&includeWarnings, "include-warnings", false, "Also show warning-severity diagnostics in the summary") return cmd @@ -268,4 +275,3 @@ func renderScanLLM(w io.Writer, r ScanResult) error { _, err := fmt.Fprintln(w) return err } - diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index d5cc24a..ac4b21e 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -9,6 +9,7 @@ import ( "github.com/infracost/cli/internal/api/dashboard" "github.com/infracost/cli/internal/cache" + "github.com/infracost/cli/internal/cluster" "github.com/infracost/cli/internal/format" "github.com/infracost/cli/internal/trace" "github.com/infracost/cli/pkg/logging" @@ -38,6 +39,52 @@ type Scanner struct { Dashboard dashboard.Config Currency string PricingEndpoint string + // KubernetesClusterFrom is an optional path to the Terraform defining + // the cluster K8s manifests deploy onto. When set, the scanner derives + // the cluster's node pools and exposes them to the kubernetes provider + // plugin so it can price the workloads. + KubernetesClusterFrom string +} + +// resolveKubernetesCluster derives the cluster topology from the Terraform at +// KubernetesClusterFrom and returns it as JSON for delivery to the kubernetes +// provider plugin via TreeInput.raw_options. Returns "" (best-effort) on any +// failure or when no cluster is configured — K8s workloads are then reported +// uncosted. +func (s *Scanner) resolveKubernetesCluster(ctx context.Context) string { + if s.KubernetesClusterFrom == "" { + return "" + } + + // Resolve on a dedicated, short-lived manager so we only launch the + // terraform parser needed to read the cluster IaC, independent of the + // shared scan manager. + mgr := plugins.NewManager(plugins.ManagerOptions{Dir: s.Plugins.PluginDir(), SkipInstall: true}) + defer mgr.Close() + + parser, err := mgr.LoadParserPluginForProject(ctx, "terraform") + if err != nil { + logging.WithError(err).Msgf("could not load the terraform parser to resolve --kubernetes-cluster-from %q; K8s workloads will be uncosted", s.KubernetesClusterFrom) + return "" + } + + cfg, err := cluster.ResolveFromDir(ctx, s.KubernetesClusterFrom, parser) + if err != nil { + logging.WithError(err).Msgf("failed to resolve cluster from %q; K8s workloads will be uncosted", s.KubernetesClusterFrom) + return "" + } + if cfg == nil { + logging.Warnf("no Kubernetes cluster (EKS node groups) found in %q; K8s workloads will be uncosted", s.KubernetesClusterFrom) + return "" + } + + js, err := cfg.JSON() + if err != nil { + logging.WithError(err).Msg("failed to encode resolved cluster config") + return "" + } + logging.Infof("resolved kubernetes cluster from %q: %d compute pools, region %q", s.KubernetesClusterFrom, len(cfg.ComputePools), cfg.Region) + return js } type FinOpsPolicy struct { @@ -188,6 +235,15 @@ func (s *Scanner) Scan(ctx context.Context, runParameters dashboard.RunParameter return nil, fmt.Errorf("failed to install plugins: %w", err) } + // Resolve any provider-plugin options once, before the per-project loop. + // Currently only the kubernetes provider takes options (its cluster spec, + // derived from --kubernetes-cluster-from); they travel via the generic + // TreeInput.raw_options channel, keyed by provider plugin name. + var providerOptions map[string][]byte + if clusterJSON := s.resolveKubernetesCluster(ctx); clusterJSON != "" { + providerOptions = map[string][]byte{cluster.KubernetesProviderName: []byte(clusterJSON)} + } + stat, err := os.Stat(absolutePath) if err != nil { return nil, err @@ -303,6 +359,7 @@ func (s *Scanner) Scan(ctx context.Context, runParameters dashboard.RunParameter TagPolicies: tagPolicies, UsageDefaults: usageDefaults, RepoUsage: repoUsage, + ProviderOptions: providerOptions, Plugins: s.Plugins, Logging: s.Logging, }) @@ -362,4 +419,3 @@ func (s *Scanner) Scan(ctx context.Context, runParameters dashboard.RunParameter return &result, nil } - diff --git a/pkg/plugins/config.go b/pkg/plugins/config.go index ec5648f..dc54552 100644 --- a/pkg/plugins/config.go +++ b/pkg/plugins/config.go @@ -105,6 +105,22 @@ func (c *Config) ProviderPlugins(ctx context.Context) ([]*ProviderPlugin, error) return manager.LoadProviderPlugins(ctx) } +// Reset tears down the plugin manager so the next EnsurePlugins re-runs install +// + discovery and reconnects. Long-lived hosts (the language server) use this to +// recover from a transient install failure (otherwise cached forever by the +// sync.Once) or a dropped plugin connection, without a full restart. +func (c *Config) Reset() { + c.managerMu.Lock() + defer c.managerMu.Unlock() + + if c.manager != nil { + c.manager.Close() + } + c.manager = nil + c.ensureOnce = sync.Once{} + c.ensureErr = nil +} + // Close releases all plugin subprocess resources. func (c *Config) Close() { c.managerMu.Lock() diff --git a/pkg/scanner/options_test.go b/pkg/scanner/options_test.go new file mode 100644 index 0000000..2ab78dd --- /dev/null +++ b/pkg/scanner/options_test.go @@ -0,0 +1,51 @@ +package scanner + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildProviderOptions(t *testing.T) { + const k8s = "infracost/kubernetes" + clusterJSON := []byte(`{"cloud_provider":"aws","region":"us-east-1"}`) + + tests := []struct { + name string + opts *ScanProjectOptions + provider string + wantRaw []byte + wantFormat string + }{ + { + name: "matching provider returns its options as json", + opts: &ScanProjectOptions{ProviderOptions: map[string][]byte{k8s: clusterJSON}}, + provider: k8s, + wantRaw: clusterJSON, + wantFormat: "application/json", + }, + { + name: "other provider gets nothing", + opts: &ScanProjectOptions{ProviderOptions: map[string][]byte{k8s: clusterJSON}}, + provider: "infracost/ai", + }, + { + name: "no provider options at all", + opts: &ScanProjectOptions{}, + provider: k8s, + }, + { + name: "empty options value is treated as absent", + opts: &ScanProjectOptions{ProviderOptions: map[string][]byte{k8s: {}}}, + provider: k8s, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, format := buildProviderOptions(tt.opts, tt.provider) + require.Equal(t, tt.wantRaw, raw) + require.Equal(t, tt.wantFormat, format) + }) + } +} diff --git a/pkg/scanner/scan.go b/pkg/scanner/scan.go index 47e695f..1189d77 100644 --- a/pkg/scanner/scan.go +++ b/pkg/scanner/scan.go @@ -69,6 +69,13 @@ type ScanProjectOptions struct { RepoUsage *usage.Usage PreviousResourceAddresses []string + // ProviderOptions holds pre-built, plugin-specific options keyed by provider + // plugin name (GetPluginInfo.Name), JSON-encoded. Mirrors the parser's + // raw_options: the value is passed through verbatim on TreeInput.raw_options + // to the matching provider plugin. Currently only the kubernetes provider is + // populated (its resolved cluster spec). + ProviderOptions map[string][]byte + Plugins *plugins.Config Logging logging.Config } @@ -244,6 +251,9 @@ func ScanProject(ctx context.Context, opts *ScanProjectOptions) (*ProjectResult, } for _, p := range providerPlugins { + // Attach this provider's plugin-specific options (if any) to the shared + // input, mirroring how parser raw_options are built per project type. + input.RawOptions, input.RawOptionsFormat = buildProviderOptions(opts, p.Info.GetName()) resp, err := p.Process(ctx, &pluginpb.ProcessRequest{Input: input}) if err != nil { return nil, fmt.Errorf("failed to execute provider %s: %w", p.Info.GetName(), err) @@ -306,6 +316,19 @@ func buildIaCOptions(opts *ScanProjectOptions, projectType repoconfig.ProjectTyp } } +// buildProviderOptions returns the plugin-specific options to attach to a +// provider plugin's TreeInput.raw_options, keyed by provider plugin name. It +// mirrors buildIaCOptions on the parser side. Options are pre-built upstream +// (e.g. the resolved kubernetes cluster spec) and stored in +// ScanProjectOptions.ProviderOptions as JSON; this just hands them through. +func buildProviderOptions(opts *ScanProjectOptions, providerName string) ([]byte, string) { + raw, ok := opts.ProviderOptions[providerName] + if !ok || len(raw) == 0 { + return nil, "" + } + return raw, "application/json" +} + type terraformPluginOptions struct { RegexSourceMap map[string]string `json:"regexSourceMap,omitempty"` Env map[string]string `json:"env,omitempty"` @@ -466,7 +489,12 @@ func GetRequiredProvidersFromTree(tree *treepb.Tree) []provider.Provider { } p := providerconv.ToProto(raw) if p == provider.Provider_PROVIDER_UNSPECIFIED { - logging.Warnf("skipping unsupported provider: %s", raw) + // A provider key with no built-in enum (e.g. "kubernetes", "ai") is + // normal: those resources are priced by their own provider plugin, + // not the typed cloud registry. requiredProviders is no longer used + // to gate plugin invocation (every loaded provider runs), so this is + // informational, not a warning. + logging.Debugf("provider %q has no built-in pricing enum; handled by its plugin if loaded", raw) continue } seen[p] = struct{}{}