diff --git a/.werft/jobs/build/installer/installer.ts b/.werft/jobs/build/installer/installer.ts index bf611b1ff55eb4..ff5f87f43810ca 100644 --- a/.werft/jobs/build/installer/installer.ts +++ b/.werft/jobs/build/installer/installer.ts @@ -242,6 +242,8 @@ EOF`); exec(`yq w -i ${this.options.installerConfigPath} experimental.webapp.usage.enabled true`, { slice: slice }) exec(`yq w -i ${this.options.installerConfigPath} experimental.webapp.usage.schedule 1m`, { slice: slice }) exec(`yq w -i ${this.options.installerConfigPath} experimental.webapp.usage.billInstancesAfter "2022-08-11T08:05:32.499Z"`, { slice: slice }) + exec(`yq w -i ${this.options.installerConfigPath} experimental.webapp.usage.defaultSpendingLimit.forUsers 500`, { slice: slice }) + exec(`yq w -i ${this.options.installerConfigPath} experimental.webapp.usage.defaultSpendingLimit.forTeams 0`, { slice: slice }) exec(`yq w -i ${this.options.installerConfigPath} experimental.webapp.usage.creditsPerMinuteByWorkspaceClass['default'] 0.1666666667`, { slice: slice }) exec(`yq w -i ${this.options.installerConfigPath} experimental.webapp.usage.creditsPerMinuteByWorkspaceClass['gitpodio-internal-xl'] 0.3333333333`, { slice: slice }) } diff --git a/components/dashboard/src/components/UsageView.tsx b/components/dashboard/src/components/UsageView.tsx index 1d6a3257cdeaff..4358efe84b42d2 100644 --- a/components/dashboard/src/components/UsageView.tsx +++ b/components/dashboard/src/components/UsageView.tsx @@ -141,7 +141,7 @@ function UsageView({ attributionId, billingMode }: UsageViewProps) { return new Date(time).toLocaleDateString(undefined, options).replace("at ", ""); }; - const currentPaginatedResults = usagePage?.usageEntriesList ?? []; + const currentPaginatedResults = usagePage?.usageEntriesList.filter((u) => u.kind === "workspaceinstance") ?? []; return ( <> @@ -208,7 +208,7 @@ function UsageView({ attributionId, billingMode }: UsageViewProps) { {!isLoading && - (usagePage === undefined || usagePage.usageEntriesList.length === 0) && + (usagePage === undefined || currentPaginatedResults.length === 0) && !errorMessage && (

No sessions found.

diff --git a/components/gitpod-db/src/typeorm/migration/1663055856941-CostCenterNextBillingTime.ts b/components/gitpod-db/src/typeorm/migration/1663055856941-CostCenterNextBillingTime.ts new file mode 100644 index 00000000000000..84ce92bb340688 --- /dev/null +++ b/components/gitpod-db/src/typeorm/migration/1663055856941-CostCenterNextBillingTime.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { MigrationInterface, QueryRunner } from "typeorm"; +import { columnExists } from "./helper/helper"; + +const D_B_COST_CENTER = "d_b_cost_center"; +const COL_NEXT_BILLING_TIME = "nextBillingTime"; + +export class CostCenterNextBillingTime1663055856941 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + if (!(await columnExists(queryRunner, D_B_COST_CENTER, COL_NEXT_BILLING_TIME))) { + await queryRunner.query( + `ALTER TABLE ${D_B_COST_CENTER} ADD COLUMN ${COL_NEXT_BILLING_TIME} varchar(30) NOT NULL, ALGORITHM=INPLACE, LOCK=NONE `, + ); + await queryRunner.query( + `ALTER TABLE ${D_B_COST_CENTER} ADD INDEX(${COL_NEXT_BILLING_TIME}), ALGORITHM=INPLACE, LOCK=NONE `, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 3a64510cb7c77d..f34cc23def6f7b 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -2358,7 +2358,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl { draft: u.draft, workspaceInstanceId: u.workspaceInstanceId, kind: u.kind === Usage_Kind.KIND_WORKSPACE_INSTANCE ? "workspaceinstance" : "invoice", - metadata: JSON.parse(u.metadata), + metadata: !!u.metadata ? JSON.parse(u.metadata) : undefined, }; }), pagination: response.pagination diff --git a/components/usage-api/go/v1/usage.pb.go b/components/usage-api/go/v1/usage.pb.go index a1396f3b48e140..15293e799f1188 100644 --- a/components/usage-api/go/v1/usage.pb.go +++ b/components/usage-api/go/v1/usage.pb.go @@ -838,6 +838,8 @@ type CostCenter struct { AttributionId string `protobuf:"bytes,1,opt,name=attribution_id,json=attributionId,proto3" json:"attribution_id,omitempty"` SpendingLimit int32 `protobuf:"varint,2,opt,name=spending_limit,json=spendingLimit,proto3" json:"spending_limit,omitempty"` BillingStrategy CostCenter_BillingStrategy `protobuf:"varint,3,opt,name=billing_strategy,json=billingStrategy,proto3,enum=usage.v1.CostCenter_BillingStrategy" json:"billing_strategy,omitempty"` + // next_billing_time specifies when the next billing cycle happens. Only set when billing strategy is 'other'. This property is readonly. + NextBillingTime *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=next_billing_time,json=nextBillingTime,proto3" json:"next_billing_time,omitempty"` } func (x *CostCenter) Reset() { @@ -893,6 +895,13 @@ func (x *CostCenter) GetBillingStrategy() CostCenter_BillingStrategy { return CostCenter_BILLING_STRATEGY_STRIPE } +func (x *CostCenter) GetNextBillingTime() *timestamppb.Timestamp { + if x != nil { + return x.NextBillingTime + } + return nil +} + var File_usage_v1_usage_proto protoreflect.FileDescriptor var file_usage_v1_usage_proto_rawDesc = []byte{ @@ -996,7 +1005,7 @@ var file_usage_v1_usage_proto_rawDesc = []byte{ 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x35, 0x0a, 0x0b, 0x63, 0x6f, 0x73, 0x74, 0x5f, 0x63, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x52, - 0x0a, 0x63, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x22, 0xf7, 0x01, 0x0a, 0x0a, + 0x0a, 0x63, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x22, 0xbf, 0x02, 0x0a, 0x0a, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x49, @@ -1007,37 +1016,41 @@ var file_usage_v1_usage_proto_rawDesc = []byte{ 0x28, 0x0e, 0x32, 0x24, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x2e, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x52, 0x0f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, - 0x67, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x22, 0x4a, 0x0a, 0x0f, 0x42, 0x69, 0x6c, - 0x6c, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x1b, 0x0a, 0x17, - 0x42, 0x49, 0x4c, 0x4c, 0x49, 0x4e, 0x47, 0x5f, 0x53, 0x54, 0x52, 0x41, 0x54, 0x45, 0x47, 0x59, - 0x5f, 0x53, 0x54, 0x52, 0x49, 0x50, 0x45, 0x10, 0x00, 0x12, 0x1a, 0x0a, 0x16, 0x42, 0x49, 0x4c, - 0x4c, 0x49, 0x4e, 0x47, 0x5f, 0x53, 0x54, 0x52, 0x41, 0x54, 0x45, 0x47, 0x59, 0x5f, 0x4f, 0x54, - 0x48, 0x45, 0x52, 0x10, 0x01, 0x32, 0xd5, 0x02, 0x0a, 0x0c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x53, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x52, 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x73, - 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x12, 0x1e, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, - 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, - 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x52, 0x0a, 0x0d, 0x53, 0x65, - 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x12, 0x1e, 0x2e, 0x75, 0x73, - 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, - 0x6e, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x75, 0x73, - 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, - 0x6e, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x55, - 0x0a, 0x0e, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x55, 0x73, 0x61, 0x67, 0x65, - 0x12, 0x1f, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, - 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x20, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, - 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, 0x09, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x61, - 0x67, 0x65, 0x12, 0x1a, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, - 0x73, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, - 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, - 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x2a, 0x5a, - 0x28, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x69, 0x74, 0x70, - 0x6f, 0x64, 0x2d, 0x69, 0x6f, 0x2f, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, 0x2f, 0x75, 0x73, 0x61, - 0x67, 0x65, 0x2d, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x33, + 0x67, 0x53, 0x74, 0x72, 0x61, 0x74, 0x65, 0x67, 0x79, 0x12, 0x46, 0x0a, 0x11, 0x6e, 0x65, 0x78, + 0x74, 0x5f, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x52, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x54, 0x69, 0x6d, + 0x65, 0x22, 0x4a, 0x0a, 0x0f, 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x53, 0x74, 0x72, 0x61, + 0x74, 0x65, 0x67, 0x79, 0x12, 0x1b, 0x0a, 0x17, 0x42, 0x49, 0x4c, 0x4c, 0x49, 0x4e, 0x47, 0x5f, + 0x53, 0x54, 0x52, 0x41, 0x54, 0x45, 0x47, 0x59, 0x5f, 0x53, 0x54, 0x52, 0x49, 0x50, 0x45, 0x10, + 0x00, 0x12, 0x1a, 0x0a, 0x16, 0x42, 0x49, 0x4c, 0x4c, 0x49, 0x4e, 0x47, 0x5f, 0x53, 0x54, 0x52, + 0x41, 0x54, 0x45, 0x47, 0x59, 0x5f, 0x4f, 0x54, 0x48, 0x45, 0x52, 0x10, 0x01, 0x32, 0xd5, 0x02, + 0x0a, 0x0c, 0x55, 0x73, 0x61, 0x67, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x52, + 0x0a, 0x0d, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x12, + 0x1e, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, + 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x1f, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, + 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x12, 0x52, 0x0a, 0x0d, 0x53, 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, + 0x74, 0x65, 0x72, 0x12, 0x1e, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, + 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, + 0x65, 0x74, 0x43, 0x6f, 0x73, 0x74, 0x43, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x55, 0x0a, 0x0e, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, + 0x69, 0x6c, 0x65, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1f, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, + 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x55, 0x73, 0x61, + 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x75, 0x73, 0x61, 0x67, + 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x55, 0x73, + 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x46, 0x0a, + 0x09, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1a, 0x2e, 0x75, 0x73, 0x61, + 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, + 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x2a, 0x5a, 0x28, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, 0x2d, 0x69, 0x6f, 0x2f, 0x67, 0x69, + 0x74, 0x70, 0x6f, 0x64, 0x2f, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2d, 0x61, 0x70, 0x69, 0x2f, 0x76, + 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1086,19 +1099,20 @@ var file_usage_v1_usage_proto_depIdxs = []int32{ 14, // 10: usage.v1.SetCostCenterRequest.cost_center:type_name -> usage.v1.CostCenter 14, // 11: usage.v1.GetCostCenterResponse.cost_center:type_name -> usage.v1.CostCenter 2, // 12: usage.v1.CostCenter.billing_strategy:type_name -> usage.v1.CostCenter.BillingStrategy - 12, // 13: usage.v1.UsageService.GetCostCenter:input_type -> usage.v1.GetCostCenterRequest - 10, // 14: usage.v1.UsageService.SetCostCenter:input_type -> usage.v1.SetCostCenterRequest - 3, // 15: usage.v1.UsageService.ReconcileUsage:input_type -> usage.v1.ReconcileUsageRequest - 7, // 16: usage.v1.UsageService.ListUsage:input_type -> usage.v1.ListUsageRequest - 13, // 17: usage.v1.UsageService.GetCostCenter:output_type -> usage.v1.GetCostCenterResponse - 11, // 18: usage.v1.UsageService.SetCostCenter:output_type -> usage.v1.SetCostCenterResponse - 4, // 19: usage.v1.UsageService.ReconcileUsage:output_type -> usage.v1.ReconcileUsageResponse - 8, // 20: usage.v1.UsageService.ListUsage:output_type -> usage.v1.ListUsageResponse - 17, // [17:21] is the sub-list for method output_type - 13, // [13:17] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 15, // 13: usage.v1.CostCenter.next_billing_time:type_name -> google.protobuf.Timestamp + 12, // 14: usage.v1.UsageService.GetCostCenter:input_type -> usage.v1.GetCostCenterRequest + 10, // 15: usage.v1.UsageService.SetCostCenter:input_type -> usage.v1.SetCostCenterRequest + 3, // 16: usage.v1.UsageService.ReconcileUsage:input_type -> usage.v1.ReconcileUsageRequest + 7, // 17: usage.v1.UsageService.ListUsage:input_type -> usage.v1.ListUsageRequest + 13, // 18: usage.v1.UsageService.GetCostCenter:output_type -> usage.v1.GetCostCenterResponse + 11, // 19: usage.v1.UsageService.SetCostCenter:output_type -> usage.v1.SetCostCenterResponse + 4, // 20: usage.v1.UsageService.ReconcileUsage:output_type -> usage.v1.ReconcileUsageResponse + 8, // 21: usage.v1.UsageService.ListUsage:output_type -> usage.v1.ListUsageResponse + 18, // [18:22] is the sub-list for method output_type + 14, // [14:18] is the sub-list for method input_type + 14, // [14:14] is the sub-list for extension type_name + 14, // [14:14] is the sub-list for extension extendee + 0, // [0:14] is the sub-list for field type_name } func init() { file_usage_v1_usage_proto_init() } diff --git a/components/usage-api/typescript/src/usage/v1/usage.pb.ts b/components/usage-api/typescript/src/usage/v1/usage.pb.ts index 82c375d627ae73..f1c5ec748e2b87 100644 --- a/components/usage-api/typescript/src/usage/v1/usage.pb.ts +++ b/components/usage-api/typescript/src/usage/v1/usage.pb.ts @@ -186,6 +186,8 @@ export interface CostCenter { attributionId: string; spendingLimit: number; billingStrategy: CostCenter_BillingStrategy; + /** next_billing_time specifies when the next billing cycle happens. Only set when billing strategy is 'other'. This property is readonly. */ + nextBillingTime: Date | undefined; } export enum CostCenter_BillingStrategy { @@ -961,7 +963,12 @@ export const GetCostCenterResponse = { }; function createBaseCostCenter(): CostCenter { - return { attributionId: "", spendingLimit: 0, billingStrategy: CostCenter_BillingStrategy.BILLING_STRATEGY_STRIPE }; + return { + attributionId: "", + spendingLimit: 0, + billingStrategy: CostCenter_BillingStrategy.BILLING_STRATEGY_STRIPE, + nextBillingTime: undefined, + }; } export const CostCenter = { @@ -975,6 +982,9 @@ export const CostCenter = { if (message.billingStrategy !== CostCenter_BillingStrategy.BILLING_STRATEGY_STRIPE) { writer.uint32(24).int32(costCenter_BillingStrategyToNumber(message.billingStrategy)); } + if (message.nextBillingTime !== undefined) { + Timestamp.encode(toTimestamp(message.nextBillingTime), writer.uint32(34).fork()).ldelim(); + } return writer; }, @@ -994,6 +1004,9 @@ export const CostCenter = { case 3: message.billingStrategy = costCenter_BillingStrategyFromJSON(reader.int32()); break; + case 4: + message.nextBillingTime = fromTimestamp(Timestamp.decode(reader, reader.uint32())); + break; default: reader.skipType(tag & 7); break; @@ -1009,6 +1022,7 @@ export const CostCenter = { billingStrategy: isSet(object.billingStrategy) ? costCenter_BillingStrategyFromJSON(object.billingStrategy) : CostCenter_BillingStrategy.BILLING_STRATEGY_STRIPE, + nextBillingTime: isSet(object.nextBillingTime) ? fromJsonTimestamp(object.nextBillingTime) : undefined, }; }, @@ -1018,6 +1032,7 @@ export const CostCenter = { message.spendingLimit !== undefined && (obj.spendingLimit = Math.round(message.spendingLimit)); message.billingStrategy !== undefined && (obj.billingStrategy = costCenter_BillingStrategyToJSON(message.billingStrategy)); + message.nextBillingTime !== undefined && (obj.nextBillingTime = message.nextBillingTime.toISOString()); return obj; }, @@ -1026,6 +1041,7 @@ export const CostCenter = { message.attributionId = object.attributionId ?? ""; message.spendingLimit = object.spendingLimit ?? 0; message.billingStrategy = object.billingStrategy ?? CostCenter_BillingStrategy.BILLING_STRATEGY_STRIPE; + message.nextBillingTime = object.nextBillingTime ?? undefined; return message; }, }; diff --git a/components/usage-api/usage/v1/usage.proto b/components/usage-api/usage/v1/usage.proto index 24cec207bf05d4..c25819c458315d 100644 --- a/components/usage-api/usage/v1/usage.proto +++ b/components/usage-api/usage/v1/usage.proto @@ -114,4 +114,7 @@ message CostCenter { BILLING_STRATEGY_OTHER = 1; } BillingStrategy billing_strategy = 3; + + // next_billing_time specifies when the next billing cycle happens. Only set when billing strategy is 'other'. This property is readonly. + google.protobuf.Timestamp next_billing_time = 4; } diff --git a/components/usage/config.json b/components/usage/config.json index 6633d13d6f7bcf..fcbe12feebd096 100644 --- a/components/usage/config.json +++ b/components/usage/config.json @@ -1,10 +1,13 @@ { "controllerSchedule": "1h", + "defaultSpendingLimit": { + "forUsers": 5000, + "forTeams": 0 + }, "creditsPerMinuteByWorkspaceClass": { "default": 0.1666666667, "gitpodio-internal-xl": 0.3333333333 }, - "billInstancesAfter":"2022-08-11T08:05:32.499Z", "server": { "services": { "grpc": { diff --git a/components/usage/pkg/apiv1/usage.go b/components/usage/pkg/apiv1/usage.go index 1e727ade1b5634..9f13aaae1e180a 100644 --- a/components/usage/pkg/apiv1/usage.go +++ b/components/usage/pkg/apiv1/usage.go @@ -6,7 +6,6 @@ package apiv1 import ( "context" - "errors" "fmt" "math" "time" @@ -25,9 +24,10 @@ import ( var _ v1.UsageServiceServer = (*UsageService)(nil) type UsageService struct { - conn *gorm.DB - nowFunc func() time.Time - pricer *WorkspacePricer + conn *gorm.DB + nowFunc func() time.Time + pricer *WorkspacePricer + costCenterManager *db.CostCenterManager v1.UnimplementedUsageServiceServer } @@ -108,6 +108,12 @@ func (s *UsageService) ListUsage(ctx context.Context, in *v1.ListUsageRequest) ( if usageRecord.Kind == db.InvoiceUsageKind { kind = v1.Usage_KIND_INVOICE } + + var workspaceInstanceID string + if usageRecord.WorkspaceInstanceID != nil { + workspaceInstanceID = (*usageRecord.WorkspaceInstanceID).String() + } + usageDataEntry := &v1.Usage{ Id: usageRecord.ID.String(), AttributionId: string(usageRecord.AttributionID), @@ -115,7 +121,7 @@ func (s *UsageService) ListUsage(ctx context.Context, in *v1.ListUsageRequest) ( // convert cents back to full credits Credits: usageRecord.CreditCents.ToCredits(), Kind: kind, - WorkspaceInstanceId: usageRecord.WorkspaceInstanceID.String(), + WorkspaceInstanceId: workspaceInstanceID, Draft: usageRecord.Draft, Metadata: string(usageRecord.Metadata), } @@ -151,65 +157,61 @@ func (s *UsageService) ListUsage(ctx context.Context, in *v1.ListUsageRequest) ( } func (s *UsageService) GetCostCenter(ctx context.Context, in *v1.GetCostCenterRequest) (*v1.GetCostCenterResponse, error) { - var attributionIdReq string - if in.AttributionId == "" { return nil, status.Errorf(codes.InvalidArgument, "Empty attributionId") } - - attributionIdReq = in.AttributionId - - attributionId, err := db.ParseAttributionID(attributionIdReq) + attributionId, err := db.ParseAttributionID(in.AttributionId) if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "Failed to parse attribution ID: %s", err.Error()) + return nil, status.Errorf(codes.InvalidArgument, "Bad attributionId %s", in.AttributionId) } - result, err := db.GetCostCenter(ctx, s.conn, db.AttributionID(attributionIdReq)) + result, err := s.costCenterManager.GetOrCreateCostCenter(ctx, attributionId) if err != nil { - if errors.Is(err, db.CostCenterNotFound) { - return nil, status.Errorf(codes.NotFound, "Cost center not found: %s", err.Error()) - } - return nil, status.Errorf(codes.Internal, "Failed to get cost center %s from DB: %s", in.AttributionId, err.Error()) + return nil, err } - - billingStrategy := v1.CostCenter_BILLING_STRATEGY_OTHER - if result.BillingStrategy == db.CostCenter_Stripe { - billingStrategy = v1.CostCenter_BILLING_STRATEGY_STRIPE - } - return &v1.GetCostCenterResponse{ CostCenter: &v1.CostCenter{ - AttributionId: string(attributionId), + AttributionId: string(result.ID), SpendingLimit: result.SpendingLimit, - BillingStrategy: billingStrategy, + BillingStrategy: convertBillingStrategyToAPI(result.BillingStrategy), + NextBillingTime: timestamppb.New(result.NextBillingTime.Time()), }, }, nil } +func convertBillingStrategyToDB(in v1.CostCenter_BillingStrategy) db.BillingStrategy { + if in == v1.CostCenter_BILLING_STRATEGY_STRIPE { + return db.CostCenter_Stripe + } + return db.CostCenter_Other +} + +func convertBillingStrategyToAPI(in db.BillingStrategy) v1.CostCenter_BillingStrategy { + if in == db.CostCenter_Stripe { + return v1.CostCenter_BILLING_STRATEGY_STRIPE + } + return v1.CostCenter_BILLING_STRATEGY_OTHER +} + func (s *UsageService) SetCostCenter(ctx context.Context, in *v1.SetCostCenterRequest) (*v1.SetCostCenterResponse, error) { if in.CostCenter == nil { return nil, status.Errorf(codes.InvalidArgument, "Empty CostCenter") } - attributionID, err := db.ParseAttributionID(in.CostCenter.AttributionId) + attrID, err := db.ParseAttributionID(in.CostCenter.AttributionId) if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "Failed to parse attribution ID: %s", err.Error()) + return nil, err } - billingStrategy := db.CostCenter_Other - if in.CostCenter.BillingStrategy == v1.CostCenter_BILLING_STRATEGY_STRIPE { - billingStrategy = db.CostCenter_Stripe - } - - _, err = db.SaveCostCenter(ctx, s.conn, &db.CostCenter{ - ID: attributionID, + costCenter := db.CostCenter{ + ID: attrID, SpendingLimit: in.CostCenter.SpendingLimit, - BillingStrategy: billingStrategy, - }) + BillingStrategy: convertBillingStrategyToDB(in.CostCenter.BillingStrategy), + } + _, err = s.costCenterManager.UpdateCostCenter(ctx, costCenter) if err != nil { - return nil, status.Errorf(codes.Internal, "Failed to save cost center %s: %s", attributionID, err.Error()) + return nil, err } - return &v1.SetCostCenterResponse{}, nil } @@ -293,7 +295,7 @@ func reconcileUsage(instances []db.WorkspaceInstanceForUsage, drafts []db.Usage, draftsByWorkspaceID := map[uuid.UUID]db.Usage{} for _, draft := range drafts { - draftsByWorkspaceID[draft.WorkspaceInstanceID] = draft + draftsByWorkspaceID[*draft.WorkspaceInstanceID] = draft } for instanceID, instance := range instancesByID { @@ -336,7 +338,7 @@ func newUsageFromInstance(instance db.WorkspaceInstanceForUsage, pricer *Workspa CreditCents: db.NewCreditCents(pricer.CreditsUsedByInstance(&instance, now)), EffectiveTime: db.NewVarcharTime(effectiveTime), Kind: db.WorkspaceInstanceUsageKind, - WorkspaceInstanceID: instance.ID, + WorkspaceInstanceID: &instance.ID, Draft: draft, } @@ -381,7 +383,7 @@ func updateUsageFromInstance(instance db.WorkspaceInstanceForUsage, usage db.Usa func collectWorkspaceInstanceIDs(usage []db.Usage) []uuid.UUID { var ids []uuid.UUID for _, u := range usage { - ids = append(ids, u.WorkspaceInstanceID) + ids = append(ids, *u.WorkspaceInstanceID) } return ids } @@ -394,9 +396,10 @@ func dedupeWorkspaceInstancesForUsage(instances []db.WorkspaceInstanceForUsage) return set } -func NewUsageService(conn *gorm.DB, pricer *WorkspacePricer) *UsageService { +func NewUsageService(conn *gorm.DB, pricer *WorkspacePricer, costCenterManager *db.CostCenterManager) *UsageService { return &UsageService{ - conn: conn, + conn: conn, + costCenterManager: costCenterManager, nowFunc: func() time.Time { return time.Now().UTC() }, diff --git a/components/usage/pkg/apiv1/usage_test.go b/components/usage/pkg/apiv1/usage_test.go index 46b13a3eac7fe8..ec51aee1ecbfbd 100644 --- a/components/usage/pkg/apiv1/usage_test.go +++ b/components/usage/pkg/apiv1/usage_test.go @@ -51,7 +51,7 @@ func TestUsageService_ReconcileUsage(t *testing.T) { dbtest.CreateUsageRecords(t, dbconn, dbtest.NewUsage(t, db.Usage{ ID: uuid.New(), AttributionID: attributionID, - WorkspaceInstanceID: instance.ID, + WorkspaceInstanceID: &instance.ID, Kind: db.WorkspaceInstanceUsageKind, Draft: true, })) @@ -79,7 +79,12 @@ func newUsageService(t *testing.T, dbconn *gorm.DB) v1.UsageServiceClient { baseserver.WithGRPC(baseserver.MustUseRandomLocalAddress(t)), ) - v1.RegisterUsageServiceServer(srv.GRPC(), NewUsageService(dbconn, DefaultWorkspacePricer)) + costCenterManager := db.NewCostCenterManager(dbconn, db.DefaultSpendingLimit{ + ForTeams: 0, + ForUsers: 500, + }) + + v1.RegisterUsageServiceServer(srv.GRPC(), NewUsageService(dbconn, DefaultWorkspacePricer, costCenterManager)) baseserver.StartServerForTests(t, srv) conn, err := grpc.Dial(srv.GRPCAddress(), grpc.WithTransportCredentials(insecure.NewCredentials())) @@ -142,7 +147,7 @@ func TestReconcile(t *testing.T) { CreditCents: db.NewCreditCents(pricer.CreditsUsedByInstance(&instance, now)), EffectiveTime: db.NewVarcharTime(now), Kind: db.WorkspaceInstanceUsageKind, - WorkspaceInstanceID: instance.ID, + WorkspaceInstanceID: &instance.ID, Draft: true, Metadata: nil, } @@ -183,7 +188,7 @@ func TestReconcile(t *testing.T) { CreditCents: 1, EffectiveTime: db.VarcharTime{}, Kind: db.WorkspaceInstanceUsageKind, - WorkspaceInstanceID: instance.ID, + WorkspaceInstanceID: &instance.ID, Draft: true, Metadata: nil, }) @@ -200,7 +205,7 @@ func TestReconcile(t *testing.T) { CreditCents: db.NewCreditCents(pricer.CreditsUsedByInstance(&instance, now)), EffectiveTime: db.NewVarcharTime(now), Kind: db.WorkspaceInstanceUsageKind, - WorkspaceInstanceID: instance.ID, + WorkspaceInstanceID: &instance.ID, Draft: true, Metadata: nil, } @@ -258,7 +263,6 @@ func TestGetAndSetCostCenter(t *testing.T) { require.Equal(t, costCenter.SpendingLimit, retrieved.CostCenter.SpendingLimit) require.Equal(t, costCenter.BillingStrategy, retrieved.CostCenter.BillingStrategy) } - } func TestListUsage(t *testing.T) { diff --git a/components/usage/pkg/db/cost_center.go b/components/usage/pkg/db/cost_center.go index 835ee5f596ff52..7af5182e9405c6 100644 --- a/components/usage/pkg/db/cost_center.go +++ b/components/usage/pkg/db/cost_center.go @@ -10,6 +10,8 @@ import ( "fmt" "time" + "github.com/gitpod-io/gitpod/common-go/log" + "github.com/google/uuid" "gorm.io/gorm" ) @@ -27,8 +29,8 @@ type CostCenter struct { CreationTime VarcharTime `gorm:"primary_key;column:creationTime;type:varchar;size:255;" json:"creationTime"` SpendingLimit int32 `gorm:"column:spendingLimit;type:int;default:0;" json:"spendingLimit"` BillingStrategy BillingStrategy `gorm:"column:billingStrategy;type:varchar;size:255;" json:"billingStrategy"` - - LastModified time.Time `gorm:"->:column:_lastModified;type:timestamp;default:CURRENT_TIMESTAMP(6);" json:"_lastModified"` + NextBillingTime VarcharTime `gorm:"column:nextBillingTime;type:varchar;size:255;" json:"nextBillingTime"` + LastModified time.Time `gorm:"->:column:_lastModified;type:timestamp;default:CURRENT_TIMESTAMP(6);" json:"_lastModified"` } // TableName sets the insert table name for this struct type @@ -36,27 +38,133 @@ func (d *CostCenter) TableName() string { return "d_b_cost_center" } -func GetCostCenter(ctx context.Context, conn *gorm.DB, attributionId AttributionID) (*CostCenter, error) { +type DefaultSpendingLimit struct { + ForTeams int32 `json:"forTeams"` + ForUsers int32 `json:"forUsers"` +} + +func NewCostCenterManager(conn *gorm.DB, cfg DefaultSpendingLimit) *CostCenterManager { + return &CostCenterManager{ + conn: conn, + cfg: cfg, + } +} + +type CostCenterManager struct { + conn *gorm.DB + cfg DefaultSpendingLimit +} + +// GetOrCreateCostCenter returns the latest version of cost center for the given attributionID. +// This method creates a codt center and stores it in the DB if there is no preexisting one. +func (c *CostCenterManager) GetOrCreateCostCenter(ctx context.Context, attributionID AttributionID) (CostCenter, error) { + logger := log.WithField("attributionId", attributionID) + + result, err := getCostCenter(ctx, c.conn, attributionID) + if err != nil { + if errors.Is(err, CostCenterNotFound) { + logger.Info("No existing cost center. Creating one.") + defaultSpendingLimit := c.cfg.ForUsers + if attributionID.IsEntity(AttributionEntity_Team) { + defaultSpendingLimit = c.cfg.ForTeams + } + result = CostCenter{ + ID: attributionID, + CreationTime: NewVarcharTime(time.Now()), + BillingStrategy: CostCenter_Other, + SpendingLimit: defaultSpendingLimit, + NextBillingTime: NewVarcharTime(time.Now().AddDate(0, 1, 0)), + } + err := c.conn.Save(&result).Error + if err != nil { + return CostCenter{}, err + } + } else { + return CostCenter{}, err + } + } + + return result, nil +} + +func getCostCenter(ctx context.Context, conn *gorm.DB, attributionId AttributionID) (CostCenter, error) { db := conn.WithContext(ctx) var results []CostCenter db = db.Where("id = ?", attributionId).Order("creationTime DESC").Limit(1).Find(&results) if db.Error != nil { - return nil, fmt.Errorf("failed to get cost center: %w", db.Error) + return CostCenter{}, fmt.Errorf("failed to get cost center: %w", db.Error) } if len(results) == 0 { - return nil, CostCenterNotFound + return CostCenter{}, CostCenterNotFound } costCenter := results[0] - return &costCenter, nil + return costCenter, nil } -func SaveCostCenter(ctx context.Context, conn *gorm.DB, costCenter *CostCenter) (*CostCenter, error) { - db := conn.WithContext(ctx) - costCenter.CreationTime = NewVarcharTime(time.Now()) - db = db.Save(costCenter) +func (c *CostCenterManager) UpdateCostCenter(ctx context.Context, costCenter CostCenter) (CostCenter, error) { + + // retrieving the existing cost center to maintain the readonly values + existingCostCenter, err := c.GetOrCreateCostCenter(ctx, costCenter.ID) + if err != nil { + return CostCenter{}, err + } + + now := time.Now() + + // we don't allow setting the creationTime or the nextBillingTime from outside + costCenter.CreationTime = existingCostCenter.CreationTime + costCenter.NextBillingTime = existingCostCenter.NextBillingTime + + // Do we have a billing strategy update? + if costCenter.BillingStrategy != existingCostCenter.BillingStrategy { + if existingCostCenter.BillingStrategy == CostCenter_Other { + // moving to stripe -> let's run a finalization + finalizationUsage, err := c.ComputeInvoiceUsageRecord(ctx, costCenter.ID) + if err != nil { + return CostCenter{}, err + } + if finalizationUsage != nil { + err = UpdateUsage(ctx, c.conn, *finalizationUsage) + if err != nil { + return CostCenter{}, err + } + } + } + c.updateNextBillingTime(&costCenter, now) + } + + // we update the creationTime + costCenter.CreationTime = NewVarcharTime(now) + db := c.conn.Save(&costCenter) if db.Error != nil { - return nil, fmt.Errorf("failed to save cost center: %w", db.Error) + return CostCenter{}, fmt.Errorf("failed to save cost center for attributionID %s: %w", costCenter.ID, db.Error) } return costCenter, nil } + +func (c *CostCenterManager) ComputeInvoiceUsageRecord(ctx context.Context, attributionID AttributionID) (*Usage, error) { + now := time.Now() + summary, err := GetUsageSummary(ctx, c.conn, attributionID, now, now, false) + if err != nil { + return nil, err + } + if summary.CreditCentsBalanceAtEnd <= int64(0) { + // account has not debt, do nothing + return nil, nil + } + return &Usage{ + ID: uuid.New(), + AttributionID: attributionID, + Description: "Credits", + CreditCents: CreditCents(summary.CreditCentsBalanceAtEnd * -1), + EffectiveTime: NewVarcharTime(now), + Kind: InvoiceUsageKind, + Draft: false, + }, nil +} + +func (c *CostCenterManager) updateNextBillingTime(costCenter *CostCenter, now time.Time) { + nextMonth := NewVarcharTime(time.Now().AddDate(0, 1, 0)) + costCenter.NextBillingTime = nextMonth +} diff --git a/components/usage/pkg/db/cost_center_test.go b/components/usage/pkg/db/cost_center_test.go index f80fb51855db99..b467e053310565 100644 --- a/components/usage/pkg/db/cost_center_test.go +++ b/components/usage/pkg/db/cost_center_test.go @@ -7,11 +7,13 @@ package db_test import ( "context" "testing" + "time" "github.com/gitpod-io/gitpod/usage/pkg/db" "github.com/gitpod-io/gitpod/usage/pkg/db/dbtest" "github.com/google/uuid" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) func TestCostCenter_WriteRead(t *testing.T) { @@ -21,6 +23,7 @@ func TestCostCenter_WriteRead(t *testing.T) { ID: db.NewTeamAttributionID(uuid.New().String()), SpendingLimit: 100, } + cleanUp(t, conn, costCenter.ID) tx := conn.Create(costCenter) require.NoError(t, tx.Error) @@ -30,28 +33,78 @@ func TestCostCenter_WriteRead(t *testing.T) { require.NoError(t, tx.Error) require.Equal(t, costCenter.ID, read.ID) require.Equal(t, costCenter.SpendingLimit, read.SpendingLimit) +} + +func TestCostCenterManager_GetOrCreateCostCenter(t *testing.T) { + conn := dbtest.ConnectForTests(t) + mnr := db.NewCostCenterManager(conn, db.DefaultSpendingLimit{ + ForTeams: 0, + ForUsers: 500, + }) + team := db.NewTeamAttributionID(uuid.New().String()) + user := db.NewUserAttributionID(uuid.New().String()) + cleanUp(t, conn, team, user) + teamCC, err := mnr.GetOrCreateCostCenter(context.Background(), team) + require.NoError(t, err) + userCC, err := mnr.GetOrCreateCostCenter(context.Background(), user) + require.NoError(t, err) t.Cleanup(func() { - conn.Model(&db.CostCenter{}).Delete(costCenter) + conn.Model(&db.CostCenter{}).Delete(teamCC, userCC) }) + require.Equal(t, int32(0), teamCC.SpendingLimit) + require.Equal(t, int32(500), userCC.SpendingLimit) } -func TestGetCostCenter(t *testing.T) { +func TestCostCenterManager_UpdateCostCenter(t *testing.T) { conn := dbtest.ConnectForTests(t) + mnr := db.NewCostCenterManager(conn, db.DefaultSpendingLimit{ + ForTeams: 0, + ForUsers: 500, + }) + team := db.NewTeamAttributionID(uuid.New().String()) + cleanUp(t, conn, team) + teamCC, err := mnr.GetOrCreateCostCenter(context.Background(), team) + t.Cleanup(func() { + conn.Model(&db.CostCenter{}).Delete(teamCC) + }) + require.NoError(t, err) + require.Equal(t, int32(0), teamCC.SpendingLimit) - costCenter := &db.CostCenter{ - ID: db.NewTeamAttributionID(uuid.New().String()), - SpendingLimit: 300, - } - - require.NoError(t, conn.Create(costCenter).Error) + teamCC.SpendingLimit = 2000 + teamCC, err = mnr.UpdateCostCenter(context.Background(), teamCC) + require.NoError(t, err) + t.Cleanup(func() { + conn.Model(&db.CostCenter{}).Delete(teamCC) + }) + require.Equal(t, int32(2000), teamCC.SpendingLimit) +} - results, err := db.GetCostCenter(context.Background(), conn, costCenter.ID) +func TestSaveCostCenterMovedToStripe(t *testing.T) { + conn := dbtest.ConnectForTests(t) + mnr := db.NewCostCenterManager(conn, db.DefaultSpendingLimit{ + ForTeams: 0, + ForUsers: 500, + }) + team := db.NewTeamAttributionID(uuid.New().String()) + cleanUp(t, conn, team) + teamCC, err := mnr.GetOrCreateCostCenter(context.Background(), team) + require.NoError(t, err) + require.Equal(t, int32(0), teamCC.SpendingLimit) + teamCC.BillingStrategy = db.CostCenter_Stripe + newTeamCC, err := mnr.UpdateCostCenter(context.Background(), teamCC) require.NoError(t, err) - require.Equal(t, costCenter.ID, results.ID) + require.Equal(t, db.CostCenter_Stripe, newTeamCC.BillingStrategy) + require.Equal(t, newTeamCC.CreationTime.Time().AddDate(0, 1, 0).Truncate(time.Second), newTeamCC.NextBillingTime.Time().Truncate(time.Second)) +} +func cleanUp(t *testing.T, conn *gorm.DB, attributionIds ...db.AttributionID) { + t.Helper() t.Cleanup(func() { - conn.Model(&db.CostCenter{}).Delete(costCenter) + for _, attributionId := range attributionIds { + conn.Where("id = ?", string(attributionId)).Delete(&db.CostCenter{}) + conn.Where("attributionId = ?", string(attributionId)).Delete(&db.Usage{}) + } }) } diff --git a/components/usage/pkg/db/dbtest/usage.go b/components/usage/pkg/db/dbtest/usage.go index 591aeba85831b3..ea2c466734689f 100644 --- a/components/usage/pkg/db/dbtest/usage.go +++ b/components/usage/pkg/db/dbtest/usage.go @@ -17,6 +17,8 @@ import ( func NewUsage(t *testing.T, record db.Usage) db.Usage { t.Helper() + workspaceInstanceId := uuid.New() + result := db.Usage{ ID: uuid.New(), AttributionID: db.NewUserAttributionID(uuid.New().String()), @@ -24,7 +26,7 @@ func NewUsage(t *testing.T, record db.Usage) db.Usage { CreditCents: 42, EffectiveTime: db.VarcharTime{}, Kind: db.WorkspaceInstanceUsageKind, - WorkspaceInstanceID: uuid.New(), + WorkspaceInstanceID: &workspaceInstanceId, } if record.ID.ID() != 0 { @@ -42,7 +44,7 @@ func NewUsage(t *testing.T, record db.Usage) db.Usage { if record.CreditCents != 0 { result.CreditCents = record.CreditCents } - if record.WorkspaceInstanceID.ID() != 0 { + if record.WorkspaceInstanceID != nil && (*record.WorkspaceInstanceID).ID() != 0 { result.WorkspaceInstanceID = record.WorkspaceInstanceID } if record.Kind != "" { diff --git a/components/usage/pkg/db/usage.go b/components/usage/pkg/db/usage.go index 68a2ff0deb3e44..b712c06ba97eec 100644 --- a/components/usage/pkg/db/usage.go +++ b/components/usage/pkg/db/usage.go @@ -43,7 +43,7 @@ type Usage struct { CreditCents CreditCents `gorm:"column:creditCents;type:bigint;" json:"creditCents"` EffectiveTime VarcharTime `gorm:"column:effectiveTime;type:varchar;size:255;" json:"effectiveTime"` Kind UsageKind `gorm:"column:kind;type:char;size:10;" json:"kind"` - WorkspaceInstanceID uuid.UUID `gorm:"column:workspaceInstanceId;type:char;size:36;" json:"workspaceInstanceId"` + WorkspaceInstanceID *uuid.UUID `gorm:"column:workspaceInstanceId;type:char;size:36;" json:"workspaceInstanceId"` Draft bool `gorm:"column:draft;type:boolean;" json:"draft"` Metadata datatypes.JSON `gorm:"column:metadata;type:text;size:65535" json:"metadata"` } diff --git a/components/usage/pkg/db/workspace_instance.go b/components/usage/pkg/db/workspace_instance.go index e47f0c24b1b428..dbee2c163294f0 100644 --- a/components/usage/pkg/db/workspace_instance.go +++ b/components/usage/pkg/db/workspace_instance.go @@ -8,10 +8,11 @@ import ( "context" "database/sql" "fmt" - "github.com/gitpod-io/gitpod/common-go/log" "strings" "time" + "github.com/gitpod-io/gitpod/common-go/log" + "github.com/google/uuid" "gorm.io/datatypes" "gorm.io/gorm" @@ -176,6 +177,11 @@ func (a AttributionID) Values() (entity string, identifier string) { return tokens[0], tokens[1] } +func (a AttributionID) IsEntity(entity string) bool { + e, _ := a.Values() + return e == entity +} + func ParseAttributionID(s string) (AttributionID, error) { tokens := strings.Split(s, ":") if len(tokens) != 2 { diff --git a/components/usage/pkg/server/server.go b/components/usage/pkg/server/server.go index 9f22ff18e27478..f8e30b311d9aad 100644 --- a/components/usage/pkg/server/server.go +++ b/components/usage/pkg/server/server.go @@ -6,11 +6,12 @@ package server import ( "fmt" - "github.com/gitpod-io/gitpod/usage/pkg/scheduler" "net" "os" "time" + "github.com/gitpod-io/gitpod/usage/pkg/scheduler" + grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus" "google.golang.org/grpc" @@ -35,6 +36,8 @@ type Config struct { StripeCredentialsFile string `json:"stripeCredentialsFile,omitempty"` Server *baseserver.Configuration `json:"server,omitempty"` + + DefaultSpendingLimit db.DefaultSpendingLimit `json:"defaultSpendingLimit"` } func Start(cfg Config) error { @@ -124,7 +127,7 @@ func Start(cfg Config) error { sched.Start() defer sched.Stop() - err = registerGRPCServices(srv, conn, stripeClient, pricer) + err = registerGRPCServices(srv, conn, stripeClient, pricer, cfg) if err != nil { return fmt.Errorf("failed to register gRPC services: %w", err) } @@ -147,8 +150,9 @@ func Start(cfg Config) error { return nil } -func registerGRPCServices(srv *baseserver.Server, conn *gorm.DB, stripeClient *stripe.Client, pricer *apiv1.WorkspacePricer) error { - v1.RegisterUsageServiceServer(srv.GRPC(), apiv1.NewUsageService(conn, pricer)) +func registerGRPCServices(srv *baseserver.Server, conn *gorm.DB, stripeClient *stripe.Client, pricer *apiv1.WorkspacePricer, cfg Config) error { + ccManager := db.NewCostCenterManager(conn, cfg.DefaultSpendingLimit) + v1.RegisterUsageServiceServer(srv.GRPC(), apiv1.NewUsageService(conn, pricer, ccManager)) if stripeClient == nil { v1.RegisterBillingServiceServer(srv.GRPC(), &apiv1.BillingServiceNoop{}) } else { diff --git a/components/usage/telepresence.sh b/components/usage/telepresence.sh new file mode 100755 index 00000000000000..6efcf98aab7c6b --- /dev/null +++ b/components/usage/telepresence.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +sudo curl -fL https://app.getambassador.io/download/tel2/linux/amd64/latest/telepresence -o /usr/local/bin/telepresence2 +sudo chmod a+x /usr/local/bin/telepresence2 + +telepresence2 helm install +telepresence2 connect +telepresence2 list + +echo "Type 'telepresence2 intercept usage --service usage --port 9001 --mount=true -- go run . run'" diff --git a/install/installer/pkg/components/usage/configmap.go b/install/installer/pkg/components/usage/configmap.go index 782f7380d2e592..a5b983c7eb8f87 100644 --- a/install/installer/pkg/components/usage/configmap.go +++ b/install/installer/pkg/components/usage/configmap.go @@ -5,7 +5,9 @@ package usage import ( "fmt" + "github.com/gitpod-io/gitpod/common-go/baseserver" + "github.com/gitpod-io/gitpod/usage/pkg/db" "github.com/gitpod-io/gitpod/usage/pkg/server" "github.com/gitpod-io/gitpod/installer/pkg/common" @@ -25,14 +27,21 @@ func configmap(ctx *common.RenderContext) ([]runtime.Object, error) { }, }, }, + DefaultSpendingLimit: db.DefaultSpendingLimit{ + // because we only want spending limits in SaaS, if not configured we go with a very high (i.e. no) spending limit + ForTeams: 1_000_000_000, + ForUsers: 1_000_000_000, + }, } - expConfig := getExperimentalConfig(ctx) + if expConfig != nil { if expConfig.Schedule != "" { cfg.ControllerSchedule = expConfig.Schedule } - + if expConfig.DefaultSpendingLimit != nil { + cfg.DefaultSpendingLimit = *expConfig.DefaultSpendingLimit + } cfg.CreditsPerMinuteByWorkspaceClass = expConfig.CreditsPerMinuteByWorkspaceClass } diff --git a/install/installer/pkg/components/usage/configmap_test.go b/install/installer/pkg/components/usage/configmap_test.go index da6177548ae27d..1b40027e8817ba 100644 --- a/install/installer/pkg/components/usage/configmap_test.go +++ b/install/installer/pkg/components/usage/configmap_test.go @@ -4,10 +4,11 @@ package usage import ( + "testing" + "github.com/gitpod-io/gitpod/installer/pkg/config/v1/experimental" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" - "testing" ) func TestConfigMap_ContainsSchedule(t *testing.T) { @@ -23,6 +24,10 @@ func TestConfigMap_ContainsSchedule(t *testing.T) { `{ "controllerSchedule": "2m", "stripeCredentialsFile": "stripe-secret/apikeys", + "defaultSpendingLimit": { + "forUsers": 1000000000, + "forTeams": 1000000000 + }, "server": { "services": { "grpc": { diff --git a/install/installer/pkg/config/v1/config.md b/install/installer/pkg/config/v1/config.md index 7bc29021534275..6c4da147893e78 100644 --- a/install/installer/pkg/config/v1/config.md +++ b/install/installer/pkg/config/v1/config.md @@ -142,6 +142,8 @@ Additional config parameters that are in experimental state |`experimental.webapp.disableMigration`|bool|N| || |`experimental.webapp.usage.enabled`|bool|N| || |`experimental.webapp.usage.schedule`|string|N| || +|`experimental.webapp.usage.defaultSpendingLimit.ForUsers`||N| || +|`experimental.webapp.usage.defaultSpendingLimit.ForTeams`||N| || |`experimental.webapp.usage.creditsPerMinuteByWorkspaceClass`||N| || |`experimental.webapp.configcatKey`|string|N| || |`experimental.ide.resolveLatest`|bool|N| | Disable resolution of latest images and use bundled latest versions instead| diff --git a/install/installer/pkg/config/v1/experimental/experimental.go b/install/installer/pkg/config/v1/experimental/experimental.go index ff6e15816ff0f4..fa7b889e74c86f 100644 --- a/install/installer/pkg/config/v1/experimental/experimental.go +++ b/install/installer/pkg/config/v1/experimental/experimental.go @@ -15,6 +15,7 @@ import ( agentSmith "github.com/gitpod-io/gitpod/agent-smith/pkg/config" "github.com/gitpod-io/gitpod/common-go/grpc" + "github.com/gitpod-io/gitpod/usage/pkg/db" "github.com/gitpod-io/gitpod/ws-daemon/pkg/cpulimit" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -243,10 +244,11 @@ type PublicAPIConfig struct { } type UsageConfig struct { - Enabled bool `json:"enabled"` - Schedule string `json:"schedule"` - BillInstancesAfter *time.Time `json:"billInstancesAfter"` - CreditsPerMinuteByWorkspaceClass map[string]float64 `json:"creditsPerMinuteByWorkspaceClass"` + Enabled bool `json:"enabled"` + Schedule string `json:"schedule"` + BillInstancesAfter *time.Time `json:"billInstancesAfter"` + DefaultSpendingLimit *db.DefaultSpendingLimit `json:"defaultSpendingLimit"` + CreditsPerMinuteByWorkspaceClass map[string]float64 `json:"creditsPerMinuteByWorkspaceClass"` } type WebAppWorkspaceClass struct {