Skip to content

Commit 3863ce7

Browse files
committed
[ws-manager-mk2] Implement MarkActive
1 parent fc19e56 commit 3863ce7

File tree

6 files changed

+220
-21
lines changed

6 files changed

+220
-21
lines changed

components/ws-manager-api/go/crd/v1/workspace_types.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ type WorkspaceStatus struct {
130130
Runtime *WorkspaceRuntimeStatus `json:"runtime,omitempty"`
131131
}
132132

133-
// +kubebuilder:validation:Enum=Deployed;Failed;Timeout;UserActivity;HeadlessTaskFailed;StoppedByRequest;EverReady;ContentReady;BackupComplete;BackupFailure
133+
// +kubebuilder:validation:Enum=Deployed;Failed;Timeout;FirstUserActivity;Closed;HeadlessTaskFailed;StoppedByRequest;EverReady;ContentReady;BackupComplete;BackupFailure
134134
type WorkspaceCondition string
135135

136136
const (
@@ -144,8 +144,11 @@ const (
144144
// Timeout contains the reason the workspace has timed out.
145145
WorkspaceConditionTimeout WorkspaceCondition = "Timeout"
146146

147-
// UserActivity is the time when MarkActive was first called on the workspace
148-
WorkspaceConditionUserActivity WorkspaceCondition = "UserActivity"
147+
// FirstUserActivity is the time when MarkActive was first called on the workspace
148+
WorkspaceConditionFirstUserActivity WorkspaceCondition = "FirstUserActivity"
149+
150+
// Closed indicates that a workspace is marked as closed. This will shorten its timeout.
151+
WorkspaceConditionClosed WorkspaceCondition = "Closed"
149152

150153
// HeadlessTaskFailed indicates that a headless workspace task failed
151154
WorkspaceConditionsHeadlessTaskFailed WorkspaceCondition = "HeadlessTaskFailed"

components/ws-manager-mk2/controllers/workspace_controller.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,9 @@ func (r *WorkspaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
7575

7676
var workspace workspacev1.Workspace
7777
if err := r.Get(ctx, req.NamespacedName, &workspace); err != nil {
78-
log.Error(err, "unable to fetch workspace")
78+
if !errors.IsNotFound(err) {
79+
log.Error(err, "unable to fetch workspace")
80+
}
7981
// we'll ignore not-found errors, since they can't be fixed by an immediate
8082
// requeue (we'll need to wait for a new notification), and we can get them
8183
// on deleted requests.

components/ws-manager-mk2/main.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import (
3939
"github.com/gitpod-io/gitpod/common-go/pprof"
4040
regapi "github.com/gitpod-io/gitpod/registry-facade/api"
4141
"github.com/gitpod-io/gitpod/ws-manager-mk2/controllers"
42+
"github.com/gitpod-io/gitpod/ws-manager-mk2/pkg/activity"
4243
"github.com/gitpod-io/gitpod/ws-manager-mk2/service"
4344
wsmanapi "github.com/gitpod-io/gitpod/ws-manager/api"
4445
config "github.com/gitpod-io/gitpod/ws-manager/api/config"
@@ -103,7 +104,8 @@ func main() {
103104
os.Exit(1)
104105
}
105106

106-
wsmanService, err := setupGRPCService(cfg, mgr.GetClient())
107+
activity := &activity.WorkspaceActivity{}
108+
wsmanService, err := setupGRPCService(cfg, mgr.GetClient(), activity)
107109
if err != nil {
108110
setupLog.Error(err, "unable to start manager service")
109111
os.Exit(1)
@@ -137,7 +139,7 @@ func main() {
137139
}
138140
}
139141

140-
func setupGRPCService(cfg *config.ServiceConfiguration, k8s client.Client) (*service.WorkspaceManagerServer, error) {
142+
func setupGRPCService(cfg *config.ServiceConfiguration, k8s client.Client, activity *activity.WorkspaceActivity) (*service.WorkspaceManagerServer, error) {
141143
// TODO(cw): remove use of common-go/log
142144

143145
if len(cfg.RPCServer.RateLimits) > 0 {
@@ -170,7 +172,7 @@ func setupGRPCService(cfg *config.ServiceConfiguration, k8s client.Client) (*ser
170172

171173
grpcOpts = append(grpcOpts, grpc.UnknownServiceHandler(proxy.TransparentHandler(imagebuilderDirector(cfg.ImageBuilderProxy.TargetAddr))))
172174

173-
srv := service.NewWorkspaceManagerServer(k8s, &cfg.Manager, metrics.Registry)
175+
srv := service.NewWorkspaceManagerServer(k8s, &cfg.Manager, metrics.Registry, activity)
174176

175177
grpcServer := grpc.NewServer(grpcOpts...)
176178
grpc_prometheus.Register(grpcServer)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright (c) 2023 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 activity
6+
7+
import (
8+
"sync"
9+
"time"
10+
)
11+
12+
// WorkspaceActivity is used to track the last user activity per workspace. This is
13+
// stored in memory instead of on the Workspace resource to limit load on the k8s API,
14+
// as this value will update often for each workspace.
15+
type WorkspaceActivity struct {
16+
m sync.Map
17+
}
18+
19+
func (w *WorkspaceActivity) Store(workspaceId string, lastActivity time.Time) {
20+
w.m.Store(workspaceId, &lastActivity)
21+
}
22+
23+
func (w *WorkspaceActivity) GetLastActivity(workspaceId string) *time.Time {
24+
lastActivity, ok := w.m.Load(workspaceId)
25+
if ok {
26+
return lastActivity.(*time.Time)
27+
}
28+
return nil
29+
}

components/ws-manager-mk2/service/manager.go

Lines changed: 126 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@ import (
2424
wsk8s "github.com/gitpod-io/gitpod/common-go/kubernetes"
2525
"github.com/gitpod-io/gitpod/common-go/log"
2626
"github.com/gitpod-io/gitpod/common-go/tracing"
27+
"github.com/gitpod-io/gitpod/ws-manager-mk2/pkg/activity"
2728
"github.com/gitpod-io/gitpod/ws-manager/api"
2829
wsmanapi "github.com/gitpod-io/gitpod/ws-manager/api"
2930
"github.com/gitpod-io/gitpod/ws-manager/api/config"
3031
workspacev1 "github.com/gitpod-io/gitpod/ws-manager/api/crd/v1"
3132

33+
"github.com/sirupsen/logrus"
3234
corev1 "k8s.io/api/core/v1"
3335
"k8s.io/apimachinery/pkg/api/errors"
3436
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -41,24 +43,26 @@ import (
4143
"sigs.k8s.io/controller-runtime/pkg/client"
4244
)
4345

44-
func NewWorkspaceManagerServer(clnt client.Client, cfg *config.Configuration, reg prometheus.Registerer) *WorkspaceManagerServer {
46+
func NewWorkspaceManagerServer(clnt client.Client, cfg *config.Configuration, reg prometheus.Registerer, activity *activity.WorkspaceActivity) *WorkspaceManagerServer {
4547
metrics := newWorkspaceMetrics()
4648
reg.MustRegister(metrics)
4749

4850
return &WorkspaceManagerServer{
49-
Client: clnt,
50-
Config: cfg,
51-
metrics: metrics,
51+
Client: clnt,
52+
Config: cfg,
53+
metrics: metrics,
54+
activity: activity,
5255
subs: subscriptions{
5356
subscribers: make(map[string]chan *wsmanapi.SubscribeResponse),
5457
},
5558
}
5659
}
5760

5861
type WorkspaceManagerServer struct {
59-
Client client.Client
60-
Config *config.Configuration
61-
metrics *workspaceMetrics
62+
Client client.Client
63+
Config *config.Configuration
64+
metrics *workspaceMetrics
65+
activity *activity.WorkspaceActivity
6266

6367
subs subscriptions
6468
wsmanapi.UnimplementedWorkspaceManagerServer
@@ -280,10 +284,15 @@ func (wsm *WorkspaceManagerServer) DescribeWorkspace(ctx context.Context, req *w
280284
return nil, status.Errorf(codes.Internal, "cannot lookup workspace: %v", err)
281285
}
282286

283-
return &wsmanapi.DescribeWorkspaceResponse{
287+
result := &wsmanapi.DescribeWorkspaceResponse{
284288
Status: extractWorkspaceStatus(&ws),
285-
// TODO(cw): Add lastActivity
286-
}, nil
289+
}
290+
291+
lastActivity := wsm.activity.GetLastActivity(req.Id)
292+
if lastActivity != nil {
293+
result.LastActivity = lastActivity.UTC().Format(time.RFC3339Nano)
294+
}
295+
return result, nil
287296
}
288297

289298
// Subscribe streams all status updates to a client
@@ -296,8 +305,91 @@ func (m *WorkspaceManagerServer) Subscribe(req *api.SubscribeRequest, srv api.Wo
296305
return m.subs.Subscribe(srv.Context(), sub)
297306
}
298307

299-
func (wsm *WorkspaceManagerServer) MarkActive(ctx context.Context, req *wsmanapi.MarkActiveRequest) (*wsmanapi.MarkActiveResponse, error) {
300-
return nil, status.Errorf(codes.Unimplemented, "method MarkActive not implemented")
308+
// MarkActive records a workspace as being active which prevents it from timing out
309+
func (wsm *WorkspaceManagerServer) MarkActive(ctx context.Context, req *wsmanapi.MarkActiveRequest) (res *wsmanapi.MarkActiveResponse, err error) {
310+
//nolint:ineffassign
311+
span, ctx := tracing.FromContext(ctx, "MarkActive")
312+
tracing.ApplyOWI(span, log.OWI("", "", req.Id))
313+
defer tracing.FinishSpan(span, &err)
314+
315+
workspaceID := req.Id
316+
317+
var ws workspacev1.Workspace
318+
err = wsm.Client.Get(ctx, types.NamespacedName{Namespace: wsm.Config.Namespace, Name: req.Id}, &ws)
319+
if errors.IsNotFound(err) {
320+
return nil, status.Errorf(codes.NotFound, "workspace %s does not exist", req.Id)
321+
}
322+
if err != nil {
323+
return nil, status.Errorf(codes.Internal, "cannot mark workspace: %v", err)
324+
}
325+
326+
var firstUserActivity *timestamppb.Timestamp
327+
for _, c := range ws.Status.Conditions {
328+
if c.Type == string(workspacev1.WorkspaceConditionFirstUserActivity) {
329+
firstUserActivity = timestamppb.New(c.LastTransitionTime.Time)
330+
}
331+
}
332+
333+
// if user already mark workspace as active and this request has IgnoreIfActive flag, just simple ignore it
334+
if firstUserActivity != nil && req.IgnoreIfActive {
335+
return &api.MarkActiveResponse{}, nil
336+
}
337+
338+
// We do not keep the last activity in the workspace resource to limit the load we're placing
339+
// on the K8S master in check. Thus, this state lives locally in a map.
340+
now := time.Now().UTC()
341+
wsm.activity.Store(req.Id, now)
342+
343+
// We do however maintain the the "closed" flag as annotation on the workspace. This flag should not change
344+
// very often and provides a better UX if it persists across ws-manager restarts.
345+
isMarkedClosed := conditionPresentAndTrue(ws.Status.Conditions, string(workspacev1.WorkspaceConditionClosed))
346+
if req.Closed && !isMarkedClosed {
347+
err = wsm.modifyWorkspace(ctx, req.Id, true, func(ws *workspacev1.Workspace) error {
348+
ws.Status.Conditions = addUniqueCondition(ws.Status.Conditions, metav1.Condition{
349+
Type: string(workspacev1.WorkspaceConditionClosed),
350+
Status: metav1.ConditionTrue,
351+
LastTransitionTime: metav1.NewTime(now),
352+
Reason: "MarkActiveRequest",
353+
})
354+
return nil
355+
})
356+
} else if !req.Closed && isMarkedClosed {
357+
err = wsm.modifyWorkspace(ctx, req.Id, true, func(ws *workspacev1.Workspace) error {
358+
ws.Status.Conditions = addUniqueCondition(ws.Status.Conditions, metav1.Condition{
359+
Type: string(workspacev1.WorkspaceConditionClosed),
360+
Status: metav1.ConditionFalse,
361+
LastTransitionTime: metav1.NewTime(now),
362+
Reason: "MarkActiveRequest",
363+
})
364+
return nil
365+
})
366+
}
367+
if err != nil {
368+
logFields := logrus.Fields{
369+
"closed": req.Closed,
370+
"isMarkedClosed": isMarkedClosed,
371+
}
372+
log.WithError(err).WithFields(log.OWI("", "", workspaceID)).WithFields(logFields).Warn("was unable to mark workspace properly")
373+
}
374+
375+
// If it's the first call: Mark the pod with FirstUserActivity condition.
376+
if firstUserActivity == nil {
377+
err := wsm.modifyWorkspace(ctx, req.Id, true, func(ws *workspacev1.Workspace) error {
378+
ws.Status.Conditions = addUniqueCondition(ws.Status.Conditions, metav1.Condition{
379+
Type: string(workspacev1.WorkspaceConditionFirstUserActivity),
380+
Status: metav1.ConditionTrue,
381+
LastTransitionTime: metav1.NewTime(now),
382+
Reason: "MarkActiveRequest",
383+
})
384+
return nil
385+
})
386+
if err != nil {
387+
log.WithError(err).WithFields(log.OWI("", "", workspaceID)).Warn("was unable to set FirstUserActivity condition on workspace")
388+
return nil, err
389+
}
390+
}
391+
392+
return &api.MarkActiveResponse{}, nil
301393
}
302394

303395
func (wsm *WorkspaceManagerServer) SetTimeout(ctx context.Context, req *wsmanapi.SetTimeoutRequest) (*wsmanapi.SetTimeoutResponse, error) {
@@ -501,7 +593,7 @@ func extractWorkspaceStatus(ws *workspacev1.Workspace) *wsmanapi.WorkspaceStatus
501593

502594
var timeout string
503595
if ws.Spec.Timeout.Time != nil {
504-
timeout = ws.Spec.Timeout.Time.String()
596+
timeout = ws.Spec.Timeout.Time.Duration.String()
505597
}
506598

507599
var phase wsmanapi.WorkspacePhase
@@ -527,7 +619,7 @@ func extractWorkspaceStatus(ws *workspacev1.Workspace) *wsmanapi.WorkspaceStatus
527619

528620
var firstUserActivity *timestamppb.Timestamp
529621
for _, c := range ws.Status.Conditions {
530-
if c.Type == string(workspacev1.WorkspaceConditionUserActivity) {
622+
if c.Type == string(workspacev1.WorkspaceConditionFirstUserActivity) {
531623
firstUserActivity = timestamppb.New(c.LastTransitionTime.Time)
532624
}
533625
}
@@ -878,3 +970,23 @@ func (m *workspaceMetrics) Describe(ch chan<- *prometheus.Desc) {
878970
func (m *workspaceMetrics) Collect(ch chan<- prometheus.Metric) {
879971
m.totalStartsCounterVec.Collect(ch)
880972
}
973+
974+
func addUniqueCondition(conds []metav1.Condition, cond metav1.Condition) []metav1.Condition {
975+
for i, c := range conds {
976+
if c.Type == cond.Type {
977+
conds[i] = cond
978+
return conds
979+
}
980+
}
981+
982+
return append(conds, cond)
983+
}
984+
985+
func conditionPresentAndTrue(cond []metav1.Condition, tpe string) bool {
986+
for _, c := range cond {
987+
if c.Type == tpe {
988+
return c.Status == metav1.ConditionTrue
989+
}
990+
}
991+
return false
992+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"folders": [
3+
{ "path": "../common-go" },
4+
{ "path": "../ws-manager" },
5+
{ "path": "../ws-manager-api" },
6+
{ "path": "../ws-manager-mk2" },
7+
{ "path": "../server" },
8+
{ "path": "../../test" },
9+
{ "path": "../../dev/gpctl" },
10+
{ "path": "../../install/installer" }
11+
],
12+
"settings": {
13+
"typescript.tsdk": "gitpod/node_modules/typescript/lib",
14+
"[json]": {
15+
"editor.insertSpaces": true,
16+
"editor.tabSize": 2
17+
},
18+
"[yaml]": {
19+
"editor.insertSpaces": true,
20+
"editor.tabSize": 2
21+
},
22+
"[go]": {
23+
"editor.formatOnSave": true
24+
},
25+
"[tf]": {
26+
"editor.insertSpaces": true,
27+
"editor.tabSize": 2
28+
},
29+
"go.formatTool": "goimports",
30+
"go.useLanguageServer": true,
31+
"workspace.supportMultiRootWorkspace": true,
32+
"database.connections": [
33+
{
34+
"type": "mysql",
35+
"name": "devstaging DB",
36+
"host": "127.0.0.1:23306",
37+
"username": "gitpod",
38+
"database": "gitpod",
39+
"password": "test"
40+
}
41+
],
42+
"launch": {},
43+
"files.exclude": {
44+
"**/.git": true
45+
},
46+
"go.lintTool": "golangci-lint",
47+
"gopls": {
48+
"allowModfileModifications": true
49+
}
50+
}
51+
}

0 commit comments

Comments
 (0)