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 {