diff --git a/components/usage/pkg/apiv1/billing.go b/components/usage/pkg/apiv1/billing.go index 2ab071d2ce3f44..0920dc23802ffb 100644 --- a/components/usage/pkg/apiv1/billing.go +++ b/components/usage/pkg/apiv1/billing.go @@ -6,7 +6,7 @@ package apiv1 import ( "context" - "fmt" + "github.com/gitpod-io/gitpod/usage/pkg/contentservice" "math" "time" @@ -34,11 +34,22 @@ type BillingService struct { stripeClient *stripe.Client billInstancesAfter time.Time + contentService contentservice.Interface + v1.UnimplementedBillingServiceServer } func (s *BillingService) UpdateInvoices(ctx context.Context, in *v1.UpdateInvoicesRequest) (*v1.UpdateInvoicesResponse, error) { - credits, err := s.creditSummaryForTeams(in.GetSessions()) + if in.GetReportId() == "" { + return nil, status.Errorf(codes.InvalidArgument, "Missing report ID") + } + + report, err := s.contentService.DownloadUsageReport(ctx, in.GetReportId()) + if err != nil { + return nil, status.Errorf(codes.Internal, "Failed to download usage report with ID: %s", in.GetReportId()) + } + + credits, err := s.creditSummaryForTeams(report) if err != nil { log.Log.WithError(err).Errorf("Failed to compute credit summary.") return nil, status.Errorf(codes.InvalidArgument, "failed to compute credit summary") @@ -89,20 +100,15 @@ func (s *BillingService) GetUpcomingInvoice(ctx context.Context, in *v1.GetUpcom }, nil } -func (s *BillingService) creditSummaryForTeams(sessions []*v1.BilledSession) (map[string]int64, error) { +func (s *BillingService) creditSummaryForTeams(sessions db.UsageReport) (map[string]int64, error) { creditsPerTeamID := map[string]float64{} for _, session := range sessions { - if session.StartTime.AsTime().Before(s.billInstancesAfter) { + if session.StartedAt.Before(s.billInstancesAfter) { continue } - attributionID, err := db.ParseAttributionID(session.AttributionId) - if err != nil { - return nil, fmt.Errorf("failed to parse attribution ID: %w", err) - } - - entity, id := attributionID.Values() + entity, id := session.AttributionID.Values() if entity != db.AttributionEntity_Team { continue } @@ -111,7 +117,7 @@ func (s *BillingService) creditSummaryForTeams(sessions []*v1.BilledSession) (ma creditsPerTeamID[id] = 0 } - creditsPerTeamID[id] += session.GetCredits() + creditsPerTeamID[id] += session.CreditsUsed } rounded := map[string]int64{} diff --git a/components/usage/pkg/apiv1/billing_test.go b/components/usage/pkg/apiv1/billing_test.go index 5710de769d571b..49e58a126cd6d4 100644 --- a/components/usage/pkg/apiv1/billing_test.go +++ b/components/usage/pkg/apiv1/billing_test.go @@ -8,12 +8,10 @@ import ( "testing" "time" - v1 "github.com/gitpod-io/gitpod/usage-api/v1" "github.com/gitpod-io/gitpod/usage/pkg/db" "github.com/gitpod-io/gitpod/usage/pkg/stripe" "github.com/google/uuid" "github.com/stretchr/testify/require" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" "gorm.io/gorm" ) @@ -23,22 +21,22 @@ func TestCreditSummaryForTeams(t *testing.T) { scenarios := []struct { Name string - Sessions []*v1.BilledSession + Sessions db.UsageReport BillSessionsAfter time.Time Expected map[string]int64 }{ { Name: "no instances in report, no summary", BillSessionsAfter: time.Time{}, - Sessions: []*v1.BilledSession{}, + Sessions: nil, Expected: map[string]int64{}, }, { Name: "skips user attributions", BillSessionsAfter: time.Time{}, - Sessions: []*v1.BilledSession{ + Sessions: []db.WorkspaceInstanceUsage{ { - AttributionId: string(db.NewUserAttributionID(uuid.New().String())), + AttributionID: db.NewUserAttributionID(uuid.New().String()), }, }, Expected: map[string]int64{}, @@ -46,16 +44,16 @@ func TestCreditSummaryForTeams(t *testing.T) { { Name: "two workspace instances", BillSessionsAfter: time.Time{}, - Sessions: []*v1.BilledSession{ + Sessions: []db.WorkspaceInstanceUsage{ { // has 1 day and 23 hours of usage - AttributionId: string(teamAttributionID_A), - Credits: (24 + 23) * 10, + AttributionID: teamAttributionID_A, + CreditsUsed: (24 + 23) * 10, }, { // has 1 hour of usage - AttributionId: string(teamAttributionID_A), - Credits: 10, + AttributionID: teamAttributionID_A, + CreditsUsed: 10, }, }, Expected: map[string]int64{ @@ -66,16 +64,16 @@ func TestCreditSummaryForTeams(t *testing.T) { { Name: "multiple teams", BillSessionsAfter: time.Time{}, - Sessions: []*v1.BilledSession{ + Sessions: []db.WorkspaceInstanceUsage{ { // has 12 hours of usage - AttributionId: string(teamAttributionID_A), - Credits: (12) * 10, + AttributionID: teamAttributionID_A, + CreditsUsed: (12) * 10, }, { // has 1 day of usage - AttributionId: string(teamAttributionID_B), - Credits: (24) * 10, + AttributionID: teamAttributionID_B, + CreditsUsed: (24) * 10, }, }, Expected: map[string]int64{ @@ -87,18 +85,18 @@ func TestCreditSummaryForTeams(t *testing.T) { { Name: "two instances, same team, one of which started too early to be considered", BillSessionsAfter: time.Now().AddDate(0, 0, -2), - Sessions: []*v1.BilledSession{ + Sessions: []db.WorkspaceInstanceUsage{ { // has 12 hours of usage, started yesterday - AttributionId: string(teamAttributionID_A), - Credits: (12) * 10, - StartTime: timestamppb.New(time.Now().AddDate(0, 0, -1)), + AttributionID: teamAttributionID_A, + CreditsUsed: (12) * 10, + StartedAt: time.Now().AddDate(0, 0, -1), }, { // has 1 day of usage, but started three days ago - AttributionId: string(teamAttributionID_A), - Credits: (24) * 10, - StartTime: timestamppb.New(time.Now().AddDate(0, 0, -3)), + AttributionID: teamAttributionID_A, + CreditsUsed: (24) * 10, + StartedAt: time.Now().AddDate(0, 0, -3), }, }, Expected: map[string]int64{ diff --git a/components/usage/pkg/apiv1/size_test.go b/components/usage/pkg/apiv1/size_test.go deleted file mode 100644 index 6d8bf6ba54b407..00000000000000 --- a/components/usage/pkg/apiv1/size_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// 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. - -package apiv1 - -import ( - "context" - "testing" - "time" - - "github.com/gitpod-io/gitpod/common-go/baseserver" - v1 "github.com/gitpod-io/gitpod/usage-api/v1" - "github.com/gitpod-io/gitpod/usage/pkg/stripe" - "github.com/stretchr/testify/require" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - "gorm.io/gorm" -) - -func TestServerCanReceiveLargeMessages(t *testing.T) { - srv := baseserver.NewForTests(t, - baseserver.WithGRPC(baseserver.MustUseRandomLocalAddress(t)), - ) - - v1.RegisterBillingServiceServer(srv.GRPC(), NewBillingService(&stripe.Client{}, time.Time{}, &gorm.DB{})) - baseserver.StartServerForTests(t, srv) - - conn, err := grpc.Dial(srv.GRPCAddress(), grpc.WithTransportCredentials(insecure.NewCredentials())) - require.NoError(t, err) - - client := v1.NewBillingServiceClient(conn) - - _, err = client.UpdateInvoices(context.Background(), &v1.UpdateInvoicesRequest{ - Sessions: getBilledSessions(), - }) - - require.NoError(t, err) -} - -func getBilledSessions() (sessions []*v1.BilledSession) { - for i := 0; i < 900000; i++ { - sessions = append(sessions, &v1.BilledSession{ - AttributionId: "user:1234", - UserId: "1234", - TeamId: "", - WorkspaceId: "", - WorkspaceType: "", - ProjectId: "", - InstanceId: "", - WorkspaceClass: "", - StartTime: ×tamppb.Timestamp{}, - EndTime: ×tamppb.Timestamp{}, - CreditsDeprecated: 0, - Credits: 0, - }) - } - return -} diff --git a/components/usage/pkg/apiv1/usage.go b/components/usage/pkg/apiv1/usage.go index f0f37897df55d4..6f6bdffdfaf88c 100644 --- a/components/usage/pkg/apiv1/usage.go +++ b/components/usage/pkg/apiv1/usage.go @@ -125,13 +125,7 @@ func (s *UsageService) ReconcileUsage(ctx context.Context, req *v1.ReconcileUsag return nil, status.Error(codes.Internal, "failed to persist usage report to content service") } - var sessions []*v1.BilledSession - for _, instance := range report.UsageRecords { - sessions = append(sessions, usageRecordToBilledUsageProto(instance)) - } - return &v1.ReconcileUsageResponse{ - Sessions: sessions, ReportId: filename, }, nil @@ -175,26 +169,6 @@ func NewUsageService(conn *gorm.DB, reportGenerator *ReportGenerator, contentSvc } } -func usageRecordToBilledUsageProto(usageRecord db.WorkspaceInstanceUsage) *v1.BilledSession { - var endTime *timestamppb.Timestamp - if usageRecord.StoppedAt.Valid { - endTime = timestamppb.New(usageRecord.StoppedAt.Time) - } - return &v1.BilledSession{ - AttributionId: string(usageRecord.AttributionID), - UserId: usageRecord.UserID.String(), - WorkspaceId: usageRecord.WorkspaceID, - TeamId: "", - WorkspaceType: string(usageRecord.WorkspaceType), - ProjectId: usageRecord.ProjectID, - InstanceId: usageRecord.InstanceID.String(), - WorkspaceClass: usageRecord.WorkspaceClass, - StartTime: timestamppb.New(usageRecord.StartedAt), - EndTime: endTime, - Credits: usageRecord.CreditsUsed, - } -} - func instancesToUsageRecords(instances []db.WorkspaceInstanceForUsage, pricer *WorkspacePricer, now time.Time) []db.WorkspaceInstanceUsage { var usageRecords []db.WorkspaceInstanceUsage diff --git a/components/usage/pkg/contentservice/client.go b/components/usage/pkg/contentservice/client.go index c11513702824e4..83dac6a4f1cc62 100644 --- a/components/usage/pkg/contentservice/client.go +++ b/components/usage/pkg/contentservice/client.go @@ -31,7 +31,7 @@ func New(service api.UsageReportServiceClient) *Client { } func (c *Client) UploadUsageReport(ctx context.Context, filename string, report db.UsageReport) error { - uploadURLResp, err := c.service.UploadURL(ctx, &api.UsageReportUploadURLRequest{Name: key}) + uploadURLResp, err := c.service.UploadURL(ctx, &api.UsageReportUploadURLRequest{Name: filename}) if err != nil { return fmt.Errorf("failed to get upload URL from usage report service: %w", err) } @@ -61,7 +61,7 @@ func (c *Client) UploadUsageReport(ctx context.Context, filename string, report return fmt.Errorf("failed to make http request: %w", err) } if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected http response code: %s", uploadURLResp.Status) + return fmt.Errorf("unexpected http response code: %s", resp.Status) } log.Info("Upload complete") @@ -76,7 +76,17 @@ func (c *Client) DownloadUsageReport(ctx context.Context, filename string) (db.U return nil, fmt.Errorf("failed to get download URL: %w", err) } - resp, err := http.Get(downloadURlResp.GetUrl()) + req, err := http.NewRequest(http.MethodGet, downloadURlResp.GetUrl(), nil) + if err != nil { + return nil, fmt.Errorf("failed to construct request: %w", err) + } + + // We want to receive it as gzip, this disables transcoding of the response + req.Header.Set("Accept-Encoding", "gzip") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Encoding", "gzip") + + resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request to download usage report: %w", err) } @@ -88,13 +98,13 @@ func (c *Client) DownloadUsageReport(ctx context.Context, filename string) (db.U body := resp.Body defer body.Close() - decomressor, err := gzip.NewReader(body) + decompressor, err := gzip.NewReader(body) if err != nil { return nil, fmt.Errorf("failed to construct gzip decompressor from response: %w", err) } - defer decomressor.Close() + defer decompressor.Close() - decoder := json.NewDecoder(decomressor) + decoder := json.NewDecoder(body) var records []db.WorkspaceInstanceUsage if err := decoder.Decode(&records); err != nil { return nil, fmt.Errorf("failed to deserialize report: %w", err) diff --git a/components/usage/pkg/contentservice/noop.go b/components/usage/pkg/contentservice/noop.go index 9e7bafa573420b..a43acec57a850d 100644 --- a/components/usage/pkg/contentservice/noop.go +++ b/components/usage/pkg/contentservice/noop.go @@ -6,12 +6,19 @@ package contentservice import ( "context" + "errors" "github.com/gitpod-io/gitpod/usage/pkg/db" ) +var notImplementedError = errors.New("not implemented") + type NoOpClient struct{} -func (c *NoOpClient) UploadUsageReport(ctx context.Context, filename string, report []db.WorkspaceInstanceUsage) error { - return nil +func (c *NoOpClient) UploadUsageReport(ctx context.Context, filename string, report db.UsageReport) error { + return notImplementedError +} + +func (c *NoOpClient) DownloadUsageReport(ctx context.Context, filename string) (db.UsageReport, error) { + return nil, notImplementedError } diff --git a/components/usage/pkg/controller/reconciler.go b/components/usage/pkg/controller/reconciler.go index 36c23c0870ca54..c40daa4dc5c74a 100644 --- a/components/usage/pkg/controller/reconciler.go +++ b/components/usage/pkg/controller/reconciler.go @@ -57,15 +57,11 @@ func (r *UsageAndBillingReconciler) Reconcile() (err error) { return fmt.Errorf("failed to reconcile usage: %w", err) } - sessions := usageResp.GetSessions() - reportSessionsRetrievedTotal(len(sessions)) - reportID := usageResp.GetReportId() _, err = r.billingClient.UpdateInvoices(ctx, &v1.UpdateInvoicesRequest{ StartTime: timestamppb.New(startOfCurrentMonth), EndTime: timestamppb.New(startOfNextMonth), - Sessions: sessions, ReportId: reportID, }) if err != nil { diff --git a/components/usage/pkg/controller/reporter.go b/components/usage/pkg/controller/reporter.go index cf82f715d15e02..100c1e133249cb 100644 --- a/components/usage/pkg/controller/reporter.go +++ b/components/usage/pkg/controller/reporter.go @@ -30,20 +30,12 @@ var ( Help: "Histogram of reconcile duration", Buckets: prometheus.LinearBuckets(30, 30, 10), // every 30 secs, starting at 30secs }, []string{"outcome"}) - - sessionsRetrievedTotal = prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: namespace, - Subsystem: subsystem, - Name: "usage_records_retrieved_total", - Help: "Number of usage records retrieved during usage collection run", - }) ) func RegisterMetrics(reg *prometheus.Registry) error { metrics := []prometheus.Collector{ reconcileStartedTotal, reconcileStartedDurationSeconds, - sessionsRetrievedTotal, } for _, metric := range metrics { err := reg.Register(metric) @@ -66,7 +58,3 @@ func reportUsageReconcileFinished(duration time.Duration, err error) { } reconcileStartedDurationSeconds.WithLabelValues(outcome).Observe(duration.Seconds()) } - -func reportSessionsRetrievedTotal(count int) { - sessionsRetrievedTotal.Set(float64(count)) -} diff --git a/components/usage/pkg/db/workspace_instance_usage.go b/components/usage/pkg/db/workspace_instance_usage.go index 1d7e81074ee138..a57bbacc558015 100644 --- a/components/usage/pkg/db/workspace_instance_usage.go +++ b/components/usage/pkg/db/workspace_instance_usage.go @@ -89,3 +89,5 @@ func ListUsage(ctx context.Context, conn *gorm.DB, attributionId AttributionID, } return usageRecords, nil } + +type UsageReport []WorkspaceInstanceUsage diff --git a/components/usage/pkg/server/server.go b/components/usage/pkg/server/server.go index e8fc8ec64e92cb..33761cfd5ce4f5 100644 --- a/components/usage/pkg/server/server.go +++ b/components/usage/pkg/server/server.go @@ -6,6 +6,7 @@ package server import ( "fmt" + "github.com/gitpod-io/gitpod/content-service/api" "net" "os" "time" @@ -131,7 +132,11 @@ func Start(cfg Config) error { var contentService contentservice.Interface = &contentservice.NoOpClient{} if cfg.ContentServiceAddress != "" { - contentService = contentservice.New(cfg.ContentServiceAddress) + contentServiceConn, err := grpc.Dial(cfg.ContentServiceAddress, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("failed to dial contentservice: %w", err) + } + contentService = contentservice.New(api.NewUsageReportServiceClient(contentServiceConn)) } reportGenerator := apiv1.NewReportGenerator(conn, pricer)