Skip to content

Commit 463ee02

Browse files
committed
[ws-manager-mk2] Implement MarkActive
1 parent fc19e56 commit 463ee02

File tree

6 files changed

+215
-21
lines changed

6 files changed

+215
-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: 121 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ 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"
@@ -41,24 +42,26 @@ import (
4142
"sigs.k8s.io/controller-runtime/pkg/client"
4243
)
4344

44-
func NewWorkspaceManagerServer(clnt client.Client, cfg *config.Configuration, reg prometheus.Registerer) *WorkspaceManagerServer {
45+
func NewWorkspaceManagerServer(clnt client.Client, cfg *config.Configuration, reg prometheus.Registerer, activity *activity.WorkspaceActivity) *WorkspaceManagerServer {
4546
metrics := newWorkspaceMetrics()
4647
reg.MustRegister(metrics)
4748

4849
return &WorkspaceManagerServer{
49-
Client: clnt,
50-
Config: cfg,
51-
metrics: metrics,
50+
Client: clnt,
51+
Config: cfg,
52+
metrics: metrics,
53+
activity: activity,
5254
subs: subscriptions{
5355
subscribers: make(map[string]chan *wsmanapi.SubscribeResponse),
5456
},
5557
}
5658
}
5759

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

6366
subs subscriptions
6467
wsmanapi.UnimplementedWorkspaceManagerServer
@@ -280,10 +283,15 @@ func (wsm *WorkspaceManagerServer) DescribeWorkspace(ctx context.Context, req *w
280283
return nil, status.Errorf(codes.Internal, "cannot lookup workspace: %v", err)
281284
}
282285

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

289297
// Subscribe streams all status updates to a client
@@ -296,8 +304,87 @@ func (m *WorkspaceManagerServer) Subscribe(req *api.SubscribeRequest, srv api.Wo
296304
return m.subs.Subscribe(srv.Context(), sub)
297305
}
298306

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

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

502589
var timeout string
503590
if ws.Spec.Timeout.Time != nil {
504-
timeout = ws.Spec.Timeout.Time.String()
591+
timeout = ws.Spec.Timeout.Time.Duration.String()
505592
}
506593

507594
var phase wsmanapi.WorkspacePhase
@@ -527,7 +614,7 @@ func extractWorkspaceStatus(ws *workspacev1.Workspace) *wsmanapi.WorkspaceStatus
527614

528615
var firstUserActivity *timestamppb.Timestamp
529616
for _, c := range ws.Status.Conditions {
530-
if c.Type == string(workspacev1.WorkspaceConditionUserActivity) {
617+
if c.Type == string(workspacev1.WorkspaceConditionFirstUserActivity) {
531618
firstUserActivity = timestamppb.New(c.LastTransitionTime.Time)
532619
}
533620
}
@@ -878,3 +965,23 @@ func (m *workspaceMetrics) Describe(ch chan<- *prometheus.Desc) {
878965
func (m *workspaceMetrics) Collect(ch chan<- prometheus.Metric) {
879966
m.totalStartsCounterVec.Collect(ch)
880967
}
968+
969+
func addUniqueCondition(conds []metav1.Condition, cond metav1.Condition) []metav1.Condition {
970+
for i, c := range conds {
971+
if c.Type == cond.Type {
972+
conds[i] = cond
973+
return conds
974+
}
975+
}
976+
977+
return append(conds, cond)
978+
}
979+
980+
func conditionPresentAndTrue(cond []metav1.Condition, tpe string) bool {
981+
for _, c := range cond {
982+
if c.Type == tpe {
983+
return c.Status == metav1.ConditionTrue
984+
}
985+
}
986+
return false
987+
}
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)