Skip to content

Commit 9121279

Browse files
easyCZroboquat
authored andcommitted
[usage] List workspaces for each workspace instance in usage period
1 parent bc74f87 commit 9121279

File tree

5 files changed

+179
-8
lines changed

5 files changed

+179
-8
lines changed

components/usage/pkg/controller/reconciler.go

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package controller
66

77
import (
88
"context"
9+
"errors"
910
"fmt"
1011
"github.com/gitpod-io/gitpod/common-go/log"
1112
"github.com/gitpod-io/gitpod/usage/pkg/db"
@@ -38,6 +39,8 @@ type UsageReconcileStatus struct {
3839

3940
WorkspaceInstances int
4041
InvalidWorkspaceInstances int
42+
43+
Workspaces int
4144
}
4245

4346
func (u *UsageReconciler) Reconcile() error {
@@ -71,11 +74,59 @@ func (u *UsageReconciler) ReconcileTimeRange(ctx context.Context, from, to time.
7174
if len(invalidInstances) > 0 {
7275
log.WithField("invalid_workspace_instances", invalidInstances).Errorf("Detected %d invalid instances. These will be skipped in the current run.", len(invalidInstances))
7376
}
74-
7577
log.WithField("workspace_instances", instances).Debug("Successfully loaded workspace instances.")
78+
79+
workspaces, err := u.loadWorkspaces(ctx, instances)
80+
if err != nil {
81+
return nil, fmt.Errorf("failed to load workspaces for workspace instances in time range: %w", err)
82+
}
83+
status.Workspaces = len(workspaces)
84+
7685
return status, nil
7786
}
7887

88+
type workspaceWithInstances struct {
89+
Workspace db.Workspace
90+
Instances []db.WorkspaceInstance
91+
}
92+
93+
func (u *UsageReconciler) loadWorkspaces(ctx context.Context, instances []db.WorkspaceInstance) ([]workspaceWithInstances, error) {
94+
var workspaceIDs []string
95+
for _, instance := range instances {
96+
workspaceIDs = append(workspaceIDs, instance.WorkspaceID)
97+
}
98+
99+
workspaces, err := db.ListWorkspacesByID(ctx, u.conn, toSet(workspaceIDs))
100+
if err != nil {
101+
return nil, fmt.Errorf("failed to find workspaces for provided workspace instances: %w", err)
102+
}
103+
104+
// Map workspaces to corresponding instances
105+
workspacesWithInstancesByID := map[string]workspaceWithInstances{}
106+
for _, workspace := range workspaces {
107+
workspacesWithInstancesByID[workspace.ID] = workspaceWithInstances{
108+
Workspace: workspace,
109+
}
110+
}
111+
112+
// We need to also add the instances to corresponding records, a single workspace can have multiple instances
113+
for _, instance := range instances {
114+
item, ok := workspacesWithInstancesByID[instance.WorkspaceID]
115+
if !ok {
116+
return nil, errors.New("encountered instance without a corresponding workspace record")
117+
}
118+
item.Instances = append(item.Instances, instance)
119+
}
120+
121+
// Flatten results into a list
122+
var workspacesWithInstances []workspaceWithInstances
123+
for _, w := range workspacesWithInstancesByID {
124+
workspacesWithInstances = append(workspacesWithInstances, w)
125+
}
126+
127+
return workspacesWithInstances, nil
128+
}
129+
79130
func (u *UsageReconciler) loadWorkspaceInstances(ctx context.Context, from, to time.Time) ([]db.WorkspaceInstance, []invalidWorkspaceInstance, error) {
80131
log.Infof("Gathering usage data from %s to %s", from, to)
81132
instances, err := db.ListWorkspaceInstancesInRange(ctx, u.conn, from, to)
@@ -132,8 +183,8 @@ func trimStartStopTime(instances []db.WorkspaceInstance, maximumStart, minimumSt
132183
var updated []db.WorkspaceInstance
133184

134185
for _, instance := range instances {
135-
if instance.StartedTime.Time().Before(maximumStart) {
136-
instance.StartedTime = db.NewVarcharTime(maximumStart)
186+
if instance.CreationTime.Time().Before(maximumStart) {
187+
instance.CreationTime = db.NewVarcharTime(maximumStart)
137188
}
138189

139190
if instance.StoppedTime.Time().After(minimumStop) {
@@ -144,3 +195,16 @@ func trimStartStopTime(instances []db.WorkspaceInstance, maximumStart, minimumSt
144195
}
145196
return updated
146197
}
198+
199+
func toSet(items []string) []string {
200+
m := map[string]struct{}{}
201+
for _, i := range items {
202+
m[i] = struct{}{}
203+
}
204+
205+
var result []string
206+
for s := range m {
207+
result = append(result, s)
208+
}
209+
return result
210+
}

components/usage/pkg/controller/reconciler_test.go

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package controller
77
import (
88
"context"
99
"github.com/gitpod-io/gitpod/usage/pkg/db"
10+
"github.com/gitpod-io/gitpod/usage/pkg/db/dbtest"
1011
"github.com/google/uuid"
1112
"github.com/stretchr/testify/require"
1213
"testing"
@@ -15,22 +16,30 @@ import (
1516

1617
func TestUsageReconciler_Reconcile(t *testing.T) {
1718
conn := db.ConnectForTests(t)
18-
workspaceID := "gitpodio-gitpod-gyjr82jkfnd"
1919
instanceStatus := []byte(`{"phase": "stopped", "conditions": {"deployed": false, "pullingImages": false, "serviceExists": false}}`)
2020
startOfMay := time.Date(2022, 05, 1, 0, 00, 00, 00, time.UTC)
2121
startOfJune := time.Date(2022, 06, 1, 0, 00, 00, 00, time.UTC)
22+
workspace := dbtest.NewWorkspace(t, "gitpodio-gitpod-gyjr82jkfnd")
2223
instances := []db.WorkspaceInstance{
24+
// Ran throughout the reconcile period
2325
{
2426
ID: uuid.New(),
25-
WorkspaceID: workspaceID,
27+
WorkspaceID: workspace.ID,
2628
CreationTime: db.NewVarcharTime(time.Date(2022, 05, 1, 00, 00, 00, 00, time.UTC)),
2729
StoppedTime: db.NewVarcharTime(time.Date(2022, 06, 1, 1, 0, 0, 0, time.UTC)),
2830
Status: instanceStatus,
2931
},
32+
// Still running
33+
{
34+
ID: uuid.New(),
35+
WorkspaceID: workspace.ID,
36+
CreationTime: db.NewVarcharTime(time.Date(2022, 05, 30, 00, 00, 00, 00, time.UTC)),
37+
Status: instanceStatus,
38+
},
3039
// No creation time, invalid record
3140
{
3241
ID: uuid.New(),
33-
WorkspaceID: workspaceID,
42+
WorkspaceID: workspace.ID,
3443
StoppedTime: db.NewVarcharTime(time.Date(2022, 06, 1, 1, 0, 0, 0, time.UTC)),
3544
Status: instanceStatus,
3645
},
@@ -39,14 +48,18 @@ func TestUsageReconciler_Reconcile(t *testing.T) {
3948
tx := conn.Create(instances)
4049
require.NoError(t, tx.Error)
4150

51+
tx = conn.Create(&workspace)
52+
require.NoError(t, tx.Error)
53+
4254
reconciler := NewUsageReconciler(conn)
4355

4456
status, err := reconciler.ReconcileTimeRange(context.Background(), startOfMay, startOfJune)
4557
require.NoError(t, err)
4658
require.Equal(t, &UsageReconcileStatus{
4759
StartTime: startOfMay,
4860
EndTime: startOfJune,
49-
WorkspaceInstances: 1,
61+
WorkspaceInstances: 2,
5062
InvalidWorkspaceInstances: 1,
63+
Workspaces: 1,
5164
}, status)
5265
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2+
// Licensed under the GNU Affero General Public License (AGPL).
3+
// See License-AGPL.txt in the project root for license information.
4+
5+
package dbtest
6+
7+
import (
8+
"github.com/gitpod-io/gitpod/usage/pkg/db"
9+
"github.com/google/uuid"
10+
"testing"
11+
)
12+
13+
func NewWorkspace(t *testing.T, id string) db.Workspace {
14+
t.Helper()
15+
16+
return db.Workspace{
17+
ID: id,
18+
OwnerID: uuid.New(),
19+
Type: "prebuild",
20+
ContextURL: "https://github.com/gitpod-io/gitpod",
21+
Context: []byte(`{"title":"[usage] List workspaces for each workspace instance in usage period","repository":{"cloneUrl":"https://github.com/gitpod-io/gitpod.git","host":"github.com","name":"gitpod","owner":"gitpod-io","private":false},"ref":"mp/usage-list-workspaces","refType":"branch","revision":"586f22ecaeeb3b4796fd92f9ae1ca3512ca1e330","nr":10495,"base":{"repository":{"cloneUrl":"https://github.com/gitpod-io/gitpod.git","host":"github.com","name":"gitpod","owner":"gitpod-io","private":false},"ref":"mp/usage-validate-instances","refType":"branch"},"normalizedContextURL":"https://github.com/gitpod-io/gitpod/pull/10495","checkoutLocation":"gitpod"}`),
22+
Config: []byte(`{"image":"eu.gcr.io/gitpod-core-dev/dev/dev-environment:me-me-image.1","workspaceLocation":"gitpod/gitpod-ws.code-workspace","checkoutLocation":"gitpod","ports":[{"port":1337,"onOpen":"open-preview"},{"port":3000,"onOpen":"ignore"},{"port":3001,"onOpen":"ignore"},{"port":3306,"onOpen":"ignore"},{"port":4000,"onOpen":"ignore"},{"port":5900,"onOpen":"ignore"},{"port":6080,"onOpen":"ignore"},{"port":7777,"onOpen":"ignore"},{"port":9229,"onOpen":"ignore"},{"port":9999,"onOpen":"ignore"},{"port":13001,"onOpen":"ignore"},{"port":13444}],"tasks":[{"name":"Install Preview Environment kube-context","command":"(cd dev/preview/previewctl && go install .)\npreviewctl install-context\nexit\n"},{"name":"Add Harvester kubeconfig","command":"./dev/preview/util/download-and-merge-harvester-kubeconfig.sh\nexit 0\n"},{"name":"Java","command":"if [ -z \"$RUN_GRADLE_TASK\" ]; then\n read -r -p \"Press enter to continue Java gradle task\"\nfi\nleeway exec --package components/supervisor-api/java:lib --package components/gitpod-protocol/java:lib -- ./gradlew --build-cache build\nleeway exec --package components/ide/jetbrains/backend-plugin:plugin --package components/ide/jetbrains/gateway-plugin:publish --parallel -- ./gradlew --build-cache buildPlugin\n"},{"name":"TypeScript","before":"scripts/branch-namespace.sh","init":"yarn --network-timeout 100000 && yarn build"},{"name":"Go","before":"pre-commit install --install-hooks","init":"leeway exec --filter-type go -v -- go mod verify","openMode":"split-right"}],"vscode":{"extensions":["bradlc.vscode-tailwindcss","EditorConfig.EditorConfig","golang.go","hashicorp.terraform","ms-azuretools.vscode-docker","ms-kubernetes-tools.vscode-kubernetes-tools","stkb.rewrap","zxh404.vscode-proto3","matthewpi.caddyfile-support","heptio.jsonnet","timonwong.shellcheck","vscjava.vscode-java-pack","fwcd.kotlin","dbaeumer.vscode-eslint","esbenp.prettier-vscode"]},"jetbrains":{"goland":{"prebuilds":{"version":"stable"}}},"_origin":"repo","_featureFlags":[]}`),
23+
}
24+
}

components/usage/pkg/db/workspace.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,19 @@
55
package db
66

77
import (
8+
"context"
89
"database/sql"
10+
"fmt"
11+
"github.com/google/uuid"
912
"gorm.io/datatypes"
13+
"gorm.io/gorm"
1014
"time"
1115
)
1216

1317
// Workspace represents the underlying DB object
1418
type Workspace struct {
1519
ID string `gorm:"primary_key;column:id;type:char;size:36;" json:"id"`
16-
OwnerID string `gorm:"column:ownerId;type:char;size:36;" json:"ownerId"`
20+
OwnerID uuid.UUID `gorm:"column:ownerId;type:char;size:36;" json:"ownerId"`
1721
ProjectID sql.NullString `gorm:"column:projectId;type:char;size:36;" json:"projectId"`
1822
Description string `gorm:"column:description;type:varchar;size:255;" json:"description"`
1923
Type string `gorm:"column:type;type:char;size:16;default:regular;" json:"type"`
@@ -46,3 +50,17 @@ type Workspace struct {
4650
func (d *Workspace) TableName() string {
4751
return "d_b_workspace"
4852
}
53+
54+
func ListWorkspacesByID(ctx context.Context, conn *gorm.DB, ids []string) ([]Workspace, error) {
55+
if len(ids) == 0 {
56+
return nil, nil
57+
}
58+
59+
var workspaces []Workspace
60+
tx := conn.WithContext(ctx).Where(ids).Find(&workspaces)
61+
if tx.Error != nil {
62+
return nil, fmt.Errorf("failed to list workspaces by id: %w", tx.Error)
63+
}
64+
65+
return workspaces, nil
66+
}

components/usage/pkg/db/workspace_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
package db_test
66

77
import (
8+
"context"
89
"fmt"
910
"github.com/gitpod-io/gitpod/usage/pkg/db"
11+
"github.com/gitpod-io/gitpod/usage/pkg/db/dbtest"
1012
"github.com/stretchr/testify/require"
1113
"gorm.io/gorm"
1214
"strings"
@@ -83,3 +85,53 @@ func stringToVarchar(t *testing.T, s string) db.VarcharTime {
8385
require.NoError(t, err)
8486
return converted
8587
}
88+
89+
func TestListWorkspacesByID(t *testing.T) {
90+
conn := db.ConnectForTests(t)
91+
92+
workspaces := []db.Workspace{
93+
dbtest.NewWorkspace(t, "gitpodio-gitpod-aaaaaaaaaaa"),
94+
dbtest.NewWorkspace(t, "gitpodio-gitpod-bbbbbbbbbbb"),
95+
}
96+
tx := conn.Create(workspaces)
97+
require.NoError(t, tx.Error)
98+
99+
for _, scenario := range []struct {
100+
Name string
101+
QueryIDs []string
102+
Expected int
103+
}{
104+
{
105+
Name: "no query ids returns empty results",
106+
QueryIDs: nil,
107+
Expected: 0,
108+
},
109+
{
110+
Name: "not found id returns emtpy results",
111+
QueryIDs: []string{"gitpodio-gitpod-xxxxxxxxxxx"},
112+
Expected: 0,
113+
},
114+
{
115+
Name: "one matching returns results",
116+
QueryIDs: []string{workspaces[0].ID},
117+
Expected: 1,
118+
},
119+
{
120+
Name: "one matching and one non existent returns one found result",
121+
QueryIDs: []string{workspaces[0].ID, "gitpodio-gitpod-xxxxxxxxxxx"},
122+
Expected: 1,
123+
},
124+
{
125+
Name: "multiple matching ids return results for each",
126+
QueryIDs: []string{workspaces[0].ID, workspaces[1].ID},
127+
Expected: 2,
128+
},
129+
} {
130+
t.Run(scenario.Name, func(t *testing.T) {
131+
results, err := db.ListWorkspacesByID(context.Background(), conn, scenario.QueryIDs)
132+
require.NoError(t, err)
133+
require.Len(t, results, scenario.Expected)
134+
})
135+
136+
}
137+
}

0 commit comments

Comments
 (0)