Просмотр исходного кода

Add STACKIT as a cloud provider (#3741)

Signed-off-by: Stanislav Kopp <stanislav.kopp@digits.schwarz>
Co-authored-by: Alex Meijer <ameijer@users.noreply.github.com>
Stan Kopp 4 дней назад
Родитель
Сommit
1e39c8ecf0

+ 1 - 0
Dockerfile.cross

@@ -24,6 +24,7 @@ ADD --chmod=500 ./configs/gcp.json /models/gcp.json
 ADD --chmod=500 ./configs/alibaba.json /models/alibaba.json
 ADD --chmod=500 ./configs/alibaba.json /models/alibaba.json
 ADD --chmod=500 ./configs/oracle.json /models/oracle.json
 ADD --chmod=500 ./configs/oracle.json /models/oracle.json
 ADD --chmod=500 ./configs/otc.json /models/otc.json
 ADD --chmod=500 ./configs/otc.json /models/otc.json
+ADD --chmod=500 ./configs/stackit.json /models/stackit.json
 RUN chown -R 1001:1001 /models
 RUN chown -R 1001:1001 /models
 
 
 COPY ${binarypath} /go/bin/app
 COPY ${binarypath} /go/bin/app

+ 22 - 0
THIRD_PARTY_LICENSES.txt

@@ -4741,6 +4741,28 @@ Copyright 2019 Scaleway.
 
 
 --------------------------------- (separator) ----------------------------------
 --------------------------------- (separator) ----------------------------------
 
 
+== Dependency
+github.com/stackitcloud/stackit-sdk-go/core
+
+== License Type
+SPDX:Apache-2.0
+
+== Copyright
+Copyright 2023 STACKIT GmbH & Co. KG
+
+--------------------------------- (separator) ----------------------------------
+
+== Dependency
+github.com/stackitcloud/stackit-sdk-go/services/cost
+
+== License Type
+SPDX:Apache-2.0
+
+== Copyright
+Copyright 2023 STACKIT GmbH & Co. KG
+
+--------------------------------- (separator) ----------------------------------
+
 == Dependency
 == Dependency
 github.com/shopspring/decimal
 github.com/shopspring/decimal
 
 

+ 17 - 0
configs/stackit.json

@@ -0,0 +1,17 @@
+{
+    "provider": "STACKIT",
+    "currencyCode": "EUR",
+    "description": "STACKIT cloud prices derived from g2i.1 (1vCPU/4GB) at 36.83 EUR/month (compute only)",
+    "CPU": "0.02523",
+    "spotCPU": "0.02523",
+    "RAM": "0.00631",
+    "spotRAM": "0.00631",
+    "GPU": "0",
+    "storage": "0.0000712",
+    "zoneNetworkEgress": "0.01",
+    "regionNetworkEgress": "0.01",
+    "internetNetworkEgress": "0.12",
+    "natGatewayEgress": "0.045",
+    "natGatewayIngress": "0.045",
+    "defaultLBPrice": "0"
+}

+ 5 - 0
core/pkg/opencost/assetprops.go

@@ -196,6 +196,9 @@ const DigitalOceanProvider = "DigitalOcean"
 // OVHProvider describes the provider OVH
 // OVHProvider describes the provider OVH
 const OVHProvider = "OVH"
 const OVHProvider = "OVH"
 
 
+// STACKITProvider describes the provider STACKIT
+const STACKITProvider = "STACKIT"
+
 // NilProvider describes unknown provider
 // NilProvider describes unknown provider
 const NilProvider = "-"
 const NilProvider = "-"
 
 
@@ -220,6 +223,8 @@ func ParseProvider(str string) string {
 		return DigitalOceanProvider
 		return DigitalOceanProvider
 	case "ovh", "ovhcloud", "ovh-mks":
 	case "ovh", "ovhcloud", "ovh-mks":
 		return OVHProvider
 		return OVHProvider
+	case "stackit", "ske":
+		return STACKITProvider
 	default:
 	default:
 		return NilProvider
 		return NilProvider
 	}
 	}

+ 2 - 0
go.mod

@@ -53,6 +53,8 @@ require (
 	github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36
 	github.com/scaleway/scaleway-sdk-go v1.0.0-beta.36
 	github.com/spf13/cobra v1.10.2
 	github.com/spf13/cobra v1.10.2
 	github.com/spf13/viper v1.21.0
 	github.com/spf13/viper v1.21.0
+	github.com/stackitcloud/stackit-sdk-go/core v0.24.0
+	github.com/stackitcloud/stackit-sdk-go/services/cost v0.2.1
 	github.com/stretchr/testify v1.11.1
 	github.com/stretchr/testify v1.11.1
 	go.opentelemetry.io/otel v1.41.0
 	go.opentelemetry.io/otel v1.41.0
 	golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa
 	golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa

+ 4 - 0
go.sum

@@ -432,6 +432,10 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
 github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
 github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
 github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
 github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
 github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
 github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
+github.com/stackitcloud/stackit-sdk-go/core v0.24.0 h1:kHCcezCJ5OGSP7RRuGOxD5rF2wejpkEiRr/OdvNcuPQ=
+github.com/stackitcloud/stackit-sdk-go/core v0.24.0/go.mod h1:osMglDby4csGZ5sIfhNyYq1bS1TxIdPY88+skE/kkmI=
+github.com/stackitcloud/stackit-sdk-go/services/cost v0.2.1 h1:U2sBfMeBCdZUvCW+vqPbo+HPtGxMjCF21PYyQncPnpg=
+github.com/stackitcloud/stackit-sdk-go/services/cost v0.2.1/go.mod h1:Qt/scoasQrONlQ9FauvafUJ/3sP3xIFnhBQC8/Yhqgc=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

+ 40 - 0
pkg/cloud/config/configurations.go

@@ -11,6 +11,7 @@ import (
 	"github.com/opencost/opencost/pkg/cloud/azure"
 	"github.com/opencost/opencost/pkg/cloud/azure"
 	"github.com/opencost/opencost/pkg/cloud/gcp"
 	"github.com/opencost/opencost/pkg/cloud/gcp"
 	"github.com/opencost/opencost/pkg/cloud/oracle"
 	"github.com/opencost/opencost/pkg/cloud/oracle"
+	"github.com/opencost/opencost/pkg/cloud/stackit"
 )
 )
 
 
 // MultiCloudConfig struct is used to unmarshal cloud configs for each provider out of cloud-integration file
 // MultiCloudConfig struct is used to unmarshal cloud configs for each provider out of cloud-integration file
@@ -68,6 +69,7 @@ type Configurations struct {
 	Azure   *AzureConfigs   `json:"azure,omitempty"`
 	Azure   *AzureConfigs   `json:"azure,omitempty"`
 	Alibaba *AlibabaConfigs `json:"alibaba,omitempty"`
 	Alibaba *AlibabaConfigs `json:"alibaba,omitempty"`
 	OCI     *OCIConfigs     `json:"oci,omitempty"`
 	OCI     *OCIConfigs     `json:"oci,omitempty"`
+	STACKIT *STACKITConfigs `json:"stackit,omitempty"`
 }
 }
 
 
 // UnmarshalJSON custom json unmarshalling to maintain support for MultiCloudConfig format
 // UnmarshalJSON custom json unmarshalling to maintain support for MultiCloudConfig format
@@ -122,6 +124,10 @@ func (c *Configurations) Equals(that *Configurations) bool {
 		return false
 		return false
 	}
 	}
 
 
+	if !c.STACKIT.Equals(that.STACKIT) {
+		return false
+	}
+
 	return true
 	return true
 }
 }
 
 
@@ -157,6 +163,11 @@ func (c *Configurations) Insert(keyedConfig cloud.Config) error {
 			c.OCI = &OCIConfigs{}
 			c.OCI = &OCIConfigs{}
 		}
 		}
 		c.OCI.UsageAPI = append(c.OCI.UsageAPI, keyedConfig.(*oracle.UsageApiConfiguration))
 		c.OCI.UsageAPI = append(c.OCI.UsageAPI, keyedConfig.(*oracle.UsageApiConfiguration))
+	case *stackit.CostConfiguration:
+		if c.STACKIT == nil {
+			c.STACKIT = &STACKITConfigs{}
+		}
+		c.STACKIT.CostAPI = append(c.STACKIT.CostAPI, keyedConfig.(*stackit.CostConfiguration))
 	default:
 	default:
 		return fmt.Errorf("Configurations: Insert: failed to insert config of type: %T", keyedConfig)
 		return fmt.Errorf("Configurations: Insert: failed to insert config of type: %T", keyedConfig)
 	}
 	}
@@ -199,6 +210,12 @@ func (c *Configurations) ToSlice() []cloud.KeyedConfig {
 		}
 		}
 	}
 	}
 
 
+	if c.STACKIT != nil {
+		for _, costConfig := range c.STACKIT.CostAPI {
+			keyedConfigs = append(keyedConfigs, costConfig)
+		}
+	}
+
 	return keyedConfigs
 	return keyedConfigs
 
 
 }
 }
@@ -339,3 +356,26 @@ func (oc *OCIConfigs) Equals(that *OCIConfigs) bool {
 
 
 	return true
 	return true
 }
 }
+
+type STACKITConfigs struct {
+	CostAPI []*stackit.CostConfiguration `json:"costApi,omitempty"`
+}
+
+func (sc *STACKITConfigs) Equals(that *STACKITConfigs) bool {
+	if sc == nil && that == nil {
+		return true
+	}
+	if sc == nil || that == nil {
+		return false
+	}
+	if len(sc.CostAPI) != len(that.CostAPI) {
+		return false
+	}
+	for i, thisCost := range sc.CostAPI {
+		thatCost := that.CostAPI[i]
+		if !thisCost.Equals(thatCost) {
+			return false
+		}
+	}
+	return true
+}

+ 6 - 0
pkg/cloud/config/statuses.go

@@ -10,6 +10,7 @@ import (
 	"github.com/opencost/opencost/pkg/cloud/azure"
 	"github.com/opencost/opencost/pkg/cloud/azure"
 	"github.com/opencost/opencost/pkg/cloud/gcp"
 	"github.com/opencost/opencost/pkg/cloud/gcp"
 	"github.com/opencost/opencost/pkg/cloud/oracle"
 	"github.com/opencost/opencost/pkg/cloud/oracle"
+	"github.com/opencost/opencost/pkg/cloud/stackit"
 )
 )
 
 
 const (
 const (
@@ -18,6 +19,7 @@ const (
 	BigQueryConfigType     = "bigquery"
 	BigQueryConfigType     = "bigquery"
 	AzureStorageConfigType = "azurestorage"
 	AzureStorageConfigType = "azurestorage"
 	UsageApiConfigType     = "usageapi"
 	UsageApiConfigType     = "usageapi"
+	STACKITCostConfigType  = "stackitcost"
 )
 )
 
 
 func ConfigTypeFromConfig(config cloud.KeyedConfig) (string, error) {
 func ConfigTypeFromConfig(config cloud.KeyedConfig) (string, error) {
@@ -32,6 +34,8 @@ func ConfigTypeFromConfig(config cloud.KeyedConfig) (string, error) {
 		return AzureStorageConfigType, nil
 		return AzureStorageConfigType, nil
 	case *oracle.UsageApiConfiguration:
 	case *oracle.UsageApiConfiguration:
 		return UsageApiConfigType, nil
 		return UsageApiConfigType, nil
+	case *stackit.CostConfiguration:
+		return STACKITCostConfigType, nil
 	}
 	}
 	return "", fmt.Errorf("failed to determine config type for config with key: %s, type %T", config.Key(), config)
 	return "", fmt.Errorf("failed to determine config type for config with key: %s, type %T", config.Key(), config)
 }
 }
@@ -120,6 +124,8 @@ func (s *Status) UnmarshalJSON(b []byte) error {
 		config = &azure.StorageConfiguration{}
 		config = &azure.StorageConfiguration{}
 	case UsageApiConfigType:
 	case UsageApiConfigType:
 		config = &oracle.UsageApiConfiguration{}
 		config = &oracle.UsageApiConfiguration{}
+	case STACKITCostConfigType:
+		config = &stackit.CostConfiguration{}
 	default:
 	default:
 		return fmt.Errorf("Status: UnmarshalJSON: config type '%s' is not recognized", configType)
 		return fmt.Errorf("Status: UnmarshalJSON: config type '%s' is not recognized", configType)
 	}
 	}

+ 23 - 0
pkg/cloud/provider/provider.go

@@ -22,6 +22,7 @@ import (
 	"github.com/opencost/opencost/pkg/cloud/otc"
 	"github.com/opencost/opencost/pkg/cloud/otc"
 	"github.com/opencost/opencost/pkg/cloud/ovh"
 	"github.com/opencost/opencost/pkg/cloud/ovh"
 	"github.com/opencost/opencost/pkg/cloud/scaleway"
 	"github.com/opencost/opencost/pkg/cloud/scaleway"
+	"github.com/opencost/opencost/pkg/cloud/stackit"
 
 
 	"github.com/opencost/opencost/core/pkg/opencost"
 	"github.com/opencost/opencost/core/pkg/opencost"
 	"github.com/opencost/opencost/core/pkg/util"
 	"github.com/opencost/opencost/core/pkg/util"
@@ -115,6 +116,8 @@ func NewProvider(cache clustercache.ClusterCache, apiKey string, config *config.
 			cp.configFileName = "otc.json"
 			cp.configFileName = "otc.json"
 		case opencost.OVHProvider:
 		case opencost.OVHProvider:
 			cp.configFileName = "ovh.json"
 			cp.configFileName = "ovh.json"
+		case opencost.STACKITProvider:
+			cp.configFileName = "stackit.json"
 		case opencost.CSVProvider:
 		case opencost.CSVProvider:
 			cp.configFileName = "default.json"
 			cp.configFileName = "default.json"
 		}
 		}
@@ -220,6 +223,14 @@ func NewProvider(cache clustercache.ClusterCache, apiKey string, config *config.
 			ClusterAccountID: cp.accountID,
 			ClusterAccountID: cp.accountID,
 			Config:           NewProviderConfig(config, cp.configFileName),
 			Config:           NewProviderConfig(config, cp.configFileName),
 		}, nil
 		}, nil
+	case opencost.STACKITProvider:
+		log.Info("Found STACKIT provider, using STACKIT Provider")
+		return &stackit.STACKIT{
+			Clientset:        cache,
+			ClusterRegion:    cp.region,
+			ClusterAccountID: cp.accountID,
+			Config:           NewProviderConfig(config, cp.configFileName),
+		}, nil
 	case opencost.DigitalOceanProvider:
 	case opencost.DigitalOceanProvider:
 		log.Info("Detected DigitalOcean, using DOKS")
 		log.Info("Detected DigitalOcean, using DOKS")
 		return &digitalocean.DOKS{
 		return &digitalocean.DOKS{
@@ -312,6 +323,18 @@ func getClusterProperties(node *clustercache.Node) clusterProperties {
 		log.Debug("using DigitalOcean provider")
 		log.Debug("using DigitalOcean provider")
 		cp.provider = opencost.DigitalOceanProvider
 		cp.provider = opencost.DigitalOceanProvider
 		cp.configFileName = "digitalocean.json"
 		cp.configFileName = "digitalocean.json"
+	} else if strings.HasPrefix(providerID, "stackit") || strings.Contains(providerID, "stackit") {
+		log.Debug("using STACKIT provider")
+		cp.provider = opencost.STACKITProvider
+		cp.configFileName = "stackit.json"
+	} else if _, ok := node.Labels["node.stackit.cloud/ske"]; ok {
+		log.Debug("using STACKIT provider (detected via node label)")
+		cp.provider = opencost.STACKITProvider
+		cp.configFileName = "stackit.json"
+	} else if _, ok := node.Labels["topology.block-storage.csi.stackit.cloud/zone"]; ok {
+		log.Debug("using STACKIT provider (detected via CSI topology label)")
+		cp.provider = opencost.STACKITProvider
+		cp.configFileName = "stackit.json"
 	}
 	}
 	// Override provider to CSV if CSVProvider is used and custom provider is not set
 	// Override provider to CSV if CSVProvider is used and custom provider is not set
 	if env.IsUseCSVProvider() {
 	if env.IsUseCSVProvider() {

+ 92 - 0
pkg/cloud/stackit/costconfiguration.go

@@ -0,0 +1,92 @@
+package stackit
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/pkg/cloud"
+)
+
+// CostConfiguration holds the configuration needed to connect to STACKIT Cost API
+type CostConfiguration struct {
+	CustomerAccountID     string `json:"customerAccountId"`
+	ProjectID             string `json:"projectId"`
+	ServiceAccountKeyPath string `json:"serviceAccountKeyPath,omitempty"`
+}
+
+func (c *CostConfiguration) Validate() error {
+	if c.CustomerAccountID == "" {
+		return fmt.Errorf("CostConfiguration: missing customerAccountId")
+	}
+	if c.ProjectID == "" {
+		return fmt.Errorf("CostConfiguration: missing projectId")
+	}
+	return nil
+}
+
+func (c *CostConfiguration) Equals(config cloud.Config) bool {
+	if config == nil {
+		return false
+	}
+	thatConfig, ok := config.(*CostConfiguration)
+	if !ok {
+		return false
+	}
+
+	return c.CustomerAccountID == thatConfig.CustomerAccountID &&
+		c.ProjectID == thatConfig.ProjectID &&
+		c.ServiceAccountKeyPath == thatConfig.ServiceAccountKeyPath
+}
+
+func (c *CostConfiguration) Sanitize() cloud.Config {
+	sanitized := &CostConfiguration{
+		CustomerAccountID: c.CustomerAccountID,
+		ProjectID:         c.ProjectID,
+	}
+	if c.ServiceAccountKeyPath != "" {
+		sanitized.ServiceAccountKeyPath = cloud.Redacted
+	}
+	return sanitized
+}
+
+func (c *CostConfiguration) Key() string {
+	return c.CustomerAccountID + "/" + c.ProjectID
+}
+
+func (c *CostConfiguration) Provider() string {
+	return opencost.STACKITProvider
+}
+
+func (c *CostConfiguration) UnmarshalJSON(b []byte) error {
+	var f interface{}
+	err := json.Unmarshal(b, &f)
+	if err != nil {
+		return err
+	}
+
+	fmap, ok := f.(map[string]interface{})
+	if !ok {
+		return fmt.Errorf("CostConfiguration: UnmarshalJSON: expected object")
+	}
+
+	customerAccountID, err := cloud.GetInterfaceValue[string](fmap, "customerAccountId")
+	if err != nil {
+		return fmt.Errorf("CostConfiguration: UnmarshalJSON: %w", err)
+	}
+	c.CustomerAccountID = customerAccountID
+
+	projectID, err := cloud.GetInterfaceValue[string](fmap, "projectId")
+	if err != nil {
+		return fmt.Errorf("CostConfiguration: UnmarshalJSON: %w", err)
+	}
+	c.ProjectID = projectID
+
+	if saKeyPath, ok := fmap["serviceAccountKeyPath"]; ok {
+		if s, ok := saKeyPath.(string); ok {
+			c.ServiceAccountKeyPath = s
+		}
+	}
+
+	return nil
+}

+ 228 - 0
pkg/cloud/stackit/costintegration.go

@@ -0,0 +1,228 @@
+package stackit
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/pkg/cloud"
+	"github.com/stackitcloud/stackit-sdk-go/core/config"
+	cost "github.com/stackitcloud/stackit-sdk-go/services/cost/v3api"
+)
+
+type CostIntegration struct {
+	CostConfiguration
+	ConnectionStatus cloud.ConnectionStatus
+}
+
+func (ci *CostIntegration) GetCloudCost(start time.Time, end time.Time) (*opencost.CloudCostSetRange, error) {
+	var opts []config.ConfigurationOption
+	if ci.ServiceAccountKeyPath != "" {
+		opts = append(opts, config.WithServiceAccountKeyPath(ci.ServiceAccountKeyPath))
+	}
+
+	client, err := cost.NewAPIClient(opts...)
+	if err != nil {
+		ci.ConnectionStatus = cloud.FailedConnection
+		return nil, fmt.Errorf("creating STACKIT cost API client: %w", err)
+	}
+
+	fromStr := start.Format("2006-01-02")
+	// STACKIT Cost API uses inclusive end dates; OpenCost windows are end-exclusive,
+	// so subtract one day to align.
+	toStr := end.AddDate(0, 0, -1).Format("2006-01-02")
+
+	resp, err := client.DefaultAPI.
+		GetCostsForProject(context.Background(), ci.CustomerAccountID, ci.ProjectID).
+		From(fromStr).
+		To(toStr).
+		Depth("service").
+		Granularity("daily").
+		Execute()
+	if err != nil {
+		ci.ConnectionStatus = cloud.FailedConnection
+		return nil, fmt.Errorf("querying STACKIT costs: %w", err)
+	}
+
+	ccsr, err := opencost.NewCloudCostSetRange(start, end, opencost.AccumulateOptionDay, ci.Key())
+	if err != nil {
+		return nil, err
+	}
+
+	if resp == nil || resp.ProjectCostWithDetailedServices == nil {
+		if ci.ConnectionStatus != cloud.SuccessfulConnection {
+			ci.ConnectionStatus = cloud.MissingData
+		}
+		return ccsr, nil
+	}
+
+	detailed := resp.ProjectCostWithDetailedServices
+
+	for _, svc := range detailed.GetServices() {
+		serviceName := svc.GetServiceName()
+		category := selectSTACKITCategory(serviceName)
+		sku := svc.GetSku()
+		regionID := extractRegionFromServiceName(serviceName)
+
+		reportData := svc.GetReportData()
+		if len(reportData) == 0 {
+			// No daily granularity data; use total charge
+			totalChargeCents := svc.GetTotalCharge()
+			totalDiscountCents := svc.GetTotalDiscount()
+			totalCharge := totalChargeCents / 100.0
+			totalDiscount := totalDiscountCents / 100.0
+			netCost := totalCharge
+
+			properties := &opencost.CloudCostProperties{
+				Provider:        opencost.STACKITProvider,
+				AccountID:       ci.CustomerAccountID,
+				InvoiceEntityID: ci.CustomerAccountID,
+				RegionID:        regionID,
+				Service:         serviceName,
+				Category:        category,
+				ProviderID:      sku,
+				Labels:          opencost.CloudCostLabels{},
+			}
+
+			listCost := totalCharge + totalDiscount
+
+			cc := &opencost.CloudCost{
+				Properties: properties,
+				Window:     opencost.NewWindow(&start, &end),
+				ListCost: opencost.CostMetric{
+					Cost: listCost,
+				},
+				NetCost: opencost.CostMetric{
+					Cost: netCost,
+				},
+				AmortizedNetCost: opencost.CostMetric{
+					Cost: netCost,
+				},
+				AmortizedCost: opencost.CostMetric{
+					Cost: listCost,
+				},
+				InvoicedCost: opencost.CostMetric{
+					Cost: netCost,
+				},
+			}
+
+			ccsr.LoadCloudCost(cc)
+			continue
+		}
+
+		for _, rd := range reportData {
+			chargeCents := rd.GetCharge()
+			discountCents := rd.GetDiscount()
+			charge := chargeCents / 100.0
+			discount := discountCents / 100.0
+
+			tp := rd.GetTimePeriod()
+			periodStart, periodEnd := parsePeriod(tp.GetStart(), tp.GetEnd(), start, end)
+
+			properties := &opencost.CloudCostProperties{
+				Provider:        opencost.STACKITProvider,
+				AccountID:       ci.CustomerAccountID,
+				InvoiceEntityID: ci.CustomerAccountID,
+				RegionID:        regionID,
+				Service:         serviceName,
+				Category:        category,
+				ProviderID:      sku,
+				Labels:          opencost.CloudCostLabels{},
+			}
+
+			listCost := charge + discount
+
+			cc := &opencost.CloudCost{
+				Properties: properties,
+				Window:     opencost.NewWindow(&periodStart, &periodEnd),
+				ListCost: opencost.CostMetric{
+					Cost: listCost,
+				},
+				NetCost: opencost.CostMetric{
+					Cost: charge,
+				},
+				AmortizedNetCost: opencost.CostMetric{
+					Cost: charge,
+				},
+				AmortizedCost: opencost.CostMetric{
+					Cost: listCost,
+				},
+				InvoicedCost: opencost.CostMetric{
+					Cost: charge,
+				},
+			}
+
+			ccsr.LoadCloudCost(cc)
+		}
+	}
+
+	ci.ConnectionStatus = cloud.SuccessfulConnection
+	return ccsr, nil
+}
+
+// parsePeriod parses start/end date strings from the STACKIT API, falling back to the given defaults.
+func parsePeriod(startStr, endStr string, defaultStart, defaultEnd time.Time) (time.Time, time.Time) {
+	periodStart := defaultStart
+	periodEnd := defaultEnd
+
+	if startStr != "" {
+		if t, err := time.Parse("2006-01-02", startStr); err == nil {
+			periodStart = t
+		} else if t, err := time.Parse(time.RFC3339, startStr); err == nil {
+			periodStart = t
+		}
+	}
+
+	if endStr != "" {
+		if t, err := time.Parse("2006-01-02", endStr); err == nil {
+			// End date is inclusive in the API, add one day for the window
+			periodEnd = t.AddDate(0, 0, 1)
+		} else if t, err := time.Parse(time.RFC3339, endStr); err == nil {
+			periodEnd = t
+		}
+	}
+
+	return periodStart, periodEnd
+}
+
+func (ci *CostIntegration) GetStatus() cloud.ConnectionStatus {
+	if ci.ConnectionStatus.String() == "" {
+		ci.ConnectionStatus = cloud.InitialStatus
+	}
+	return ci.ConnectionStatus
+}
+
+func (ci *CostIntegration) RefreshStatus() cloud.ConnectionStatus {
+	log.Warn("status refresh is not supported for the STACKIT provider")
+	return ci.ConnectionStatus
+}
+
+// extractRegionFromServiceName extracts the region suffix from a STACKIT Cost API
+// service name (e.g. "Tiny Server-t1.2-EU01" -> "eu01").
+func extractRegionFromServiceName(serviceName string) string {
+	idx := strings.LastIndex(serviceName, "-")
+	if idx >= 0 {
+		suffix := strings.ToLower(serviceName[idx+1:])
+		if strings.HasPrefix(suffix, "eu") || strings.HasPrefix(suffix, "us") {
+			return suffix
+		}
+	}
+	return "eu01"
+}
+
+func selectSTACKITCategory(serviceName string) string {
+	lower := strings.ToLower(serviceName)
+	switch {
+	case strings.Contains(lower, "compute") || strings.Contains(lower, "server") || strings.Contains(lower, "ske"):
+		return opencost.ComputeCategory
+	case strings.Contains(lower, "storage") || strings.Contains(lower, "object store") || strings.Contains(lower, "backup"):
+		return opencost.StorageCategory
+	case strings.Contains(lower, "network") || strings.Contains(lower, "load balancer") || strings.Contains(lower, "dns"):
+		return opencost.NetworkCategory
+	default:
+		return opencost.OtherCategory
+	}
+}

+ 109 - 0
pkg/cloud/stackit/costintegration_test.go

@@ -0,0 +1,109 @@
+package stackit
+
+import (
+	"testing"
+	"time"
+)
+
+func TestParsePeriodDateOnly(t *testing.T) {
+	defaultStart := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	defaultEnd := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)
+
+	start, end := parsePeriod("2026-01-05", "2026-01-10", defaultStart, defaultEnd)
+
+	expectedStart := time.Date(2026, 1, 5, 0, 0, 0, 0, time.UTC)
+	expectedEnd := time.Date(2026, 1, 11, 0, 0, 0, 0, time.UTC) // inclusive end + 1 day
+
+	if !start.Equal(expectedStart) {
+		t.Errorf("start = %v, want %v", start, expectedStart)
+	}
+	if !end.Equal(expectedEnd) {
+		t.Errorf("end = %v, want %v", end, expectedEnd)
+	}
+}
+
+func TestParsePeriodRFC3339(t *testing.T) {
+	defaultStart := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	defaultEnd := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)
+
+	start, end := parsePeriod("2026-01-05T10:00:00Z", "2026-01-10T18:00:00Z", defaultStart, defaultEnd)
+
+	expectedStart := time.Date(2026, 1, 5, 10, 0, 0, 0, time.UTC)
+	expectedEnd := time.Date(2026, 1, 10, 18, 0, 0, 0, time.UTC) // RFC3339 used as-is
+
+	if !start.Equal(expectedStart) {
+		t.Errorf("start = %v, want %v", start, expectedStart)
+	}
+	if !end.Equal(expectedEnd) {
+		t.Errorf("end = %v, want %v", end, expectedEnd)
+	}
+}
+
+func TestParsePeriodEmpty(t *testing.T) {
+	defaultStart := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	defaultEnd := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)
+
+	start, end := parsePeriod("", "", defaultStart, defaultEnd)
+
+	if !start.Equal(defaultStart) {
+		t.Errorf("start = %v, want default %v", start, defaultStart)
+	}
+	if !end.Equal(defaultEnd) {
+		t.Errorf("end = %v, want default %v", end, defaultEnd)
+	}
+}
+
+func TestParsePeriodInvalidFallsBack(t *testing.T) {
+	defaultStart := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+	defaultEnd := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)
+
+	start, end := parsePeriod("not-a-date", "also-not", defaultStart, defaultEnd)
+
+	if !start.Equal(defaultStart) {
+		t.Errorf("start = %v, want default %v", start, defaultStart)
+	}
+	if !end.Equal(defaultEnd) {
+		t.Errorf("end = %v, want default %v", end, defaultEnd)
+	}
+}
+
+func TestExtractRegionFromServiceName(t *testing.T) {
+	tests := []struct {
+		service string
+		want    string
+	}{
+		{"Tiny Server-t1.2-EU01", "eu01"},
+		{"Object Storage Premium-EU02", "eu02"},
+		{"GPU Server-n2.14d.g1-EU01", "eu01"},
+		{"DNS-100-EU01", "eu01"},
+		{"Some Future Service-US01", "us01"},
+		{"NoRegionSuffix", "eu01"},
+	}
+	for _, tt := range tests {
+		got := extractRegionFromServiceName(tt.service)
+		if got != tt.want {
+			t.Errorf("extractRegionFromServiceName(%q) = %q, want %q", tt.service, got, tt.want)
+		}
+	}
+}
+
+func TestSelectSTACKITCategory(t *testing.T) {
+	tests := []struct {
+		service string
+		want    string
+	}{
+		{"Compute Engine", "Compute"},
+		{"SKE Cluster", "Compute"},
+		{"Block Storage", "Storage"},
+		{"Object Store", "Storage"},
+		{"Load Balancer", "Network"},
+		{"DNS Service", "Network"},
+		{"Some Other Service", "Other"},
+	}
+	for _, tt := range tests {
+		got := selectSTACKITCategory(tt.service)
+		if got != tt.want {
+			t.Errorf("selectSTACKITCategory(%q) = %q, want %q", tt.service, got, tt.want)
+		}
+	}
+}

+ 321 - 0
pkg/cloud/stackit/pim.go

@@ -0,0 +1,321 @@
+package stackit
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/opencost/opencost/core/pkg/log"
+)
+
+const (
+	pimBaseURL    = "https://pim-service.pim.stackit.cloud"
+	pimMaxPage    = 100
+	pimMaxPages   = 200 // safety limit to prevent infinite pagination
+	pimTimeoutSec = 30
+)
+
+var pimHTTPClient = &http.Client{
+	Timeout: pimTimeoutSec * time.Second,
+}
+
+// pimFlavorPricing holds PIM-fetched pricing for a VM flavor.
+type pimFlavorPricing struct {
+	HourlyCost string
+	VCPU       int
+	RAMGB      float64
+	GPUCount   int
+	GPUType    string
+}
+
+// pimStoragePricing holds PIM-fetched pricing for a storage class.
+type pimStoragePricing struct {
+	CostPerGBHr string
+}
+
+// pimSearchRequest is the POST body for /v2/skus/search.
+type pimSearchRequest struct {
+	GeneralProductGroup string `json:"generalProductGroup,omitempty"`
+	CategoryName        string `json:"categoryName,omitempty"`
+	ProductName         string `json:"productName,omitempty"`
+	Metro               *bool  `json:"metro,omitempty"`
+}
+
+// pimSKU represents the relevant fields of a PublicSKU from the PIM API.
+type pimSKU struct {
+	ID                        string                  `json:"id"`
+	Name                      string                  `json:"name"`
+	CategoryName              string                  `json:"categoryName"`
+	GeneralProductGroup       string                  `json:"generalProductGroup"`
+	Unit                      string                  `json:"unit"`
+	UnitBilling               string                  `json:"unitBilling"`
+	Region                    string                  `json:"region"`
+	CPUOverprovisioning       *bool                   `json:"cpuOverprovisioning"`
+	Prices                    []pimPrice              `json:"prices"`
+	ProductSpecificAttributes pimProductSpecificAttrs `json:"productSpecificAttributes"`
+	ServiceID                 []string                `json:"serviceId"`
+}
+
+type pimPrice struct {
+	Value        string `json:"value"`
+	MonthlyPrice string `json:"monthlyPrice"`
+	CurrencyCode string `json:"currencyCode"`
+}
+
+type pimProductSpecificAttrs struct {
+	Discriminator string   `json:"discriminator"`
+	Flavor        string   `json:"flavor"`
+	Hardware      string   `json:"hardware"`
+	VCPU          *int     `json:"vCPU"`
+	RAM           *float64 `json:"ram"`
+	Metro         *bool    `json:"metro"`
+	OS            string   `json:"os"`
+	// Storage-specific
+	Class       string `json:"class"`
+	StorageType string `json:"storage"`
+	Type        string `json:"type"`
+}
+
+type pimSearchResponse struct {
+	Meta struct {
+		NextCursor string `json:"nextCursor"`
+		PageSize   int    `json:"pageSize"`
+	} `json:"meta"`
+	Data []pimSKU `json:"data"`
+}
+
+// fetchAllPIMSKUs fetches all SKUs matching the search criteria, handling pagination.
+func fetchAllPIMSKUs(req pimSearchRequest) ([]pimSKU, error) {
+	var allSKUs []pimSKU
+	cursor := ""
+
+	for page := 0; page < pimMaxPages; page++ {
+		reqURL := fmt.Sprintf("%s/v2/skus/search?pageSize=%d", pimBaseURL, pimMaxPage)
+		if cursor != "" {
+			reqURL += "&cursor=" + url.QueryEscape(cursor)
+		}
+
+		body, err := json.Marshal(req)
+		if err != nil {
+			return nil, fmt.Errorf("marshaling PIM search request: %w", err)
+		}
+
+		httpReq, err := http.NewRequest("POST", reqURL, bytes.NewReader(body))
+		if err != nil {
+			return nil, fmt.Errorf("creating PIM request: %w", err)
+		}
+		httpReq.Header.Set("Content-Type", "application/json")
+
+		resp, err := pimHTTPClient.Do(httpReq)
+		if err != nil {
+			return nil, fmt.Errorf("PIM API request failed: %w", err)
+		}
+
+		respBody, err := io.ReadAll(resp.Body)
+		resp.Body.Close()
+		if err != nil {
+			return nil, fmt.Errorf("reading PIM response: %w", err)
+		}
+
+		if resp.StatusCode != http.StatusOK {
+			return nil, fmt.Errorf("PIM API returned status %d: %s", resp.StatusCode, string(respBody))
+		}
+
+		var searchResp pimSearchResponse
+		if err := json.Unmarshal(respBody, &searchResp); err != nil {
+			return nil, fmt.Errorf("decoding PIM response: %w", err)
+		}
+
+		allSKUs = append(allSKUs, searchResp.Data...)
+
+		if searchResp.Meta.NextCursor == "" || len(searchResp.Data) == 0 || len(searchResp.Data) < pimMaxPage {
+			break
+		}
+		cursor = searchResp.Meta.NextCursor
+	}
+
+	return allSKUs, nil
+}
+
+// parsePIMVMFlavors converts VM SKUs into a flavor -> pricing map.
+// It filters for non-metro SKUs and extracts flavor name, vCPU, RAM, and hourly price.
+func parsePIMVMFlavors(skus []pimSKU) map[string]*pimFlavorPricing {
+	flavors := make(map[string]*pimFlavorPricing)
+
+	for _, sku := range skus {
+		attrs := sku.ProductSpecificAttributes
+		flavor := attrs.Flavor
+		if flavor == "" {
+			continue
+		}
+
+		// Skip metro variants (priced separately, ~2x)
+		if attrs.Metro != nil && *attrs.Metro {
+			continue
+		}
+
+		// Must have pricing data
+		if len(sku.Prices) == 0 {
+			continue
+		}
+
+		hourlyPrice := sku.Prices[0].Value
+		if hourlyPrice == "" {
+			continue
+		}
+
+		vcpu := 0
+		if attrs.VCPU != nil {
+			vcpu = *attrs.VCPU
+		}
+		ramGB := 0.0
+		if attrs.RAM != nil {
+			ramGB = *attrs.RAM
+		}
+
+		// Detect GPU count from flavor name (e.g. "n1.14d.g1" -> 1, "n1.28d.g2" -> 2)
+		gpuCount := 0
+		gpuType := ""
+		if strings.HasPrefix(flavor, "n1.") || strings.HasPrefix(flavor, "n2.") || strings.HasPrefix(flavor, "n3.") {
+			gpuCount = gpuCountFromFlavor(flavor)
+			if gpuCount > 0 {
+				gpuType = gpuTypeFromFlavor(flavor)
+			}
+		}
+
+		flavors[flavor] = &pimFlavorPricing{
+			HourlyCost: hourlyPrice,
+			VCPU:       vcpu,
+			RAMGB:      ramGB,
+			GPUCount:   gpuCount,
+			GPUType:    gpuType,
+		}
+	}
+
+	return flavors
+}
+
+// parsePIMStoragePricing extracts per-GB-hour storage pricing from Storage SKUs.
+// Returns a map keyed by storage class name (e.g. "storage_premium_perf0").
+// Also includes a "default" entry for the cheapest capacity-based storage found.
+func parsePIMStoragePricing(skus []pimSKU) map[string]*pimStoragePricing {
+	pricing := make(map[string]*pimStoragePricing)
+	var defaultCost float64
+
+	for _, sku := range skus {
+		attrs := sku.ProductSpecificAttributes
+
+		// Skip metro variants
+		if attrs.Metro != nil && *attrs.Metro {
+			continue
+		}
+
+		// Only capacity-based storage with per-GB/hour billing
+		if sku.UnitBilling != "per GB/hour" {
+			continue
+		}
+
+		if len(sku.Prices) == 0 || sku.Prices[0].Value == "" {
+			continue
+		}
+
+		costStr := sku.Prices[0].Value
+
+		// Key by storage class if available
+		if attrs.Class != "" {
+			pricing[attrs.Class] = &pimStoragePricing{CostPerGBHr: costStr}
+		}
+
+		// Track cheapest for default
+		cost, err := strconv.ParseFloat(costStr, 64)
+		if err == nil && (defaultCost == 0 || cost < defaultCost) {
+			defaultCost = cost
+			pricing["default"] = &pimStoragePricing{CostPerGBHr: costStr}
+		}
+	}
+
+	return pricing
+}
+
+// gpuCountFromFlavor extracts GPU count from a STACKIT GPU flavor name.
+// e.g. "n1.14d.g1" -> 1, "n1.28d.g2" -> 2, "n3.104d.g8" -> 8
+func gpuCountFromFlavor(flavor string) int {
+	parts := strings.Split(flavor, ".g")
+	if len(parts) == 2 {
+		if count, err := strconv.Atoi(parts[1]); err == nil {
+			return count
+		}
+	}
+	return 0
+}
+
+// gpuTypeFromFlavor returns the GPU model based on the flavor prefix.
+func gpuTypeFromFlavor(flavor string) string {
+	switch {
+	case strings.HasPrefix(flavor, "n1."):
+		return "NVIDIA A100"
+	case strings.HasPrefix(flavor, "n2."):
+		return "NVIDIA L40S"
+	case strings.HasPrefix(flavor, "n3."):
+		return "NVIDIA H100 HGX"
+	default:
+		return ""
+	}
+}
+
+// downloadPIMPricing fetches all VM, GPU, and storage pricing from the PIM API.
+// Returns the flavor map, storage map, and any error.
+func downloadPIMPricing() (map[string]*pimFlavorPricing, map[string]*pimStoragePricing, error) {
+	metro := false
+
+	// 1. Fetch non-metro VM SKUs
+	log.Infof("STACKIT: fetching VM pricing from PIM API...")
+	vmSKUs, err := fetchAllPIMSKUs(pimSearchRequest{
+		GeneralProductGroup: "Virtual Machines",
+		Metro:               &metro,
+	})
+	if err != nil {
+		return nil, nil, fmt.Errorf("fetching VM SKUs: %w", err)
+	}
+
+	flavors := parsePIMVMFlavors(vmSKUs)
+	log.Infof("STACKIT: fetched %d VM flavor prices from PIM API", len(flavors))
+
+	// 2. Fetch GPU SKUs (separate category)
+	log.Infof("STACKIT: fetching GPU pricing from PIM API...")
+	gpuSKUs, err := fetchAllPIMSKUs(pimSearchRequest{
+		CategoryName: "Compute Engine GPU",
+		Metro:        &metro,
+	})
+	if err != nil {
+		log.Warnf("STACKIT: failed to fetch GPU pricing from PIM API: %v", err)
+	} else {
+		gpuFlavors := parsePIMVMFlavors(gpuSKUs)
+		for k, v := range gpuFlavors {
+			flavors[k] = v
+		}
+		log.Infof("STACKIT: fetched %d GPU flavor prices from PIM API", len(gpuFlavors))
+	}
+
+	// 3. Fetch Storage SKUs
+	log.Infof("STACKIT: fetching Storage pricing from PIM API...")
+	storageSKUs, err := fetchAllPIMSKUs(pimSearchRequest{
+		CategoryName: "Storage",
+		Metro:        &metro,
+	})
+	var storagePricing map[string]*pimStoragePricing
+	if err != nil {
+		log.Warnf("STACKIT: failed to fetch storage pricing from PIM API: %v", err)
+	} else {
+		storagePricing = parsePIMStoragePricing(storageSKUs)
+		log.Infof("STACKIT: fetched %d storage price entries from PIM API", len(storagePricing))
+	}
+
+	return flavors, storagePricing, nil
+}

+ 247 - 0
pkg/cloud/stackit/pim_test.go

@@ -0,0 +1,247 @@
+package stackit
+
+import (
+	"testing"
+)
+
+func TestGpuCountFromFlavor(t *testing.T) {
+	tests := []struct {
+		flavor string
+		want   int
+	}{
+		{"n1.14d.g1", 1},
+		{"n1.28d.g2", 2},
+		{"n3.104d.g8", 8},
+		{"c1i.2", 0},
+		{"g2i.1", 0},
+		{"n1.14d", 0},
+	}
+	for _, tt := range tests {
+		got := gpuCountFromFlavor(tt.flavor)
+		if got != tt.want {
+			t.Errorf("gpuCountFromFlavor(%q) = %d, want %d", tt.flavor, got, tt.want)
+		}
+	}
+}
+
+func TestGpuTypeFromFlavor(t *testing.T) {
+	tests := []struct {
+		flavor string
+		want   string
+	}{
+		{"n1.14d.g1", "NVIDIA A100"},
+		{"n2.28d.g2", "NVIDIA L40S"},
+		{"n3.104d.g8", "NVIDIA H100 HGX"},
+		{"c1i.2", ""},
+	}
+	for _, tt := range tests {
+		got := gpuTypeFromFlavor(tt.flavor)
+		if got != tt.want {
+			t.Errorf("gpuTypeFromFlavor(%q) = %q, want %q", tt.flavor, got, tt.want)
+		}
+	}
+}
+
+func TestParsePIMVMFlavors(t *testing.T) {
+	metro := true
+	nonMetro := false
+	vcpu2 := 2
+	ram4 := 4.0
+
+	skus := []pimSKU{
+		{
+			Name: "g2i.1",
+			ProductSpecificAttributes: pimProductSpecificAttrs{
+				Flavor: "g2i.1",
+				VCPU:   &vcpu2,
+				RAM:    &ram4,
+				Metro:  &nonMetro,
+			},
+			Prices: []pimPrice{{Value: "0.05045"}},
+		},
+		{
+			// Metro variant should be skipped
+			Name: "g2i.1-metro",
+			ProductSpecificAttributes: pimProductSpecificAttrs{
+				Flavor: "g2i.1",
+				VCPU:   &vcpu2,
+				RAM:    &ram4,
+				Metro:  &metro,
+			},
+			Prices: []pimPrice{{Value: "0.10"}},
+		},
+		{
+			// No flavor -> skipped
+			Name:                      "no-flavor",
+			ProductSpecificAttributes: pimProductSpecificAttrs{},
+			Prices:                    []pimPrice{{Value: "0.01"}},
+		},
+		{
+			// No price -> skipped
+			Name: "no-price",
+			ProductSpecificAttributes: pimProductSpecificAttrs{
+				Flavor: "c1i.2",
+				VCPU:   &vcpu2,
+				RAM:    &ram4,
+			},
+			Prices: []pimPrice{},
+		},
+	}
+
+	flavors := parsePIMVMFlavors(skus)
+
+	if len(flavors) != 1 {
+		t.Fatalf("expected 1 flavor, got %d", len(flavors))
+	}
+
+	f, ok := flavors["g2i.1"]
+	if !ok {
+		t.Fatal("expected flavor g2i.1")
+	}
+	if f.HourlyCost != "0.05045" {
+		t.Errorf("expected hourly cost 0.05045, got %s", f.HourlyCost)
+	}
+	if f.VCPU != 2 {
+		t.Errorf("expected 2 vCPU, got %d", f.VCPU)
+	}
+	if f.RAMGB != 4.0 {
+		t.Errorf("expected 4.0 RAM GB, got %f", f.RAMGB)
+	}
+}
+
+func TestParsePIMVMFlavorsGPU(t *testing.T) {
+	nonMetro := false
+	vcpu14 := 14
+	ram56 := 56.0
+
+	skus := []pimSKU{
+		{
+			Name: "n1.14d.g1",
+			ProductSpecificAttributes: pimProductSpecificAttrs{
+				Flavor: "n1.14d.g1",
+				VCPU:   &vcpu14,
+				RAM:    &ram56,
+				Metro:  &nonMetro,
+			},
+			Prices: []pimPrice{{Value: "3.50"}},
+		},
+	}
+
+	flavors := parsePIMVMFlavors(skus)
+	f, ok := flavors["n1.14d.g1"]
+	if !ok {
+		t.Fatal("expected flavor n1.14d.g1")
+	}
+	if f.GPUCount != 1 {
+		t.Errorf("expected GPU count 1, got %d", f.GPUCount)
+	}
+	if f.GPUType != "NVIDIA A100" {
+		t.Errorf("expected GPU type NVIDIA A100, got %s", f.GPUType)
+	}
+}
+
+func TestParsePIMVMFlavorsNonGPUNPrefix(t *testing.T) {
+	nonMetro := false
+	vcpu14 := 14
+	ram56 := 56.0
+
+	skus := []pimSKU{
+		{
+			Name: "n1.14d",
+			ProductSpecificAttributes: pimProductSpecificAttrs{
+				Flavor: "n1.14d",
+				VCPU:   &vcpu14,
+				RAM:    &ram56,
+				Metro:  &nonMetro,
+			},
+			Prices: []pimPrice{{Value: "1.50"}},
+		},
+	}
+
+	flavors := parsePIMVMFlavors(skus)
+	f, ok := flavors["n1.14d"]
+	if !ok {
+		t.Fatal("expected flavor n1.14d")
+	}
+	if f.GPUCount != 0 {
+		t.Errorf("expected GPU count 0, got %d", f.GPUCount)
+	}
+	if f.GPUType != "" {
+		t.Errorf("expected empty GPU type for non-GPU n1 flavor, got %q", f.GPUType)
+	}
+}
+
+func TestParsePIMStoragePricing(t *testing.T) {
+	nonMetro := false
+
+	skus := []pimSKU{
+		{
+			Name:        "premium-perf0",
+			UnitBilling: "per GB/hour",
+			ProductSpecificAttributes: pimProductSpecificAttrs{
+				Class: "storage_premium_perf0",
+				Metro: &nonMetro,
+			},
+			Prices: []pimPrice{{Value: "0.0001"}},
+		},
+		{
+			Name:        "premium-perf2",
+			UnitBilling: "per GB/hour",
+			ProductSpecificAttributes: pimProductSpecificAttrs{
+				Class: "storage_premium_perf2",
+				Metro: &nonMetro,
+			},
+			Prices: []pimPrice{{Value: "0.0005"}},
+		},
+		{
+			// Non-GB/hour billing -> skipped
+			Name:        "iops-based",
+			UnitBilling: "per IOPS/hour",
+			ProductSpecificAttributes: pimProductSpecificAttrs{
+				Class: "storage_iops",
+				Metro: &nonMetro,
+			},
+			Prices: []pimPrice{{Value: "0.01"}},
+		},
+	}
+
+	pricing := parsePIMStoragePricing(skus)
+
+	if _, ok := pricing["storage_premium_perf0"]; !ok {
+		t.Error("expected storage_premium_perf0")
+	}
+	if _, ok := pricing["storage_premium_perf2"]; !ok {
+		t.Error("expected storage_premium_perf2")
+	}
+	if _, ok := pricing["storage_iops"]; ok {
+		t.Error("storage_iops should have been skipped (non GB/hour billing)")
+	}
+
+	// Default should be the cheapest
+	def, ok := pricing["default"]
+	if !ok {
+		t.Fatal("expected default storage entry")
+	}
+	if def.CostPerGBHr != "0.0001" {
+		t.Errorf("expected default cost 0.0001, got %s", def.CostPerGBHr)
+	}
+}
+
+func TestPaginationTermination(t *testing.T) {
+	// Verify that empty data or empty cursor terminates pagination.
+	// This is a logic check - fetchAllPIMSKUs breaks on:
+	//   searchResp.Meta.NextCursor == "" || len(searchResp.Data) == 0
+	// We can't call the real API, but we verify the parsing logic
+	// handles the termination conditions in parsePIMVMFlavors.
+
+	// Empty input should produce empty output
+	flavors := parsePIMVMFlavors(nil)
+	if len(flavors) != 0 {
+		t.Errorf("expected 0 flavors from nil input, got %d", len(flavors))
+	}
+
+	flavors = parsePIMVMFlavors([]pimSKU{})
+	if len(flavors) != 0 {
+		t.Errorf("expected 0 flavors from empty input, got %d", len(flavors))
+	}
+}

+ 390 - 0
pkg/cloud/stackit/provider.go

@@ -0,0 +1,390 @@
+package stackit
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+	"sync"
+
+	coreenv "github.com/opencost/opencost/core/pkg/env"
+	"github.com/opencost/opencost/pkg/cloud/models"
+	"github.com/opencost/opencost/pkg/cloud/utils"
+
+	"github.com/opencost/opencost/core/pkg/clustercache"
+	"github.com/opencost/opencost/core/pkg/opencost"
+	"github.com/opencost/opencost/core/pkg/util"
+	"github.com/opencost/opencost/core/pkg/util/json"
+	"github.com/opencost/opencost/pkg/env"
+
+	"github.com/opencost/opencost/core/pkg/log"
+)
+
+const (
+	StackitPIMPricingSource = "STACKIT PIM API Pricing"
+)
+
+type STACKIT struct {
+	Clientset               clustercache.ClusterCache
+	Config                  models.ProviderConfig
+	ClusterRegion           string
+	ClusterAccountID        string
+	DownloadPricingDataLock sync.RWMutex
+
+	// PIM API pricing cache (protected by DownloadPricingDataLock)
+	pimFlavors map[string]*pimFlavorPricing
+	pimStorage map[string]*pimStoragePricing
+}
+
+func (s *STACKIT) PricingSourceSummary() interface{} {
+	s.DownloadPricingDataLock.RLock()
+	defer s.DownloadPricingDataLock.RUnlock()
+	return s.pimFlavors
+}
+
+func (s *STACKIT) DownloadPricingData() error {
+	s.DownloadPricingDataLock.Lock()
+	defer s.DownloadPricingDataLock.Unlock()
+
+	flavors, storage, err := downloadPIMPricing()
+	if err != nil {
+		return fmt.Errorf("STACKIT: failed to download pricing from PIM API: %w", err)
+	}
+
+	if len(flavors) == 0 {
+		return fmt.Errorf("STACKIT: PIM API returned no VM flavor pricing data")
+	}
+
+	s.pimFlavors = flavors
+	s.pimStorage = storage
+	return nil
+}
+
+func (s *STACKIT) AllNodePricing() (interface{}, error) {
+	s.DownloadPricingDataLock.RLock()
+	defer s.DownloadPricingDataLock.RUnlock()
+	return s.pimFlavors, nil
+}
+
+type stackitKey struct {
+	Labels map[string]string
+}
+
+func (k *stackitKey) Features() string {
+	instanceType, _ := util.GetInstanceType(k.Labels)
+	zone, _ := util.GetZone(k.Labels)
+	return zone + "," + instanceType
+}
+
+func (k *stackitKey) GPUCount() int {
+	return 0
+}
+
+func (k *stackitKey) GPUType() string {
+	return ""
+}
+
+func (k *stackitKey) ID() string {
+	return ""
+}
+
+func (s *STACKIT) NodePricing(key models.Key) (*models.Node, models.PricingMetadata, error) {
+	s.DownloadPricingDataLock.RLock()
+	defer s.DownloadPricingDataLock.RUnlock()
+
+	meta := models.PricingMetadata{}
+
+	// Extract instance type from key features ("zone,instanceType")
+	features := key.Features()
+	parts := strings.Split(features, ",")
+	instanceType := ""
+	if len(parts) >= 2 {
+		instanceType = parts[1]
+	}
+
+	pf, ok := s.pimFlavors[instanceType]
+	if !ok {
+		return nil, meta, fmt.Errorf("STACKIT: no pricing data found for instance type %q", instanceType)
+	}
+
+	ramBytes := int64(pf.RAMGB * 1024 * 1024 * 1024)
+	return &models.Node{
+		Cost:         pf.HourlyCost,
+		VCPU:         fmt.Sprintf("%d", pf.VCPU),
+		RAM:          fmt.Sprintf("%g", pf.RAMGB),
+		RAMBytes:     fmt.Sprintf("%d", ramBytes),
+		GPU:          fmt.Sprintf("%d", pf.GPUCount),
+		GPUName:      pf.GPUType,
+		InstanceType: instanceType,
+		Region:       s.ClusterRegion,
+		PricingType:  models.DefaultPrices,
+	}, meta, nil
+}
+
+func (s *STACKIT) LoadBalancerPricing() (*models.LoadBalancer, error) {
+	config, err := s.GetConfig()
+	if err != nil {
+		return nil, fmt.Errorf("unable to get config: %w", err)
+	}
+
+	lbPrice := 0.0
+	if config.DefaultLBPrice != "" {
+		lbPrice, _ = strconv.ParseFloat(config.DefaultLBPrice, 64)
+	}
+
+	return &models.LoadBalancer{
+		Cost: lbPrice,
+	}, nil
+}
+
+func (s *STACKIT) NetworkPricing() (*models.Network, error) {
+	config, err := s.GetConfig()
+	if err != nil {
+		return nil, fmt.Errorf("unable to get config: %w", err)
+	}
+
+	zoneEgress, _ := strconv.ParseFloat(config.ZoneNetworkEgress, 64)
+	regionEgress, _ := strconv.ParseFloat(config.RegionNetworkEgress, 64)
+	internetEgress, _ := strconv.ParseFloat(config.InternetNetworkEgress, 64)
+	natEgress, _ := strconv.ParseFloat(config.NatGatewayEgress, 64)
+	natIngress, _ := strconv.ParseFloat(config.NatGatewayIngress, 64)
+
+	return &models.Network{
+		ZoneNetworkEgressCost:     zoneEgress,
+		RegionNetworkEgressCost:   regionEgress,
+		InternetNetworkEgressCost: internetEgress,
+		NatGatewayEgressCost:      natEgress,
+		NatGatewayIngressCost:     natIngress,
+	}, nil
+}
+
+func (s *STACKIT) GetKey(l map[string]string, n *clustercache.Node) models.Key {
+	return &stackitKey{
+		Labels: l,
+	}
+}
+
+type stackitPVKey struct {
+	Labels                 map[string]string
+	StorageClassName       string
+	StorageClassParameters map[string]string
+	Name                   string
+	Zone                   string
+}
+
+func (key *stackitPVKey) ID() string {
+	return ""
+}
+
+func (key *stackitPVKey) GetStorageClass() string {
+	return key.StorageClassName
+}
+
+func (key *stackitPVKey) Features() string {
+	return key.Zone
+}
+
+func (s *STACKIT) GetPVKey(pv *clustercache.PersistentVolume, parameters map[string]string, defaultRegion string) models.PVKey {
+	zone := defaultRegion
+	if pv.Spec.CSI != nil {
+		parts := strings.Split(pv.Spec.CSI.VolumeHandle, "/")
+		if len(parts) >= 2 && parts[0] != "" {
+			zone = parts[0]
+		}
+	}
+	return &stackitPVKey{
+		Labels:                 pv.Labels,
+		StorageClassName:       pv.Spec.StorageClassName,
+		StorageClassParameters: parameters,
+		Name:                   pv.Name,
+		Zone:                   zone,
+	}
+}
+
+func (s *STACKIT) GpuPricing(nodeLabels map[string]string) (string, error) {
+	s.DownloadPricingDataLock.RLock()
+	defer s.DownloadPricingDataLock.RUnlock()
+
+	instanceType, _ := util.GetInstanceType(nodeLabels)
+	pf, ok := s.pimFlavors[instanceType]
+	if !ok || pf.GPUCount == 0 || pf.HourlyCost == "" {
+		return "", nil
+	}
+
+	hourlyCost, err := strconv.ParseFloat(pf.HourlyCost, 64)
+	if err != nil {
+		return "", fmt.Errorf("parsing STACKIT GPU hourly cost %q for %q: %w", pf.HourlyCost, instanceType, err)
+	}
+
+	perGPUCost := hourlyCost / float64(pf.GPUCount)
+	return strconv.FormatFloat(perGPUCost, 'f', -1, 64), nil
+}
+
+func (s *STACKIT) PVPricing(pvk models.PVKey) (*models.PV, error) {
+	s.DownloadPricingDataLock.RLock()
+	defer s.DownloadPricingDataLock.RUnlock()
+
+	storageClass := pvk.GetStorageClass()
+
+	if len(s.pimStorage) > 0 {
+		// Exact storage class match
+		if sp, ok := s.pimStorage[storageClass]; ok {
+			return &models.PV{
+				Cost:  sp.CostPerGBHr,
+				Class: storageClass,
+			}, nil
+		}
+		// Default to cheapest capacity-based storage
+		if sp, ok := s.pimStorage["default"]; ok {
+			return &models.PV{
+				Cost:  sp.CostPerGBHr,
+				Class: storageClass,
+			}, nil
+		}
+	}
+
+	log.Debugf("STACKIT: no PV pricing found for storage class %q", storageClass)
+	return &models.PV{}, nil
+}
+
+func (s *STACKIT) ServiceAccountStatus() *models.ServiceAccountStatus {
+	return &models.ServiceAccountStatus{
+		Checks: []*models.ServiceAccountCheck{},
+	}
+}
+
+func (*STACKIT) ClusterManagementPricing() (string, float64, error) {
+	return "", 0.0, nil
+}
+
+func (s *STACKIT) CombinedDiscountForNode(instanceType string, isPreemptible bool, defaultDiscount, negotiatedDiscount float64) float64 {
+	return 1.0 - ((1.0 - defaultDiscount) * (1.0 - negotiatedDiscount))
+}
+
+func (s *STACKIT) Regions() []string {
+	regionOverrides := env.GetRegionOverrideList()
+	if len(regionOverrides) > 0 {
+		log.Debugf("Overriding STACKIT regions with configured region list: %+v", regionOverrides)
+		return regionOverrides
+	}
+	return []string{"eu01"}
+}
+
+func (*STACKIT) ApplyReservedInstancePricing(map[string]*models.Node) {}
+
+func (*STACKIT) GetAddresses() ([]byte, error) {
+	return nil, nil
+}
+
+func (*STACKIT) GetDisks() ([]byte, error) {
+	return nil, nil
+}
+
+func (*STACKIT) GetOrphanedResources() ([]models.OrphanedResource, error) {
+	return nil, errors.New("not implemented")
+}
+
+func (s *STACKIT) ClusterInfo() (map[string]string, error) {
+	remoteEnabled := env.IsRemoteEnabled()
+
+	m := make(map[string]string)
+	m["name"] = "STACKIT Cluster #1"
+	c, err := s.GetConfig()
+	if err != nil {
+		return nil, err
+	}
+	if c.ClusterName != "" {
+		m["name"] = c.ClusterName
+	}
+	m["provider"] = opencost.STACKITProvider
+	m["region"] = s.ClusterRegion
+	m["account"] = s.ClusterAccountID
+	m["remoteReadEnabled"] = strconv.FormatBool(remoteEnabled)
+	m["id"] = coreenv.GetClusterID()
+	return m, nil
+}
+
+func (s *STACKIT) UpdateConfigFromConfigMap(a map[string]string) (*models.CustomPricing, error) {
+	return s.Config.UpdateFromMap(a)
+}
+
+func (s *STACKIT) UpdateConfig(r io.Reader, updateType string) (*models.CustomPricing, error) {
+	cp, err := s.Config.Update(func(c *models.CustomPricing) error {
+		a := make(map[string]interface{})
+		err := json.NewDecoder(r).Decode(&a)
+		if err != nil {
+			return err
+		}
+		for k, v := range a {
+			kUpper := utils.ToTitle.String(k)
+			vstr, ok := v.(string)
+			if ok {
+				err := models.SetCustomPricingField(c, kUpper, vstr)
+				if err != nil {
+					return fmt.Errorf("error setting custom pricing field: %w", err)
+				}
+			} else {
+				return fmt.Errorf("type error while updating config for %s", kUpper)
+			}
+		}
+
+		if env.IsRemoteEnabled() {
+			err := utils.UpdateClusterMeta(coreenv.GetClusterID(), c.ClusterName)
+			if err != nil {
+				return err
+			}
+		}
+
+		return nil
+	})
+	if err != nil {
+		return cp, err
+	}
+
+	if refreshErr := s.DownloadPricingData(); refreshErr != nil {
+		log.Warnf("STACKIT: failed to refresh pricing after config update: %v", refreshErr)
+	}
+
+	return cp, nil
+}
+
+func (s *STACKIT) GetConfig() (*models.CustomPricing, error) {
+	c, err := s.Config.GetCustomPricingData()
+	if err != nil {
+		return nil, err
+	}
+	if c.Discount == "" {
+		c.Discount = "0%"
+	}
+	if c.NegotiatedDiscount == "" {
+		c.NegotiatedDiscount = "0%"
+	}
+	if c.CurrencyCode == "" {
+		c.CurrencyCode = "EUR"
+	}
+	return c, nil
+}
+
+func (s *STACKIT) GetManagementPlatform() (string, error) {
+	nodes := s.Clientset.GetAllNodes()
+	if len(nodes) > 0 {
+		n := nodes[0]
+		if _, ok := n.Labels["node.stackit.cloud/ske"]; ok {
+			return "ske", nil
+		}
+	}
+	return "", nil
+}
+
+func (s *STACKIT) PricingSourceStatus() map[string]*models.PricingSource {
+	s.DownloadPricingDataLock.RLock()
+	defer s.DownloadPricingDataLock.RUnlock()
+	return map[string]*models.PricingSource{
+		StackitPIMPricingSource: {
+			Name:      StackitPIMPricingSource,
+			Enabled:   true,
+			Available: len(s.pimFlavors) > 0,
+		},
+	}
+}

+ 192 - 0
pkg/cloud/stackit/provider_test.go

@@ -0,0 +1,192 @@
+package stackit
+
+import (
+	"testing"
+)
+
+func newTestProvider(flavors map[string]*pimFlavorPricing, storage map[string]*pimStoragePricing) *STACKIT {
+	return &STACKIT{
+		ClusterRegion: "eu01",
+		pimFlavors:    flavors,
+		pimStorage:    storage,
+	}
+}
+
+func TestNodePricing(t *testing.T) {
+	s := newTestProvider(map[string]*pimFlavorPricing{
+		"g2i.4": {HourlyCost: "0.201", VCPU: 4, RAMGB: 16.0},
+	}, nil)
+
+	key := &stackitKey{Labels: map[string]string{
+		"node.kubernetes.io/instance-type":        "g2i.4",
+		"topology.kubernetes.io/zone":             "eu01-3",
+	}}
+
+	node, _, err := s.NodePricing(key)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if node.Cost != "0.201" {
+		t.Errorf("Cost = %q, want %q", node.Cost, "0.201")
+	}
+	if node.VCPU != "4" {
+		t.Errorf("VCPU = %q, want %q", node.VCPU, "4")
+	}
+	if node.RAM != "16" {
+		t.Errorf("RAM = %q, want %q", node.RAM, "16")
+	}
+	if node.RAMBytes != "17179869184" {
+		t.Errorf("RAMBytes = %q, want %q", node.RAMBytes, "17179869184")
+	}
+	if node.InstanceType != "g2i.4" {
+		t.Errorf("InstanceType = %q, want %q", node.InstanceType, "g2i.4")
+	}
+	if node.Region != "eu01" {
+		t.Errorf("Region = %q, want %q", node.Region, "eu01")
+	}
+}
+
+func TestNodePricingUnknownType(t *testing.T) {
+	s := newTestProvider(map[string]*pimFlavorPricing{}, nil)
+
+	key := &stackitKey{Labels: map[string]string{
+		"node.kubernetes.io/instance-type": "unknown.1",
+	}}
+
+	_, _, err := s.NodePricing(key)
+	if err == nil {
+		t.Error("expected error for unknown instance type")
+	}
+}
+
+func TestNodePricingGPU(t *testing.T) {
+	s := newTestProvider(map[string]*pimFlavorPricing{
+		"n1.14d.g1": {HourlyCost: "3.50", VCPU: 14, RAMGB: 56.0, GPUCount: 1, GPUType: "NVIDIA A100"},
+	}, nil)
+
+	key := &stackitKey{Labels: map[string]string{
+		"node.kubernetes.io/instance-type": "n1.14d.g1",
+	}}
+
+	node, _, err := s.NodePricing(key)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if node.GPU != "1" {
+		t.Errorf("GPU = %q, want %q", node.GPU, "1")
+	}
+	if node.GPUName != "NVIDIA A100" {
+		t.Errorf("GPUName = %q, want %q", node.GPUName, "NVIDIA A100")
+	}
+}
+
+func TestGpuPricingPerGPU(t *testing.T) {
+	s := newTestProvider(map[string]*pimFlavorPricing{
+		"n1.28d.g2": {HourlyCost: "7.00", VCPU: 28, RAMGB: 112.0, GPUCount: 2, GPUType: "NVIDIA A100"},
+	}, nil)
+
+	labels := map[string]string{
+		"node.kubernetes.io/instance-type": "n1.28d.g2",
+	}
+
+	cost, err := s.GpuPricing(labels)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if cost != "3.5" {
+		t.Errorf("per-GPU cost = %q, want %q", cost, "3.5")
+	}
+}
+
+func TestGpuPricingNoGPU(t *testing.T) {
+	s := newTestProvider(map[string]*pimFlavorPricing{
+		"g2i.4": {HourlyCost: "0.201", VCPU: 4, RAMGB: 16.0, GPUCount: 0},
+	}, nil)
+
+	labels := map[string]string{
+		"node.kubernetes.io/instance-type": "g2i.4",
+	}
+
+	cost, err := s.GpuPricing(labels)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if cost != "" {
+		t.Errorf("expected empty cost for non-GPU instance, got %q", cost)
+	}
+}
+
+func TestGpuPricingUnknownInstance(t *testing.T) {
+	s := newTestProvider(map[string]*pimFlavorPricing{}, nil)
+
+	labels := map[string]string{
+		"node.kubernetes.io/instance-type": "unknown.1",
+	}
+
+	cost, err := s.GpuPricing(labels)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if cost != "" {
+		t.Errorf("expected empty cost for unknown instance, got %q", cost)
+	}
+}
+
+func TestPVPricingExactMatch(t *testing.T) {
+	s := newTestProvider(nil, map[string]*pimStoragePricing{
+		"storage_premium_perf2": {CostPerGBHr: "0.0005"},
+		"default":              {CostPerGBHr: "0.0001"},
+	})
+
+	pvk := &stackitPVKey{StorageClassName: "storage_premium_perf2"}
+	pv, err := s.PVPricing(pvk)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if pv.Cost != "0.0005" {
+		t.Errorf("Cost = %q, want %q", pv.Cost, "0.0005")
+	}
+}
+
+func TestPVPricingDefaultFallback(t *testing.T) {
+	s := newTestProvider(nil, map[string]*pimStoragePricing{
+		"default": {CostPerGBHr: "0.0001"},
+	})
+
+	pvk := &stackitPVKey{StorageClassName: "unknown-class"}
+	pv, err := s.PVPricing(pvk)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if pv.Cost != "0.0001" {
+		t.Errorf("Cost = %q, want default %q", pv.Cost, "0.0001")
+	}
+	if pv.Class != "unknown-class" {
+		t.Errorf("Class = %q, want %q", pv.Class, "unknown-class")
+	}
+}
+
+func TestPVPricingNoPricing(t *testing.T) {
+	s := newTestProvider(nil, nil)
+
+	pvk := &stackitPVKey{StorageClassName: "some-class"}
+	pv, err := s.PVPricing(pvk)
+	if err != nil {
+		t.Fatalf("unexpected error: %v", err)
+	}
+	if pv.Cost != "" {
+		t.Errorf("expected empty cost when no pricing data, got %q", pv.Cost)
+	}
+}
+
+func TestStackitKeyFeatures(t *testing.T) {
+	key := &stackitKey{Labels: map[string]string{
+		"node.kubernetes.io/instance-type":    "g2i.4",
+		"topology.kubernetes.io/zone":         "eu01-3",
+	}}
+
+	features := key.Features()
+	if features != "eu01-3,g2i.4" {
+		t.Errorf("Features() = %q, want %q", features, "eu01-3,g2i.4")
+	}
+}

+ 5 - 0
pkg/cloudcost/integration.go

@@ -10,6 +10,7 @@ import (
 	"github.com/opencost/opencost/pkg/cloud/azure"
 	"github.com/opencost/opencost/pkg/cloud/azure"
 	"github.com/opencost/opencost/pkg/cloud/gcp"
 	"github.com/opencost/opencost/pkg/cloud/gcp"
 	"github.com/opencost/opencost/pkg/cloud/oracle"
 	"github.com/opencost/opencost/pkg/cloud/oracle"
+	"github.com/opencost/opencost/pkg/cloud/stackit"
 )
 )
 
 
 // CloudCostIntegration is an interface for retrieving daily granularity CloudCost data for a given range
 // CloudCostIntegration is an interface for retrieving daily granularity CloudCost data for a given range
@@ -105,6 +106,10 @@ func GetIntegrationFromConfig(kc cloud.KeyedConfig) CloudCostIntegration {
 		return &oracle.UsageApiIntegration{
 		return &oracle.UsageApiIntegration{
 			UsageApiConfiguration: *keyedConfig,
 			UsageApiConfiguration: *keyedConfig,
 		}
 		}
+	case *stackit.CostConfiguration:
+		return &stackit.CostIntegration{
+			CostConfiguration: *keyedConfig,
+		}
 	default:
 	default:
 		return nil
 		return nil
 	}
 	}

+ 1 - 0
pkg/env/costmodel.go

@@ -40,6 +40,7 @@ const (
 	AzureRegionInfoEnvVar     = "AZURE_REGION_INFO"
 	AzureRegionInfoEnvVar     = "AZURE_REGION_INFO"
 
 
 	DigitalOceanAccessTokenEnvVar = "DIGITALOCEAN_ACCESS_TOKEN"
 	DigitalOceanAccessTokenEnvVar = "DIGITALOCEAN_ACCESS_TOKEN"
+
 	// Azure rate card filter environment variables
 	// Azure rate card filter environment variables
 
 
 	// Currently being used for OCI and DigitalOcean
 	// Currently being used for OCI and DigitalOcean