Path: blob/main/components/ws-manager-mk2/controllers/status.go
2498 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 controllers56import (7"bytes"8"context"9"encoding/json"10"fmt"11"strings"12"time"1314wsk8s "github.com/gitpod-io/gitpod/common-go/kubernetes"15"github.com/gitpod-io/gitpod/common-go/tracing"16config "github.com/gitpod-io/gitpod/ws-manager/api/config"17workspacev1 "github.com/gitpod-io/gitpod/ws-manager/api/crd/v1"18"github.com/go-logr/logr"19"golang.org/x/xerrors"20corev1 "k8s.io/api/core/v1"21"k8s.io/apimachinery/pkg/api/errors"22metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"23"k8s.io/apimachinery/pkg/types"24"sigs.k8s.io/controller-runtime/pkg/log"25)2627const (28// containerKilledExitCode is the exit code Kubernetes uses for a container which was killed by the system.29// We expect such containers to be restarted by Kubernetes if they're supposed to be running.30// We never deliberately terminate a container like this.31containerKilledExitCode = 1373233// containerUnknownExitCode is the exit code containerd uses if it cannot determine the cause/exit status of34// a stopped container.35containerUnknownExitCode = 2553637// headlessTaskFailedPrefix is the prefix of the pod termination message if a headless task failed (e.g. user error38// or aborted prebuild).39headlessTaskFailedPrefix = "headless task failed: "4041// podRejectedReasonNodeAffinity is the value of pod.status.Reason in case the pod got rejected by kubelet because of a NodeAffinity mismatch42podRejectedReasonNodeAffinity = "NodeAffinity"4344// podRejectedReasonOutOfCPU is the value of pod.status.Reason in case the pod got rejected by kubelet because of insufficient CPU available45podRejectedReasonOutOfCPU = "OutOfcpu"4647// podRejectedReasonOutOfMemory is the value of pod.status.Reason in case the pod got rejected by kubelet because of insufficient memory available48podRejectedReasonOutOfMemory = "OutOfmemory"49)5051func (r *WorkspaceReconciler) updateWorkspaceStatus(ctx context.Context, workspace *workspacev1.Workspace, pods *corev1.PodList, cfg *config.Configuration) (err error) {52span, ctx := tracing.FromContext(ctx, "updateWorkspaceStatus")53defer tracing.FinishSpan(span, &err)54log := log.FromContext(ctx).WithValues("owi", workspace.OWI())55ctx = logr.NewContext(ctx, log)5657oldPhase := workspace.Status.Phase58defer func() {59if oldPhase != workspace.Status.Phase {60log.Info("workspace phase updated", "oldPhase", oldPhase, "phase", workspace.Status.Phase)61if workspace.Status.Phase == workspacev1.WorkspacePhaseStopping {62t := metav1.Now()63workspace.Status.PodStoppingTime = &t64}65}66}()6768switch len(pods.Items) {69case 0:70if workspace.Status.Phase == "" {71workspace.Status.Phase = workspacev1.WorkspacePhasePending72}7374if workspace.Status.Phase == workspacev1.WorkspacePhaseStopping && isDisposalFinished(workspace) {75workspace.Status.Phase = workspacev1.WorkspacePhaseStopped76}7778if workspace.Status.Phase == workspacev1.WorkspacePhaseStopped && workspace.Status.PodDeletionTime == nil {79// Set the timestamp when we first saw the pod as deleted.80// This is used for the delaying eventual pod restarts81podDeletionTime := metav1.NewTime(time.Now())82workspace.Status.PodDeletionTime = &podDeletionTime83}8485workspace.UpsertConditionOnStatusChange(workspacev1.NewWorkspaceConditionContainerRunning(metav1.ConditionFalse))86return nil87case 1:88// continue below89default:90// This is exceptional - not sure what to do here. Probably fail the pod91workspace.Status.SetCondition(92workspacev1.NewWorkspaceConditionFailed("multiple pods exists - this should never happen"))93return nil94}9596if c := wsk8s.GetCondition(workspace.Status.Conditions, string(workspacev1.WorkspaceConditionDeployed)); c == nil {97workspace.Status.SetCondition(workspacev1.NewWorkspaceConditionDeployed())98}99100pod := &pods.Items[0]101102if workspace.Status.Runtime == nil {103workspace.Status.Runtime = &workspacev1.WorkspaceRuntimeStatus{}104}105if workspace.Status.Runtime.NodeName == "" && pod.Spec.NodeName != "" {106workspace.Status.Runtime.NodeName = pod.Spec.NodeName107}108if workspace.Status.Runtime.HostIP == "" && pod.Status.HostIP != "" {109workspace.Status.Runtime.HostIP = pod.Status.HostIP110}111if workspace.Status.Runtime.PodName == "" && pod.Name != "" {112workspace.Status.Runtime.PodName = pod.Name113}114115workspace.Status.Runtime.PodIP = pod.Status.PodIP116117// Check if the node has disappeared. If so, ws-daemon has also disappeared and we need to118// mark the workspace backup as failed if it didn't complete disposal yet.119// Otherwise, the workspace will be stuck in the Stopping phase forever.120if err := r.checkNodeDisappeared(ctx, workspace, pod); err != nil {121return err122}123124if workspace.Status.URL == "" {125url, err := config.RenderWorkspaceURL(cfg.WorkspaceURLTemplate, workspace.Name, workspace.Spec.Ownership.WorkspaceID, cfg.GitpodHostURL)126if err != nil {127return xerrors.Errorf("cannot get workspace URL: %w", err)128}129workspace.Status.URL = url130}131132if workspace.Status.OwnerToken == "" {133ownerToken, err := getRandomString(32)134if err != nil {135return xerrors.Errorf("cannot create owner token: %w", err)136}137workspace.Status.OwnerToken = ownerToken138}139140failure, phase := r.extractFailure(ctx, workspace, pod)141if phase != nil {142workspace.Status.Phase = *phase143}144145if failure != "" && !workspace.IsConditionTrue(workspacev1.WorkspaceConditionPodRejected) {146// Check: A situation where we want to retry?147if isPodRejected(pod) {148if !workspace.IsConditionTrue(workspacev1.WorkspaceConditionEverReady) {149// This is a situation where we want to re-create the pod!150log.Info("workspace got rejected", "workspace", workspace.Name, "reason", failure)151workspace.Status.SetCondition(workspacev1.NewWorkspaceConditionPodRejected(failure, metav1.ConditionTrue))152r.Recorder.Event(workspace, corev1.EventTypeWarning, "PodRejected", failure)153} else {154log.Info("workspace got rejected, but we don't handle it, because EveryReady=true", "workspace", workspace.Name, "reason", failure)155}156}157}158159if failure != "" && !workspace.IsConditionTrue(workspacev1.WorkspaceConditionFailed) {160var nodeName string161if workspace.Status.Runtime != nil {162nodeName = workspace.Status.Runtime.NodeName163}164// workspaces can fail only once - once there is a failed condition set, stick with it165log.Info("workspace failed", "workspace", workspace.Name, "node", nodeName, "reason", failure)166workspace.Status.SetCondition(workspacev1.NewWorkspaceConditionFailed(failure))167r.Recorder.Event(workspace, corev1.EventTypeWarning, "Failed", failure)168}169170if workspace.IsHeadless() && !workspace.IsConditionTrue(workspacev1.WorkspaceConditionsHeadlessTaskFailed) {171for _, cs := range pod.Status.ContainerStatuses {172if cs.State.Terminated != nil && cs.State.Terminated.Message != "" {173workspace.Status.SetCondition(workspacev1.NewWorkspaceConditionHeadlessTaskFailed(cs.State.Terminated.Message))174break175}176}177}178179if isWorkspaceContainerRunning(pod.Status.ContainerStatuses) {180workspace.UpsertConditionOnStatusChange(workspacev1.NewWorkspaceConditionContainerRunning(metav1.ConditionTrue))181} else {182workspace.UpsertConditionOnStatusChange(workspacev1.NewWorkspaceConditionContainerRunning(metav1.ConditionFalse))183}184185switch {186case isPodBeingDeleted(pod):187if workspace.Status.Phase == workspacev1.WorkspacePhaseStopping && isDisposalFinished(workspace) {188workspace.Status.Phase = workspacev1.WorkspacePhaseStopped189} else if workspace.Status.Phase != workspacev1.WorkspacePhaseStopped {190// Move to (or stay in) Stopping if not yet Stopped.191workspace.Status.Phase = workspacev1.WorkspacePhaseStopping192}193194case pod.Status.Phase == corev1.PodPending:195var creating bool196// check if any container is still pulling images197for _, cs := range pod.Status.ContainerStatuses {198if cs.State.Waiting != nil {199switch cs.State.Waiting.Reason {200case "ContainerCreating", "ImagePullBackOff", "ErrImagePull":201creating = true202}203204if creating {205break206}207}208}209if creating {210workspace.Status.Phase = workspacev1.WorkspacePhaseCreating211} else {212workspace.Status.Phase = workspacev1.WorkspacePhasePending213}214215case pod.Status.Phase == corev1.PodRunning:216everReady := workspace.IsConditionTrue(workspacev1.WorkspaceConditionEverReady)217if everReady {218// If the workspace has been ready before, stay in a Running state, even219// if the workspace container is not ready anymore. This is to avoid the workspace220// moving back to Initializing and becoming unusable.221workspace.Status.Phase = workspacev1.WorkspacePhaseRunning222} else {223contentReady := workspace.IsConditionTrue(workspacev1.WorkspaceConditionContentReady)224var ideReady bool225for _, cs := range pod.Status.ContainerStatuses {226if cs.Ready {227ideReady = true228break229}230}231ready := contentReady && ideReady232233if ready {234// workspace is ready - hence content init is done235workspace.Status.Phase = workspacev1.WorkspacePhaseRunning236if !workspace.IsConditionTrue(workspacev1.WorkspaceConditionEverReady) {237workspace.Status.SetCondition(workspacev1.NewWorkspaceConditionEverReady())238}239} else {240// workspace has not become ready yet - it must be initializing then.241workspace.Status.Phase = workspacev1.WorkspacePhaseInitializing242}243}244245case workspace.IsHeadless() && (pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed):246if pod.Status.Phase == corev1.PodSucceeded && !workspace.IsConditionTrue(workspacev1.WorkspaceConditionEverReady) {247// Fix for Prebuilds that instantly succeed (e.g. empty task), sometimes we don't observe the248// workspace `Running` phase for these, and never had the opportunity to add the EverReady condition.249// This would then cause a "start failure" in the metrics. So we retroactively add the EverReady250// condition here if the pod succeeded.251workspace.Status.SetCondition(workspacev1.NewWorkspaceConditionEverReady())252}253254if workspace.Status.Phase == workspacev1.WorkspacePhaseStopping && isDisposalFinished(workspace) {255workspace.Status.Phase = workspacev1.WorkspacePhaseStopped256} else if workspace.Status.Phase != workspacev1.WorkspacePhaseStopped {257// Should be in Stopping phase, but isn't yet.258// Move to Stopping to start disposal, but only if maintenance mode is disabled.259if !r.maintenance.IsEnabled(ctx) {260workspace.Status.Phase = workspacev1.WorkspacePhaseStopping261}262}263264case pod.Status.Phase == corev1.PodUnknown:265workspace.Status.Phase = workspacev1.WorkspacePhaseUnknown266267default:268log.Info("cannot determine workspace phase", "podStatus", pod.Status)269workspace.Status.Phase = workspacev1.WorkspacePhaseUnknown270271}272273return nil274}275276func (r *WorkspaceReconciler) checkNodeDisappeared(ctx context.Context, workspace *workspacev1.Workspace, pod *corev1.Pod) (err error) {277span, ctx := tracing.FromContext(ctx, "checkNodeDisappeared")278defer tracing.FinishSpan(span, &err)279280if pod.Spec.NodeName == "" {281// Not yet scheduled.282return nil283}284285var node corev1.Node286err = r.Get(ctx, types.NamespacedName{Namespace: "", Name: pod.Spec.NodeName}, &node)287if err == nil || !errors.IsNotFound(err) {288return err289}290291// If NodeDisappeared is already set, return early, we've already made the below checks previously.292if workspace.IsConditionTrue(workspacev1.WorkspaceConditionNodeDisappeared) {293return nil294}295296if !isDisposalFinished(workspace) {297// Node disappeared before a backup could be taken, mark it with a backup failure.298log.FromContext(ctx).Error(nil, "workspace node disappeared while disposal has not finished yet", "node", pod.Spec.NodeName)299workspace.Status.SetCondition(workspacev1.NewWorkspaceConditionBackupFailure("workspace node disappeared before backup was taken"))300}301302// Must set this after checking isDisposalFinished, as that method also checks for the NodeDisappeared condition.303workspace.Status.SetCondition(workspacev1.NewWorkspaceConditionNodeDisappeared())304return nil305}306307func isDisposalFinished(ws *workspacev1.Workspace) bool {308if ws.IsConditionTrue(workspacev1.WorkspaceConditionPodRejected) {309if c := wsk8s.GetCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionStateWiped)); c != nil {310// If the condition is set, we are done with the disposal311return true312}313// If the condition has not yet been set, we are not done, yet.314return false315}316317return ws.IsConditionTrue(workspacev1.WorkspaceConditionBackupComplete) ||318ws.IsConditionTrue(workspacev1.WorkspaceConditionBackupFailure) ||319ws.IsConditionTrue(workspacev1.WorkspaceConditionAborted) ||320// Nothing to dispose if content wasn't ready.321!ws.IsConditionTrue(workspacev1.WorkspaceConditionContentReady) ||322// Can't dispose if node disappeared.323ws.IsConditionTrue(workspacev1.WorkspaceConditionNodeDisappeared) ||324// Image builds have nothing to dispose.325ws.Spec.Type == workspacev1.WorkspaceTypeImageBuild326}327328// extractFailure returns a pod failure reason and possibly a phase. If phase is nil then329// one should extract the phase themselves. If the pod has not failed, this function returns "", nil.330// This failure is then stored in the Failed condition on the workspace.331func (r *WorkspaceReconciler) extractFailure(ctx context.Context, ws *workspacev1.Workspace, pod *corev1.Pod) (string, *workspacev1.WorkspacePhase) {332// Check for content init failure.333if c := wsk8s.GetCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionContentReady)); c != nil {334if c.Status == metav1.ConditionFalse && c.Reason == workspacev1.ReasonInitializationFailure {335msg := c.Message336if msg == "" {337msg = "Content initialization failed for an unknown reason"338} else {339msg = fmt.Sprintf("Content initialization failed: %s", msg)340}341return msg, nil342}343}344345// Check for backup failure.346if c := wsk8s.GetCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionBackupFailure)); c != nil {347msg := c.Message348if msg == "" {349msg = "Backup failed for an unknown reason"350} else {351msg = fmt.Sprintf("Backup failed: %s", msg)352}353return msg, nil354}355356// Check for state wiping failure.357if c := wsk8s.GetCondition(ws.Status.Conditions, string(workspacev1.WorkspaceConditionStateWiped)); c != nil && c.Status == metav1.ConditionFalse {358msg := c.Message359if msg == "" {360msg = "Wiping workspace state failed for an unknown reason"361} else {362msg = fmt.Sprintf("Wiping workspace state failed: %s", msg)363}364return msg, nil365}366367status := pod.Status368if status.Phase == corev1.PodFailed && (status.Reason != "" || status.Message != "") {369// Don't force the phase to UNKNONWN here to leave a chance that we may detect the actual phase of370// the workspace, e.g. stopping.371return fmt.Sprintf("%s: %s", status.Reason, status.Message), nil372}373374for _, cs := range status.ContainerStatuses {375if cs.State.Waiting != nil {376if cs.State.Waiting.Reason == "ImagePullBackOff" || cs.State.Waiting.Reason == "ErrImagePull" {377// If the image pull failed we were definitely in the api.WorkspacePhase_CREATING phase,378// unless of course this pod has been deleted already.379var res *workspacev1.WorkspacePhase380if isPodBeingDeleted(pod) {381// The pod is being deleted already and we have to decide the phase based on the presence of the382// finalizer and disposal status annotation. That code already exists in the remainder of getStatus,383// hence we defer the decision.384res = nil385} else {386c := workspacev1.WorkspacePhaseCreating387res = &c388}389return fmt.Sprintf("cannot pull image: %s", cs.State.Waiting.Message), res390}391}392393terminationState := cs.State.Terminated394if terminationState == nil {395terminationState = cs.LastTerminationState.Terminated396}397if terminationState != nil {398// a workspace terminated container is not neccesarily bad. During shutdown workspaces containers399// can go in this state and that's ok. However, if the workspace was shutting down due to deletion,400// we would not be here as we've checked for a DeletionTimestamp prior. So let's find out why the401// container is terminating.402if terminationState.ExitCode != 0 && terminationState.Message != "" {403var phase *workspacev1.WorkspacePhase404if !isPodBeingDeleted(pod) {405// If the wrote a termination message and is not currently being deleted,406// then it must have been/be running. If we did not force the phase here,407// we'd be in unknown.408running := workspacev1.WorkspacePhaseRunning409phase = &running410}411412if terminationState.ExitCode == containerKilledExitCode && terminationState.Reason == "ContainerStatusUnknown" {413// For some reason, the pod is killed with unknown container status and no taints on the underlying node.414// Therefore, we skip extracting the failure from the terminated message.415// ref: https://github.com/gitpod-io/gitpod/issues/12021416var node corev1.Node417if ws.Status.Runtime != nil && ws.Status.Runtime.NodeName != "" {418if err := r.Get(ctx, types.NamespacedName{Namespace: "", Name: ws.Status.Runtime.NodeName}, &node); err == nil && len(node.Spec.Taints) == 0 {419return "", nil420}421}422}423424if ws.IsHeadless() && strings.HasPrefix(terminationState.Message, headlessTaskFailedPrefix) {425// Headless task failed, not a workspace failure.426return "", nil427}428429// the container itself told us why it was terminated - use that as failure reason430return extractFailureFromLogs([]byte(terminationState.Message)), phase431} else if terminationState.Reason == "Error" {432if !isPodBeingDeleted(pod) && terminationState.ExitCode != containerKilledExitCode {433phase := workspacev1.WorkspacePhaseRunning434return fmt.Sprintf("container %s ran with an error: exit code %d", cs.Name, terminationState.ExitCode), &phase435}436} else if terminationState.Reason == "Completed" && !isPodBeingDeleted(pod) {437// Headless workspaces are expected to finish.438if !ws.IsHeadless() {439return fmt.Sprintf("container %s completed; containers of a workspace pod are not supposed to do that", cs.Name), nil440}441} else if !isPodBeingDeleted(pod) && terminationState.ExitCode == containerUnknownExitCode {442return fmt.Sprintf("workspace container %s terminated for an unknown reason: (%s) %s", cs.Name, terminationState.Reason, terminationState.Message), nil443} else if !isPodBeingDeleted(pod) {444// if a container is terminated and it wasn't because of either:445// - regular shutdown446// - the exit code "UNKNOWN" (which might be caused by an intermittent issue447// - another known error448// then we report it as UNKNOWN449phase := workspacev1.WorkspacePhaseUnknown450return fmt.Sprintf("workspace container %s terminated for an unknown reason: (%s) %s", cs.Name, terminationState.Reason, terminationState.Message), &phase451}452}453}454455return "", nil456}457458func isWorkspaceContainerRunning(statuses []corev1.ContainerStatus) bool {459for _, cs := range statuses {460if cs.Name == "workspace" {461if cs.State.Running != nil {462return true463}464break465}466}467return false468}469470// extractFailureFromLogs attempts to extract the last error message from a workspace471// container's log output.472func extractFailureFromLogs(logs []byte) string {473var sep = []byte("\n")474var msg struct {475Error string `json:"error"`476Message string `json:"message"`477}478479var nidx int480for idx := bytes.LastIndex(logs, sep); idx > 0; idx = nidx {481nidx = bytes.LastIndex(logs[:idx], sep)482if nidx < 0 {483nidx = 0484}485486line := logs[nidx:idx]487err := json.Unmarshal(line, &msg)488if err != nil {489continue490}491492if msg.Message == "" {493continue494}495496if msg.Error == "" {497return msg.Message498}499500return msg.Message + ": " + msg.Error501}502503return string(logs)504}505506// isPodBeingDeleted returns true if the pod is currently being deleted507func isPodBeingDeleted(pod *corev1.Pod) bool {508// if the pod is being deleted the only marker we have is that the deletionTimestamp is set509return pod.ObjectMeta.DeletionTimestamp != nil510}511512// isWorkspaceBeingDeleted returns true if the workspace resource is currently being deleted.513func isWorkspaceBeingDeleted(ws *workspacev1.Workspace) bool {514return ws.ObjectMeta.DeletionTimestamp != nil515}516517// isPodRejected returns true if the pod has been rejected by the kubelet518func isPodRejected(pod *corev1.Pod) bool {519return pod.Status.Phase == corev1.PodFailed && (pod.Status.Reason == podRejectedReasonNodeAffinity || pod.Status.Reason == podRejectedReasonOutOfCPU || pod.Status.Reason == podRejectedReasonOutOfMemory) && strings.HasPrefix(pod.Status.Message, "Pod was rejected")520}521522523