2
0
Эх сурвалжийг харах

get chart status by aggregating controller status

sunguroku 5 жил өмнө
parent
commit
ca9f8742c2

+ 57 - 6
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -1,7 +1,8 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
+import api from '../../../../shared/api';
 
-import { ChartType } from '../../../../shared/types';
+import { ChartType, StorageType } from '../../../../shared/types';
 import { Context } from '../../../../shared/Context';
 
 type PropsType = {
@@ -10,11 +11,14 @@ type PropsType = {
 };
 
 type StateType = {
+  expand: boolean,
+  controllers: Record<string, boolean>,
 };
 
 export default class Chart extends Component<PropsType, StateType> {
   state = {
     expand: false,
+    controllers: {} as Record<string, boolean>,
   }
 
   renderIcon = () => {
@@ -34,11 +38,58 @@ export default class Chart extends Component<PropsType, StateType> {
     return `${time} on ${date}`;
   }
 
-  render() {
-    let { chart, setCurrentChart } = this.props;
+  determineAvailability = (cs: any[]) => {
+    let controllers = {} as Record<string, boolean>;
+    cs.map((c) => {
+      switch (c.kind) {
+        case "Deployment":
+        case "ReplicaSet":
+          controllers[c.metadata.uid] = (c.status.availableReplicas == c.status.replicas)
+          break
+        case "StatefulSet":
+          controllers[c.metadata.uid] = (c.status.readyReplicas == c.status.replicas)
+          break
+        case "DaemonSet":
+          controllers[c.metadata.uid] = (c.status.numberAvailable == c.status.desiredNumberScheduled)
+          break
+        }
+    })
+    this.setState({ controllers })
+  }
 
-    console.log(chart)
+  componentDidMount () {
+    let { currentCluster, currentProject } = this.context;
+    const { chart } = this.props;
+
+    api.getChartControllers('<token>', {
+      namespace: chart.namespace,
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
+      storage: StorageType.Secret
+    }, {
+      id: currentProject.id,
+      name: chart.name,
+      revision: chart.version
+    }, (err: any, res: any) => {
+      this.determineAvailability(res.data)
+    });
+  }
 
+  getChartStatus = (chartStatus: string) => {
+    if (chartStatus === 'deployed') {
+      for (var uid in this.state.controllers) {
+        if (!this.state.controllers[uid]) {
+          return 'updating'
+        }
+      }
+      return 'deployed'
+    }
+    return chartStatus
+  }
+
+  render() {
+    let { chart, setCurrentChart } = this.props;
+    let status = this.getChartStatus(chart.info.status)
     return ( 
       <StyledChart
         onMouseEnter={() => this.setState({ expand: true })}
@@ -56,8 +107,8 @@ export default class Chart extends Component<PropsType, StateType> {
         <BottomWrapper>
           <InfoWrapper>
             <StatusIndicator>
-              <StatusColor status={chart.info.status} />
-              {chart.info.status}
+              <StatusColor status={status} />
+              {status}
             </StatusIndicator>
 
             <LastDeployed>

+ 34 - 3
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -17,7 +17,8 @@ type PropsType = {
 type StateType = {
   charts: ChartType[],
   loading: boolean,
-  error: boolean
+  error: boolean,
+  ws: any,
 };
 
 export default class ChartList extends Component<PropsType, StateType> {
@@ -25,10 +26,11 @@ export default class ChartList extends Component<PropsType, StateType> {
     charts: [] as ChartType[],
     loading: false,
     error: false,
+    ws : null as any
   }
 
   updateCharts = () => {
-    let { currentCluster, currentProject } = this.context;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
 
     this.setState({ loading: true });
     setTimeout(() => {
@@ -50,7 +52,7 @@ export default class ChartList extends Component<PropsType, StateType> {
     }, { id: currentProject.id }, (err: any, res: any) => {
         if (err) {
         console.log(err)
-        // setCurrentError(JSON.stringify(err));
+        setCurrentError(JSON.stringify(err));
         this.setState({ loading: false, error: true });
       } else {
         if (res.data) {
@@ -63,8 +65,37 @@ export default class ChartList extends Component<PropsType, StateType> {
     });
   }
 
+  setupWebsocket = () => {
+    let { currentCluster, currentProject } = this.context;
+    let ws = new WebSocket(`ws://localhost:8080/api/projects/${currentProject.id}/k8s/deployment/status?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
+
+    this.setState({ ws }, () => {
+      if (!this.state.ws) return;
+  
+      this.state.ws.onopen = () => {
+        console.log('connected to websocket')
+      }
+  
+      this.state.ws.onmessage = (evt: MessageEvent) => {
+        console.log(evt.data)
+      }
+  
+      this.state.ws.onerror = (err: ErrorEvent) => {
+        console.log(err)
+      }
+    })
+  }
+
   componentDidMount() {
     this.updateCharts();
+    this.setupWebsocket();
+  }
+
+  componentWillUnmount() {
+    if (this.state.ws) {
+      console.log('closing websocket')
+      this.state.ws.close()
+    }
   }
 
   componentDidUpdate(prevProps: PropsType) {

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/log/LogSection.tsx

@@ -1,7 +1,6 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 import api from '../../../../../shared/api';
-import { ResourceType, ChartType } from '../../../../../shared/types';
 import Logs from './Logs';
 import { Context } from '../../../../../shared/Context';
 

+ 10 - 0
dashboard/src/shared/api.tsx

@@ -71,6 +71,15 @@ const getChartComponents = baseApi<{
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/components`;
 });
 
+const getChartControllers = baseApi<{
+  namespace: string,
+  cluster_id: number,
+  service_account_id: number,
+  storage: StorageType
+}, { id: number, name: string, revision: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/controllers`;
+});
+
 const getNamespaces = baseApi<{
   cluster_id: number,
   service_account_id: number,
@@ -166,6 +175,7 @@ export default {
   getCharts,
   getChart,
   getChartComponents,
+  getChartControllers,
   getNamespaces,
   getMatchingPods,
   getRevisions,

+ 1 - 0
go.sum

@@ -1859,6 +1859,7 @@ k8s.io/apiextensions-apiserver v0.18.8/go.mod h1:7f4ySEkkvifIr4+BRrRWriKKIJjPyg9
 k8s.io/apimachinery v0.16.8/go.mod h1:Xk2vD2TRRpuWYLQNM6lT9R7DSFZUYG03SarNkbGrnKE=
 k8s.io/apimachinery v0.18.8 h1:jimPrycCqgx2QPearX3to1JePz7wSbVLq+7PdBTTwQ0=
 k8s.io/apimachinery v0.18.8/go.mod h1:6sQd+iHEqmOtALqOFjSWp2KZ9F0wlU/nWm0ZgsYWMig=
+k8s.io/apimachinery v0.19.4 h1:+ZoddM7nbzrDCp0T3SWnyxqf8cbWPT2fkZImoyvHUG0=
 k8s.io/apiserver v0.18.8/go.mod h1:12u5FuGql8Cc497ORNj79rhPdiXQC4bf53X/skR/1YM=
 k8s.io/cli-runtime v0.18.8 h1:ycmbN3hs7CfkJIYxJAOB10iW7BVPmXGXkfEyiV9NJ+k=
 k8s.io/cli-runtime v0.18.8/go.mod h1:7EzWiDbS9PFd0hamHHVoCY4GrokSTPSL32MA4rzIu0M=

+ 41 - 0
internal/helm/grapher/object.go

@@ -51,3 +51,44 @@ func ParseObjs(objs []map[string]interface{}) []Object {
 	}
 	return objArr
 }
+
+// ParseControllers parses a k8s object from a single-document yaml
+// and returns an array of controllers.
+func ParseControllers(objs []map[string]interface{}) []Object {
+	objArr := []Object{}
+
+	for i, obj := range objs {
+		kind := getField(obj, "kind")
+
+		// ignore block comments
+		if kind == nil {
+			continue
+		}
+
+		switch kind.(string) {
+		// Parse for all possible controller types
+		case "Deployment", "StatefulSet", "ReplicaSet", "DaemonSet", "Job":
+			name := getField(obj, "metadata", "name")
+			namespace := getField(obj, "metadata", "namespace")
+
+			if namespace == nil {
+				namespace = "default"
+			}
+
+			if name == nil {
+				name = ""
+			}
+
+			// First add the object that appears on the YAML
+			parsedObj := Object{
+				ID:        i,
+				Kind:      kind.(string),
+				Name:      name.(string),
+				Namespace: namespace.(string),
+			}
+			objArr = append(objArr, parsedObj)
+		}
+
+	}
+	return objArr
+}

+ 74 - 4
internal/kubernetes/agent.go

@@ -7,6 +7,7 @@ import (
 	"io"
 
 	"github.com/gorilla/websocket"
+	"github.com/porter-dev/porter/internal/helm/grapher"
 	appsv1 "k8s.io/api/apps/v1"
 	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -23,6 +24,11 @@ type Agent struct {
 	Clientset        kubernetes.Interface
 }
 
+type Message struct {
+	MessageType string
+	Object      interface{}
+}
+
 // ListNamespaces simply lists namespaces
 func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	return a.Clientset.CoreV1().Namespaces().List(
@@ -31,6 +37,42 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	)
 }
 
+// GetDeployment gets the depployment given the name and namespace
+func (a *Agent) GetDeployment(c grapher.Object) (*appsv1.Deployment, error) {
+	return a.Clientset.AppsV1().Deployments(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetStatefulSet gets the statefulset given the name and namespace
+func (a *Agent) GetStatefulSet(c grapher.Object) (*appsv1.StatefulSet, error) {
+	return a.Clientset.AppsV1().StatefulSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetReplicaSet gets the replicaset given the name and namespace
+func (a *Agent) GetReplicaSet(c grapher.Object) (*appsv1.ReplicaSet, error) {
+	return a.Clientset.AppsV1().ReplicaSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetDaemonSet gets the daemonset by name and namespace
+func (a *Agent) GetDaemonSet(c grapher.Object) (*appsv1.DaemonSet, error) {
+	return a.Clientset.AppsV1().DaemonSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
 // GetPodsByLabel retrieves pods with matching labels
 func (a *Agent) GetPodsByLabel(selector string) (*v1.PodList, error) {
 	// Search in all namespaces for matching pods
@@ -61,10 +103,12 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 	errorchan := make(chan error)
 
 	go func() {
+		// listens for websocket closing handshake
 		for {
 			if _, _, err := conn.ReadMessage(); err != nil {
 				conn.Close()
 				errorchan <- nil
+				fmt.Println("Successfully closed log stream")
 				return
 			}
 		}
@@ -108,8 +152,8 @@ func (a *Agent) StreamDeploymentStatus(conn *websocket.Conn) error {
 	factory := informers.NewSharedInformerFactory(a.Clientset, 0)
 	informer := factory.Apps().V1().Deployments().Informer()
 	stopper := make(chan struct{})
-	defer close(stopper)
-	defer fmt.Println("closing...")
+	errorchan := make(chan error)
+	defer close(errorchan)
 
 	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
 		AddFunc: func(obj interface{}) {
@@ -125,11 +169,37 @@ func (a *Agent) StreamDeploymentStatus(conn *websocket.Conn) error {
 		},
 		DeleteFunc: func(obj interface{}) {
 			d := obj.(*appsv1.Deployment)
+			msg := Message{
+				MessageType: "DELETION",
+				Object:      d,
+			}
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
 			fmt.Printf("deleting deployment %s\n", d.Name)
-			fmt.Println(d.Status.Replicas == d.Status.AvailableReplicas)
 		},
 	})
 
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				defer conn.Close()
+				defer close(stopper)
+				defer fmt.Println("Successfully closed deployment status stream")
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
 	go informer.Run(stopper)
-	return nil
+
+	for {
+		select {
+		case err := <-errorchan:
+			return err
+		}
+	}
 }

+ 128 - 1
server/api/release_handler.go

@@ -10,6 +10,7 @@ import (
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm/grapher"
+	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/repository"
 )
 
@@ -103,7 +104,7 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// HandleGetReleaseComponents retrieves a single release based on a name and revision
+// HandleGetReleaseComponents retrieves kubernetes objects listed in a release identified by name and revision
 func (app *App) HandleGetReleaseComponents(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")
 	revision, err := strconv.ParseUint(chi.URLParam(r, "revision"), 0, 64)
@@ -158,6 +159,132 @@ func (app *App) HandleGetReleaseComponents(w http.ResponseWriter, r *http.Reques
 	}
 }
 
+// HandleGetReleaseControllers retrieves a single release based on a name and revision
+func (app *App) HandleGetReleaseControllers(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+	revision, err := strconv.ParseUint(chi.URLParam(r, "revision"), 0, 64)
+
+	form := &forms.GetReleaseForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{
+				UpdateTokenCache: app.updateTokenCache,
+			},
+		},
+		Name:     name,
+		Revision: int(revision),
+	}
+
+	agent, err := app.getAgentFromQueryParams(
+		w,
+		r,
+		form.ReleaseForm,
+		form.ReleaseForm.PopulateHelmOptionsFromQueryParams,
+	)
+
+	// errors are handled in app.getAgentFromQueryParams
+	if err != nil {
+		return
+	}
+
+	release, err := agent.GetRelease(form.Name, form.Revision)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusNotFound, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	k8sForm := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			UpdateTokenCache: app.updateTokenCache,
+		},
+	}
+
+	k8sForm.PopulateK8sOptionsFromQueryParams(vals, app.repo.ServiceAccount)
+
+	// validate the form
+	if err := app.validator.Struct(k8sForm); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new kubernetes agent
+	var k8sAgent *kubernetes.Agent
+
+	if app.testing {
+		k8sAgent = app.TestAgents.K8sAgent
+	} else {
+		k8sAgent, err = kubernetes.GetAgentOutOfClusterConfig(k8sForm.OutOfClusterConfig)
+	}
+
+	yamlArr := grapher.ImportMultiDocYAML([]byte(release.Manifest))
+	controllers := grapher.ParseControllers(yamlArr)
+	retrievedControllers := []interface{}{}
+
+	// get current status of each controller
+	// TODO: refactor with type assertion
+	for _, c := range controllers {
+		switch c.Kind {
+		case "Deployment":
+			rc, err := k8sAgent.GetDeployment(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			rc.Kind = c.Kind
+			retrievedControllers = append(retrievedControllers, rc)
+		case "StatefulSet":
+			rc, err := k8sAgent.GetStatefulSet(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			rc.Kind = c.Kind
+			retrievedControllers = append(retrievedControllers, rc)
+		case "DaemonSet":
+			rc, err := k8sAgent.GetDaemonSet(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			rc.Kind = c.Kind
+			retrievedControllers = append(retrievedControllers, rc)
+		case "ReplicaSet":
+			rc, err := k8sAgent.GetReplicaSet(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			rc.Kind = c.Kind
+			retrievedControllers = append(retrievedControllers, rc)
+		}
+	}
+
+	if err := json.NewEncoder(w).Encode(retrievedControllers); err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+}
+
 // HandleListReleaseHistory retrieves a history of releases based on a release name
 func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")

+ 14 - 0
server/router/router.go

@@ -163,6 +163,20 @@ func New(
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/releases/{name}/{revision}/controllers",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleGetReleaseControllers, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/releases/{name}/history",