Path: blob/main/components/ws-daemon/pkg/controller/workspace_controller.go
2500 views
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License-AGPL.txt in the project root for license information.34package controller56import (7"context"8"fmt"9"time"1011wsk8s "github.com/gitpod-io/gitpod/common-go/kubernetes"12glog "github.com/gitpod-io/gitpod/common-go/log"13"github.com/gitpod-io/gitpod/common-go/tracing"14csapi "github.com/gitpod-io/gitpod/content-service/api"15"github.com/gitpod-io/gitpod/content-service/pkg/storage"16"github.com/gitpod-io/gitpod/ws-daemon/pkg/container"17"github.com/gitpod-io/gitpod/ws-daemon/pkg/content"18"github.com/gitpod-io/gitpod/ws-daemon/pkg/iws"19workspacev1 "github.com/gitpod-io/gitpod/ws-manager/api/crd/v1"20"github.com/opentracing/opentracing-go"21"github.com/prometheus/client_golang/prometheus"22"github.com/sirupsen/logrus"2324"google.golang.org/protobuf/proto"25corev1 "k8s.io/api/core/v1"26"k8s.io/apimachinery/pkg/api/errors"27metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"28"k8s.io/apimachinery/pkg/types"29"k8s.io/apimachinery/pkg/util/wait"30"k8s.io/client-go/tools/record"31"k8s.io/client-go/util/retry"32ctrl "sigs.k8s.io/controller-runtime"33"sigs.k8s.io/controller-runtime/pkg/client"34"sigs.k8s.io/controller-runtime/pkg/controller"35"sigs.k8s.io/controller-runtime/pkg/event"36"sigs.k8s.io/controller-runtime/pkg/log"37"sigs.k8s.io/controller-runtime/pkg/predicate"38)3940var retryParams = wait.Backoff{41Steps: 10,42Duration: 10 * time.Millisecond,43Factor: 2.0,44Jitter: 0.2,45}4647type WorkspaceControllerOpts struct {48NodeName string49ContentConfig content.Config50UIDMapperConfig iws.UidmapperConfig51ContainerRuntime container.Runtime52CGroupMountPoint string53MetricsRegistry prometheus.Registerer54}5556type WorkspaceController struct {57client.Client58NodeName string59maxConcurrentReconciles int60operations WorkspaceOperations61metrics *workspaceMetrics62secretNamespace string63recorder record.EventRecorder64runtime container.Runtime65}6667func NewWorkspaceController(c client.Client, recorder record.EventRecorder, nodeName, secretNamespace string, maxConcurrentReconciles int, ops WorkspaceOperations, reg prometheus.Registerer, runtime container.Runtime) (*WorkspaceController, error) {68metrics := newWorkspaceMetrics()69reg.Register(metrics)7071return &WorkspaceController{72Client: c,73NodeName: nodeName,74maxConcurrentReconciles: maxConcurrentReconciles,75operations: ops,76metrics: metrics,77secretNamespace: secretNamespace,78recorder: recorder,79runtime: runtime,80}, nil81}8283// SetupWithManager sets up the controller with the Manager.84func (wsc *WorkspaceController) SetupWithManager(mgr ctrl.Manager) error {85return ctrl.NewControllerManagedBy(mgr).86Named("workspace").87WithOptions(controller.Options{88MaxConcurrentReconciles: wsc.maxConcurrentReconciles,89}).90For(&workspacev1.Workspace{}).91WithEventFilter(eventFilter(wsc.NodeName)).92Complete(wsc)93}9495func eventFilter(nodeName string) predicate.Predicate {96return predicate.Funcs{97CreateFunc: func(e event.CreateEvent) bool {98return workspaceFilter(e.Object, nodeName)99},100101UpdateFunc: func(e event.UpdateEvent) bool {102return workspaceFilter(e.ObjectNew, nodeName)103},104DeleteFunc: func(e event.DeleteEvent) bool {105return false106},107}108}109110func workspaceFilter(object client.Object, nodeName string) bool {111if ws, ok := object.(*workspacev1.Workspace); ok {112return ws.Status.Runtime != nil && ws.Status.Runtime.NodeName == nodeName113}114return false115}116117func (wsc *WorkspaceController) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) {118span, ctx := opentracing.StartSpanFromContext(ctx, "Reconcile")119defer tracing.FinishSpan(span, &err)120121var workspace workspacev1.Workspace122if err := wsc.Get(ctx, req.NamespacedName, &workspace); err != nil {123// ignore not-found errors, since they can't be fixed by an immediate124// requeue (we'll need to wait for a new notification).125return ctrl.Result{}, client.IgnoreNotFound(err)126}127128if workspace.Status.Phase == workspacev1.WorkspacePhaseCreating ||129workspace.Status.Phase == workspacev1.WorkspacePhaseInitializing {130131result, err = wsc.handleWorkspaceInit(ctx, &workspace, req)132return result, err133}134135if workspace.Status.Phase == workspacev1.WorkspacePhaseRunning {136result, err = wsc.handleWorkspaceRunning(ctx, &workspace, req)137return result, err138}139140if workspace.Status.Phase == workspacev1.WorkspacePhaseStopping {141result, err = wsc.handleWorkspaceStop(ctx, &workspace, req)142return result, err143}144145return ctrl.Result{}, nil146}147148// latestWorkspace checks if the we have the latest generation of the workspace CR. We do this because149// the cache could be stale and we retrieve a workspace CR that does not have the content init/backup150// conditions even though we have set them previously. This will lead to us performing these operations151// again. To prevent this we wait until we have the latest workspace CR.152func (wsc *WorkspaceController) latestWorkspace(ctx context.Context, ws *workspacev1.Workspace) error {153ws.Status.SetCondition(workspacev1.NewWorkspaceConditionRefresh())154155err := wsc.Client.Status().Update(ctx, ws)156if err != nil && !errors.IsConflict(err) {157glog.WithFields(ws.OWI()).Warnf("could not refresh workspace: %v", err)158}159160return err161}162163func (wsc *WorkspaceController) handleWorkspaceInit(ctx context.Context, ws *workspacev1.Workspace, req ctrl.Request) (result ctrl.Result, err error) {164log := log.FromContext(ctx)165span, ctx := opentracing.StartSpanFromContext(ctx, "handleWorkspaceInit")166defer tracing.FinishSpan(span, &err)167168if c := wsk8s.GetCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionContentReady)); c == nil {169if wsc.latestWorkspace(ctx, ws) != nil {170return ctrl.Result{Requeue: true, RequeueAfter: 100 * time.Millisecond}, nil171}172173glog.WithFields(ws.OWI()).WithField("workspace", req.NamespacedName).WithField("phase", ws.Status.Phase).Info("handle workspace init")174175init, err := wsc.prepareInitializer(ctx, ws)176if err != nil {177return ctrl.Result{}, fmt.Errorf("failed to prepare initializer: %w", err)178}179180initStart := time.Now()181stats, failure, initErr := wsc.operations.InitWorkspace(ctx, InitOptions{182Meta: WorkspaceMeta{183Owner: ws.Spec.Ownership.Owner,184WorkspaceID: ws.Spec.Ownership.WorkspaceID,185InstanceID: ws.Name,186},187Initializer: init,188Headless: ws.IsHeadless(),189StorageQuota: ws.Spec.StorageQuota,190})191192initMetrics := initializerMetricsFromInitializerStats(stats)193err = retry.RetryOnConflict(retryParams, func() error {194if err := wsc.Get(ctx, req.NamespacedName, ws); err != nil {195return err196}197198// persist init failure/success199if failure != "" {200log.Error(initErr, "could not initialize workspace", "name", ws.Name)201ws.Status.SetCondition(workspacev1.NewWorkspaceConditionContentReady(metav1.ConditionFalse, workspacev1.ReasonInitializationFailure, failure))202} else {203ws.Status.SetCondition(workspacev1.NewWorkspaceConditionContentReady(metav1.ConditionTrue, workspacev1.ReasonInitializationSuccess, ""))204}205206// persist initializer metrics207if initMetrics != nil {208ws.Status.InitializerMetrics = initMetrics209}210211return wsc.Status().Update(ctx, ws)212})213214if err == nil {215wsc.metrics.recordInitializeTime(time.Since(initStart).Seconds(), ws)216} else {217err = fmt.Errorf("failed to set content ready condition (failure: '%s'): %w", failure, err)218}219220wsc.emitEvent(ws, "Content init", initErr)221return ctrl.Result{}, err222}223224return ctrl.Result{}, nil225}226227func initializerMetricsFromInitializerStats(stats *csapi.InitializerMetrics) *workspacev1.InitializerMetrics {228if stats == nil || len(*stats) == 0 {229return nil230}231232result := workspacev1.InitializerMetrics{}233for _, metric := range *stats {234switch metric.Type {235case "git":236result.Git = &workspacev1.InitializerStepMetric{237Duration: &metav1.Duration{Duration: metric.Duration},238Size: metric.Size,239}240case "fileDownload":241result.FileDownload = &workspacev1.InitializerStepMetric{242Duration: &metav1.Duration{Duration: metric.Duration},243Size: metric.Size,244}245case "snapshot":246result.Snapshot = &workspacev1.InitializerStepMetric{247Duration: &metav1.Duration{Duration: metric.Duration},248Size: metric.Size,249}250case "fromBackup":251result.Backup = &workspacev1.InitializerStepMetric{252Duration: &metav1.Duration{Duration: metric.Duration},253Size: metric.Size,254}255case "composite":256result.Composite = &workspacev1.InitializerStepMetric{257Duration: &metav1.Duration{Duration: metric.Duration},258Size: metric.Size,259}260case "prebuild":261result.Prebuild = &workspacev1.InitializerStepMetric{262Duration: &metav1.Duration{Duration: metric.Duration},263Size: metric.Size,264}265}266}267268return &result269}270271func (wsc *WorkspaceController) handleWorkspaceRunning(ctx context.Context, ws *workspacev1.Workspace, req ctrl.Request) (result ctrl.Result, err error) {272span, ctx := opentracing.StartSpanFromContext(ctx, "handleWorkspaceRunning")273defer tracing.FinishSpan(span, &err)274275var imageInfo *workspacev1.WorkspaceImageInfo = nil276if ws.Status.ImageInfo == nil {277getImageInfo := func() (*workspacev1.WorkspaceImageInfo, error) {278ctx, cancel := context.WithTimeout(ctx, 10*time.Second)279defer cancel()280id, err := wsc.runtime.WaitForContainer(ctx, ws.Name)281if err != nil {282return nil, fmt.Errorf("failed to wait for container: %w", err)283}284info, err := wsc.runtime.GetContainerImageInfo(ctx, id)285if err != nil {286return nil, fmt.Errorf("failed to get container image info: %w", err)287}288289err = retry.RetryOnConflict(retryParams, func() error {290if err := wsc.Get(ctx, req.NamespacedName, ws); err != nil {291return err292}293ws.Status.ImageInfo = info294return wsc.Status().Update(ctx, ws)295})296if err != nil {297return info, fmt.Errorf("failed to update workspace with image info: %w", err)298}299return info, nil300}301imageInfo, err = getImageInfo()302if err != nil {303glog.WithFields(ws.OWI()).WithField("workspace", req.NamespacedName).Errorf("failed to get image info: %v", err)304} else {305glog.WithFields(ws.OWI()).WithField("workspace", req.NamespacedName).WithField("imageInfo", glog.TrustedValueWrap{Value: imageInfo}).Info("updated image info")306}307}308return ctrl.Result{}, wsc.operations.SetupWorkspace(ctx, ws.Name, imageInfo)309}310311func (wsc *WorkspaceController) handleWorkspaceStop(ctx context.Context, ws *workspacev1.Workspace, req ctrl.Request) (result ctrl.Result, err error) {312span, ctx := opentracing.StartSpanFromContext(ctx, "handleWorkspaceStop")313defer tracing.FinishSpan(span, &err)314315if ws.IsConditionTrue(workspacev1.WorkspaceConditionPodRejected) {316// edge case only exercised for rejected workspace pods317if ws.IsConditionPresent(workspacev1.WorkspaceConditionStateWiped) {318// we are done here319return ctrl.Result{}, nil320}321322return wsc.doWipeWorkspace(ctx, ws, req)323}324325// regular case326return wsc.doWorkspaceContentBackup(ctx, span, ws, req)327}328329func (wsc *WorkspaceController) doWipeWorkspace(ctx context.Context, ws *workspacev1.Workspace, req ctrl.Request) (result ctrl.Result, err error) {330log := log.FromContext(ctx)331332// in this case we are not interested in any backups, but instead are concerned with completely wiping all state that might be dangling somewhere333if ws.IsConditionTrue(workspacev1.WorkspaceConditionContainerRunning) {334// Container is still running, we need to wait for it to stop.335// We should get an event when the condition changes, but requeue336// anyways to make sure we act on it in time.337return ctrl.Result{RequeueAfter: 500 * time.Millisecond}, nil338}339340if wsc.latestWorkspace(ctx, ws) != nil {341return ctrl.Result{Requeue: true, RequeueAfter: 100 * time.Millisecond}, nil342}343344setStateWipedCondition := func(success bool) {345err := retry.RetryOnConflict(retryParams, func() error {346if err := wsc.Get(ctx, req.NamespacedName, ws); err != nil {347return err348}349350if success {351ws.Status.SetCondition(workspacev1.NewWorkspaceConditionStateWiped("", metav1.ConditionTrue))352} else {353ws.Status.SetCondition(workspacev1.NewWorkspaceConditionStateWiped("", metav1.ConditionFalse))354}355return wsc.Client.Status().Update(ctx, ws)356})357if err != nil {358log.Error(err, "failed to set StateWiped condition")359}360}361log.Info("handling workspace stop - wiping mode")362defer log.Info("handling workspace stop - wiping done.")363364err = wsc.operations.WipeWorkspace(ctx, ws.Name)365if err != nil {366setStateWipedCondition(false)367wsc.emitEvent(ws, "Wiping", fmt.Errorf("failed to wipe workspace: %w", err))368return ctrl.Result{}, fmt.Errorf("failed to wipe workspace: %w", err)369}370371setStateWipedCondition(true)372373return ctrl.Result{}, nil374}375376func (wsc *WorkspaceController) doWorkspaceContentBackup(ctx context.Context, span opentracing.Span, ws *workspacev1.Workspace, req ctrl.Request) (result ctrl.Result, err error) {377log := log.FromContext(ctx)378379if c := wsk8s.GetCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionContentReady)); c == nil || c.Status == metav1.ConditionFalse {380return ctrl.Result{}, fmt.Errorf("workspace content was never ready")381}382383if ws.IsConditionTrue(workspacev1.WorkspaceConditionBackupComplete) {384return ctrl.Result{}, nil385}386387if ws.IsConditionTrue(workspacev1.WorkspaceConditionBackupFailure) {388return ctrl.Result{}, nil389}390391if ws.IsConditionTrue(workspacev1.WorkspaceConditionAborted) {392span.LogKV("event", "workspace was aborted")393return ctrl.Result{}, nil394}395396if ws.Spec.Type == workspacev1.WorkspaceTypeImageBuild {397// No disposal for image builds.398return ctrl.Result{}, nil399}400401if ws.IsConditionTrue(workspacev1.WorkspaceConditionContainerRunning) {402// Container is still running, we need to wait for it to stop.403// We will wait for this situation for up to 5 minutes.404// If the container is still in a running state after that,405// there may be an issue with state synchronization.406// We should start backup anyway to avoid data loss.407if !(ws.Status.PodStoppingTime != nil && time.Since(ws.Status.PodStoppingTime.Time) > 5*time.Minute) {408// We should get an event when the condition changes, but requeue409// anyways to make sure we act on it in time.410return ctrl.Result{RequeueAfter: 500 * time.Millisecond}, nil411}412413if !ws.IsConditionTrue(workspacev1.WorkspaceConditionForceKilledTask) {414err = wsc.forceKillContainerTask(ctx, ws)415if err != nil {416glog.WithFields(ws.OWI()).WithField("workspace", req.NamespacedName).Errorf("failed to force kill task: %v", err)417}418err = retry.RetryOnConflict(retryParams, func() error {419if err := wsc.Get(ctx, req.NamespacedName, ws); err != nil {420return err421}422ws.Status.SetCondition(workspacev1.NewWorkspaceConditionForceKilledTask())423return wsc.Client.Status().Update(ctx, ws)424})425if err != nil {426return ctrl.Result{}, fmt.Errorf("failed to set force killed task condition: %w", err)427}428return ctrl.Result{Requeue: true, RequeueAfter: 2 * time.Second}, nil429}430431if time.Since(wsk8s.GetCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionForceKilledTask)).LastTransitionTime.Time) < 2*time.Second {432return ctrl.Result{Requeue: true, RequeueAfter: 2 * time.Second}, nil433}434435glog.WithFields(ws.OWI()).WithField("workspace", req.NamespacedName).Warn("workspace container is still running after 5 minutes of deletion, starting backup anyway")436err = wsc.dumpWorkspaceContainerInfo(ctx, ws)437if err != nil {438glog.WithFields(ws.OWI()).WithField("workspace", req.NamespacedName).Errorf("failed to dump container info: %v", err)439}440}441442if wsc.latestWorkspace(ctx, ws) != nil {443return ctrl.Result{Requeue: true, RequeueAfter: 100 * time.Millisecond}, nil444}445446glog.WithFields(ws.OWI()).WithField("workspace", req.NamespacedName).WithField("phase", ws.Status.Phase).Info("handle workspace stop")447448disposeStart := time.Now()449var snapshotName string450var snapshotUrl string451if ws.Spec.Type == workspacev1.WorkspaceTypeRegular {452snapshotName = storage.DefaultBackup453} else {454snapshotUrl, snapshotName, err = wsc.operations.SnapshotIDs(ctx, ws.Name)455if err != nil {456return ctrl.Result{}, fmt.Errorf("failed to get snapshot name and URL: %w", err)457}458459// todo(ft): remove this and only set the snapshot url after the actual backup is done (see L320-322) ENT-319460// ws-manager-bridge expects to receive the snapshot url while the workspace461// is in STOPPING so instead of breaking the assumptions of ws-manager-bridge462// we set the url here and not after the snapshot has been taken as otherwise463// the workspace would already be in STOPPED and ws-manager-bridge would not464// receive the url465err = retry.RetryOnConflict(retryParams, func() error {466if err := wsc.Get(ctx, req.NamespacedName, ws); err != nil {467return err468}469470ws.Status.Snapshot = snapshotUrl471return wsc.Client.Status().Update(ctx, ws)472})473474if err != nil {475return ctrl.Result{}, fmt.Errorf("failed to set snapshot URL: %w", err)476}477}478479gitStatus, disposeErr := wsc.operations.BackupWorkspace(ctx, BackupOptions{480Meta: WorkspaceMeta{481Owner: ws.Spec.Ownership.Owner,482WorkspaceID: ws.Spec.Ownership.WorkspaceID,483InstanceID: ws.Name,484},485SnapshotName: snapshotName,486BackupLogs: ws.Spec.Type == workspacev1.WorkspaceTypePrebuild,487UpdateGitStatus: ws.Spec.Type == workspacev1.WorkspaceTypeRegular,488SkipBackupContent: false,489})490491err = retry.RetryOnConflict(retryParams, func() error {492if err := wsc.Get(ctx, req.NamespacedName, ws); err != nil {493return err494}495496ws.Status.GitStatus = toWorkspaceGitStatus(gitStatus)497498if disposeErr != nil {499log.Error(disposeErr, "failed to backup workspace", "name", ws.Name)500ws.Status.SetCondition(workspacev1.NewWorkspaceConditionBackupFailure(disposeErr.Error()))501} else {502ws.Status.SetCondition(workspacev1.NewWorkspaceConditionBackupComplete())503if ws.Spec.Type != workspacev1.WorkspaceTypeRegular {504ws.Status.Snapshot = snapshotUrl505}506}507508return wsc.Status().Update(ctx, ws)509})510511if err == nil {512wsc.metrics.recordFinalizeTime(time.Since(disposeStart).Seconds(), ws)513} else {514log.Error(err, "failed to set backup condition", "disposeErr", disposeErr)515}516517if disposeErr != nil {518wsc.emitEvent(ws, "Backup", fmt.Errorf("failed to backup workspace: %w", disposeErr))519}520521err = wsc.operations.DeleteWorkspace(ctx, ws.Name)522if err != nil {523wsc.emitEvent(ws, "Backup", fmt.Errorf("failed to clean up workspace: %w", err))524return ctrl.Result{}, fmt.Errorf("failed to clean up workspace: %w", err)525}526527return ctrl.Result{}, nil528}529530func (wsc *WorkspaceController) dumpWorkspaceContainerInfo(ctx context.Context, ws *workspacev1.Workspace) error {531id, err := wsc.runtime.WaitForContainer(ctx, ws.Name)532if err != nil {533return fmt.Errorf("failed to wait for container: %w", err)534}535task, err := wsc.runtime.GetContainerTaskInfo(ctx, id)536if err != nil {537return fmt.Errorf("failed to get container task info: %w", err)538}539glog.WithFields(ws.OWI()).WithFields(logrus.Fields{540"containerID": id,541"exitStatus": task.ExitStatus,542"pid": task.Pid,543"exitedAt": task.ExitedAt.String(),544"status": task.Status.String(),545}).Info("container task info")546return nil547}548549func (wsc *WorkspaceController) forceKillContainerTask(ctx context.Context, ws *workspacev1.Workspace) error {550id, err := wsc.runtime.WaitForContainer(ctx, ws.Name)551if err != nil {552return fmt.Errorf("failed to wait for container: %w", err)553}554return wsc.runtime.ForceKillContainerTask(ctx, id)555}556557func (wsc *WorkspaceController) prepareInitializer(ctx context.Context, ws *workspacev1.Workspace) (*csapi.WorkspaceInitializer, error) {558var init csapi.WorkspaceInitializer559err := proto.Unmarshal(ws.Spec.Initializer, &init)560if err != nil {561err = fmt.Errorf("cannot unmarshal initializer config: %w", err)562return nil, err563}564565var tokenSecret corev1.Secret566err = wsc.Get(ctx, types.NamespacedName{Name: fmt.Sprintf("%s-tokens", ws.Name), Namespace: wsc.secretNamespace}, &tokenSecret)567if err != nil {568return nil, fmt.Errorf("could not get token secret for workspace: %w", err)569}570571if err = csapi.InjectSecretsToInitializer(&init, tokenSecret.Data); err != nil {572return nil, fmt.Errorf("failed to inject secrets into initializer: %w", err)573}574575return &init, nil576}577578func (wsc *WorkspaceController) emitEvent(ws *workspacev1.Workspace, operation string, failure error) {579if failure != nil {580wsc.recorder.Eventf(ws, corev1.EventTypeWarning, "Failed", "%s failed: %s", operation, failure.Error())581}582}583584func toWorkspaceGitStatus(status *csapi.GitStatus) *workspacev1.GitStatus {585if status == nil {586return nil587}588589return &workspacev1.GitStatus{590Branch: status.Branch,591LatestCommit: status.LatestCommit,592UncommitedFiles: status.UncommitedFiles,593TotalUncommitedFiles: status.TotalUncommitedFiles,594UntrackedFiles: status.UntrackedFiles,595TotalUntrackedFiles: status.TotalUntrackedFiles,596UnpushedCommits: status.UnpushedCommits,597TotalUnpushedCommits: status.TotalUnpushedCommits,598}599}600601type workspaceMetrics struct {602initializeTimeHistVec *prometheus.HistogramVec603finalizeTimeHistVec *prometheus.HistogramVec604}605606func newWorkspaceMetrics() *workspaceMetrics {607return &workspaceMetrics{608initializeTimeHistVec: prometheus.NewHistogramVec(prometheus.HistogramOpts{609Namespace: "gitpod",610Subsystem: "ws_daemon",611Name: "workspace_initialize_seconds",612Help: "time it took to initialize workspace",613Buckets: prometheus.ExponentialBuckets(2, 2, 10),614}, []string{"type", "class"}),615finalizeTimeHistVec: prometheus.NewHistogramVec(prometheus.HistogramOpts{616Namespace: "gitpod",617Subsystem: "ws_daemon",618Name: "workspace_finalize_seconds",619Help: "time it took to finalize workspace",620Buckets: prometheus.ExponentialBuckets(2, 2, 10),621}, []string{"type", "class"}),622}623}624625func (m *workspaceMetrics) recordInitializeTime(duration float64, ws *workspacev1.Workspace) {626tpe := string(ws.Spec.Type)627class := ws.Spec.Class628629hist, err := m.initializeTimeHistVec.GetMetricWithLabelValues(tpe, class)630if err != nil {631glog.WithError(err).WithFields(ws.OWI()).WithField("type", tpe).WithField("class", class).Infof("could not retrieve initialize metric")632}633634hist.Observe(duration)635}636637func (m *workspaceMetrics) recordFinalizeTime(duration float64, ws *workspacev1.Workspace) {638tpe := string(ws.Spec.Type)639class := ws.Spec.Class640641hist, err := m.finalizeTimeHistVec.GetMetricWithLabelValues(tpe, class)642if err != nil {643glog.WithError(err).WithFields(ws.OWI()).WithField("type", tpe).WithField("class", class).Infof("could not retrieve finalize metric")644}645646hist.Observe(duration)647}648649// Describe implements Collector. It will send exactly one Desc to the provided channel.650func (m *workspaceMetrics) Describe(ch chan<- *prometheus.Desc) {651m.initializeTimeHistVec.Describe(ch)652m.finalizeTimeHistVec.Describe(ch)653}654655// Collect implements Collector.656func (m *workspaceMetrics) Collect(ch chan<- prometheus.Metric) {657m.initializeTimeHistVec.Collect(ch)658m.finalizeTimeHistVec.Collect(ch)659}660661662