Files
zijiren 6c28b33cd8 fix(account): make hourly billing reconciliation resumable and idempotent (#7126)
* fix(account): make hourly billing reconciliation resumable and idempotent

- schedule every ready billing hour from a persisted Mongo checkpoint
- retry failed owner reconciliation and preserve checkpoint progress
- use stable billing IDs with Mongo upsert semantics
- recover stable unsettled billings independently from monitor data
- make Cockroach balance and credits deductions idempotent
- reconstruct historical debt and subscription state using transaction UpdatedAt
- scope subscription history queries to active workspaces in the target hour
- add focused unit and Testcontainers runtime coverage

* perf

* fix(account): restore hourly billing deduction semantics

* feat(account): bound billing checkpoint catch-up window

- add BILLING_MAX_CATCHUP_DURATION with a default of 24h
- limit historical billing replay to the configured duration
- preserve first-start behavior to process only the latest ready hour
- log skipped hours when the persisted checkpoint exceeds the replay window
- expose durable checkpoint lag and windowed pending checkpoint metrics
- wire the setting into the account controller Helm chart
- add bounded catch-up, first-start, validation, and metric tests

* fix: ci

* perf(account): optimize indexed historical billing queries

- replace full DebtStatusRecord loading with per-user LATERAL lookup
- use half-open billing time boundaries for historical debt state
- add composite indexes for debt, subscription, and credits queries
- add Testcontainers runtime and execution-plan coverage for billing paths
- verify Mongo billing indexes and repeated initialization behavior
2026-08-03 16:52:51 +08:00

663 lines
20 KiB
Go

// Copyright © 2023 sealos.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package resources
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/labring/sealos/controllers/pkg/common"
"github.com/labring/sealos/controllers/pkg/gpu"
"github.com/labring/sealos/controllers/pkg/types"
"github.com/labring/sealos/controllers/pkg/utils/env"
"go.mongodb.org/mongo-driver/bson/primitive"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
/*
Unified base unit:
CPU: millicore (mCore) (1 core = 1000 millicores) 0.067 / 1000 * 1000000 = 67 unit price
Memory: Mebibytes (MiB) 0.033 / 1024 (2^30) * 1000000 = 33 unit price
Storage: Mebibytes (MiB) 0.0021 / 1024 * 1000000 = 2 unit price
Network bandwidth: Mebibytes (MiB) 0.00078125 / 1024 * 1000000 = 781 unit price
*/
// | property | Price | Detail |
// | ------------ | ----- | -------------- |
// | Cpu | 67 | mCore unit |
// | Memory | 33 | Mebibytes unit |
// | Disk | 2 | Mebibytes unit |
// | Mongodb | 1 | feature... |
// | Minio | 1 | Mebibytes unit |
// | Infra-Cpu | 67 | mCore unit |
// | Infra-Memory | 33 | Mebibytes unit |
// | Infra-Disk | 2 | Mebibytes unit |
//
// price: 1000000 = 1¥
type Price struct {
Property string `json:"property" bson:"property"`
Price int64 `json:"price" bson:"price"`
Detail string `json:"detail" bson:"detail"`
// Unit string `json:"unit" bson:"unit"`
}
// | Category | property | Time | value | |
// | ---------- | ------------ | ------- | ----- | ---- |
// | Namespace1 | cpu | timestamp1 | 5 | |
// | Namespace1 | memory | timestamp1 | 100 | |
// | Namespace1 | disk | timestamp1 | 30 | |
// | appid1 | mongodb | timestamp1 | 100 | |
// | appid1 | Minio | timestamp1 | 100 | |
// | Namespace2 | Infra-Cpu | timestamp1 | 5 | |
// | Namespace2 | cpu | timestamp1 | 100 | |
// | Namespace2 | Infra-Memory | timestamp1 | 30 | |
// | Namespace2 | Infra-Disk | timestamp1 | 100 | |
// | Namespace2 | memory | timestamp1 | 100 | |
// | Namespace2 | disk | timestamp1 | 100 | |
// Composite index: category, property, time, speed up query
type Monitor struct {
Time time.Time `json:"time" bson:"time"`
// equal namespace
Category string `json:"category" bson:"category"`
Type uint8 `json:"type" bson:"type"`
ParentType uint8 `json:"parent_type" bson:"parent_type"`
ParentName string `json:"parent_name" bson:"parent_name"`
Name string `json:"name" bson:"name"`
Used EnumUsedMap `json:"used" bson:"used"`
Property string `json:"property,omitempty" bson:"property,omitempty"`
}
type ActiveBilling struct {
ID primitive.ObjectID `json:"_id,omitempty" bson:"_id,omitempty"`
Time time.Time `json:"time,omitempty" bson:"time"`
Namespace string `json:"namespace" bson:"namespace"`
AppType string `json:"app_type" bson:"app_type"`
AppName string `json:"app_name" bson:"app_name"`
Used UsedMap `json:"used,omitempty" bson:"used,omitempty"`
Amount int64 `json:"amount" bson:"amount,omitempty"`
Owner string `json:"owner" bson:"owner,omitempty"`
UserUID uuid.UUID `json:"user_uid" bson:"user_uid"`
Status ConsumptionStatus `json:"status" bson:"status"`
// Rule string `json:"rule" bson:"rule,omitempty"`
}
type ConsumptionStatus string
const (
Consumed ConsumptionStatus = "consumed"
Processing ConsumptionStatus = "processing"
Unconsumed ConsumptionStatus = "unconsumed"
ErrorConsumed ConsumptionStatus = "error_consumed"
)
type BillingType int
type Billing struct {
Time time.Time `json:"time" bson:"time"`
OrderID string `json:"order_id" bson:"order_id"`
Type common.Type `json:"type" bson:"type"`
// Name string `json:"name" bson:"name"`
Namespace string `json:"namespace" bson:"namespace"`
// Used Used `json:"used" bson:"used"`
// UsedAmount Used `json:"used_amount" bson:"used_amount"`
AppCosts []AppCost `json:"app_costs,omitempty" bson:"app_costs,omitempty"`
AppName string `json:"app_name,omitempty" bson:"app_name,omitempty"`
AppType uint8 `json:"app_type,omitempty" bson:"app_type,omitempty"`
Amount int64 `json:"amount" bson:"amount,omitempty"`
Owner string `json:"owner" bson:"owner,omitempty"`
// 0: 未结算 1: 已结算
Status BillingStatus `json:"status" bson:"status"`
// if type = Consumption, then payment is not nil
Payment *Payment `json:"payment" bson:"payment,omitempty"`
// if type = Transfer, then transfer is not nil
Transfer *Transfer `json:"transfer" bson:"transfer,omitempty"`
Detail string `json:"detail" bson:"detail,omitempty"`
// UserUID uuid.UUID `json:"user_uid" bson:"user_uid,omitempty"`
}
type BillingCheckpoint struct {
ID string `json:"id" bson:"_id"`
Time time.Time `json:"time" bson:"time"`
UpdatedAt time.Time `json:"updated_at" bson:"updated_at"`
}
type Payment struct {
Method string `json:"method" bson:"method"`
UserID string `json:"user_id" bson:"user_id"`
Amount int64 `json:"amount,omitempty"`
TradeNO string `json:"tradeNO,omitempty"`
// CodeURL is the codeURL of wechatpay
CodeURL string `json:"codeURL,omitempty"`
}
type Transfer struct {
From string `json:"from" bson:"from,omitempty"`
To string `json:"to" bson:"to,omitempty"`
Amount int64 `json:"amount" bson:"amount"`
}
type AppCost struct {
Type uint8 `json:"type" bson:"type"`
Used EnumUsedMap `json:"used" bson:"used"`
UsedAmount EnumUsedMap `json:"used_amount" bson:"used_amount"`
Amount int64 `json:"amount" bson:"amount,omitempty"`
Name string `json:"name" bson:"name"`
}
type BillingHandler struct {
OrderID string `json:"order_id" bson:"order_id"`
Time time.Time `json:"time" bson:"time"`
Amount int64 `json:"amount" bson:"amount,omitempty"`
Status BillingStatus `json:"status" bson:"status,omitempty"`
}
type BillingStatus int
const (
// 0: 未结算 1: 已结算
Unsettled BillingStatus = iota
Settled
Subscription
)
const (
// Consumption 消费
Consumption common.Type = iota
// Subconsumption 子消费
SubConsumption
)
const (
// DB = 1
// APP = 2
// TERMINAL = 3
// JOB = 4
// OTHER = 5
db = iota + 1
app
terminal
job
other
objectStorage
cvm
appStore
dbBackup
devBox
llmToken
)
const (
DB = "DB"
APP = "APP"
TERMINAL = "TERMINAL"
JOB = "JOB"
OTHER = "OTHER"
ObjectStorage = "OBJECT-STORAGE"
CVM = "CLOUD-VM"
AppStore = "APP-STORE"
DBBackup = "DB-BACKUP"
DevBox = "DEV-BOX"
LLMToken = "LLM-TOKEN"
)
var AppType = map[string]uint8{
DB: db, APP: app, TERMINAL: terminal, JOB: job, OTHER: other, ObjectStorage: objectStorage, CVM: cvm, AppStore: appStore, DBBackup: dbBackup, DevBox: devBox, LLMToken: llmToken,
}
var AppTypeReverse = map[uint8]string{
db: DB, app: APP, terminal: TERMINAL, job: JOB, other: OTHER, objectStorage: ObjectStorage, cvm: CVM, appStore: AppStore, dbBackup: DBBackup, devBox: DevBox, llmToken: LLMToken,
}
// resource consumption
type EnumUsedMap map[uint8]int64
type UsedMap map[string]float64
type PropertyType struct {
// For the monitoring storage enumeration type, use uint 8 to save memory
// 0 cpu, 1 memory, 2 storage, 3 network ... expandable
Name string `json:"name" bson:"name"`
Alias string `json:"alias" bson:"alias"`
Enum uint8 `json:"enum" bson:"enum"`
// AVG, SUM, DIF value. The cumulative value is the average value by default
PriceType string `json:"price_type,omitempty" bson:"price_type,omitempty"`
// Price = UsedAmount (avg || accumulated-value || difference-value) / Unit * UnitPrice
UnitPrice float64 `json:"unit_price" bson:"unit_price"`
ViewPrice float64 `json:"view_price" bson:"view_price"`
EncryptUnitPrice string `json:"encrypt_unit_price" bson:"encrypt_unit_price"`
Unit resource.Quantity `json:"-" bson:"-"`
// <digit> ::= 0 | 1 | ... | 9
// <digits> ::= <digit> | <digit><digits>
// <number> ::= <digits> | <digits>.<digits> | <digits>. | .<digits>
// <sign> ::= "+" | "-"
// <signedNumber> ::= <number> | <sign><number>
// <suffix> ::= <binarySI> | <decimalExponent> | <decimalSI>
// <binarySI> ::= Ki | Mi | Gi | Ti | Pi | Ei
//
// (International System of units; See: http://physics.nist.gov/cuu/Units/binary.html)
//
// <decimalSI> ::= m | "" | k | M | G | T | P | E
//
// (Note that 1024 = 1Ki but 1000 = 1k; I didn't choose the capitalization.)
//
// <decimalExponent> ::= "e" <signedNumber> | "E" <signedNumber>
UnitString string `json:"unit" bson:"unit"`
// charging cycle second
UnitPeriod string `json:"unit_period,omitempty" bson:"unit_period,omitempty"`
}
type PropertyTypeLS struct {
Types []PropertyType
StringMap map[string]PropertyType
EnumMap map[uint8]PropertyType
}
const (
// average value
AVG = "AVG"
// accumulated value
SUM = "SUM"
// difference value
DIF = "DIF"
)
var DefaultPropertyTypeList = []PropertyType{
{
Name: "cpu",
Enum: 0,
PriceType: AVG,
// raw price: 67
UnitPrice: 2.237442922,
UnitString: "1m",
},
{
Name: "memory",
Enum: 1,
PriceType: AVG,
// raw price: 33
UnitPrice: 1.092501427,
UnitString: "1Mi",
},
{
Name: "storage",
Enum: 2,
PriceType: AVG,
// raw price: 21
UnitPrice: 0,
UnitString: "1Mi",
},
{
Name: "network",
Enum: 3,
PriceType: SUM,
UnitPrice: 0,
UnitString: "1Mi",
},
{
// monitor unit: 1 node port = 1000 unit
Name: "services.nodeports",
Enum: 4,
PriceType: AVG,
UnitPrice: 2.083,
ViewPrice: 2083,
UnitString: "1",
},
}
var DefaultPropertyTypeLS = newPropertyTypeLS(DefaultPropertyTypeList)
func ConvertEnumUsedToString(costs map[uint8]int64) (costsMap map[string]int64) {
costsMap = make(map[string]int64, len(costs))
for k, v := range costs {
costsMap[DefaultPropertyTypeLS.EnumMap[k].Name] = v
}
return costsMap
}
func NewPropertyTypeLS(types []PropertyType) (ls *PropertyTypeLS) {
return newPropertyTypeLS(types)
}
func newPropertyTypeLS(types []PropertyType) (ls *PropertyTypeLS) {
ls = &PropertyTypeLS{
Types: types,
StringMap: make(PropertyTypeStringMap, len(types)),
EnumMap: make(PropertyTypeEnumMap, len(types)),
}
for i := range types {
if types[i].Unit == (resource.Quantity{}) && types[i].UnitString != "" {
types[i].Unit = resource.MustParse(types[i].UnitString)
}
ls.EnumMap[types[i].Enum] = types[i]
ls.StringMap[types[i].Name] = types[i]
}
return ls
}
type PropertyTypeEnumMap map[uint8]PropertyType
type PropertyTypeStringMap map[string]PropertyType
type PropertyList []PropertyType
// GpuResourcePrefix GPUResource = gpu- + gpu.Product ; ex. gpu-tesla-v100
const GpuResourcePrefix = "gpu-"
const (
ResourceGPU corev1.ResourceName = gpu.NvidiaGpuKey
ResourceNetwork = "network"
)
const (
ResourceRequestGpu corev1.ResourceName = "requests." + gpu.NvidiaGpuKey
ResourceLimitGpu corev1.ResourceName = "limits." + gpu.NvidiaGpuKey
ResourceObjectStorageSize corev1.ResourceName = "objectstorage/size"
ResourceObjectStorageBucket corev1.ResourceName = "objectstorage/bucket"
)
func NewGpuResource(product string) corev1.ResourceName {
return corev1.ResourceName(GpuResourcePrefix + product)
}
func IsGpuResource(resource string) bool {
return strings.HasPrefix(resource, GpuResourcePrefix)
}
func GetGpuResourceProduct(resource string) string {
return strings.TrimPrefix(resource, GpuResourcePrefix)
}
func GetDefaultResourceQuota(ns, name string) *corev1.ResourceQuota {
return &corev1.ResourceQuota{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: ns,
},
Spec: corev1.ResourceQuotaSpec{
Hard: DefaultResourceQuotaHard(),
},
}
}
func GetLimit0Quota(ns, name string) *corev1.ResourceQuota {
return &corev1.ResourceQuota{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: ns,
},
Spec: corev1.ResourceQuotaSpec{
Hard: Limit0Quota,
},
}
}
var Limit0Quota = corev1.ResourceList{}
func init() {
zeroQuantity := func() (q resource.Quantity) {
q.Set(0)
return q
}
for k := range DefaultResourceQuotaHard() {
Limit0Quota[k] = zeroQuantity()
}
}
func GetDefaultLimitRange(ns, name string) *corev1.LimitRange {
return &corev1.LimitRange{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: ns,
},
Spec: corev1.LimitRangeSpec{
Limits: DefaultLimitRangeLimits(),
},
}
}
const (
QuotaLimitsCPU = "QUOTA_LIMITS_CPU"
QuotaLimitsMemory = "QUOTA_LIMITS_MEMORY"
QuotaLimitsStorage = "QUOTA_LIMITS_STORAGE"
QuotaLimitsGPU = "QUOTA_LIMITS_GPU"
QuotaLimitsPods = "QUOTA_LIMITS_PODS"
QuotaLimitsNodePorts = "QUOTA_LIMITS_NODE_PORTS"
QuotaObjectStorageSize = "QUOTA_OBJECT_STORAGE_SIZE"
QuotaObjectStorageBucket = "QUOTA_OBJECT_STORAGE_BUCKET"
LimitRangeCPU = "LIMIT_RANGE_CPU"
LimitRangeMemory = "LIMIT_RANGE_MEMORY"
LimitRangeEphemeralStorage = "LIMIT_RANGE_EPHEMERAL_STORAGE"
LimitRangeRepCPU = "LIMIT_RANGE_REP_CPU"
LimitRangeRepMemory = "LIMIT_RANGE_REP_MEMORY"
LimitRangeRepEphemeralStorage = "LIMIT_RANGE_REP_EPHEMERAL_STORAGE"
)
const (
DefaultQuotaLimitsCPU = "16"
DefaultQuotaLimitsMemory = "64Gi"
DefaultQuotaLimitsStorage = "100Gi"
DefaultQuotaLimitsGPU = "8"
DefaultQuotaLimitsNodePorts = "10"
DefaultQuotaLimitsPods = "20"
DefaultQuotaObjectStorageSize = "20Gi"
DefaultQuotaObjectStorageBucket = "5"
)
func DefaultResourceQuotaHard() corev1.ResourceList {
return corev1.ResourceList{
ResourceRequestGpu: resource.MustParse(
env.GetEnvWithDefault(QuotaLimitsGPU, DefaultQuotaLimitsGPU),
),
ResourceLimitGpu: resource.MustParse(
env.GetEnvWithDefault(QuotaLimitsGPU, DefaultQuotaLimitsGPU),
),
corev1.ResourceLimitsCPU: resource.MustParse(
env.GetEnvWithDefault(QuotaLimitsCPU, DefaultQuotaLimitsCPU),
),
corev1.ResourceLimitsMemory: resource.MustParse(
env.GetEnvWithDefault(QuotaLimitsMemory, DefaultQuotaLimitsMemory),
),
corev1.ResourceRequestsStorage: resource.MustParse(
env.GetEnvWithDefault(QuotaLimitsStorage, DefaultQuotaLimitsStorage),
),
corev1.ResourceLimitsEphemeralStorage: resource.MustParse(
env.GetEnvWithDefault(QuotaLimitsStorage, DefaultQuotaLimitsStorage),
),
corev1.ResourcePods: resource.MustParse(
env.GetEnvWithDefault(QuotaLimitsPods, DefaultQuotaLimitsPods),
),
corev1.ResourceServicesNodePorts: resource.MustParse(
env.GetEnvWithDefault(QuotaLimitsNodePorts, DefaultQuotaLimitsNodePorts),
),
ResourceObjectStorageSize: resource.MustParse(
env.GetEnvWithDefault(QuotaObjectStorageSize, DefaultQuotaObjectStorageSize),
),
ResourceObjectStorageBucket: resource.MustParse(
env.GetEnvWithDefault(QuotaObjectStorageBucket, DefaultQuotaObjectStorageBucket),
),
// TODO storage.diskio.read, storage.diskio.write
}
}
func ParseResourceLimitWithSubscription(
plans []types.SubscriptionPlan,
) (map[string]corev1.ResourceList, error) {
subPlansLimit := make(map[string]corev1.ResourceList)
for i := range plans {
// max_resources: {"cpu":"128","memory":"256Gi","storage":"500Gi"}
res := plans[i].MaxResources
if res == "" {
subPlansLimit[plans[i].Name] = DefaultResourceQuotaHard()
} else {
var maxResources map[string]string
if err := json.Unmarshal([]byte(res), &maxResources); err != nil {
return nil, fmt.Errorf("parse max_resources failed: %w", err)
}
rl := make(corev1.ResourceList)
for k, v := range maxResources {
_v, err := ParseCustomQuantity(v)
if err != nil {
return nil, fmt.Errorf("parse %s failed: %w", k, err)
}
switch k {
case "cpu":
rl[corev1.ResourceLimitsCPU] = _v
case "memory":
rl[corev1.ResourceLimitsMemory] = _v
case "storage":
rl[corev1.ResourceRequestsStorage] = _v
case "nodeports":
rl[corev1.ResourceServicesNodePorts] = _v
case corev1.ResourcePods.String():
rl[corev1.ResourcePods] = _v
case corev1.ResourceServices.String():
rl[corev1.ResourceServices] = _v
case ResourceObjectStorageSize.String():
rl[ResourceObjectStorageSize] = _v
case ResourceObjectStorageBucket.String():
rl[ResourceObjectStorageBucket] = _v
}
}
subPlansLimit[plans[i].Name] = rl
}
}
return subPlansLimit, nil
}
type HasNameAndMaxResources interface {
GetName() string
GetMaxResources() string
}
func ParseResourceLimitWithPlans[T HasNameAndMaxResources](
plans []T,
) (map[string]corev1.ResourceList, error) {
subPlansLimit := make(map[string]corev1.ResourceList)
for _, plan := range plans {
res := plan.GetMaxResources()
if res == "" {
subPlansLimit[plan.GetName()] = DefaultResourceQuotaHard()
continue
}
var maxResources map[string]string
if err := json.Unmarshal([]byte(res), &maxResources); err != nil {
return nil, fmt.Errorf("parse max_resources failed: %w", err)
}
rl := make(corev1.ResourceList)
for k, v := range maxResources {
_v, err := ParseCustomQuantity(v)
if err != nil {
return nil, fmt.Errorf("parse %s failed: %w", k, err)
}
switch k {
case "cpu":
rl[corev1.ResourceLimitsCPU] = _v
case "memory":
rl[corev1.ResourceLimitsMemory] = _v
case "storage":
rl[corev1.ResourceRequestsStorage] = _v
case "nodeports":
rl[corev1.ResourceServicesNodePorts] = _v
case corev1.ResourcePods.String():
rl[corev1.ResourcePods] = _v
case corev1.ResourceServices.String():
rl[corev1.ResourceServices] = _v
case ResourceObjectStorageSize.String():
rl[ResourceObjectStorageSize] = _v
case ResourceObjectStorageBucket.String():
rl[ResourceObjectStorageBucket] = _v
}
}
subPlansLimit[plan.GetName()] = rl
}
return subPlansLimit, nil
}
func ParseCustomQuantity(s string) (resource.Quantity, error) {
s = strings.Replace(s, "GiB", "Gi", 1)
s = strings.Replace(s, "MiB", "Mi", 1)
return resource.ParseQuantity(s)
}
func DefaultLimitRangeLimits() []corev1.LimitRangeItem {
return []corev1.LimitRangeItem{
{
Type: corev1.LimitTypeContainer,
Default: defaultLimitRange,
DefaultRequest: defaultLimitRangeReq,
},
}
}
var defaultLimitRange, defaultLimitRangeReq = getLimitRangeDefault(), getLimitRangeReq()
func getLimitRangeDefault() corev1.ResourceList {
rcList := corev1.ResourceList{}
cpu, memory, ephemeralStorage := resource.MustParse(
env.GetEnvWithDefault(LimitRangeCPU, "50m"),
), resource.MustParse(
env.GetEnvWithDefault(LimitRangeMemory, "64Mi"),
), resource.MustParse(
env.GetEnvWithDefault(LimitRangeEphemeralStorage, "100Mi"),
)
if !cpu.IsZero() {
rcList[corev1.ResourceCPU] = cpu
}
if !memory.IsZero() {
rcList[corev1.ResourceMemory] = memory
}
if !ephemeralStorage.IsZero() {
rcList[corev1.ResourceEphemeralStorage] = ephemeralStorage
}
return rcList
}
func getLimitRangeReq() corev1.ResourceList {
rcList := corev1.ResourceList{}
cpu, memory, ephemeralStorage := resource.MustParse(
env.GetEnvWithDefault(LimitRangeRepCPU, "50m"),
), resource.MustParse(
env.GetEnvWithDefault(LimitRangeRepMemory, "64Mi"),
), resource.MustParse(
env.GetEnvWithDefault(LimitRangeRepEphemeralStorage, "100Mi"),
)
if !cpu.IsZero() {
rcList[corev1.ResourceCPU] = cpu
}
if !memory.IsZero() {
rcList[corev1.ResourceMemory] = memory
}
if !ephemeralStorage.IsZero() {
rcList[corev1.ResourceEphemeralStorage] = ephemeralStorage
}
return rcList
}