Gero Posmyk-Leinemann 26f7f5d742
Add more initializer-related info to /insights API (#20572)
* [ws-manager, ws-daemon] Store initializer metrics in workspace.Status.InitializerMetrics

Tool: gitpod/catfood.gitpod.cloud

* [ws-mananger-api, -mk2] Emit new field .Status.InitializerMetrics

Tool: gitpod/catfood.gitpod.cloud

* [db] Introduce DBWorkspaceInstanceMetrics and persist all metrics from ws-manager-api into it

Tool: gitpod/catfood.gitpod.cloud

* [api] Expose session.Metrics.InitializerMetrics

Tool: gitpod/catfood.gitpod.cloud

* [dashboard] Export metrics into CSV

Tool: gitpod/catfood.gitpod.cloud

* [content-service] Fix: emit fromBackup stats

Tool: gitpod/catfood.gitpod.cloud

* Update components/ws-manager-api/core.proto

Co-authored-by: Filip Troníček <filip@gitpod.io>

---------

Co-authored-by: Filip Troníček <filip@gitpod.io>
2025-02-26 14:34:12 -05:00

1542 lines
48 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"
"crypto/sha256"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
validation "github.com/go-ozzo/ozzo-validation"
"github.com/opentracing/opentracing-go"
"github.com/prometheus/client_golang/prometheus"
"github.com/sirupsen/logrus"
"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/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
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"
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"
csapi "github.com/gitpod-io/gitpod/content-service/api"
"github.com/gitpod-io/gitpod/ws-manager-mk2/pkg/activity"
"github.com/gitpod-io/gitpod/ws-manager-mk2/pkg/constants"
"github.com/gitpod-io/gitpod/ws-manager-mk2/pkg/maintenance"
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"
)
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 possible
stopWorkspaceImmediatelyGracePeriod = 1 * time.Second
)
var (
// retryParams are custom backoff parameters used to modify a workspace.
// These params retry more quickly than the default retry.DefaultBackoff.
retryParams = wait.Backoff{
Steps: 10,
Duration: 10 * time.Millisecond,
Factor: 2.0,
Jitter: 0.2,
}
)
func NewWorkspaceManagerServer(clnt client.Client, cfg *config.Configuration, reg prometheus.Registerer, maintenance maintenance.Maintenance) *WorkspaceManagerServer {
metrics := newWorkspaceMetrics(cfg.Namespace, clnt)
reg.MustRegister(metrics)
return &WorkspaceManagerServer{
Client: clnt,
Config: cfg,
metrics: metrics,
maintenance: maintenance,
subs: subscriptions{
subscribers: make(map[string]chan *wsmanapi.SubscribeResponse),
},
}
}
type WorkspaceManagerServer struct {
Client client.Client
Config *config.Configuration
metrics *workspaceMetrics
maintenance maintenance.Maintenance
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: wsm.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 wsm.maintenance.IsEnabled(ctx) {
return &wsmanapi.StartWorkspaceResponse{}, status.Error(codes.FailedPrecondition, "under maintenance")
}
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)
}
var git *workspacev1.GitSpec
if req.Spec.Git != nil {
git = &workspacev1.GitSpec{
Username: req.Spec.Git.Username,
Email: req.Spec.Git.Email,
}
}
timeout, err := parseTimeout(req.Spec.Timeout)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
closedTimeout, err := parseTimeout(req.Spec.ClosedTimeout)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
maximumLifetime, err := parseTimeout(req.Spec.MaximumLifetime)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
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 == wsmanapi.PortVisibility_PORT_VISIBILITY_PUBLIC {
v = workspacev1.AdmissionLevelEveryone
}
protocol := workspacev1.PortProtocolHttp
if p.Protocol == wsmanapi.PortProtocol_PORT_PROTOCOL_HTTPS {
protocol = workspacev1.PortProtocolHttps
}
ports = append(ports, workspacev1.PortSpec{
Port: p.Port,
Visibility: v,
Protocol: protocol,
})
}
var classID string
_, ok := wsm.Config.WorkspaceClasses[req.Spec.Class]
if !ok {
classID = config.DefaultWorkspaceClass
} else {
classID = req.Spec.Class
}
class, ok := wsm.Config.WorkspaceClasses[classID]
if !ok {
return nil, status.Errorf(codes.InvalidArgument, "workspace class \"%s\" is unknown", req.Spec.Class)
}
storage, err := class.Container.Limits.StorageQuantity()
if err != nil {
msg := fmt.Sprintf("workspace class %s has invalid storage quantity: %v", class.Name, err)
return nil, status.Errorf(codes.InvalidArgument, msg)
}
annotations := make(map[string]string)
for k, v := range req.Metadata.Annotations {
annotations[k] = v
}
limits := class.Container.Limits
if limits != nil && limits.CPU != nil {
if limits.CPU.MinLimit != "" {
annotations[wsk8s.WorkspaceCpuMinLimitAnnotation] = limits.CPU.MinLimit
}
if limits.CPU.BurstLimit != "" {
annotations[wsk8s.WorkspaceCpuBurstLimitAnnotation] = limits.CPU.BurstLimit
}
}
var sshGatewayCAPublicKey string
for _, feature := range req.Spec.FeatureFlags {
switch feature {
case wsmanapi.WorkspaceFeatureFlag_WORKSPACE_CONNECTION_LIMITING:
annotations[wsk8s.WorkspaceNetConnLimitAnnotation] = util.BooleanTrueString
case wsmanapi.WorkspaceFeatureFlag_WORKSPACE_PSI:
annotations[wsk8s.WorkspacePressureStallInfoAnnotation] = util.BooleanTrueString
case wsmanapi.WorkspaceFeatureFlag_SSH_CA:
sshGatewayCAPublicKey = wsm.Config.SSHGatewayCAPublicKey
}
}
envSecretName := fmt.Sprintf("%s-%s", req.Id, "env")
userEnvVars, envData := extractWorkspaceUserEnv(envSecretName, req.Spec.Envvars, req.Spec.SysEnvvars)
sysEnvVars := extractWorkspaceSysEnv(req.Spec.SysEnvvars)
tokenData := extractWorkspaceTokenData(req.Spec)
initializer, err := proto.Marshal(req.Spec.Initializer)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "cannot serialise content initializer: %v", err)
}
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,
wsk8s.WorkspaceManagedByLabel: constants.ManagedBy,
},
},
Spec: workspacev1.WorkspaceSpec{
Ownership: workspacev1.Ownership{
Owner: req.Metadata.Owner,
WorkspaceID: req.Metadata.MetaId,
},
Type: workspaceType,
Class: classID,
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,
ClosedTimeout: closedTimeout,
MaximumLifetime: maximumLifetime,
},
Admission: workspacev1.AdmissionSpec{
Level: admissionLevel,
},
Ports: ports,
SshPublicKeys: req.Spec.SshPublicKeys,
StorageQuota: int(storage.Value()),
SSHGatewayCAPublicKey: sshGatewayCAPublicKey,
},
}
controllerutil.AddFinalizer(&ws, workspacev1.GitpodFinalizerName)
exists, err := wsm.workspaceExists(ctx, req.Metadata.MetaId)
if err != nil {
return nil, fmt.Errorf("cannot check if workspace %s exists: %w", req.Metadata.MetaId, err)
}
if exists {
return nil, status.Errorf(codes.AlreadyExists, "workspace %s already exists", req.Metadata.MetaId)
}
err = wsm.createWorkspaceSecret(ctx, &ws, envSecretName, wsm.Config.Namespace, envData)
if err != nil {
return nil, fmt.Errorf("cannot create env secret for workspace %s: %w", req.Id, err)
}
err = wsm.createWorkspaceSecret(ctx, &ws, fmt.Sprintf("%s-%s", req.Id, "tokens"), wsm.Config.SecretsNamespace, tokenData)
if err != nil {
return nil, fmt.Errorf("cannot create token secret for workspace %s: %w", req.Id, err)
}
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, 30*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: %q", err)
}
return &wsmanapi.StartWorkspaceResponse{
Url: wsr.Status.URL,
OwnerToken: wsr.Status.OwnerToken,
}, nil
}
func (wsm *WorkspaceManagerServer) workspaceExists(ctx context.Context, id string) (bool, error) {
var workspaces workspacev1.WorkspaceList
err := wsm.Client.List(ctx, &workspaces, client.MatchingLabels{wsk8s.WorkspaceIDLabel: id})
if err != nil {
return false, err
}
for _, ws := range workspaces.Items {
if ws.Status.Phase != workspacev1.WorkspacePhaseStopped {
return true, nil
}
}
return false, nil
}
func isProtectedEnvVar(name string, sysEnvvars []*wsmanapi.EnvironmentVariable) bool {
switch name {
case "THEIA_SUPERVISOR_TOKENS":
return true
default:
if isGitpodInternalEnvVar(name) {
return false
}
for _, env := range sysEnvvars {
if env.Name == name {
return false
}
}
return true
}
}
func isGitpodInternalEnvVar(name string) bool {
return strings.HasPrefix(name, "GITPOD_") ||
strings.HasPrefix(name, "SUPERVISOR_") ||
strings.HasPrefix(name, "BOB_") ||
strings.HasPrefix(name, "THEIA_") ||
name == "NODE_EXTRA_CA_CERTS" ||
name == "VSX_REGISTRY_URL"
}
func (wsm *WorkspaceManagerServer) createWorkspaceSecret(ctx context.Context, owner client.Object, name, namespace string, data map[string]string) error {
secret := corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: namespace,
},
StringData: data,
}
err := wsm.Client.Create(ctx, &secret)
if err != nil && !errors.IsAlreadyExists(err) {
return err
}
return 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)
if wsm.maintenance.IsEnabled(ctx) {
return &wsmanapi.StopWorkspaceResponse{}, status.Error(codes.FailedPrecondition, "under maintenance")
}
gracePeriod := stopWorkspaceNormallyGracePeriod
if req.Policy == wsmanapi.StopWorkspacePolicy_IMMEDIATELY {
span.LogKV("policy", "immediately")
gracePeriod = stopWorkspaceImmediatelyGracePeriod
} else if req.Policy == wsmanapi.StopWorkspacePolicy_ABORT {
span.LogKV("policy", "abort")
gracePeriod = stopWorkspaceImmediatelyGracePeriod
if err = wsm.modifyWorkspace(ctx, req.Id, true, func(ws *workspacev1.Workspace) error {
ws.Status.SetCondition(workspacev1.NewWorkspaceConditionAborted("StopWorkspaceRequest"))
return nil
}); err != nil {
log.WithError(err).WithFields(owi).Error("failed to add Aborted condition to workspace")
}
}
err = wsm.modifyWorkspace(ctx, req.Id, true, func(ws *workspacev1.Workspace) error {
ws.Status.SetCondition(workspacev1.NewWorkspaceConditionStoppedByRequest(gracePeriod.String()))
return nil
})
// Ignore NotFound errors, workspace has already been stopped.
if err != nil && status.Code(err) != codes.NotFound {
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, wsm.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: wsm.extractWorkspaceStatus(&ws),
}
lastActivity := activity.Last(&ws)
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 *wsmanapi.SubscribeRequest, srv wsmanapi.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) {
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 &wsmanapi.MarkActiveResponse{}, nil
}
now := time.Now().UTC()
lastActivityStatus := metav1.NewTime(now)
ws.Status.LastActivity = &lastActivityStatus
err = wsm.modifyWorkspace(ctx, req.Id, true, func(ws *workspacev1.Workspace) error {
ws.Status.LastActivity = &lastActivityStatus
return nil
})
if err != nil {
log.WithError(err).WithFields(log.OWI("", "", workspaceID)).Warn("was unable to update status")
}
// We do however maintain the "closed" flag as condition on the workspace. This flag should not change
// very often and provides a better UX if it persists across ws-manager restarts.
isMarkedClosed := ws.IsConditionTrue(workspacev1.WorkspaceConditionClosed)
if req.Closed && !isMarkedClosed {
err = wsm.modifyWorkspace(ctx, req.Id, true, func(ws *workspacev1.Workspace) error {
ws.Status.SetCondition(workspacev1.NewWorkspaceConditionClosed(metav1.ConditionTrue, "MarkActiveRequest"))
return nil
})
} else if !req.Closed && isMarkedClosed {
err = wsm.modifyWorkspace(ctx, req.Id, true, func(ws *workspacev1.Workspace) error {
ws.Status.SetCondition(workspacev1.NewWorkspaceConditionClosed(metav1.ConditionFalse, "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.SetCondition(workspacev1.NewWorkspaceConditionFirstUserActivity("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 &wsmanapi.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)
}
if req.Type == wsmanapi.TimeoutType_WORKSPACE_TIMEOUT {
err = wsm.modifyWorkspace(ctx, req.Id, false, func(ws *workspacev1.Workspace) error {
ws.Spec.Timeout.Time = &metav1.Duration{Duration: duration}
ws.Spec.Timeout.ClosedTimeout = &metav1.Duration{Duration: time.Duration(0)}
return nil
})
} else if req.Type == wsmanapi.TimeoutType_CLOSED_TIMEOUT {
err = wsm.modifyWorkspace(ctx, req.Id, false, func(ws *workspacev1.Workspace) error {
ws.Spec.Timeout.ClosedTimeout = &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) (res *wsmanapi.ControlPortResponse, err error) {
span, ctx := tracing.FromContext(ctx, "ControlPort")
tracing.ApplyOWI(span, log.OWI("", "", req.Id))
defer tracing.FinishSpan(span, &err)
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
protocol := workspacev1.PortProtocolHttp
if req.Spec.Visibility == wsmanapi.PortVisibility_PORT_VISIBILITY_PUBLIC {
visibility = workspacev1.AdmissionLevelEveryone
}
if req.Spec.Protocol == wsmanapi.PortProtocol_PORT_PROTOCOL_HTTPS {
protocol = workspacev1.PortProtocolHttps
}
ws.Spec.Ports = append(ws.Spec.Ports, workspacev1.PortSpec{
Port: port,
Visibility: visibility,
Protocol: protocol,
})
}
return nil
})
if err != nil {
return nil, err
}
return &wsmanapi.ControlPortResponse{}, nil
}
func (wsm *WorkspaceManagerServer) TakeSnapshot(ctx context.Context, req *wsmanapi.TakeSnapshotRequest) (res *wsmanapi.TakeSnapshotResponse, err error) {
span, ctx := tracing.FromContext(ctx, "TakeSnapshot")
tracing.ApplyOWI(span, log.OWI("", "", req.Id))
defer tracing.FinishSpan(span, &err)
if wsm.maintenance.IsEnabled(ctx) {
return &wsmanapi.TakeSnapshotResponse{}, status.Error(codes.FailedPrecondition, "under maintenance")
}
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)
}
if ws.Status.Phase != workspacev1.WorkspacePhaseRunning {
return nil, status.Errorf(codes.FailedPrecondition, "snapshots can only be taken of running workspaces, not %s workspaces", ws.Status.Phase)
}
snapshot := workspacev1.Snapshot{
TypeMeta: metav1.TypeMeta{
APIVersion: workspacev1.GroupVersion.String(),
Kind: "Snapshot",
},
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-%d", req.Id, time.Now().UnixNano()),
Namespace: wsm.Config.Namespace,
},
Spec: workspacev1.SnapshotSpec{
NodeName: ws.Status.Runtime.NodeName,
WorkspaceID: ws.Name,
},
}
err = controllerutil.SetOwnerReference(&ws, &snapshot, wsm.Client.Scheme())
if err != nil {
return nil, status.Errorf(codes.Internal, "cannot set owner for snapshot: %q", err)
}
err = wsm.Client.Create(ctx, &snapshot)
if err != nil {
return nil, status.Errorf(codes.Internal, "cannot create snapshot object: %q", err)
}
var sso workspacev1.Snapshot
err = wait.PollWithContext(ctx, 100*time.Millisecond, 10*time.Second, func(c context.Context) (done bool, err error) {
err = wsm.Client.Get(ctx, types.NamespacedName{Namespace: wsm.Config.Namespace, Name: snapshot.Name}, &sso)
if err != nil {
return false, err
}
if sso.Status.Error != "" {
return true, fmt.Errorf(sso.Status.Error)
}
if sso.Status.URL != "" {
return true, nil
}
return false, nil
})
if err != nil {
return nil, status.Errorf(codes.Internal, "cannot wait for snapshot URL: %v", err)
}
if !req.ReturnImmediately {
err = wait.PollWithContext(ctx, 100*time.Millisecond, 0, func(c context.Context) (done bool, err error) {
err = wsm.Client.Get(ctx, types.NamespacedName{Namespace: wsm.Config.Namespace, Name: ws.Name}, &sso)
if err != nil {
return false, nil
}
if sso.Status.Completed {
return true, nil
}
return false, nil
})
if err != nil {
return nil, status.Errorf(codes.Internal, "cannot wait for snapshot: %q", err)
}
if sso.Status.Error != "" {
return nil, status.Errorf(codes.Internal, "cannot take snapshot: %q", sso.Status.Error)
}
}
return &wsmanapi.TakeSnapshotResponse{
Url: sso.Status.URL,
}, nil
}
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
}
func (wsm *WorkspaceManagerServer) UpdateSSHKey(ctx context.Context, req *wsmanapi.UpdateSSHKeyRequest) (res *wsmanapi.UpdateSSHKeyResponse, err error) {
span, ctx := tracing.FromContext(ctx, "UpdateSSHKey")
tracing.ApplyOWI(span, log.OWI("", "", req.Id))
defer tracing.FinishSpan(span, &err)
if err = validateUpdateSSHKeyRequest(req); err != nil {
return &wsmanapi.UpdateSSHKeyResponse{}, err
}
err = wsm.modifyWorkspace(ctx, req.Id, false, func(ws *workspacev1.Workspace) error {
ws.Spec.SshPublicKeys = req.Keys
return nil
})
return &wsmanapi.UpdateSSHKeyResponse{}, err
}
func (wsm *WorkspaceManagerServer) DescribeCluster(ctx context.Context, req *wsmanapi.DescribeClusterRequest) (res *wsmanapi.DescribeClusterResponse, err error) {
//nolint:ineffassign
span, ctx := tracing.FromContext(ctx, "DescribeCluster")
defer tracing.FinishSpan(span, &err)
classes := make([]*wsmanapi.WorkspaceClass, 0, len(wsm.Config.WorkspaceClasses))
for id, class := range wsm.Config.WorkspaceClasses {
var cpu, ram, disk resource.Quantity
desc := class.Description
if desc == "" {
if class.Container.Limits != nil {
cpu, _ = resource.ParseQuantity(class.Container.Limits.CPU.BurstLimit)
ram, _ = resource.ParseQuantity(class.Container.Limits.Memory)
disk, _ = resource.ParseQuantity(class.Container.Limits.Storage)
}
if cpu.Value() == 0 && class.Container.Requests != nil {
cpu, _ = resource.ParseQuantity(class.Container.Requests.CPU)
}
if ram.Value() == 0 && class.Container.Requests != nil {
ram, _ = resource.ParseQuantity(class.Container.Requests.Memory)
}
desc = fmt.Sprintf("%d vCPU, %dGB memory, %dGB disk", cpu.Value(), ram.ScaledValue(resource.Giga), disk.ScaledValue(resource.Giga))
}
classes = append(classes, &wsmanapi.WorkspaceClass{
Id: id,
DisplayName: class.Name,
Description: desc,
CreditsPerMinute: class.CreditsPerMinute,
})
}
sort.Slice(classes, func(i, j int) bool {
return classes[i].Id < classes[j].Id
})
return &wsmanapi.DescribeClusterResponse{
WorkspaceClasses: classes,
PreferredWorkspaceClass: wsm.Config.PreferredWorkspaceClass,
}, 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) (err error) {
span, ctx := tracing.FromContext(ctx, "modifyWorkspace")
tracing.ApplyOWI(span, log.OWI("", "", id))
defer tracing.FinishSpan(span, &err)
err = retry.RetryOnConflict(retryParams, func() (err error) {
span, ctx := tracing.FromContext(ctx, "modifyWorkspaceRetryFn")
defer tracing.FinishSpan(span, &err)
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 *wsmanapi.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 == wsmanapi.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 validateUpdateSSHKeyRequest(req *wsmanapi.UpdateSSHKeyRequest) error {
err := validation.ValidateStruct(req,
validation.Field(&req.Id, validation.Required),
validation.Field(&req.Keys, validation.Required),
)
if err != nil {
return status.Errorf(codes.InvalidArgument, "invalid request: %v", err)
}
return nil
}
func isValidWorkspaceType(value interface{}) error {
s, ok := value.(wsmanapi.WorkspaceType)
if !ok {
return xerrors.Errorf("value is not a workspace type")
}
_, ok = wsmanapi.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.([]*wsmanapi.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.([]wsmanapi.WorkspaceFeatureFlag)
if !ok {
return xerrors.Errorf("value not a feature flag list")
}
idx := make(map[wsmanapi.WorkspaceFeatureFlag]struct{}, len(s))
for _, k := range s {
idx[k] = struct{}{}
}
return nil
}
func extractWorkspaceUserEnv(secretName string, userEnvs, sysEnvs []*wsmanapi.EnvironmentVariable) ([]corev1.EnvVar, map[string]string) {
envVars := make([]corev1.EnvVar, 0, len(userEnvs))
secrets := make(map[string]string)
for _, e := range userEnvs {
switch {
case e.Secret != nil:
securedEnv := corev1.EnvVar{
Name: e.Name,
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: e.Secret.SecretName},
Key: e.Secret.Key,
},
},
}
envVars = append(envVars, securedEnv)
case e.Value == "":
continue
case !isProtectedEnvVar(e.Name, sysEnvs):
unprotectedEnv := corev1.EnvVar{
Name: e.Name,
Value: e.Value,
}
envVars = append(envVars, unprotectedEnv)
default:
name := fmt.Sprintf("%x", sha256.Sum256([]byte(e.Name)))
protectedEnv := corev1.EnvVar{
Name: e.Name,
ValueFrom: &corev1.EnvVarSource{
SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
Key: name,
},
},
}
envVars = append(envVars, protectedEnv)
secrets[name] = e.Value
}
}
return envVars, secrets
}
func extractWorkspaceSysEnv(sysEnvs []*wsmanapi.EnvironmentVariable) []corev1.EnvVar {
envs := make([]corev1.EnvVar, 0, len(sysEnvs))
for _, e := range sysEnvs {
envs = append(envs, corev1.EnvVar{
Name: e.Name,
Value: e.Value,
})
}
return envs
}
func extractWorkspaceTokenData(spec *wsmanapi.StartWorkspaceSpec) map[string]string {
secrets := make(map[string]string)
for k, v := range csapi.ExtractAndReplaceSecretsFromInitializer(spec.Initializer) {
secrets[k] = v
}
return secrets
}
func (wsm *WorkspaceManagerServer) extractWorkspaceStatus(ws *workspacev1.Workspace) *wsmanapi.WorkspaceStatus {
log := log.WithFields(log.OWI(ws.Spec.Ownership.Owner, ws.Spec.Ownership.WorkspaceID, ws.Name))
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
}
timeout := wsm.Config.Timeouts.RegularWorkspace.String()
if ws.Spec.Timeout.Time != nil {
timeout = ws.Spec.Timeout.Time.Duration.String()
}
closedTimeout := wsm.Config.Timeouts.AfterClose.String()
if ws.Spec.Timeout.ClosedTimeout != nil {
closedTimeout = ws.Spec.Timeout.ClosedTimeout.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
}
ports := make([]*wsmanapi.PortSpec, 0, len(ws.Spec.Ports))
for _, p := range ws.Spec.Ports {
v := wsmanapi.PortVisibility_PORT_VISIBILITY_PRIVATE
if p.Visibility == workspacev1.AdmissionLevelEveryone {
v = wsmanapi.PortVisibility_PORT_VISIBILITY_PUBLIC
}
protocol := wsmanapi.PortProtocol_PORT_PROTOCOL_HTTP
if p.Protocol == workspacev1.PortProtocolHttps {
protocol = wsmanapi.PortProtocol_PORT_PROTOCOL_HTTPS
}
url, err := config.RenderWorkspacePortURL(wsm.Config.WorkspacePortURLTemplate, config.PortURLContext{
Host: wsm.Config.GitpodHostURL,
ID: ws.Name,
IngressPort: fmt.Sprint(p.Port),
Prefix: ws.Spec.Ownership.WorkspaceID,
WorkspacePort: fmt.Sprint(p.Port),
})
if err != nil {
log.WithError(err).WithField("port", p.Port).Error("cannot render public URL for port, excluding the port from the workspace status")
continue
}
ports = append(ports, &wsmanapi.PortSpec{
Port: p.Port,
Visibility: v,
Url: url,
Protocol: protocol,
})
}
var metrics *wsmanapi.WorkspaceMetadata_Metrics
if ws.Status.ImageInfo != nil {
metrics = &wsmanapi.WorkspaceMetadata_Metrics{
Image: &wsmanapi.WorkspaceMetadata_ImageInfo{
TotalSize: ws.Status.ImageInfo.TotalSize,
WorkspaceImageSize: ws.Status.ImageInfo.WorkspaceImageSize,
},
}
}
var initializerMetrics *wsmanapi.InitializerMetrics
if ws.Status.InitializerMetrics != nil {
initializerMetrics = mapInitializerMetrics(ws.Status.InitializerMetrics)
}
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,
Metrics: metrics,
},
Spec: &wsmanapi.WorkspaceSpec{
Class: ws.Spec.Class,
ExposedPorts: ports,
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.IsHeadless(),
Url: ws.Status.URL,
Type: tpe,
Timeout: timeout,
ClosedTimeout: closedTimeout,
},
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,
},
Repo: convertGitStatus(ws.Status.GitStatus),
InitializerMetrics: initializerMetrics,
}
return res
}
func mapInitializerMetrics(in *workspacev1.InitializerMetrics) *wsmanapi.InitializerMetrics {
result := &wsmanapi.InitializerMetrics{}
// Convert Git metrics
if in.Git != nil {
result.Git = &wsmanapi.InitializerMetric{
Duration: durationToProto(in.Git.Duration),
Size: uint64(in.Git.Size),
}
}
// Convert FileDownload metrics
if in.FileDownload != nil {
result.FileDownload = &wsmanapi.InitializerMetric{
Duration: durationToProto(in.FileDownload.Duration),
Size: uint64(in.FileDownload.Size),
}
}
// Convert Snapshot metrics
if in.Snapshot != nil {
result.Snapshot = &wsmanapi.InitializerMetric{
Duration: durationToProto(in.Snapshot.Duration),
Size: uint64(in.Snapshot.Size),
}
}
// Convert Backup metrics
if in.Backup != nil {
result.Backup = &wsmanapi.InitializerMetric{
Duration: durationToProto(in.Backup.Duration),
Size: uint64(in.Backup.Size),
}
}
// Convert Prebuild metrics
if in.Prebuild != nil {
result.Prebuild = &wsmanapi.InitializerMetric{
Duration: durationToProto(in.Prebuild.Duration),
Size: uint64(in.Prebuild.Size),
}
}
// Convert Composite metrics
if in.Composite != nil {
result.Composite = &wsmanapi.InitializerMetric{
Duration: durationToProto(in.Composite.Duration),
Size: uint64(in.Composite.Size),
}
}
return result
}
func durationToProto(d *metav1.Duration) *durationpb.Duration {
if d == nil {
return nil
}
return durationpb.New(d.Duration)
}
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 convertGitStatus(gs *workspacev1.GitStatus) *csapi.GitStatus {
if gs == nil {
return nil
}
return &csapi.GitStatus{
Branch: gs.Branch,
LatestCommit: gs.LatestCommit,
UncommitedFiles: gs.UncommitedFiles,
TotalUncommitedFiles: gs.TotalUncommitedFiles,
UntrackedFiles: gs.UntrackedFiles,
TotalUntrackedFiles: gs.TotalUntrackedFiles,
UnpushedCommits: gs.UnpushedCommits,
TotalUnpushedCommits: gs.TotalUnpushedCommits,
}
}
func convertCondition(conds []metav1.Condition, tpe string) wsmanapi.WorkspaceConditionBool {
res := wsk8s.GetCondition(conds, tpe)
if res == nil {
return wsmanapi.WorkspaceConditionBool_FALSE
}
switch res.Status {
case metav1.ConditionTrue:
return wsmanapi.WorkspaceConditionBool_TRUE
default:
return wsmanapi.WorkspaceConditionBool_FALSE
}
}
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
}
func parseTimeout(timeout string) (*metav1.Duration, error) {
var duration *metav1.Duration
if timeout != "" {
d, err := time.ParseDuration(timeout)
if err != nil {
return nil, fmt.Errorf("invalid timeout: %v", err)
}
duration = &metav1.Duration{Duration: d}
}
return duration, nil
}
type filteringSubscriber struct {
Sub subscriber
Filter *wsmanapi.MetadataFilter
}
func matchesMetadataFilter(filter *wsmanapi.MetadataFilter, md *wsmanapi.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 *wsmanapi.SubscribeResponse) error {
var md *wsmanapi.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(*wsmanapi.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 *wsmanapi.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 *wsmanapi.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 *wsmanapi.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 *wsmanapi.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, &wsmanapi.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(namespace string, k8s client.Client) *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)
}