mirror of
https://github.com/gitpod-io/gitpod.git
synced 2025-12-08 17:36:30 +00:00
1043 lines
33 KiB
Go
1043 lines
33 KiB
Go
// 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 service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
validation "github.com/go-ozzo/ozzo-validation"
|
|
"github.com/opentracing/opentracing-go"
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"golang.org/x/xerrors"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/peer"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
"google.golang.org/protobuf/types/known/timestamppb"
|
|
|
|
"github.com/gitpod-io/gitpod/common-go/kubernetes"
|
|
wsk8s "github.com/gitpod-io/gitpod/common-go/kubernetes"
|
|
"github.com/gitpod-io/gitpod/common-go/log"
|
|
"github.com/gitpod-io/gitpod/common-go/tracing"
|
|
"github.com/gitpod-io/gitpod/common-go/util"
|
|
"github.com/gitpod-io/gitpod/ws-manager-mk2/pkg/activity"
|
|
"github.com/gitpod-io/gitpod/ws-manager/api"
|
|
wsmanapi "github.com/gitpod-io/gitpod/ws-manager/api"
|
|
"github.com/gitpod-io/gitpod/ws-manager/api/config"
|
|
workspacev1 "github.com/gitpod-io/gitpod/ws-manager/api/crd/v1"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/labels"
|
|
"k8s.io/apimachinery/pkg/selection"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/apimachinery/pkg/util/wait"
|
|
"k8s.io/client-go/util/retry"
|
|
"k8s.io/utils/pointer"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
|
)
|
|
|
|
const (
|
|
// stopWorkspaceNormallyGracePeriod is the grace period we use when stopping a pod with StopWorkspaceNormally policy
|
|
stopWorkspaceNormallyGracePeriod = 30 * time.Second
|
|
// stopWorkspaceImmediatelyGracePeriod is the grace period we use when stopping a pod as soon as possbile
|
|
stopWorkspaceImmediatelyGracePeriod = 1 * time.Second
|
|
)
|
|
|
|
func NewWorkspaceManagerServer(clnt client.Client, cfg *config.Configuration, reg prometheus.Registerer, activity *activity.WorkspaceActivity) *WorkspaceManagerServer {
|
|
metrics := newWorkspaceMetrics()
|
|
reg.MustRegister(metrics)
|
|
|
|
return &WorkspaceManagerServer{
|
|
Client: clnt,
|
|
Config: cfg,
|
|
metrics: metrics,
|
|
activity: activity,
|
|
subs: subscriptions{
|
|
subscribers: make(map[string]chan *wsmanapi.SubscribeResponse),
|
|
},
|
|
}
|
|
}
|
|
|
|
type WorkspaceManagerServer struct {
|
|
Client client.Client
|
|
Config *config.Configuration
|
|
metrics *workspaceMetrics
|
|
activity *activity.WorkspaceActivity
|
|
|
|
subs subscriptions
|
|
wsmanapi.UnimplementedWorkspaceManagerServer
|
|
}
|
|
|
|
// OnWorkspaceReconcile is called by the controller whenever it reconciles a workspace.
|
|
// This function then publishes to subscribers.
|
|
func (wsm *WorkspaceManagerServer) OnWorkspaceReconcile(ctx context.Context, ws *workspacev1.Workspace) {
|
|
wsm.subs.PublishToSubscribers(ctx, &wsmanapi.SubscribeResponse{
|
|
Status: extractWorkspaceStatus(ws),
|
|
})
|
|
}
|
|
|
|
func (wsm *WorkspaceManagerServer) StartWorkspace(ctx context.Context, req *wsmanapi.StartWorkspaceRequest) (resp *wsmanapi.StartWorkspaceResponse, err error) {
|
|
owi := log.OWI(req.Metadata.Owner, req.Metadata.MetaId, req.Id)
|
|
span, ctx := tracing.FromContext(ctx, "StartWorkspace")
|
|
tracing.LogRequestSafe(span, req)
|
|
tracing.ApplyOWI(span, owi)
|
|
defer tracing.FinishSpan(span, &err)
|
|
|
|
if err := validateStartWorkspaceRequest(req); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var workspaceType workspacev1.WorkspaceType
|
|
switch req.Type {
|
|
case wsmanapi.WorkspaceType_IMAGEBUILD:
|
|
workspaceType = workspacev1.WorkspaceTypeImageBuild
|
|
case wsmanapi.WorkspaceType_PREBUILD:
|
|
workspaceType = workspacev1.WorkspaceTypePrebuild
|
|
case wsmanapi.WorkspaceType_REGULAR:
|
|
workspaceType = workspacev1.WorkspaceTypeRegular
|
|
default:
|
|
return nil, status.Errorf(codes.InvalidArgument, "unsupported workspace type: %v", req.Type)
|
|
}
|
|
|
|
initializer, err := proto.Marshal(req.Spec.Initializer)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "cannot serialise content initializer: %v", err)
|
|
}
|
|
|
|
userEnvVars := setEnvironment(req.Spec.Envvars)
|
|
sysEnvVars := setEnvironment(req.Spec.SysEnvvars)
|
|
|
|
var git *workspacev1.GitSpec
|
|
if req.Spec.Git != nil {
|
|
git = &workspacev1.GitSpec{
|
|
Username: req.Spec.Git.Username,
|
|
Email: req.Spec.Git.Email,
|
|
}
|
|
}
|
|
|
|
var timeout *metav1.Duration
|
|
if req.Spec.Timeout != "" {
|
|
d, err := time.ParseDuration(req.Spec.Timeout)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid timeout: %v", err)
|
|
}
|
|
timeout = &metav1.Duration{Duration: d}
|
|
}
|
|
|
|
var admissionLevel workspacev1.AdmissionLevel
|
|
switch req.Spec.Admission {
|
|
case wsmanapi.AdmissionLevel_ADMIT_EVERYONE:
|
|
admissionLevel = workspacev1.AdmissionLevelEveryone
|
|
case wsmanapi.AdmissionLevel_ADMIT_OWNER_ONLY:
|
|
admissionLevel = workspacev1.AdmissionLevelOwner
|
|
default:
|
|
return nil, status.Errorf(codes.InvalidArgument, "unsupported admission level: %v", req.Spec.Admission)
|
|
}
|
|
|
|
ports := make([]workspacev1.PortSpec, 0, len(req.Spec.Ports))
|
|
for _, p := range req.Spec.Ports {
|
|
v := workspacev1.AdmissionLevelOwner
|
|
if p.Visibility == api.PortVisibility_PORT_VISIBILITY_PUBLIC {
|
|
v = workspacev1.AdmissionLevelEveryone
|
|
}
|
|
ports = append(ports, workspacev1.PortSpec{
|
|
Port: p.Port,
|
|
Visibility: v,
|
|
})
|
|
}
|
|
|
|
class, ok := wsm.Config.WorkspaceClasses[req.Spec.Class]
|
|
if !ok {
|
|
return nil, status.Errorf(codes.InvalidArgument, "workspace class \"%s\" is unknown", req.Spec.Class)
|
|
}
|
|
|
|
annotations := make(map[string]string)
|
|
for k, v := range req.Metadata.Annotations {
|
|
annotations[k] = v
|
|
}
|
|
|
|
for _, feature := range req.Spec.FeatureFlags {
|
|
switch feature {
|
|
case api.WorkspaceFeatureFlag_WORKSPACE_CLASS_LIMITING:
|
|
limits := class.Container.Limits
|
|
if limits != nil && limits.CPU != nil {
|
|
if limits.CPU.MinLimit != "" {
|
|
annotations[kubernetes.WorkspaceCpuMinLimitAnnotation] = limits.CPU.MinLimit
|
|
}
|
|
|
|
if limits.CPU.BurstLimit != "" {
|
|
annotations[kubernetes.WorkspaceCpuBurstLimitAnnotation] = limits.CPU.BurstLimit
|
|
}
|
|
}
|
|
|
|
case api.WorkspaceFeatureFlag_WORKSPACE_CONNECTION_LIMITING:
|
|
annotations[kubernetes.WorkspaceNetConnLimitAnnotation] = util.BooleanTrueString
|
|
|
|
case api.WorkspaceFeatureFlag_WORKSPACE_PSI:
|
|
annotations[kubernetes.WorkspacePressureStallInfoAnnotation] = util.BooleanTrueString
|
|
}
|
|
}
|
|
|
|
ws := workspacev1.Workspace{
|
|
TypeMeta: metav1.TypeMeta{
|
|
APIVersion: workspacev1.GroupVersion.String(),
|
|
Kind: "Workspace",
|
|
},
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: req.Id,
|
|
Annotations: annotations,
|
|
Namespace: wsm.Config.Namespace,
|
|
Labels: map[string]string{
|
|
wsk8s.WorkspaceIDLabel: req.Metadata.MetaId,
|
|
wsk8s.OwnerLabel: req.Metadata.Owner,
|
|
},
|
|
},
|
|
Spec: workspacev1.WorkspaceSpec{
|
|
Ownership: workspacev1.Ownership{
|
|
Owner: req.Metadata.Owner,
|
|
WorkspaceID: req.Metadata.MetaId,
|
|
},
|
|
Type: workspaceType,
|
|
Class: req.Spec.Class,
|
|
Image: workspacev1.WorkspaceImages{
|
|
Workspace: workspacev1.WorkspaceImage{
|
|
Ref: pointer.String(req.Spec.WorkspaceImage),
|
|
},
|
|
IDE: workspacev1.IDEImages{
|
|
Web: req.Spec.IdeImage.WebRef,
|
|
Refs: req.Spec.IdeImageLayers,
|
|
Supervisor: req.Spec.IdeImage.SupervisorRef,
|
|
},
|
|
},
|
|
Initializer: initializer,
|
|
UserEnvVars: userEnvVars,
|
|
SysEnvVars: sysEnvVars,
|
|
WorkspaceLocation: req.Spec.WorkspaceLocation,
|
|
Git: git,
|
|
Timeout: workspacev1.TimeoutSpec{
|
|
Time: timeout,
|
|
},
|
|
Admission: workspacev1.AdmissionSpec{
|
|
Level: admissionLevel,
|
|
},
|
|
Ports: ports,
|
|
},
|
|
}
|
|
controllerutil.AddFinalizer(&ws, workspacev1.GitpodFinalizerName)
|
|
|
|
wsm.metrics.recordWorkspaceStart(&ws)
|
|
err = wsm.Client.Create(ctx, &ws)
|
|
if err != nil {
|
|
log.WithError(err).WithFields(owi).Error("error creating workspace")
|
|
return nil, status.Errorf(codes.FailedPrecondition, "cannot create workspace")
|
|
}
|
|
|
|
var wsr workspacev1.Workspace
|
|
err = wait.PollWithContext(ctx, 100*time.Millisecond, 5*time.Second, func(c context.Context) (done bool, err error) {
|
|
err = wsm.Client.Get(ctx, types.NamespacedName{Namespace: wsm.Config.Namespace, Name: ws.Name}, &wsr)
|
|
if err != nil {
|
|
return false, nil
|
|
}
|
|
|
|
if wsr.Status.OwnerToken != "" && wsr.Status.URL != "" {
|
|
return true, nil
|
|
}
|
|
|
|
return false, nil
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.FailedPrecondition, "cannot wait for workspace URL")
|
|
}
|
|
|
|
return &wsmanapi.StartWorkspaceResponse{
|
|
Url: wsr.Status.URL,
|
|
OwnerToken: wsr.Status.OwnerToken,
|
|
}, nil
|
|
}
|
|
|
|
func (wsm *WorkspaceManagerServer) StopWorkspace(ctx context.Context, req *wsmanapi.StopWorkspaceRequest) (res *wsmanapi.StopWorkspaceResponse, err error) {
|
|
owi := log.OWI("", "", req.Id)
|
|
span, ctx := tracing.FromContext(ctx, "StopWorkspace")
|
|
tracing.LogRequestSafe(span, req)
|
|
tracing.ApplyOWI(span, owi)
|
|
defer tracing.FinishSpan(span, &err)
|
|
|
|
gracePeriod := stopWorkspaceNormallyGracePeriod
|
|
if req.Policy == api.StopWorkspacePolicy_IMMEDIATELY {
|
|
span.LogKV("policy", "immediately")
|
|
gracePeriod = stopWorkspaceImmediatelyGracePeriod
|
|
} else if req.Policy == api.StopWorkspacePolicy_ABORT {
|
|
span.LogKV("policy", "abort")
|
|
gracePeriod = stopWorkspaceImmediatelyGracePeriod
|
|
if err = wsm.modifyWorkspace(ctx, req.Id, true, func(ws *workspacev1.Workspace) error {
|
|
ws.Status.Conditions = wsk8s.AddUniqueCondition(ws.Status.Conditions, metav1.Condition{
|
|
Type: string(workspacev1.WorkspaceConditionAborted),
|
|
Status: metav1.ConditionTrue,
|
|
Reason: "StopWorkspaceRequest",
|
|
LastTransitionTime: metav1.Now(),
|
|
})
|
|
return nil
|
|
}); err != nil {
|
|
log.Error(err, "failed to add Aborted condition to workspace")
|
|
}
|
|
}
|
|
err = wsm.modifyWorkspace(ctx, req.Id, true, func(ws *workspacev1.Workspace) error {
|
|
ws.Status.Conditions = wsk8s.AddUniqueCondition(ws.Status.Conditions, metav1.Condition{
|
|
Type: string(workspacev1.WorkspaceConditionStoppedByRequest),
|
|
Status: metav1.ConditionTrue,
|
|
LastTransitionTime: metav1.Now(),
|
|
Message: gracePeriod.String(),
|
|
Reason: "StopWorkspaceRequest",
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &wsmanapi.StopWorkspaceResponse{}, nil
|
|
}
|
|
|
|
func (wsm *WorkspaceManagerServer) GetWorkspaces(ctx context.Context, req *wsmanapi.GetWorkspacesRequest) (*wsmanapi.GetWorkspacesResponse, error) {
|
|
labelSelector, err := metadataFilterToLabelSelector(req.MustMatch)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.FailedPrecondition, "cannot convert metadata filter: %v", err)
|
|
}
|
|
|
|
var workspaces workspacev1.WorkspaceList
|
|
err = wsm.Client.List(ctx, &workspaces, &client.ListOptions{
|
|
LabelSelector: labelSelector,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.FailedPrecondition, "cannot list workspaces: %v", err)
|
|
}
|
|
|
|
res := make([]*wsmanapi.WorkspaceStatus, 0, len(workspaces.Items))
|
|
for _, ws := range workspaces.Items {
|
|
if !matchesMetadataAnnotations(&ws, req.MustMatch) {
|
|
continue
|
|
}
|
|
|
|
res = append(res, extractWorkspaceStatus(&ws))
|
|
}
|
|
|
|
return &wsmanapi.GetWorkspacesResponse{Status: res}, nil
|
|
}
|
|
|
|
func (wsm *WorkspaceManagerServer) DescribeWorkspace(ctx context.Context, req *wsmanapi.DescribeWorkspaceRequest) (*wsmanapi.DescribeWorkspaceResponse, error) {
|
|
var ws workspacev1.Workspace
|
|
err := wsm.Client.Get(ctx, types.NamespacedName{Namespace: wsm.Config.Namespace, Name: req.Id}, &ws)
|
|
if errors.IsNotFound(err) {
|
|
return nil, status.Errorf(codes.NotFound, "workspace %s not found", req.Id)
|
|
}
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "cannot lookup workspace: %v", err)
|
|
}
|
|
|
|
result := &wsmanapi.DescribeWorkspaceResponse{
|
|
Status: extractWorkspaceStatus(&ws),
|
|
}
|
|
|
|
lastActivity := wsm.activity.GetLastActivity(req.Id)
|
|
if lastActivity != nil {
|
|
result.LastActivity = lastActivity.UTC().Format(time.RFC3339Nano)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// Subscribe streams all status updates to a client
|
|
func (m *WorkspaceManagerServer) Subscribe(req *api.SubscribeRequest, srv api.WorkspaceManager_SubscribeServer) (err error) {
|
|
var sub subscriber = srv
|
|
if req.MustMatch != nil {
|
|
sub = &filteringSubscriber{srv, req.MustMatch}
|
|
}
|
|
|
|
return m.subs.Subscribe(srv.Context(), sub)
|
|
}
|
|
|
|
// MarkActive records a workspace as being active which prevents it from timing out
|
|
func (wsm *WorkspaceManagerServer) MarkActive(ctx context.Context, req *wsmanapi.MarkActiveRequest) (res *wsmanapi.MarkActiveResponse, err error) {
|
|
//nolint:ineffassign
|
|
span, ctx := tracing.FromContext(ctx, "MarkActive")
|
|
tracing.ApplyOWI(span, log.OWI("", "", req.Id))
|
|
defer tracing.FinishSpan(span, &err)
|
|
|
|
workspaceID := req.Id
|
|
|
|
var ws workspacev1.Workspace
|
|
err = wsm.Client.Get(ctx, types.NamespacedName{Namespace: wsm.Config.Namespace, Name: req.Id}, &ws)
|
|
if errors.IsNotFound(err) {
|
|
return nil, status.Errorf(codes.NotFound, "workspace %s does not exist", req.Id)
|
|
}
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "cannot mark workspace: %v", err)
|
|
}
|
|
|
|
var firstUserActivity *timestamppb.Timestamp
|
|
if c := wsk8s.GetCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionFirstUserActivity)); c != nil {
|
|
firstUserActivity = timestamppb.New(c.LastTransitionTime.Time)
|
|
}
|
|
|
|
// if user already mark workspace as active and this request has IgnoreIfActive flag, just simple ignore it
|
|
if firstUserActivity != nil && req.IgnoreIfActive {
|
|
return &api.MarkActiveResponse{}, nil
|
|
}
|
|
|
|
// We do not keep the last activity in the workspace resource to limit the load we're placing
|
|
// on the K8S master in check. Thus, this state lives locally in a map.
|
|
now := time.Now().UTC()
|
|
wsm.activity.Store(req.Id, now)
|
|
|
|
// We do however maintain the the "closed" flag as annotation on the workspace. This flag should not change
|
|
// very often and provides a better UX if it persists across ws-manager restarts.
|
|
isMarkedClosed := wsk8s.ConditionPresentAndTrue(ws.Status.Conditions, string(workspacev1.WorkspaceConditionClosed))
|
|
if req.Closed && !isMarkedClosed {
|
|
err = wsm.modifyWorkspace(ctx, req.Id, true, func(ws *workspacev1.Workspace) error {
|
|
ws.Status.Conditions = wsk8s.AddUniqueCondition(ws.Status.Conditions, metav1.Condition{
|
|
Type: string(workspacev1.WorkspaceConditionClosed),
|
|
Status: metav1.ConditionTrue,
|
|
LastTransitionTime: metav1.NewTime(now),
|
|
Reason: "MarkActiveRequest",
|
|
})
|
|
return nil
|
|
})
|
|
} else if !req.Closed && isMarkedClosed {
|
|
err = wsm.modifyWorkspace(ctx, req.Id, true, func(ws *workspacev1.Workspace) error {
|
|
ws.Status.Conditions = wsk8s.AddUniqueCondition(ws.Status.Conditions, metav1.Condition{
|
|
Type: string(workspacev1.WorkspaceConditionClosed),
|
|
Status: metav1.ConditionFalse,
|
|
LastTransitionTime: metav1.NewTime(now),
|
|
Reason: "MarkActiveRequest",
|
|
})
|
|
return nil
|
|
})
|
|
}
|
|
if err != nil {
|
|
logFields := logrus.Fields{
|
|
"closed": req.Closed,
|
|
"isMarkedClosed": isMarkedClosed,
|
|
}
|
|
log.WithError(err).WithFields(log.OWI("", "", workspaceID)).WithFields(logFields).Warn("was unable to mark workspace properly")
|
|
}
|
|
|
|
// If it's the first call: Mark the pod with FirstUserActivity condition.
|
|
if firstUserActivity == nil {
|
|
err := wsm.modifyWorkspace(ctx, req.Id, true, func(ws *workspacev1.Workspace) error {
|
|
ws.Status.Conditions = wsk8s.AddUniqueCondition(ws.Status.Conditions, metav1.Condition{
|
|
Type: string(workspacev1.WorkspaceConditionFirstUserActivity),
|
|
Status: metav1.ConditionTrue,
|
|
LastTransitionTime: metav1.NewTime(now),
|
|
Reason: "MarkActiveRequest",
|
|
})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
log.WithError(err).WithFields(log.OWI("", "", workspaceID)).Warn("was unable to set FirstUserActivity condition on workspace")
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return &api.MarkActiveResponse{}, nil
|
|
}
|
|
|
|
func (wsm *WorkspaceManagerServer) SetTimeout(ctx context.Context, req *wsmanapi.SetTimeoutRequest) (*wsmanapi.SetTimeoutResponse, error) {
|
|
duration, err := time.ParseDuration(req.Duration)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "invalid duration: %v", err)
|
|
}
|
|
|
|
err = wsm.modifyWorkspace(ctx, req.Id, false, func(ws *workspacev1.Workspace) error {
|
|
ws.Spec.Timeout.Time = &metav1.Duration{Duration: duration}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &wsmanapi.SetTimeoutResponse{}, nil
|
|
}
|
|
|
|
func (wsm *WorkspaceManagerServer) ControlPort(ctx context.Context, req *wsmanapi.ControlPortRequest) (*wsmanapi.ControlPortResponse, error) {
|
|
if req.Spec == nil {
|
|
return nil, status.Errorf(codes.InvalidArgument, "missing spec")
|
|
}
|
|
|
|
port := req.Spec.Port
|
|
err := wsm.modifyWorkspace(ctx, req.Id, false, func(ws *workspacev1.Workspace) error {
|
|
n := 0
|
|
for _, x := range ws.Spec.Ports {
|
|
if x.Port != port {
|
|
ws.Spec.Ports[n] = x
|
|
n++
|
|
}
|
|
}
|
|
ws.Spec.Ports = ws.Spec.Ports[:n]
|
|
|
|
if req.Expose {
|
|
visibility := workspacev1.AdmissionLevelOwner
|
|
if req.Spec.Visibility == api.PortVisibility_PORT_VISIBILITY_PUBLIC {
|
|
visibility = workspacev1.AdmissionLevelEveryone
|
|
}
|
|
ws.Spec.Ports = append(ws.Spec.Ports, workspacev1.PortSpec{
|
|
Port: port,
|
|
Visibility: visibility,
|
|
})
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &wsmanapi.ControlPortResponse{}, nil
|
|
}
|
|
|
|
func (wsm *WorkspaceManagerServer) TakeSnapshot(ctx context.Context, req *wsmanapi.TakeSnapshotRequest) (*wsmanapi.TakeSnapshotResponse, error) {
|
|
return nil, status.Errorf(codes.Unimplemented, "method TakeSnapshot not implemented")
|
|
}
|
|
|
|
func (wsm *WorkspaceManagerServer) ControlAdmission(ctx context.Context, req *wsmanapi.ControlAdmissionRequest) (*wsmanapi.ControlAdmissionResponse, error) {
|
|
err := wsm.modifyWorkspace(ctx, req.Id, false, func(ws *workspacev1.Workspace) error {
|
|
switch req.Level {
|
|
case wsmanapi.AdmissionLevel_ADMIT_EVERYONE:
|
|
ws.Spec.Admission.Level = workspacev1.AdmissionLevelEveryone
|
|
case wsmanapi.AdmissionLevel_ADMIT_OWNER_ONLY:
|
|
ws.Spec.Admission.Level = workspacev1.AdmissionLevelOwner
|
|
default:
|
|
return status.Errorf(codes.InvalidArgument, "unsupported admission level: %v", req.Level)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &wsmanapi.ControlAdmissionResponse{}, nil
|
|
}
|
|
|
|
// modifyWorkspace modifies a workspace object using the mod function. If the mod function returns a gRPC status error, that error
|
|
// is returned directly. If mod returns a non-gRPC error it is turned into one.
|
|
func (wsm *WorkspaceManagerServer) modifyWorkspace(ctx context.Context, id string, updateStatus bool, mod func(ws *workspacev1.Workspace) error) error {
|
|
err := retry.RetryOnConflict(retry.DefaultBackoff, func() error {
|
|
var ws workspacev1.Workspace
|
|
err := wsm.Client.Get(ctx, types.NamespacedName{Namespace: wsm.Config.Namespace, Name: id}, &ws)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = mod(&ws)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if updateStatus {
|
|
err = wsm.Client.Status().Update(ctx, &ws)
|
|
} else {
|
|
err = wsm.Client.Update(ctx, &ws)
|
|
|
|
}
|
|
return err
|
|
})
|
|
if errors.IsNotFound(err) {
|
|
return status.Errorf(codes.NotFound, "workspace %s not found", id)
|
|
}
|
|
if c := status.Code(err); c != codes.Unknown && c != codes.OK {
|
|
return err
|
|
}
|
|
if err != nil {
|
|
return status.Errorf(codes.Internal, "cannot modify workspace: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateStartWorkspaceRequest ensures that acting on this request will not leave the system in an invalid state
|
|
func validateStartWorkspaceRequest(req *api.StartWorkspaceRequest) error {
|
|
err := validation.ValidateStruct(req.Spec,
|
|
validation.Field(&req.Spec.WorkspaceImage, validation.Required),
|
|
validation.Field(&req.Spec.WorkspaceLocation, validation.Required),
|
|
validation.Field(&req.Spec.Ports, validation.By(areValidPorts)),
|
|
validation.Field(&req.Spec.Initializer, validation.Required),
|
|
validation.Field(&req.Spec.FeatureFlags, validation.By(areValidFeatureFlags)),
|
|
)
|
|
if err != nil {
|
|
return status.Errorf(codes.InvalidArgument, "invalid request: %v", err)
|
|
}
|
|
|
|
rules := make([]*validation.FieldRules, 0)
|
|
rules = append(rules, validation.Field(&req.Id, validation.Required))
|
|
rules = append(rules, validation.Field(&req.Spec, validation.Required))
|
|
rules = append(rules, validation.Field(&req.Type, validation.By(isValidWorkspaceType)))
|
|
if req.Type == api.WorkspaceType_REGULAR {
|
|
rules = append(rules, validation.Field(&req.ServicePrefix, validation.Required))
|
|
}
|
|
err = validation.ValidateStruct(req, rules...)
|
|
if err != nil {
|
|
return status.Errorf(codes.InvalidArgument, "invalid request: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func isValidWorkspaceType(value interface{}) error {
|
|
s, ok := value.(api.WorkspaceType)
|
|
if !ok {
|
|
return xerrors.Errorf("value is not a workspace type")
|
|
}
|
|
|
|
_, ok = api.WorkspaceType_name[int32(s)]
|
|
if !ok {
|
|
return xerrors.Errorf("value %d is out of range", s)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func areValidPorts(value interface{}) error {
|
|
s, ok := value.([]*api.PortSpec)
|
|
if !ok {
|
|
return xerrors.Errorf("value is not a port spec list")
|
|
}
|
|
|
|
idx := make(map[uint32]struct{})
|
|
for _, p := range s {
|
|
if _, exists := idx[p.Port]; exists {
|
|
return xerrors.Errorf("port %d is not unique", p.Port)
|
|
}
|
|
idx[p.Port] = struct{}{}
|
|
|
|
// TODO [cw]: probably the target should be unique as well.
|
|
// I don't want to introduce new issues with too
|
|
// tight validation though.
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func areValidFeatureFlags(value interface{}) error {
|
|
s, ok := value.([]api.WorkspaceFeatureFlag)
|
|
if !ok {
|
|
return xerrors.Errorf("value not a feature flag list")
|
|
}
|
|
|
|
idx := make(map[api.WorkspaceFeatureFlag]struct{}, len(s))
|
|
for _, k := range s {
|
|
idx[k] = struct{}{}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func setEnvironment(envs []*wsmanapi.EnvironmentVariable) []corev1.EnvVar {
|
|
envVars := make([]corev1.EnvVar, 0, len(envs))
|
|
for _, e := range envs {
|
|
env := corev1.EnvVar{Name: e.Name, Value: e.Value}
|
|
if len(e.Value) == 0 && e.Secret != nil {
|
|
env.ValueFrom = &corev1.EnvVarSource{
|
|
SecretKeyRef: &corev1.SecretKeySelector{
|
|
LocalObjectReference: corev1.LocalObjectReference{Name: e.Secret.SecretName},
|
|
Key: e.Secret.Key,
|
|
},
|
|
}
|
|
}
|
|
envVars = append(envVars, env)
|
|
}
|
|
|
|
return envVars
|
|
}
|
|
|
|
func extractWorkspaceStatus(ws *workspacev1.Workspace) *wsmanapi.WorkspaceStatus {
|
|
version, _ := strconv.ParseUint(ws.ResourceVersion, 10, 64)
|
|
|
|
var tpe wsmanapi.WorkspaceType
|
|
switch ws.Spec.Type {
|
|
case workspacev1.WorkspaceTypeImageBuild:
|
|
tpe = wsmanapi.WorkspaceType_IMAGEBUILD
|
|
case workspacev1.WorkspaceTypePrebuild:
|
|
tpe = wsmanapi.WorkspaceType_PREBUILD
|
|
case workspacev1.WorkspaceTypeRegular:
|
|
tpe = wsmanapi.WorkspaceType_REGULAR
|
|
}
|
|
|
|
var timeout string
|
|
if ws.Spec.Timeout.Time != nil {
|
|
timeout = ws.Spec.Timeout.Time.Duration.String()
|
|
}
|
|
|
|
var phase wsmanapi.WorkspacePhase
|
|
switch ws.Status.Phase {
|
|
case workspacev1.WorkspacePhasePending:
|
|
phase = wsmanapi.WorkspacePhase_PENDING
|
|
case workspacev1.WorkspacePhaseImageBuild:
|
|
// TODO(cw): once we have an imagebuild phase on the protocol, map this properly
|
|
phase = wsmanapi.WorkspacePhase_PENDING
|
|
case workspacev1.WorkspacePhaseCreating:
|
|
phase = wsmanapi.WorkspacePhase_CREATING
|
|
case workspacev1.WorkspacePhaseInitializing:
|
|
phase = wsmanapi.WorkspacePhase_INITIALIZING
|
|
case workspacev1.WorkspacePhaseRunning:
|
|
phase = wsmanapi.WorkspacePhase_RUNNING
|
|
case workspacev1.WorkspacePhaseStopping:
|
|
phase = wsmanapi.WorkspacePhase_STOPPING
|
|
case workspacev1.WorkspacePhaseStopped:
|
|
phase = wsmanapi.WorkspacePhase_STOPPED
|
|
case workspacev1.WorkspacePhaseUnknown:
|
|
phase = wsmanapi.WorkspacePhase_UNKNOWN
|
|
}
|
|
|
|
var firstUserActivity *timestamppb.Timestamp
|
|
for _, c := range ws.Status.Conditions {
|
|
if c.Type == string(workspacev1.WorkspaceConditionFirstUserActivity) {
|
|
firstUserActivity = timestamppb.New(c.LastTransitionTime.Time)
|
|
}
|
|
}
|
|
|
|
var runtime *wsmanapi.WorkspaceRuntimeInfo
|
|
if rt := ws.Status.Runtime; rt != nil {
|
|
runtime = &wsmanapi.WorkspaceRuntimeInfo{
|
|
NodeName: rt.NodeName,
|
|
NodeIp: rt.HostIP,
|
|
PodName: rt.PodName,
|
|
}
|
|
}
|
|
|
|
var admissionLevel wsmanapi.AdmissionLevel
|
|
switch ws.Spec.Admission.Level {
|
|
case workspacev1.AdmissionLevelEveryone:
|
|
admissionLevel = wsmanapi.AdmissionLevel_ADMIT_EVERYONE
|
|
case workspacev1.AdmissionLevelOwner:
|
|
admissionLevel = wsmanapi.AdmissionLevel_ADMIT_OWNER_ONLY
|
|
}
|
|
|
|
res := &wsmanapi.WorkspaceStatus{
|
|
Id: ws.Name,
|
|
StatusVersion: version,
|
|
Metadata: &wsmanapi.WorkspaceMetadata{
|
|
Owner: ws.Spec.Ownership.Owner,
|
|
MetaId: ws.Spec.Ownership.WorkspaceID,
|
|
StartedAt: timestamppb.New(ws.CreationTimestamp.Time),
|
|
Annotations: ws.Annotations,
|
|
},
|
|
Spec: &wsmanapi.WorkspaceSpec{
|
|
WorkspaceImage: pointer.StringDeref(ws.Spec.Image.Workspace.Ref, ""),
|
|
IdeImage: &wsmanapi.IDEImage{
|
|
WebRef: ws.Spec.Image.IDE.Web,
|
|
SupervisorRef: ws.Spec.Image.IDE.Supervisor,
|
|
},
|
|
IdeImageLayers: ws.Spec.Image.IDE.Refs,
|
|
Headless: ws.Status.Headless,
|
|
Url: ws.Status.URL,
|
|
Type: tpe,
|
|
Timeout: timeout,
|
|
},
|
|
Phase: phase,
|
|
Conditions: &wsmanapi.WorkspaceConditions{
|
|
Failed: getConditionMessageIfTrue(ws.Status.Conditions, string(workspacev1.WorkspaceConditionFailed)),
|
|
Timeout: getConditionMessageIfTrue(ws.Status.Conditions, string(workspacev1.WorkspaceConditionTimeout)),
|
|
Snapshot: ws.Status.Snapshot,
|
|
Deployed: convertCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionDeployed)),
|
|
FirstUserActivity: firstUserActivity,
|
|
HeadlessTaskFailed: getConditionMessageIfTrue(ws.Status.Conditions, string(workspacev1.WorkspaceConditionsHeadlessTaskFailed)),
|
|
StoppedByRequest: convertCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionStoppedByRequest)),
|
|
FinalBackupComplete: convertCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionBackupComplete)),
|
|
Aborted: convertCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionAborted)),
|
|
},
|
|
Runtime: runtime,
|
|
Auth: &wsmanapi.WorkspaceAuthentication{
|
|
Admission: admissionLevel,
|
|
OwnerToken: ws.Status.OwnerToken,
|
|
},
|
|
}
|
|
return res
|
|
}
|
|
|
|
func getConditionMessageIfTrue(conds []metav1.Condition, tpe string) string {
|
|
for _, c := range conds {
|
|
if c.Type == tpe && c.Status == metav1.ConditionTrue {
|
|
return c.Message
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func convertCondition(conds []metav1.Condition, tpe string) wsmanapi.WorkspaceConditionBool {
|
|
res := wsk8s.GetCondition(conds, tpe)
|
|
if res == nil {
|
|
return wsmanapi.WorkspaceConditionBool_EMPTY
|
|
}
|
|
|
|
switch res.Status {
|
|
case metav1.ConditionTrue:
|
|
return wsmanapi.WorkspaceConditionBool_TRUE
|
|
case metav1.ConditionFalse:
|
|
return wsmanapi.WorkspaceConditionBool_FALSE
|
|
default:
|
|
return wsmanapi.WorkspaceConditionBool_EMPTY
|
|
}
|
|
}
|
|
|
|
func matchesMetadataAnnotations(ws *workspacev1.Workspace, filter *wsmanapi.MetadataFilter) bool {
|
|
if filter == nil {
|
|
return true
|
|
}
|
|
for k, v := range filter.Annotations {
|
|
av, ok := ws.Annotations[k]
|
|
if !ok || av != v {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func metadataFilterToLabelSelector(filter *wsmanapi.MetadataFilter) (labels.Selector, error) {
|
|
if filter == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
res := labels.NewSelector()
|
|
if filter.MetaId != "" {
|
|
req, err := labels.NewRequirement(wsk8s.WorkspaceIDLabel, selection.Equals, []string{filter.MetaId})
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("cannot create metaID filter: %w", err)
|
|
}
|
|
res.Add(*req)
|
|
}
|
|
if filter.Owner != "" {
|
|
req, err := labels.NewRequirement(wsk8s.OwnerLabel, selection.Equals, []string{filter.Owner})
|
|
if err != nil {
|
|
return nil, xerrors.Errorf("cannot create owner filter: %w", err)
|
|
}
|
|
res.Add(*req)
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
type filteringSubscriber struct {
|
|
Sub subscriber
|
|
Filter *api.MetadataFilter
|
|
}
|
|
|
|
func matchesMetadataFilter(filter *api.MetadataFilter, md *api.WorkspaceMetadata) bool {
|
|
if filter == nil {
|
|
return true
|
|
}
|
|
|
|
if filter.MetaId != "" && filter.MetaId != md.MetaId {
|
|
return false
|
|
}
|
|
if filter.Owner != "" && filter.Owner != md.Owner {
|
|
return false
|
|
}
|
|
for k, v := range filter.Annotations {
|
|
av, ok := md.Annotations[k]
|
|
if !ok || av != v {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (f *filteringSubscriber) Send(resp *api.SubscribeResponse) error {
|
|
var md *api.WorkspaceMetadata
|
|
if sts := resp.GetStatus(); sts != nil {
|
|
md = sts.Metadata
|
|
}
|
|
if md == nil {
|
|
// no metadata, no forwarding
|
|
return nil
|
|
}
|
|
if !matchesMetadataFilter(f.Filter, md) {
|
|
return nil
|
|
}
|
|
|
|
return f.Sub.Send(resp)
|
|
}
|
|
|
|
type subscriber interface {
|
|
Send(*api.SubscribeResponse) error
|
|
}
|
|
|
|
type subscriptions struct {
|
|
mu sync.RWMutex
|
|
subscribers map[string]chan *wsmanapi.SubscribeResponse
|
|
}
|
|
|
|
func (subs *subscriptions) Subscribe(ctx context.Context, recv subscriber) (err error) {
|
|
incoming := make(chan *api.SubscribeResponse, 250)
|
|
|
|
var key string
|
|
peer, ok := peer.FromContext(ctx)
|
|
if ok {
|
|
key = fmt.Sprintf("k%s@%d", peer.Addr.String(), time.Now().UnixNano())
|
|
}
|
|
|
|
subs.mu.Lock()
|
|
if key == "" {
|
|
// if for some reason we didn't get peer information,
|
|
// we must generate they key within the lock, otherwise we might end up with duplicate keys
|
|
key = fmt.Sprintf("k%d@%d", len(subs.subscribers), time.Now().UnixNano())
|
|
}
|
|
subs.subscribers[key] = incoming
|
|
log.WithField("subscriberKey", key).WithField("subscriberCount", len(subs.subscribers)).Info("new subscriber")
|
|
subs.mu.Unlock()
|
|
|
|
defer func() {
|
|
subs.mu.Lock()
|
|
delete(subs.subscribers, key)
|
|
subs.mu.Unlock()
|
|
}()
|
|
|
|
for {
|
|
var inc *api.SubscribeResponse
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case inc = <-incoming:
|
|
}
|
|
|
|
if inc == nil {
|
|
log.WithField("subscriberKey", key).Warn("subscription was canceled")
|
|
return xerrors.Errorf("subscription was canceled")
|
|
}
|
|
|
|
err = recv.Send(inc)
|
|
if err != nil {
|
|
log.WithField("subscriberKey", key).WithError(err).Error("cannot send update - dropping subscriber")
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
func (subs *subscriptions) PublishToSubscribers(ctx context.Context, update *api.SubscribeResponse) {
|
|
subs.mu.RLock()
|
|
var dropouts []string
|
|
for k, sub := range subs.subscribers {
|
|
select {
|
|
case sub <- update:
|
|
// all is well
|
|
default:
|
|
// writing to subscriber cannel blocked, which means the subscriber isn't consuming fast enough and
|
|
// would block others. We'll drop this consumer later (do not drop here to avoid concurrency issues).
|
|
dropouts = append(dropouts, k)
|
|
}
|
|
}
|
|
// we cannot defer this call as dropSubscriber will attempt to acquire a write lock
|
|
subs.mu.RUnlock()
|
|
|
|
// we check if there are any dropouts here to avoid the non-inlinable dropSubscriber call.
|
|
if len(dropouts) > 0 {
|
|
subs.DropSubscriber(dropouts)
|
|
}
|
|
}
|
|
|
|
func (subs *subscriptions) DropSubscriber(dropouts []string) {
|
|
defer func() {
|
|
err := recover()
|
|
if err != nil {
|
|
log.WithField("error", err).Error("caught panic in dropSubscriber")
|
|
}
|
|
}()
|
|
|
|
subs.mu.Lock()
|
|
defer subs.mu.Unlock()
|
|
|
|
for _, k := range dropouts {
|
|
sub, ok := subs.subscribers[k]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
log.WithField("subscriber", k).WithField("subscriberCount", len(subs.subscribers)).Warn("subscriber channel was full - dropping subscriber")
|
|
// despite closing the subscriber channel, the subscriber's serve Go routine will still try to send
|
|
// all prior updates up to this point. See https://play.golang.org/p/XR-9nLrQLQs
|
|
close(sub)
|
|
delete(subs.subscribers, k)
|
|
}
|
|
}
|
|
|
|
// onChange is the default OnChange implementation which publishes workspace status updates to subscribers
|
|
func (subs *subscriptions) OnChange(ctx context.Context, status *api.WorkspaceStatus) {
|
|
log := log.WithFields(log.OWI(status.Metadata.Owner, status.Metadata.MetaId, status.Id))
|
|
|
|
header := make(map[string]string)
|
|
span := opentracing.SpanFromContext(ctx)
|
|
if span != nil {
|
|
tracingHeader := make(opentracing.HTTPHeadersCarrier)
|
|
err := opentracing.GlobalTracer().Inject(span.Context(), opentracing.HTTPHeaders, tracingHeader)
|
|
if err != nil {
|
|
// if the error was caused by the span coming from the Noop tracer - ignore it.
|
|
// This can happen if the workspace doesn't have a span associated with it, then we resort to creating Noop spans.
|
|
if _, isNoopTracer := span.Tracer().(opentracing.NoopTracer); !isNoopTracer {
|
|
log.WithError(err).Debug("unable to extract tracing information - trace will be broken")
|
|
}
|
|
} else {
|
|
for k, v := range tracingHeader {
|
|
if len(v) != 1 {
|
|
continue
|
|
}
|
|
header[k] = v[0]
|
|
}
|
|
}
|
|
}
|
|
|
|
subs.PublishToSubscribers(ctx, &api.SubscribeResponse{
|
|
Status: status,
|
|
Header: header,
|
|
})
|
|
|
|
// subs.metrics.OnChange(status)
|
|
|
|
// There are some conditions we'd like to get notified about, for example while running experiements or because
|
|
// they represent out-of-the-ordinary situations.
|
|
// We attempt to use the GCP Error Reporting for this, hence log these situations as errors.
|
|
if status.Conditions.Failed != "" {
|
|
log.WithField("status", status).Error("workspace failed")
|
|
}
|
|
if status.Phase == 0 {
|
|
log.WithField("status", status).Error("workspace in UNKNOWN phase")
|
|
}
|
|
}
|
|
|
|
type workspaceMetrics struct {
|
|
totalStartsCounterVec *prometheus.CounterVec
|
|
}
|
|
|
|
func newWorkspaceMetrics() *workspaceMetrics {
|
|
return &workspaceMetrics{
|
|
totalStartsCounterVec: prometheus.NewCounterVec(prometheus.CounterOpts{
|
|
Namespace: "gitpod",
|
|
Subsystem: "ws_manager_mk2",
|
|
Name: "workspace_starts_total",
|
|
Help: "total number of workspaces started",
|
|
}, []string{"type", "class"}),
|
|
}
|
|
}
|
|
|
|
func (m *workspaceMetrics) recordWorkspaceStart(ws *workspacev1.Workspace) {
|
|
tpe := string(ws.Spec.Type)
|
|
class := ws.Spec.Class
|
|
|
|
counter, err := m.totalStartsCounterVec.GetMetricWithLabelValues(tpe, class)
|
|
if err != nil {
|
|
log.WithError(err).WithField("type", tpe).WithField("class", class)
|
|
}
|
|
counter.Inc()
|
|
}
|
|
|
|
// Describe implements Collector. It will send exactly one Desc to the provided channel.
|
|
func (m *workspaceMetrics) Describe(ch chan<- *prometheus.Desc) {
|
|
m.totalStartsCounterVec.Describe(ch)
|
|
}
|
|
|
|
// Collect implements Collector.
|
|
func (m *workspaceMetrics) Collect(ch chan<- prometheus.Metric) {
|
|
m.totalStartsCounterVec.Collect(ch)
|
|
}
|