Răsfoiți Sursa

Merge pull request #1486 from porter-dev/staging

builder + buildpack selection, pod level events, use docker cache during build
sunguroku 4 ani în urmă
părinte
comite
fd43a895b7
94 a modificat fișierele cu 20178 adăugiri și 1641 ștergeri
  1. 3 0
      .gitignore
  2. 4 4
      Makefile
  3. 51 0
      api/server/handlers/cluster/detect_agent_installed.go
  4. 111 0
      api/server/handlers/cluster/install_agent.go
  5. 43 27
      api/server/handlers/gitinstallation/get_buildpack.go
  6. 213 0
      api/server/handlers/kube_events/create.go
  7. 46 0
      api/server/handlers/kube_events/get.go
  8. 96 0
      api/server/handlers/kube_events/get_log_buckets.go
  9. 97 0
      api/server/handlers/kube_events/get_logs.go
  10. 60 0
      api/server/handlers/kube_events/list.go
  11. 73 0
      api/server/handlers/namespace/get_pod.go
  12. 40 0
      api/server/handlers/release/create.go
  13. 14 3
      api/server/handlers/release/get.go
  14. 1 1
      api/server/handlers/release/get_steps.go
  15. 6 5
      api/server/handlers/release/ugprade.go
  16. 73 0
      api/server/handlers/release/update_build_config.go
  17. 3 3
      api/server/handlers/release/update_steps.go
  18. 5 4
      api/server/handlers/release/upgrade_webhook.go
  19. 200 0
      api/server/router/cluster.go
  20. 33 0
      api/server/router/namespace.go
  21. 30 0
      api/server/router/release.go
  22. 20 0
      api/types/build_config.go
  23. 0 5
      api/types/git_installation.go
  24. 91 0
      api/types/kube_events.go
  25. 4 0
      api/types/release.go
  26. 5 2
      cli/cmd/deploy/build.go
  27. 2 2
      cli/cmd/deploy/create.go
  28. 7 4
      cli/cmd/deploy/deploy.go
  29. 4 0
      cli/cmd/docker/builder.go
  30. 10 1
      cli/cmd/pack/pack.go
  31. 14115 695
      dashboard/package-lock.json
  32. 1 0
      dashboard/package.json
  33. 157 0
      dashboard/src/components/Dropdown.tsx
  34. 1 0
      dashboard/src/components/ResourceTab.tsx
  35. 1 1
      dashboard/src/components/SaveButton.tsx
  36. 2 1
      dashboard/src/components/Selector.tsx
  37. 1 1
      dashboard/src/components/UnexpectedErrorPage.tsx
  38. 202 0
      dashboard/src/components/events/EventCard.tsx
  39. 360 0
      dashboard/src/components/events/SubEventsList.tsx
  40. 166 0
      dashboard/src/components/events/sub-events/LogBucketCard.tsx
  41. 57 0
      dashboard/src/components/events/sub-events/SubEventCard.tsx
  42. 214 0
      dashboard/src/components/events/useEvents.ts
  43. 89 0
      dashboard/src/components/events/useLastSeenPodStatus.ts
  44. 4 3
      dashboard/src/components/image-selector/ImageList.tsx
  45. 2 2
      dashboard/src/components/image-selector/TagList.tsx
  46. 8 8
      dashboard/src/components/porter-form/types.ts
  47. 2 0
      dashboard/src/components/repo-selector/ActionConfEditor.tsx
  48. 499 135
      dashboard/src/components/repo-selector/ActionDetails.tsx
  49. 6 0
      dashboard/src/components/repo-selector/ContentsList.tsx
  50. 40 3
      dashboard/src/index.html
  51. 2 1
      dashboard/src/main/home/Home.tsx
  52. 5 1
      dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx
  53. 215 0
      dashboard/src/main/home/cluster-dashboard/dashboard/events/EventsTab.tsx
  54. 1 1
      dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx
  55. 114 111
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  56. 0 120
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventCard.tsx
  57. 0 94
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventDetail.tsx
  58. 189 159
      dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx
  59. 25 28
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  60. 3 0
      dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx
  61. 3 0
      dashboard/src/main/home/launch/launch-flow/SourcePage.tsx
  62. 60 48
      dashboard/src/main/home/navbar/Help.tsx
  63. 11 11
      dashboard/src/main/home/navbar/Navbar.tsx
  64. 2 1
      dashboard/src/main/home/sidebar/Sidebar.tsx
  65. 79 0
      dashboard/src/shared/api.tsx
  66. 16 0
      dashboard/src/shared/types.tsx
  67. 4 2
      docker/Dockerfile
  68. 4 2
      ee/docker/ee.Dockerfile
  69. 12 19
      go.mod
  70. 121 50
      go.sum
  71. 101 0
      internal/integrations/buildpacks/go.go
  72. 339 0
      internal/integrations/buildpacks/nodejs.go
  73. 149 0
      internal/integrations/buildpacks/python.go
  74. 280 0
      internal/integrations/buildpacks/ruby.go
  75. 69 0
      internal/integrations/buildpacks/shared.go
  76. 65 30
      internal/integrations/slack/notifier.go
  77. 45 0
      internal/kubernetes/agent.go
  78. 124 0
      internal/kubernetes/porter_agent/logs.go
  79. 25 0
      internal/models/build_config.go
  80. 77 0
      internal/models/kube_events.go
  81. 19 3
      internal/models/notification.go
  82. 1 0
      internal/models/release.go
  83. 10 0
      internal/repository/build_config.go
  84. 18 2
      internal/repository/event.go
  85. 48 0
      internal/repository/gorm/build_config.go
  86. 248 11
      internal/repository/gorm/event.go
  87. 201 0
      internal/repository/gorm/event_test.go
  88. 87 19
      internal/repository/gorm/helpers_test.go
  89. 3 0
      internal/repository/gorm/migrate.go
  90. 16 4
      internal/repository/gorm/repository.go
  91. 3 1
      internal/repository/repository.go
  92. 48 0
      internal/repository/test/build_config.go
  93. 48 9
      internal/repository/test/event.go
  94. 16 4
      internal/repository/test/repository.go

+ 3 - 0
.gitignore

@@ -15,6 +15,9 @@ staging.sh
 *.key
 bin
 
+# Local docs directories
+/docs/.obsidian
+
 # Local .terraform directories
 **/.terraform/*
 

+ 4 - 4
Makefile

@@ -7,14 +7,14 @@ start-dev: install setup-env-files
 run-migrate-dev: install setup-env-files
 	bash ./scripts/dev-environment/RunMigrateDev.sh
 
-install: 
+install:
 	bash ./scripts/dev-environment/SetupEnvironment.sh
 
-setup-env-files: 
+setup-env-files:
 	bash ./scripts/dev-environment/CreateDefaultEnvFiles.sh
 
-build-cli: 
+build-cli:
 	go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${VERSION}'" -a -tags cli -o $(BINDIR)/porter ./cli
 
 build-cli-dev:
-	go build -tags cli -o $(BINDIR)/porter ./cli
+	go build -tags cli -o $(BINDIR)/porter ./cli

+ 51 - 0
api/server/handlers/cluster/detect_agent_installed.go

@@ -0,0 +1,51 @@
+package cluster
+
+import (
+	"errors"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type DetectAgentInstalledHandler struct {
+	handlers.PorterHandler
+	authz.KubernetesAgentGetter
+}
+
+func NewDetectAgentInstalledHandler(
+	config *config.Config,
+) *DetectAgentInstalledHandler {
+	return &DetectAgentInstalledHandler{
+		PorterHandler:         handlers.NewDefaultPorterHandler(config, nil, nil),
+		KubernetesAgentGetter: authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *DetectAgentInstalledHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	_, err = agent.GetPorterAgent()
+
+	if targetErr := kubernetes.IsNotFoundError; err != nil && errors.Is(err, targetErr) {
+		http.NotFound(w, r)
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 111 - 0
api/server/handlers/cluster/install_agent.go

@@ -0,0 +1,111 @@
+package cluster
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/helm/loader"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type InstallAgentHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewInstallAgentHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *InstallAgentHandler {
+	return &InstallAgentHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	helmAgent, err := c.GetHelmAgent(r, cluster, "porter-agent-system")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	chart, err := loader.LoadChartPublic(c.Config().ServerConf.DefaultAddonHelmRepoURL, "porter-agent", "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// create namespace if not exists
+	_, err = helmAgent.K8sAgent.CreateNamespace("porter-agent-system")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// add api token to values
+	jwt, err := token.GetTokenForAPI(user.ID, proj.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	encoded, err := jwt.EncodeToken(c.Config().TokenConf)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	porterAgentValues := map[string]interface{}{
+		"agent": map[string]interface{}{
+			"image":       "public.ecr.aws/o1j4x7p4/porter-agent:latest",
+			"porterHost":  c.Config().ServerConf.ServerURL,
+			"porterPort":  "443",
+			"porterToken": encoded,
+			"privateRegistry": map[string]interface{}{
+				"enabled": false,
+			},
+			"clusterID": fmt.Sprintf("%d", cluster.ID),
+			"projectID": fmt.Sprintf("%d", proj.ID),
+		},
+	}
+
+	conf := &helm.InstallChartConfig{
+		Chart:     chart,
+		Name:      "porter-agent",
+		Namespace: "porter-agent-system",
+		Cluster:   cluster,
+		Repo:      c.Repo(),
+		Values:    porterAgentValues,
+	}
+
+	_, err = helmAgent.InstallChart(conf, c.Config().DOConf)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("error installing a new chart: %s", err.Error()),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}

+ 43 - 27
api/server/handlers/gitinstallation/get_buildpack.go

@@ -2,7 +2,9 @@ package gitinstallation
 
 import (
 	"context"
+	"fmt"
 	"net/http"
+	"sync"
 
 	"github.com/google/go-github/github"
 	"github.com/porter-dev/porter/api/server/authz"
@@ -11,8 +13,27 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/integrations/buildpacks"
 )
 
+func initBuilderInfo() map[string]*buildpacks.BuilderInfo {
+	builders := make(map[string]*buildpacks.BuilderInfo)
+	builders[buildpacks.PaketoBuilder] = &buildpacks.BuilderInfo{
+		Name: "Paketo",
+		Builders: []string{
+			"paketobuildpacks/builder:full",
+		},
+	}
+	builders[buildpacks.HerokuBuilder] = &buildpacks.BuilderInfo{
+		Name: "Heroku",
+		Builders: []string{
+			"heroku/buildpacks:20",
+			"heroku/buildpacks:18",
+		},
+	}
+	return builders
+}
+
 type GithubGetBuildpackHandler struct {
 	handlers.PorterHandlerReadWriter
 	authz.KubernetesAgentGetter
@@ -71,34 +92,29 @@ func (c *GithubGetBuildpackHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 		return
 	}
 
-	var BREQS = map[string]string{
-		"requirements.txt": "Python",
-		"Gemfile":          "Ruby",
-		"package.json":     "Node.js",
-		"pom.xml":          "Java",
-		"composer.json":    "PHP",
+	builderInfoMap := initBuilderInfo()
+	var wg sync.WaitGroup
+	wg.Add(len(buildpacks.Runtimes))
+	for i := range buildpacks.Runtimes {
+		go func(idx int) {
+			defer func() {
+				if rec := recover(); rec != nil {
+					c.HandleAPIError(w, r, apierrors.NewErrInternal(fmt.Errorf("panic detected in runtime detection")))
+					return
+				}
+			}()
+			buildpacks.Runtimes[idx].Detect(
+				client, directoryContents, owner, name, request.Dir, repoContentOptions,
+				builderInfoMap[buildpacks.PaketoBuilder], builderInfoMap[buildpacks.HerokuBuilder],
+			)
+			wg.Done()
+		}(i)
 	}
+	wg.Wait()
 
-	res := &types.GetBuildpackResponse{
-		Valid: true,
+	var builders []*buildpacks.BuilderInfo
+	for _, v := range builderInfoMap {
+		builders = append(builders, v)
 	}
-
-	matches := 0
-
-	for i := range directoryContents {
-		name := *directoryContents[i].Name
-
-		bname, ok := BREQS[name]
-		if ok {
-			matches++
-			res.Name = bname
-		}
-	}
-
-	if matches != 1 {
-		res.Valid = false
-		res.Name = ""
-	}
-
-	c.WriteResult(w, r, res)
+	c.WriteResult(w, r, builders)
 }

+ 213 - 0
api/server/handlers/kube_events/create.go

@@ -0,0 +1,213 @@
+package kube_events
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/integrations/slack"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type CreateKubeEventHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewCreateKubeEventHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateKubeEventHandler {
+	return &CreateKubeEventHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *CreateKubeEventHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.CreateKubeEventRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	// Look for an event matching by the name, namespace, and was last updated within the
+	// grouping threshold time. If so, we append a subevent to the existing event.
+	kubeEvent, err := c.Repo().KubeEvent().ReadEventByGroup(proj.ID, cluster.ID, &types.GroupOptions{
+		Name:          request.Name,
+		Namespace:     request.Namespace,
+		ResourceType:  request.ResourceType,
+		ThresholdTime: time.Now().Add(-15 * time.Minute),
+	})
+
+	foundMatchedEvent := kubeEvent != nil
+
+	if !foundMatchedEvent {
+		kubeEvent, err = c.Repo().KubeEvent().CreateEvent(&models.KubeEvent{
+			ProjectID:    proj.ID,
+			ClusterID:    cluster.ID,
+			ResourceType: request.ResourceType,
+			Name:         request.Name,
+			OwnerType:    request.OwnerType,
+			OwnerName:    request.OwnerName,
+			Namespace:    request.Namespace,
+		})
+
+		if err != nil {
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+			return
+		}
+	}
+
+	// append the subevent to the event
+	err = c.Repo().KubeEvent().AppendSubEvent(kubeEvent, &models.KubeSubEvent{
+		EventType: request.EventType,
+		Message:   request.Message,
+		Reason:    request.Reason,
+		Timestamp: request.Timestamp,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	w.WriteHeader(http.StatusCreated)
+
+	if strings.ToLower(string(request.EventType)) == "critical" && strings.ToLower(request.ResourceType) == "pod" {
+		err := notifyPodCrashing(c.Config(), proj, cluster, request)
+
+		if err != nil {
+			c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(err))
+		}
+	}
+}
+
+func notifyPodCrashing(
+	config *config.Config,
+	project *models.Project,
+	cluster *models.Cluster,
+	event *types.CreateKubeEventRequest,
+) error {
+	// attempt to get a matching Porter release to get the notification configuration
+	var conf *models.NotificationConfig
+	var notifConfig *types.NotificationConfig
+	var err error
+	matchedRel := getMatchedPorterRelease(config, cluster.ID, event.OwnerName, event.Namespace)
+
+	// for now, we only notify for Porter releases that have been deployed through Porter
+	if matchedRel == nil {
+		return nil
+	}
+
+	conf, err = config.Repo.NotificationConfig().ReadNotificationConfig(matchedRel.NotificationConfig)
+
+	if err != nil && errors.Is(err, gorm.ErrRecordNotFound) {
+		conf = &models.NotificationConfig{
+			Enabled: true,
+			Success: true,
+			Failure: true,
+		}
+
+		conf, err = config.Repo.NotificationConfig().CreateNotificationConfig(conf)
+
+		if err != nil {
+			return err
+		}
+
+		if err != nil {
+			return err
+		}
+
+		matchedRel.NotificationConfig = conf.ID
+		matchedRel, err = config.Repo.Release().UpdateRelease(matchedRel)
+
+		if err != nil {
+			return err
+		}
+
+		notifConfig = conf.ToNotificationConfigType()
+	} else if err != nil {
+		return err
+	} else if err == nil && conf != nil {
+		if !conf.ShouldNotify() {
+			return nil
+		}
+
+		notifConfig = conf.ToNotificationConfigType()
+	}
+
+	slackInts, _ := config.Repo.SlackIntegration().ListSlackIntegrationsByProjectID(project.ID)
+
+	notifier := slack.NewSlackNotifier(notifConfig, slackInts...)
+
+	notifyOpts := &slack.NotifyOpts{
+		ProjectID:   cluster.ProjectID,
+		ClusterID:   cluster.ID,
+		ClusterName: cluster.Name,
+		Name:        event.OwnerName,
+		Namespace:   event.Namespace,
+		Info:        fmt.Sprintf("%s:%s", event.Reason, event.Message),
+		URL: fmt.Sprintf(
+			"%s/applications/%s/%s/%s?project_id=%d",
+			config.ServerConf.ServerURL,
+			url.PathEscape(cluster.Name),
+			matchedRel.Namespace,
+			matchedRel.Name,
+			cluster.ProjectID,
+		),
+	}
+
+	notifyOpts.Status = slack.StatusPodCrashed
+
+	err = notifier.Notify(notifyOpts)
+
+	if err != nil {
+		return err
+	}
+
+	// update the last updated time
+	if matchedRel != nil && conf != nil {
+		conf.LastNotifiedTime = time.Now()
+		conf, err = config.Repo.NotificationConfig().UpdateNotificationConfig(conf)
+	}
+
+	return err
+}
+
+// getMatchedPorterRelease attempts to find a matching Porter release from the name of a controller.
+// For example, if the controller has a suffix "-web", it is likely a Porter web application, and
+// so we query for a Porter release with a matching name. Returns nil if no match is found
+func getMatchedPorterRelease(config *config.Config, clusterID uint, ownerName, namespace string) *models.Release {
+	matchingName := ""
+
+	if strings.Contains(ownerName, "-web") {
+		matchingName = strings.Split(ownerName, "-web")[0]
+	} else if strings.Contains(ownerName, "-worker") {
+		matchingName = strings.Split(ownerName, "-worker")[0]
+	} else if strings.Contains(ownerName, "-job") {
+		matchingName = strings.Split(ownerName, "-job")[0]
+	}
+
+	rel, err := config.Repo.Release().ReadRelease(clusterID, matchingName, namespace)
+
+	if err != nil {
+		return nil
+	}
+
+	return rel
+}

+ 46 - 0
api/server/handlers/kube_events/get.go

@@ -0,0 +1,46 @@
+package kube_events
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetKubeEventHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetKubeEventHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetKubeEventHandler {
+	return &GetKubeEventHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetKubeEventHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	kubeEventID, _ := requestutils.GetURLParamUint(r, types.URLParamKubeEventID)
+
+	// handle write to the database
+	kubeEvent, err := c.Repo().KubeEvent().ReadEvent(kubeEventID, proj.ID, cluster.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, kubeEvent.ToKubeEventType())
+}

+ 96 - 0
api/server/handlers/kube_events/get_log_buckets.go

@@ -0,0 +1,96 @@
+package kube_events
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/porter_agent"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetKubeEventLogBucketsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetKubeEventLogBucketsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetKubeEventLogBucketsHandler {
+	return &GetKubeEventLogBucketsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetKubeEventLogBucketsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	kubeEventID, _ := requestutils.GetURLParamUint(r, types.URLParamKubeEventID)
+
+	kubeEvent, err := c.Repo().KubeEvent().ReadEvent(kubeEventID, proj.ID, cluster.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// if the kube event is not a pod type, throw a bad request error to the user
+	if strings.ToLower(kubeEvent.ResourceType) != "pod" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("event resource type must be pod to get logs"),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	req := &types.GetKubeEventLogsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get agent service
+	agentSvc, err := porter_agent.GetAgentService(agent.Clientset)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	resp, err := porter_agent.GetLogBucketsFromPorterAgent(agent.Clientset, agentSvc, &porter_agent.LogBucketPathOpts{
+		Pod:       kubeEvent.Name,
+		Namespace: kubeEvent.Namespace,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if resp.Error != "" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf(resp.Error), http.StatusBadRequest))
+		return
+	}
+
+	c.WriteResult(w, r, &types.GetKubeEventLogBucketsResponse{
+		LogBuckets: resp.AvailableBuckets,
+	})
+}

+ 97 - 0
api/server/handlers/kube_events/get_logs.go

@@ -0,0 +1,97 @@
+package kube_events
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes/porter_agent"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetKubeEventLogsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetKubeEventLogsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *GetKubeEventLogsHandler {
+	return &GetKubeEventLogsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetKubeEventLogsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	kubeEventID, _ := requestutils.GetURLParamUint(r, types.URLParamKubeEventID)
+
+	kubeEvent, err := c.Repo().KubeEvent().ReadEvent(kubeEventID, proj.ID, cluster.ID)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// if the kube event is not a pod type, throw a bad request error to the user
+	if strings.ToLower(kubeEvent.ResourceType) != "pod" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("event resource type must be pod to get logs"),
+			http.StatusBadRequest,
+		))
+
+		return
+	}
+
+	req := &types.GetKubeEventLogsRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, req); !ok {
+		return
+	}
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	// get agent service
+	agentSvc, err := porter_agent.GetAgentService(agent.Clientset)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	resp, err := porter_agent.GetLogsFromPorterAgent(agent.Clientset, agentSvc, &porter_agent.LogPathOpts{
+		Timestamp: req.Timestamp,
+		Pod:       kubeEvent.Name,
+		Namespace: kubeEvent.Namespace,
+	})
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	if resp.Error != "" {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(fmt.Errorf(resp.Error), http.StatusBadRequest))
+		return
+	}
+
+	c.WriteResult(w, r, &types.GetKubeEventLogsResponse{
+		Logs: resp.Logs,
+	})
+}

+ 60 - 0
api/server/handlers/kube_events/list.go

@@ -0,0 +1,60 @@
+package kube_events
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListKubeEventsHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewListKubeEventsHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *ListKubeEventsHandler {
+	return &ListKubeEventsHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *ListKubeEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	request := &types.ListKubeEventRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	kubeEvents, count, err := c.Repo().KubeEvent().ListEventsByProjectID(proj.ID, cluster.ID, request)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	resp := &types.ListKubeEventsResponse{
+		Count:      count,
+		Limit:      request.Limit,
+		Skip:       request.Skip,
+		KubeEvents: []*types.KubeEvent{},
+	}
+
+	for _, kubeEvent := range kubeEvents {
+		resp.KubeEvents = append(resp.KubeEvents, kubeEvent.ToKubeEventType())
+	}
+
+	c.WriteResult(w, r, resp)
+}

+ 73 - 0
api/server/handlers/namespace/get_pod.go

@@ -0,0 +1,73 @@
+package namespace
+
+import (
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type GetPodHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+func NewGetPodHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *GetPodHandler {
+	return &GetPodHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+func (c *GetPodHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+
+	agent, err := c.GetAgent(r, cluster, "")
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	name, err := requestutils.GetURLParamString(r, types.URLParamPodName)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	namespace, err := requestutils.GetURLParamString(r, types.URLParamNamespace)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	pod, err := agent.GetPodByName(name, namespace)
+
+	if errors.Is(err, kubernetes.IsNotFoundError) {
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
+			fmt.Errorf("pod %s/%s was not found", namespace, name),
+			http.StatusNotFound,
+		))
+
+		return
+	} else if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	c.WriteResult(w, r, pod)
+}

+ 40 - 0
api/server/handlers/release/create.go

@@ -1,6 +1,7 @@
 package release
 
 import (
+	"encoding/json"
 	"fmt"
 	"net/http"
 	"strings"
@@ -134,6 +135,15 @@ func (c *CreateReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		}
 	}
 
+	if request.BuildConfig != nil {
+		_, err = createBuildConfig(c.Config(), release, request.BuildConfig)
+	}
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
 	c.Config().AnalyticsClient.Track(analytics.ApplicationLaunchSuccessTrack(
 		&analytics.ApplicationLaunchSuccessTrackOpts{
 			ApplicationScopedTrackOpts: analytics.GetApplicationScopedTrackOpts(
@@ -306,6 +316,36 @@ func createGitAction(
 	return ga.ToGitActionConfigType(), workflowYAML, nil
 }
 
+func createBuildConfig(
+	config *config.Config,
+	release *models.Release,
+	bcRequest *types.CreateBuildConfigRequest,
+) (*types.BuildConfig, error) {
+	data, err := json.Marshal(bcRequest.Config)
+	if err != nil {
+		return nil, err
+	}
+
+	// handle write to the database
+	bc, err := config.Repo.BuildConfig().CreateBuildConfig(&models.BuildConfig{
+		Builder:    bcRequest.Builder,
+		Buildpacks: strings.Join(bcRequest.Buildpacks, ","),
+		Config:     data,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	release.BuildConfig = bc.ID
+
+	_, err = config.Repo.Release().UpdateRelease(release)
+	if err != nil {
+		return nil, err
+	}
+
+	return bc.ToBuildConfigType(), nil
+}
+
 type containerEnvConfig struct {
 	Container struct {
 		Env struct {

+ 14 - 3
api/server/handlers/release/get.go

@@ -52,6 +52,17 @@ func (c *ReleaseGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		if release.GitActionConfig != nil {
 			res.GitActionConfig = release.GitActionConfig.ToGitActionConfigType()
 		}
+
+		if release.BuildConfig != 0 {
+			bc, err := c.Repo().BuildConfig().GetBuildConfig(release.BuildConfig)
+
+			if err != nil {
+				c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+				return
+			}
+
+			res.BuildConfig = bc.ToBuildConfigType()
+		}
 	} else if err != gorm.ErrRecordNotFound {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
@@ -136,7 +147,7 @@ tabs:
   label: Certificates
   sections:
   - name: section_one
-    contents: 
+    contents:
     - type: heading
       label: Certificates
     - type: resource-list
@@ -156,9 +167,9 @@ tabs:
                     version: v1
                     resource: certificates
       value: |
-        .items[] | { 
+        .items[] | {
           metadata: .metadata,
-          name: "\(.spec.dnsNames | join(","))", 
+          name: "\(.spec.dnsNames | join(","))",
           label: "\(.metadata.namespace)/\(.metadata.name)",
           status: (
             ([.status.conditions[].type] | index("Ready")) as $index | (

+ 1 - 1
api/server/handlers/release/get_steps.go

@@ -50,7 +50,7 @@ func (c *GetReleaseStepsHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
 	res := make(types.GetReleaseStepsResponse, 0)
 
 	if release.EventContainer != 0 {
-		subevents, err := c.Repo().Event().ReadEventsByContainerID(release.EventContainer)
+		subevents, err := c.Repo().BuildEvent().ReadEventsByContainerID(release.EventContainer)
 
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))

+ 6 - 5
api/server/handlers/release/ugprade.go

@@ -144,16 +144,17 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		Name:        helmRelease.Name,
 		Namespace:   helmRelease.Namespace,
 		URL: fmt.Sprintf(
-			"%s/applications/%s/%s/%s",
+			"%s/applications/%s/%s/%s?project_id=%d",
 			c.Config().ServerConf.ServerURL,
 			url.PathEscape(cluster.Name),
-			cluster.Name,
+			helmRelease.Namespace,
 			helmRelease.Name,
-		) + fmt.Sprintf("?project_id=%d", cluster.ProjectID),
+			cluster.ProjectID,
+		),
 	}
 
 	if upgradeErr != nil {
-		notifyOpts.Status = slack.StatusFailed
+		notifyOpts.Status = slack.StatusHelmFailed
 		notifyOpts.Info = upgradeErr.Error()
 
 		notifier.Notify(notifyOpts)
@@ -166,7 +167,7 @@ func (c *UpgradeReleaseHandler) ServeHTTP(w http.ResponseWriter, r *http.Request
 		return
 	}
 
-	notifyOpts.Status = string(helmRelease.Info.Status)
+	notifyOpts.Status = slack.StatusHelmDeployed
 	notifyOpts.Version = helmRelease.Version
 
 	notifier.Notify(notifyOpts)

+ 73 - 0
api/server/handlers/release/update_build_config.go

@@ -0,0 +1,73 @@
+package release
+
+import (
+	"encoding/json"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+type UpdateBuildConfigHandler struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewUpdateBuildConfigHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *UpdateBuildConfigHandler {
+	return &UpdateBuildConfigHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (c *UpdateBuildConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	cluster, _ := r.Context().Value(types.ClusterScope).(*models.Cluster)
+	name, _ := requestutils.GetURLParamString(r, types.URLParamReleaseName)
+	namespace := r.Context().Value(types.NamespaceScope).(string)
+
+	request := &types.UpdateBuildConfigRequest{}
+
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	release, err := c.Repo().Release().ReadRelease(cluster.ID, name, namespace)
+
+	if err != nil {
+		if err == gorm.ErrRecordNotFound {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+	}
+
+	config, err := json.Marshal(request.Config)
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	buildConfig := &models.BuildConfig{
+		Builder:    request.Builder,
+		Buildpacks: strings.Join(request.Buildpacks, ","),
+		Config:     config,
+	}
+
+	buildConfig.ID = release.BuildConfig
+	_, err = c.Repo().BuildConfig().UpdateBuildConfig(buildConfig)
+
+	if err != nil {
+		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+}

+ 3 - 3
api/server/handlers/release/update_steps.go

@@ -55,7 +55,7 @@ func (c *UpdateReleaseStepsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 
 	if release.EventContainer == 0 {
 		// create new event container
-		container, err := c.Repo().Event().CreateEventContainer(&models.EventContainer{ReleaseID: release.ID})
+		container, err := c.Repo().BuildEvent().CreateEventContainer(&models.EventContainer{ReleaseID: release.ID})
 		if err != nil {
 			c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 			return
@@ -72,14 +72,14 @@ func (c *UpdateReleaseStepsHandler) ServeHTTP(w http.ResponseWriter, r *http.Req
 
 	}
 
-	container, err := c.Repo().Event().ReadEventContainer(release.EventContainer)
+	container, err := c.Repo().BuildEvent().ReadEventContainer(release.EventContainer)
 
 	if err != nil {
 		c.HandleAPIError(w, r, apierrors.NewErrInternal(err))
 		return
 	}
 
-	if err := c.Repo().Event().AppendEvent(container, &models.SubEvent{
+	if err := c.Repo().BuildEvent().AppendEvent(container, &models.SubEvent{
 		EventContainerID: container.ID,
 		EventID:          request.Event.EventID,
 		Name:             request.Event.Name,

+ 5 - 4
api/server/handlers/release/upgrade_webhook.go

@@ -157,18 +157,19 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		Name:        rel.Name,
 		Namespace:   rel.Namespace,
 		URL: fmt.Sprintf(
-			"%s/applications/%s/%s/%s",
+			"%s/applications/%s/%s/%s?project_id=%d",
 			c.Config().ServerConf.ServerURL,
 			url.PathEscape(cluster.Name),
 			release.Namespace,
 			rel.Name,
-		) + fmt.Sprintf("?project_id=%d", release.ProjectID),
+			cluster.ProjectID,
+		),
 	}
 
 	rel, err = helmAgent.UpgradeReleaseByValues(conf, c.Config().DOConf)
 
 	if err != nil {
-		notifyOpts.Status = slack.StatusFailed
+		notifyOpts.Status = slack.StatusHelmFailed
 		notifyOpts.Info = err.Error()
 
 		notifier.Notify(notifyOpts)
@@ -181,7 +182,7 @@ func (c *WebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	notifyOpts.Status = string(rel.Info.Status)
+	notifyOpts.Status = slack.StatusHelmDeployed
 	notifyOpts.Version = rel.Version
 
 	notifier.Notify(notifyOpts)

+ 200 - 0
api/server/router/cluster.go

@@ -5,6 +5,7 @@ import (
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/handlers/cluster"
+	"github.com/porter-dev/porter/api/server/handlers/kube_events"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
@@ -451,6 +452,205 @@ func getClusterRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/agent/detect -> cluster.NewDetectAgentInstalledHandler
+	detectAgentInstalledEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/agent/detect",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	detectAgentInstalledHandler := cluster.NewDetectAgentInstalledHandler(config)
+
+	routes = append(routes, &Route{
+		Endpoint: detectAgentInstalledEndpoint,
+		Handler:  detectAgentInstalledHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/agent/install -> cluster.NewInstallAgentHandler
+	installAgentEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/agent/install",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	installAgentHandler := cluster.NewInstallAgentHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: installAgentEndpoint,
+		Handler:  installAgentHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events -> kube_events.NewGetKubeEventHandler
+	listKubeEventsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/kube_events",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	listKubeEventsHandler := kube_events.NewListKubeEventsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listKubeEventsEndpoint,
+		Handler:  listKubeEventsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events -> kube_events.NewGetKubeEventHandler
+	getKubeEventEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/kube_events/{%s}", relPath, types.URLParamKubeEventID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getKubeEventHandler := kube_events.NewGetKubeEventHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getKubeEventEndpoint,
+		Handler:  getKubeEventHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events/{kube_event_id}/logs -> kube_events.NewGetKubeEventLogsHandler
+	getKubeEventLogsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/kube_events/{%s}/logs", relPath, types.URLParamKubeEventID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getKubeEventLogsHandler := kube_events.NewGetKubeEventLogsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getKubeEventLogsEndpoint,
+		Handler:  getKubeEventLogsHandler,
+		Router:   r,
+	})
+
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/kube_events/{kube_event_id}/log_buckets -> kube_events.NewGetKubeEventLogBucketsHandler
+	getKubeEventLogBucketsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/kube_events/{%s}/log_buckets", relPath, types.URLParamKubeEventID),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	getKubeEventLogBucketsHandler := kube_events.NewGetKubeEventLogBucketsHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getKubeEventLogBucketsEndpoint,
+		Handler:  getKubeEventLogBucketsHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/kube_events -> kube_events.NewCreateKubeEventHandler
+	createKubeEventsEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/kube_events",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	createKubeEventsHandler := kube_events.NewCreateKubeEventHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: createKubeEventsEndpoint,
+		Handler:  createKubeEventsHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/prometheus/ingresses -> cluster.NewListNGINXIngressesHandler
 	listNGINXIngressesEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 33 - 0
api/server/router/namespace.go

@@ -425,6 +425,39 @@ func getNamespaceRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/pods/{name} -> namespace.NewGetPodHandler
+	getPodEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent: basePath,
+				RelativePath: fmt.Sprintf(
+					"%s/pods/{%s}",
+					relPath,
+					types.URLParamPodName,
+				),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	getPodHandler := namespace.NewGetPodHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getPodEndpoint,
+		Handler:  getPodHandler,
+		Router:   r,
+	})
+
 	// DELETE /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/pods/{name} -> namespace.NewDeletePodHandler
 	deletePodEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 30 - 0
api/server/router/release.go

@@ -293,6 +293,36 @@ func getReleaseRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/buildconfig -> release.NewUpdateBuildConfigHandler
+	updateBuildConfigEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/releases/{name}/buildconfig",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+				types.NamespaceScope,
+			},
+		},
+	)
+
+	updateBuildConfigHandler := release.NewUpdateBuildConfigHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: updateBuildConfigEndpoint,
+		Handler:  updateBuildConfigHandler,
+		Router:   r,
+	})
+
 	// GET /api/projects/{project_id}/clusters/{cluster_id}/namespaces/{namespace}/releases/{name}/webhook -> release.NewGetWebhookHandler
 	getWebhookEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{

+ 20 - 0
api/types/build_config.go

@@ -0,0 +1,20 @@
+package types
+
+// BuildConfig
+type BuildConfig struct {
+	Builder    string   `json:"builder"`
+	Buildpacks []string `json:"buildpacks"`
+	Config     []byte   `json:"config"`
+}
+
+type CreateBuildConfigRequest struct {
+	Builder    string                 `json:"builder" form:"required"`
+	Buildpacks []string               `json:"buildpacks"`
+	Config     map[string]interface{} `json:"config,omitempty"`
+}
+
+type UpdateBuildConfigRequest struct {
+	Builder    string                 `json:"builder"`
+	Buildpacks []string               `json:"buildpacks"`
+	Config     map[string]interface{} `json:"config,omitempty"`
+}

+ 0 - 5
api/types/git_installation.go

@@ -39,11 +39,6 @@ type GetBuildpackRequest struct {
 	GithubDirectoryRequest
 }
 
-type GetBuildpackResponse struct {
-	Valid bool   `json:"valid"`
-	Name  string `json:"name"`
-}
-
 type GetContentsRequest struct {
 	GithubDirectoryRequest
 }

+ 91 - 0
api/types/kube_events.go

@@ -0,0 +1,91 @@
+package types
+
+import "time"
+
+const (
+	URLParamKubeEventID = "kube_event_id"
+)
+
+type KubeEventType string
+
+const (
+	KubeEventTypeCritical KubeEventType = "critical"
+	KubeEventTypeNormal   KubeEventType = "normal"
+)
+
+type GroupOptions struct {
+	ResourceType  string
+	Name          string
+	Namespace     string
+	ThresholdTime time.Time
+}
+
+// CreateKubeEventRequest is the type for creating a new kube event
+type CreateKubeEventRequest struct {
+	ResourceType string        `json:"resource_type" form:"required"`
+	Name         string        `json:"name" form:"required"`
+	OwnerType    string        `json:"owner_type"`
+	OwnerName    string        `json:"owner_name"`
+	EventType    KubeEventType `json:"event_type" form:"required"`
+	Namespace    string        `json:"namespace"`
+	Message      string        `json:"message" form:"required"`
+	Reason       string        `json:"reason"`
+	Timestamp    time.Time     `json:"timestamp" form:"required"`
+}
+
+type KubeEvent struct {
+	CreatedAt time.Time `json:"created_at"`
+	UpdatedAt time.Time `json:"updated_at"`
+
+	ID        uint `json:"id"`
+	ProjectID uint `json:"project_id"`
+	ClusterID uint `json:"cluster_id"`
+
+	ResourceType string `json:"resource_type"`
+	Name         string `json:"name"`
+	OwnerType    string `json:"owner_type"`
+	OwnerName    string `json:"owner_name"`
+	Namespace    string `json:"namespace"`
+
+	SubEvents []*KubeSubEvent `json:"sub_events"`
+}
+
+type KubeSubEvent struct {
+	EventType KubeEventType `json:"event_type"`
+	Message   string        `json:"message"`
+	Reason    string        `json:"reason"`
+	Timestamp time.Time     `json:"timestamp"`
+}
+
+type ListKubeEventRequest struct {
+	Limit int `schema:"limit"`
+	Skip  int `schema:"skip"`
+
+	// can only be "timestamp" for now
+	SortBy string `schema:"sort_by"`
+
+	OwnerType string `schema:"owner_type"`
+	OwnerName string `schema:"owner_name"`
+
+	ResourceType string `schema:"resource_type"`
+}
+
+type ListKubeEventsResponse struct {
+	Count int64 `json:"count"`
+	Limit int   `json:"limit"`
+	Skip  int   `json:"skip"`
+
+	KubeEvents []*KubeEvent `json:"kube_events"`
+}
+
+type GetKubeEventLogsRequest struct {
+	Timestamp int `schema:"timestamp"`
+}
+
+type GetKubeEventLogsResponse struct {
+	Logs []string `json:"logs"`
+}
+
+type GetKubeEventLogBucketsResponse struct {
+	LogBuckets []string `json:"log_buckets"`
+}

+ 4 - 0
api/types/release.go

@@ -20,6 +20,7 @@ type PorterRelease struct {
 	LatestVersion   string           `json:"latest_version"`
 	GitActionConfig *GitActionConfig `json:"git_action_config,omitempty"`
 	ImageRepoURI    string           `json:"image_repo_uri"`
+	BuildConfig     *BuildConfig     `json:"build_config,omitempty"`
 }
 
 type GetReleaseResponse Release
@@ -45,6 +46,7 @@ type CreateReleaseRequest struct {
 
 	ImageURL           string                        `json:"image_url" form:"required"`
 	GithubActionConfig *CreateGitActionConfigRequest `json:"github_action_config,omitempty"`
+	BuildConfig        *CreateBuildConfigRequest     `json:"build_config,omitempty"`
 }
 
 type CreateAddonRequest struct {
@@ -114,6 +116,8 @@ type NotificationConfig struct {
 	Enabled bool `json:"enabled"`
 	Success bool `json:"success"`
 	Failure bool `json:"failure"`
+
+	NotifLimit string `json:"notif_limit"`
 }
 
 type GetNotificationConfigResponse struct {

+ 5 - 2
cli/cmd/deploy/build.go

@@ -7,6 +7,7 @@ import (
 	"strings"
 
 	api "github.com/porter-dev/porter/api/client"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/pack"
 )
@@ -28,6 +29,7 @@ func (b *BuildAgent) BuildDocker(
 	buildCtx,
 	dockerfilePath,
 	tag string,
+	currentTag string,
 ) error {
 	buildCtx, dockerfilePath, isDockerfileInCtx, err := ResolveDockerPaths(
 		basePath,
@@ -42,6 +44,7 @@ func (b *BuildAgent) BuildDocker(
 	opts := &docker.BuildOpts{
 		ImageRepo:         b.imageRepo,
 		Tag:               tag,
+		CurrentTag:        currentTag,
 		BuildContext:      buildCtx,
 		Env:               b.env,
 		DockerfilePath:    dockerfilePath,
@@ -54,7 +57,7 @@ func (b *BuildAgent) BuildDocker(
 }
 
 // BuildPack uses the cloud-native buildpack client to build a container image
-func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag string) error {
+func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag string, buildConfig *types.BuildConfig) error {
 	// retag the image with "pack-cache" tag so that it doesn't re-pull from the registry
 	if b.imageExists {
 		err := dockerAgent.TagImage(
@@ -81,7 +84,7 @@ func (b *BuildAgent) BuildPack(dockerAgent *docker.Agent, dst, tag string) error
 	}
 
 	// call builder
-	err := packAgent.Build(opts)
+	err := packAgent.Build(opts, buildConfig)
 
 	if err != nil {
 		return err

+ 2 - 2
cli/cmd/deploy/create.go

@@ -281,9 +281,9 @@ func (c *CreateAgent) CreateFromDocker(
 	}
 
 	if opts.Method == DeployBuildTypeDocker {
-		err = buildAgent.BuildDocker(agent, opts.LocalPath, opts.LocalPath, opts.LocalDockerfile, "latest")
+		err = buildAgent.BuildDocker(agent, opts.LocalPath, opts.LocalPath, opts.LocalDockerfile, "latest", "")
 	} else {
-		err = buildAgent.BuildPack(agent, opts.LocalPath, "latest")
+		err = buildAgent.BuildPack(agent, opts.LocalPath, "latest", nil)
 	}
 
 	if err != nil {

+ 7 - 4
cli/cmd/deploy/deploy.go

@@ -238,10 +238,12 @@ func (d *DeployAgent) Build() error {
 		}
 	}
 
-	if d.tag == "" {
-		currImageSection := d.release.Config["image"].(map[string]interface{})
+	// retrieve current image to use for cache
+	currImageSection := d.release.Config["image"].(map[string]interface{})
+	currentTag := currImageSection["tag"].(string)
 
-		d.tag = currImageSection["tag"].(string)
+	if d.tag == "" {
+		d.tag = currentTag
 	}
 
 	err = d.pullCurrentReleaseImage()
@@ -269,10 +271,11 @@ func (d *DeployAgent) Build() error {
 			buildCtx,
 			d.dockerfilePath,
 			d.tag,
+			currentTag,
 		)
 	}
 
-	return buildAgent.BuildPack(d.agent, buildCtx, d.tag)
+	return buildAgent.BuildPack(d.agent, buildCtx, d.tag, d.release.BuildConfig)
 }
 
 // Push pushes a local image to the remote repository linked in the release

+ 4 - 0
cli/cmd/docker/builder.go

@@ -21,6 +21,7 @@ import (
 type BuildOpts struct {
 	ImageRepo         string
 	Tag               string
+	CurrentTag        string
 	BuildContext      string
 	DockerfilePath    string
 	IsDockerfileInCtx bool
@@ -67,6 +68,9 @@ func (a *Agent) BuildLocal(opts *BuildOpts) error {
 		Tags: []string{
 			fmt.Sprintf("%s:%s", opts.ImageRepo, opts.Tag),
 		},
+		CacheFrom: []string{
+			fmt.Sprintf("%s:%s", opts.ImageRepo, opts.CurrentTag),
+		},
 		Remove: true,
 	})
 

+ 10 - 1
cli/cmd/pack/pack.go

@@ -6,12 +6,13 @@ import (
 	"path/filepath"
 
 	"github.com/buildpacks/pack"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 )
 
 type Agent struct{}
 
-func (a *Agent) Build(opts *docker.BuildOpts) error {
+func (a *Agent) Build(opts *docker.BuildOpts, buildConfig *types.BuildConfig) error {
 	//create a context object
 	context := context.Background()
 
@@ -37,5 +38,13 @@ func (a *Agent) Build(opts *docker.BuildOpts) error {
 		Env:             opts.Env,
 	}
 
+	if buildConfig != nil {
+		buildOpts.Builder = buildConfig.Builder
+		if len(buildConfig.Buildpacks) > 0 {
+			buildOpts.Buildpacks = buildConfig.Buildpacks
+		}
+		// FIXME: use all the config vars
+	}
+
 	return client.Build(context, buildOpts)
 }

Fișier diff suprimat deoarece este prea mare
+ 14115 - 695
dashboard/package-lock.json


+ 1 - 0
dashboard/package.json

@@ -39,6 +39,7 @@
     "react-ace": "^9.1.3",
     "react-dom": "^16.13.1",
     "react-error-boundary": "^3.1.3",
+    "react-infinite-scroll-component": "^6.1.0",
     "react-modal": "^3.11.2",
     "react-router-dom": "^5.2.0",
     "react-table": "^7.7.0",

+ 157 - 0
dashboard/src/components/Dropdown.tsx

@@ -0,0 +1,157 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+type Option = {
+  value: unknown;
+  label: string;
+};
+
+type DropdownProps = {
+  options: Array<Option>;
+  selectedOption: Option;
+  onSelect?: (selectedOption: Option) => unknown;
+  selectLabel?: (currentLabel: string) => void;
+  selectValue?: (currentValue: any) => void;
+};
+
+const Dropdown: React.FunctionComponent<DropdownProps> = ({
+  options,
+  selectedOption,
+  selectLabel,
+  selectValue,
+  onSelect,
+}) => {
+  const [isDropdownExpanded, setIsDropdownExpanded] = useState(false);
+
+  const handleSelectOption = (option: Option) => {
+    if (selectedOption.label === option.label) {
+      return;
+    }
+    onSelect(option);
+    typeof selectLabel === "function" && selectLabel(option.label);
+    typeof selectValue === "function" && selectValue(option.value);
+  };
+
+  const renderDropdown = () => {
+    if (isDropdownExpanded) {
+      return (
+        <>
+          <DropdownOverlay onClick={() => setIsDropdownExpanded(false)} />
+          <OptionWrapper
+            dropdownWidth="230px"
+            dropdownMaxHeight="200px"
+            onClick={() => setIsDropdownExpanded(false)}
+          >
+            {renderOptionList()}
+          </OptionWrapper>
+        </>
+      );
+    }
+  };
+
+  const renderOptionList = () => {
+    return options.map((option, i, originalArray) => {
+      return (
+        <Option
+          key={i}
+          selected={option.label === selectedOption.label}
+          onClick={() => handleSelectOption(option)}
+          lastItem={i === originalArray.length - 1}
+        >
+          {option.label}
+        </Option>
+      );
+    });
+  };
+
+  return (
+    <DropdownSelector
+      onClick={() => setIsDropdownExpanded(!isDropdownExpanded)}
+    >
+      <DropdownLabel>{selectedOption?.label}</DropdownLabel>
+      <i className="material-icons">arrow_drop_down</i>
+      {renderDropdown()}
+    </DropdownSelector>
+  );
+};
+
+export default Dropdown;
+
+const DropdownSelector = styled.div`
+  font-size: 13px;
+  font-weight: 500;
+  position: relative;
+  color: #ffffff;
+  display: flex;
+  align-items: center;
+  cursor: pointer;
+  border-radius: 5px;
+  :hover {
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 20px;
+    margin-left: 10px;
+  }
+`;
+
+const DropdownLabel = styled.div`
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+  max-width: 200px;
+`;
+
+const DropdownOverlay = styled.div`
+  position: fixed;
+  width: 100%;
+  height: 100%;
+  z-index: 10;
+  left: 0px;
+  top: 0px;
+  cursor: default;
+`;
+
+const OptionWrapper = styled.div`
+  position: absolute;
+  left: 0;
+  top: calc(100% + 10px);
+  background: #26282f;
+  width: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownWidth};
+  max-height: ${(props: { dropdownWidth: string; dropdownMaxHeight: string }) =>
+    props.dropdownMaxHeight || "300px"};
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0px 4px 10px 0px #00000088;
+`;
+
+const Option = styled.div`
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid
+    ${(props: { selected: boolean; lastItem: boolean }) =>
+      props.lastItem ? "#ffffff00" : "#ffffff15"};
+  height: 37px;
+  font-size: 13px;
+  padding-top: 9px;
+  align-items: center;
+  padding-left: 15px;
+  cursor: pointer;
+  padding-right: 10px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  background: ${(props: { selected: boolean; lastItem: boolean }) =>
+    props.selected ? "#ffffff11" : ""};
+
+  :hover {
+    background: #ffffff22;
+  }
+`;

+ 1 - 0
dashboard/src/components/ResourceTab.tsx

@@ -142,6 +142,7 @@ export default class ResourceTab extends Component<PropsType, StateType> {
 const StyledResourceTab = styled.div`
   width: 100%;
   margin-bottom: 2px;
+  font-size: 13px;
   background: #ffffff11;
   border-bottom-left-radius: ${(props: {
     isLast: boolean;

+ 1 - 1
dashboard/src/components/SaveButton.tsx

@@ -77,7 +77,7 @@ const SaveButton: React.FC<Props> = (props) => {
         rounded={props.rounded}
         disabled={props.disabled}
         onClick={props.onClick}
-        color={props.color || "#616FEEcc"}
+        color={props.color || "#5561C0"}
       >
         {props.children || props.text}
       </Button>

+ 2 - 1
dashboard/src/components/Selector.tsx

@@ -16,6 +16,7 @@ type PropsType = {
   closeOverlay?: boolean;
   placeholder?: string;
   scrollBuffer?: boolean;
+  disableTooltip?: boolean;
 };
 
 type StateType = {};
@@ -185,7 +186,7 @@ export default class Selector extends Component<PropsType, StateType> {
           </Flex>
           <i className="material-icons">arrow_drop_down</i>
         </MainSelector>
-        {this.state.showTooltip && (
+        {!this.props.disableTooltip && this.state.showTooltip && (
           <Tooltip>
             {activeValue
               ? activeValue === ""

+ 1 - 1
dashboard/src/components/UnexpectedErrorPage.tsx

@@ -15,7 +15,7 @@ const UnexpectedErrorPage = ({ error, resetErrorBoundary }: any) => (
         </BackButton>
         <Splitter>|</Splitter>
         <Helper>
-          Sorry for the inconvinience! The Porter team has been notified
+          Sorry for the inconvenience! The Porter team has been notified.
         </Helper>
       </Flex>
     </StyledPageNotFound>

+ 202 - 0
dashboard/src/components/events/EventCard.tsx

@@ -0,0 +1,202 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+type CardProps = {
+  event: any;
+  selectEvent?: (event: any) => void;
+  overrideName?: string;
+};
+
+export const getReadableDate = (s: string) => {
+  let ts = new Date(s);
+  let date = ts.toLocaleDateString();
+  let time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} ${date}`;
+};
+
+// Rename to Event Card
+const EventCard: React.FunctionComponent<CardProps> = ({
+  event,
+  selectEvent,
+  overrideName,
+}) => {
+  const [showTooltip, setShowTooltip] = useState(false);
+  return (
+    <>
+      <StyledCard
+        onClick={() => selectEvent(event)}
+        status={event.event_type.toLowerCase()}
+      >
+        <ContentContainer>
+          <Icon
+            status={event.event_type.toLowerCase() as any}
+            className="material-icons-outlined"
+          >
+            {event.event_type === "critical" ? "report_problem" : "info"}
+          </Icon>
+          <EventInformation>
+            <EventName>
+              <Helper>{event.resource_type}:</Helper>
+              {event.name}
+            </EventName>
+            <EventReason>{event.last_message}</EventReason>
+          </EventInformation>
+        </ContentContainer>
+        <ActionContainer>
+          <TimestampContainer>
+            <TimestampIcon className="material-icons-outlined">
+              access_time
+            </TimestampIcon>
+            <span>{getReadableDate(event.timestamp)}</span>
+          </TimestampContainer>
+        </ActionContainer>
+      </StyledCard>
+    </>
+  );
+};
+
+export default EventCard;
+
+const StyledCard = styled.div<{ status: string }>`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid
+    ${({ status }) => (status === "critical" ? "#ff385d" : "#ffffff44")};
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 10px;
+  padding: 14px;
+  overflow: hidden;
+  height: 80px;
+  font-size: 13px;
+  cursor: pointer;
+  :hover {
+    background: #ffffff11;
+    border: 1px solid
+      ${({ status }) => (status === "critical" ? "#ff385d" : "#ffffff66")};
+  }
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span<{ status: "critical" | "normal" }>`
+  font-size: 20px;
+  margin-left: 10px;
+  margin-right: 20px;
+  color: ${({ status }) => (status === "critical" ? "#ff385d" : "#aaaabb")};
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const Helper = styled.span`
+  text-transform: capitalize;
+  color: #ffffff44;
+  margin-right: 5px;
+`;
+
+const EventReason = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  margin-top: 5px;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const HistoryButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  color: #ffffff44;
+  :hover {
+    background: #32343a;
+    cursor: pointer;
+  }
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: 0px;
+  word-wrap: break-word;
+  top: 38px;
+  min-height: 18px;
+  padding: 5px 7px;
+  background: #272731;
+  z-index: 999;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const TimestampContainer = styled.div`
+  display: flex;
+  white-space: nowrap;
+  align-items: center;
+  justify-self: flex-end;
+  color: #ffffff55;
+  margin-right: 10px;
+  font-size: 13px;
+  min-width: 130px;
+  justify-content: space-between;
+`;
+
+const TimestampIcon = styled.span`
+  margin-right: 7px;
+  font-size: 18px;
+`;

+ 360 - 0
dashboard/src/components/events/SubEventsList.tsx

@@ -0,0 +1,360 @@
+import React, { useContext, useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import SubEventCard from "./sub-events/SubEventCard";
+import Loading from "components/Loading";
+import LogBucketCard from "./sub-events/LogBucketCard";
+import useLastSeenPodStatus from "./useLastSeenPodStatus";
+
+const getReadableDate = (s: number) => {
+  let ts = new Date(s);
+  let date = ts.toLocaleDateString();
+  let time = ts.toLocaleTimeString([], {
+    hour: "numeric",
+    minute: "2-digit",
+  });
+  return `${time} ${date}`;
+};
+
+const SubEventsList: React.FC<{
+  clearSelectedEvent: () => void;
+  event: any;
+  enableTopMargin?: boolean;
+}> = ({ event, clearSelectedEvent, enableTopMargin }) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const {
+    status,
+    hasError: hasPodStatusErrored,
+    isLoading: isPodStatusLoading,
+  } = useLastSeenPodStatus({
+    podName: event.name,
+    namespace: event.namespace,
+    resource_type: event.resource_type,
+  });
+  const [isLoading, setIsLoading] = useState(true);
+  const [subEvents, setSubEvents] = useState(null);
+
+  const getData = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+    const kube_event_id = event?.id;
+    let updatedEvent: any = null;
+    try {
+      updatedEvent = await api
+        .getKubeEvent("<token>", {}, { project_id, cluster_id, kube_event_id })
+        .then((res) => res?.data);
+    } catch (error) {
+      console.error(error);
+    }
+
+    let logBucketsParsed = [];
+    try {
+      const logBucketsData = await api
+        .getLogBuckets("token", {}, { project_id, cluster_id, kube_event_id })
+        .then((res) => res?.data);
+
+      logBucketsParsed = logBucketsData.log_buckets.map((bucket: string) => {
+        const [
+          _resourceType,
+          _namespace,
+          resource_name,
+          timestamp,
+        ] = bucket.split(":");
+        return {
+          event_type: "log_bucket",
+          resource_name,
+          timestamp: new Date(Number(timestamp) * 1000).toUTCString(),
+          parent_id: updatedEvent?.id,
+        };
+      });
+    } catch (error) {
+      console.error(error);
+    }
+
+    const subEventsSorted = (updatedEvent.sub_events as any[])
+      .map((s: any) => ({
+        ...s,
+        timestamp: new Date(s.timestamp).getTime(),
+      }))
+      .sort((prev: any, next: any) => next.timestamp - prev.timestamp);
+
+    const firstEvent = subEventsSorted.shift();
+    const lastEvent = subEventsSorted.pop();
+
+    const filteredLogBuckets = (logBucketsParsed as any[]).filter((bucket) => {
+      const bucketTime = new Date(bucket.timestamp).getTime();
+      return (
+        bucketTime >= lastEvent.timestamp && bucketTime <= firstEvent.timestamp
+      );
+    });
+
+    setSubEvents([...updatedEvent.sub_events, ...filteredLogBuckets]);
+    setIsLoading(false);
+  };
+
+  useEffect(() => {
+    getData();
+  }, [event, currentCluster, currentProject]);
+
+  const sortedSubEvents = useMemo(() => {
+    if (!Array.isArray(subEvents)) {
+      return [];
+    }
+    return subEvents
+      .map((s) => ({
+        ...s,
+        timestamp: new Date(s.timestamp).getTime(),
+      }))
+      .sort((prev, next) => next.timestamp - prev.timestamp)
+      .map((s) => ({
+        ...s,
+        timestamp: new Date(s.timestamp).toUTCString(),
+      }));
+  }, [subEvents]);
+
+  return (
+    <>
+      <Timeline enableTopMargin={enableTopMargin}>
+        <ControlRow>
+          <BackButton onClick={clearSelectedEvent}>
+            <i className="material-icons">close</i>
+          </BackButton>
+          <Icon
+            status={event.event_type.toLowerCase() as any}
+            className="material-icons-outlined"
+          >
+            {event.event_type === "critical" ? "report_problem" : "info"}
+          </Icon>
+          <div>
+            Pod {event.name} crashed
+            {event?.resource_type?.toLowerCase() === "pod" && (
+              <StyledHelper>
+                {hasPodStatusErrored ? (
+                  "We couldn't retrieve last pod status, please try again later"
+                ) : (
+                  <>
+                    {isPodStatusLoading ? (
+                      "Loading last seen pod status"
+                    ) : (
+                      <>
+                        Last seen pod status: {status}{" "}
+                        <StatusColor
+                          status={status?.toLowerCase()}
+                        ></StatusColor>
+                      </>
+                    )}
+                  </>
+                )}
+              </StyledHelper>
+            )}
+          </div>
+        </ControlRow>
+        {isLoading ? (
+          <Placeholder>
+            <Loading />
+          </Placeholder>
+        ) : sortedSubEvents?.length ? (
+          <EventsGrid>
+            <Rail />
+            {sortedSubEvents.map((subEvent: any, i: number) => {
+              if (subEvent?.event_type === "log_bucket") {
+                return (
+                  <Wrapper>
+                    <TimelineNode>
+                      <Penumbra>
+                        <Circle />
+                      </Penumbra>
+                      {getReadableDate(subEvent.timestamp)}
+                    </TimelineNode>
+                    <LogBucketCard logEvent={subEvent} />
+                    {i === sortedSubEvents.length - 1 && <RailCover />}
+                  </Wrapper>
+                );
+              }
+              return (
+                <Wrapper>
+                  <TimelineNode>
+                    <Penumbra>
+                      <Circle />
+                    </Penumbra>
+                    {getReadableDate(subEvent.timestamp)}
+                  </TimelineNode>
+                  <SubEventCard subEvent={subEvent} />
+                  {i === sortedSubEvents.length - 1 && <RailCover />}
+                </Wrapper>
+              );
+            })}
+          </EventsGrid>
+        ) : (
+          <Placeholder>
+            <i className="material-icons">search</i>
+            No sub-events were found.
+          </Placeholder>
+        )}
+      </Timeline>
+    </>
+  );
+};
+
+export default SubEventsList;
+
+const StyledHelper = styled.div`
+  color: #aaaabb;
+  line-height: 1.6em;
+  font-size: 13px;
+`;
+
+const Placeholder = styled.div`
+  padding: 30px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 340px;
+  margin-top: 20px;
+  background: #ffffff08;
+  height: calc(50vh - 60px);
+  border-radius: 8px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const RailCover = styled.div`
+  background: #202227;
+  height: 100%;
+  width: 35px;
+  position: absolute;
+  top: 20px;
+  left: 0;
+`;
+
+const Penumbra = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #202227;
+  padding: 8px;
+  border-radius: 30px;
+  margin-right: 4px;
+`;
+
+const TimelineNode = styled.div`
+  position: absolute;
+  top: 0;
+  left: 7px;
+  display: flex;
+  align-items: center;
+  color: #aaaabb;
+  font-size: 13px;
+`;
+
+const Circle = styled.div`
+  width: 7px;
+  height: 7px;
+  border-radius: 20px;
+  background: #aaaabb;
+`;
+
+const Wrapper = styled.div`
+  position: relative;
+  width: 100%;
+  padding-top: 35px;
+  padding-left: 35px;
+`;
+
+const Rail = styled.div`
+  position: absolute;
+  top: -8px;
+  left: 17px;
+  width: 3px;
+  height: 100%;
+  z-index: -1;
+  background: #36383d;
+`;
+
+const Timeline = styled.div`
+  margin-top: ${(props: { enableTopMargin: boolean }) =>
+    props.enableTopMargin ? "30px" : "unset"};
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const Icon = styled.span<{ status: "critical" | "normal" }>`
+  font-size: 26px;
+  margin-left: 17px;
+  margin-right: 10px;
+  color: ${({ status }) => (status === "critical" ? "#ff385d" : "#aaaabb")};
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: flex-start;
+  align-items: center;
+  margin-bottom: 15px;
+  padding-left: 0px;
+  font-weight: 500;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  width: 37px;
+  z-index: 1;
+  cursor: pointer;
+  height: 37px;
+  align-items: center;
+  justify-content: center;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  background: #ffffff11;
+
+  > i {
+    font-size: 20px;
+  }
+
+  :hover {
+    background: #ffffff22;
+    > img {
+      opacity: 1;
+    }
+  }
+`;
+
+const EventsGrid = styled.div`
+  position: relative;
+  padding-top: 9px;
+`;
+
+const StatusColor = styled.div`
+  display: inline-block;
+  margin-right: 7px;
+  width: 7px;
+  min-width: 7px;
+  height: 7px;
+  background: ${(props: { status: string }) =>
+    props.status === "running"
+      ? "#4797ff"
+      : props.status === "failed" || props.status === "deleted"
+      ? "#ed5f85"
+      : props.status === "completed"
+      ? "#00d12a"
+      : "#f5cb42"};
+  border-radius: 20px;
+`;

+ 166 - 0
dashboard/src/components/events/sub-events/LogBucketCard.tsx

@@ -0,0 +1,166 @@
+import React, { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import styled, { keyframes } from "styled-components";
+
+type LogBucketCardProps = {
+  logEvent: any;
+};
+
+const LogBucketCard: React.FunctionComponent<LogBucketCardProps> = ({
+  logEvent,
+}) => {
+  const { currentProject, currentCluster } = useContext(Context);
+  const [isLoading, setIsLoading] = useState(true);
+  const [isExpanded, setIsExpanded] = useState(false);
+  const [logs, setLogs] = useState([]);
+
+  const getLogsForBucket = async () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+    const kube_event_id = logEvent?.parent_id;
+    const timestamp = logEvent?.timestamp;
+    try {
+      const logsData = await api
+        .getLogBucketLogs(
+          "<token>",
+          { timestamp: new Date(timestamp).getTime() },
+          { project_id, cluster_id, kube_event_id }
+        )
+        .then((res) => res?.data);
+
+      if (!Array.isArray(logsData.logs)) {
+        setLogs([]);
+        setIsLoading(false);
+        return;
+      }
+
+      const filteredLogs = logsData.logs.filter((log: string | unknown) => {
+        if (typeof log === "string") {
+          return log.length;
+        }
+        return false;
+      });
+      setLogs(filteredLogs);
+      setIsLoading(false);
+    } catch (error) {
+      console.error(error);
+    }
+  };
+
+  useEffect(() => {
+    if (!isExpanded) {
+      return;
+    }
+
+    if (!Array.isArray(logs) || !logs.length) {
+      getLogsForBucket();
+    }
+  }, [currentProject, currentCluster, logEvent, isExpanded]);
+
+  return (
+    <StyledCard>
+      <FlexCenter expandLogs={isExpanded}>
+        <ShowLogsButton
+          onClick={() => setIsExpanded((prevIsExpanded) => !prevIsExpanded)}
+        >
+          {isExpanded ? "Hide logs" : "Display logs"}
+          <ButtonIcon className="material-icons">
+            {isExpanded ? "arrow_drop_up" : "arrow_drop_down"}
+          </ButtonIcon>
+        </ShowLogsButton>
+      </FlexCenter>
+      {isExpanded && (
+        <>
+          {/* Case: Is still getting logs and user triggered expanded */}
+          {isLoading && <Loading>Loading . . .</Loading>}
+          {/* Case: No logs found after the api call */}
+          {!isLoading && !logs?.length && <Loading>No logs found.</Loading>}
+          {/* Case: Logs were found successfully  */}
+          {!isLoading && !!logs?.length && logs?.map((l) => <Log>{l}</Log>)}
+        </>
+      )}
+    </StyledCard>
+  );
+};
+
+export default LogBucketCard;
+
+const Loading = styled.div`
+  margin-top: 5px;
+  margin-left: 5px;
+`;
+
+const Log = styled.div`
+  font-family: monospace, sans-serif;
+  font-size: 12px;
+  color: white;
+`;
+
+const FlexCenter = styled.div`
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  ${(props: { expandLogs: boolean }) => {
+    if (!props.expandLogs) {
+      return "";
+    }
+
+    return `
+      border-bottom: solid 1px;
+      padding-bottom: 15px;
+      margin-bottom: 15px;
+      border-color: #515256;
+    `;
+  }}
+  transition-property: all;
+  transition-duration: 0.5s;
+  transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+`;
+
+const fadeInKeyframe = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
+
+const StyledCard = styled.div`
+  border: 1px solid #ffffff44;
+  margin-bottom: 30px;
+  border-radius: 10px;
+  padding: 14px;
+  padding-left: 13px;
+  font-size: 13px;
+  background: #121318;
+  user-select: text;
+  overflow-wrap: break-word;
+  overflow-y: auto;
+  min-height: 55px;
+  color: #aaaabb;
+
+  animation: ${fadeInKeyframe} 0.5s;
+`;
+
+const ShowLogsButton = styled.button`
+  border: solid 1px;
+  border-radius: 10px;
+  border-color: #515256;
+  color: white;
+  background: none;
+  padding: 8px 12px 8px 20px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+
+  :hover {
+    cursor: pointer;
+    background: #5152569c;
+  }
+`;
+
+const ButtonIcon = styled.i`
+  padding-left: 5px;
+`;

+ 57 - 0
dashboard/src/components/events/sub-events/SubEventCard.tsx

@@ -0,0 +1,57 @@
+import React, { useState } from "react";
+import styled from "styled-components";
+
+type CardProps = {
+  subEvent: any;
+};
+
+const SubEventCard: React.FunctionComponent<CardProps> = ({ subEvent }) => {
+  return (
+    <StyledCard status={subEvent.event_type.toLowerCase()}>
+      <Icon
+        status={subEvent.event_type.toLowerCase() as any}
+        className="material-icons-outlined"
+      >
+        {subEvent.event_type.toLowerCase() === "critical"
+          ? "report_problem"
+          : "info"}
+      </Icon>
+      {subEvent.message}
+    </StyledCard>
+  );
+};
+
+export default SubEventCard;
+
+const StyledCard = styled.div<{ status: string }>`
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  border: 1px solid
+    ${({ status }) => (status === "critical" ? "#ff385d" : "#ffffff44")};
+  background: #ffffff08;
+  margin-bottom: 30px;
+  border-radius: 10px;
+  padding: 14px;
+  padding-left: 13px;
+  overflow: hidden;
+  min-height: 55px;
+  font-size: 13px;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from {
+      opacity: 0;
+    }
+    to {
+      opacity: 1;
+    }
+  }
+`;
+
+const Icon = styled.span<{ status: "critical" | "normal" }>`
+  font-size: 20px;
+  margin-left: 10px;
+  margin-right: 13px;
+  color: ${({ status }) => (status === "critical" ? "#ff385d" : "#aaaabb")};
+`;

+ 214 - 0
dashboard/src/components/events/useEvents.ts

@@ -0,0 +1,214 @@
+import { unionBy } from "lodash";
+import { useContext, useEffect, useMemo, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { KubeEvent } from "shared/types";
+
+type UseKubeEventsProps = {
+  resourceType: "NODE" | "POD" | "HPA";
+  ownerName?: string;
+  ownerType?: string;
+  shouldWaitForOwner?: boolean;
+};
+
+export const useKubeEvents = ({
+  resourceType,
+  ownerName,
+  ownerType,
+  shouldWaitForOwner,
+}: UseKubeEventsProps) => {
+  const { currentCluster, currentProject } = useContext(Context);
+  const [hasPorterAgent, setHasPorterAgent] = useState(false);
+
+  const [isLoading, setIsLoading] = useState(true);
+  const [kubeEvents, setKubeEvents] = useState<KubeEvent[]>([]);
+  const [hasMore, setHasMore] = useState(true);
+  const [totalCount, setTotalCount] = useState(0);
+
+  // Check if the porter agent is installed or not
+  useEffect(() => {
+    let isSubscribed = true;
+
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    api
+      .detectPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then(() => {
+        setHasPorterAgent(true);
+      })
+      .catch(() => {
+        setHasPorterAgent(false);
+        setIsLoading(false);
+      });
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [currentProject, currentCluster]);
+
+  // Get events
+  useEffect(() => {
+    let isSubscribed = true;
+
+    if (shouldWaitForOwner && !ownerName?.length && !ownerType?.length) {
+      return () => {
+        isSubscribed = false;
+      };
+    }
+
+    if (hasPorterAgent) {
+      fetchData(true).then(() => {
+        if (isSubscribed) {
+          setIsLoading(false);
+        }
+      });
+    }
+
+    return () => {
+      isSubscribed = false;
+    };
+  }, [
+    currentProject?.id,
+    currentCluster?.id,
+    hasPorterAgent,
+    resourceType,
+    ownerType,
+    ownerName,
+  ]);
+
+  const fetchData = async (clear?: boolean) => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+    let skipBy;
+    if (!clear) {
+      skipBy = kubeEvents?.length;
+    } else {
+      setHasMore(true);
+    }
+
+    const type = resourceType;
+
+    try {
+      const data = await api
+        .getKubeEvents(
+          "<token>",
+          {
+            skip: skipBy,
+            resource_type: type,
+            owner_name: ownerName,
+            owner_type: ownerType,
+          },
+          { project_id, cluster_id }
+        )
+        .then((res) => res.data);
+
+      const newKubeEvents = data?.kube_events;
+      const totalCount = data?.count;
+
+      setTotalCount(totalCount);
+
+      if (!newKubeEvents?.length) {
+        setHasMore(false);
+        return;
+      }
+
+      if (clear) {
+        setKubeEvents(newKubeEvents);
+
+        if (totalCount === newKubeEvents.length) {
+          setHasMore(false);
+        } else {
+          setHasMore(true);
+        }
+
+        return;
+      }
+
+      const newEvents = unionBy(kubeEvents, newKubeEvents, "id");
+
+      if (totalCount === newEvents.length) {
+        setHasMore(false);
+      } else {
+        setHasMore(true);
+      }
+
+      setKubeEvents(newEvents);
+    } catch (error) {
+      console.log(error);
+    }
+  };
+
+  const installPorterAgent = () => {
+    const project_id = currentProject?.id;
+    const cluster_id = currentCluster?.id;
+
+    api
+      .installPorterAgent("<token>", {}, { project_id, cluster_id })
+      .then(() => {
+        setHasPorterAgent(true);
+      })
+      .catch(() => {
+        setHasPorterAgent(false);
+      });
+  };
+
+  const getLastSubEvent = (
+    subEvents: {
+      event_type: string;
+      message: string;
+      reason: string;
+      timestamp: string;
+    }[]
+  ) => {
+    const sortedEvents = subEvents
+      .map((s) => {
+        return {
+          ...s,
+          timestamp: new Date(s.timestamp).getTime(),
+        };
+      })
+      .sort((prev, next) => next.timestamp - prev.timestamp);
+
+    return sortedEvents[0];
+  };
+
+  // Fill up the data missing on events with the subevents
+  const processedKubeEvents = useMemo(() => {
+    return kubeEvents
+      .filter((event) => {
+        if (
+          !Array.isArray(event?.sub_events) ||
+          event.sub_events.length === 0
+        ) {
+          return false;
+        }
+        return true;
+      })
+      .map((e: any) => {
+        const lastSubEvent = getLastSubEvent(e.sub_events);
+
+        return {
+          ...e,
+          event_type: lastSubEvent.event_type,
+          timestamp: new Date(lastSubEvent.timestamp).getTime(),
+          last_message: lastSubEvent.message,
+        };
+      })
+      .sort((prev, next) => next.timestamp - prev.timestamp)
+      .map((s) => ({
+        ...s,
+        timestamp: new Date(s.timestamp).toUTCString(),
+      }));
+  }, [kubeEvents]);
+
+  return {
+    hasPorterAgent,
+    isLoading,
+    kubeEvents: processedKubeEvents,
+    hasMore,
+    totalCount,
+    loadMoreEvents: () => fetchData(),
+    triggerInstall: installPorterAgent,
+  };
+};

+ 89 - 0
dashboard/src/components/events/useLastSeenPodStatus.ts

@@ -0,0 +1,89 @@
+import { useContext, useEffect, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+const useLastSeenPodStatus = ({
+  podName,
+  namespace,
+  resource_type,
+}: {
+  podName: string;
+  namespace: string;
+  resource_type: string;
+}) => {
+  const [status, setCurrentStatus] = useState(null);
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasError, setHasError] = useState(false);
+  const { currentProject, currentCluster } = useContext(Context);
+
+  const getPodStatus = (status: any) => {
+    if (
+      status?.phase === "Pending" &&
+      status?.containerStatuses !== undefined
+    ) {
+      return status.containerStatuses[0].state.waiting.reason;
+    } else if (status?.phase === "Pending") {
+      return "Pending";
+    }
+
+    if (status?.phase === "Failed") {
+      return "failed";
+    }
+
+    if (status?.phase === "Running") {
+      let collatedStatus = "running";
+
+      status?.containerStatuses?.forEach((s: any) => {
+        if (s.state?.waiting) {
+          collatedStatus =
+            s.state?.waiting.reason === "CrashLoopBackOff"
+              ? "failed"
+              : "waiting";
+        } else if (s.state?.terminated) {
+          collatedStatus = "failed";
+        }
+      });
+      return collatedStatus;
+    }
+  };
+
+  const updatePods = async () => {
+    try {
+      const res = await api.getPodByName(
+        "<token>",
+        {},
+        {
+          project_id: currentProject.id,
+          cluster_id: currentCluster.id,
+          namespace: "default",
+          name: podName,
+        }
+      );
+      console.log(getPodStatus(res.data.status));
+
+      setCurrentStatus(getPodStatus(res.data.status));
+    } catch (error) {
+      if (error?.response?.status === 404) {
+        setCurrentStatus("Deleted");
+      } else {
+        setHasError(true);
+      }
+    } finally {
+      setIsLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (resource_type?.toLowerCase() === "pod") {
+      updatePods();
+    }
+  }, [podName, namespace, resource_type]);
+
+  return {
+    status,
+    isLoading,
+    hasError,
+  };
+};
+
+export default useLastSeenPodStatus;

+ 4 - 3
dashboard/src/components/image-selector/ImageList.tsx

@@ -280,17 +280,18 @@ const BackButton = styled.div`
   }
 `;
 
-const ImageItem = styled.div<{ lastItem: boolean, isSelected: boolean }>`
+const ImageItem = styled.div<{ lastItem: boolean; isSelected: boolean }>`
   display: flex;
   width: 100%;
   font-size: 13px;
-  border-bottom: 1px solid ${props => props.lastItem ? "#00000000" : "#606166"};
+  border-bottom: 1px solid
+    ${(props) => (props.lastItem ? "#00000000" : "#606166")};
   color: #ffffff;
   user-select: none;
   align-items: center;
   padding: 10px 0px;
   cursor: pointer;
-  background: ${props => props.isSelected ? "#ffffff11" : ""};
+  background: ${(props) => (props.isSelected ? "#ffffff11" : "")};
   :hover {
     background: #ffffff22;
 

+ 2 - 2
dashboard/src/components/image-selector/TagList.tsx

@@ -174,13 +174,13 @@ const TagName = styled.div<{ lastItem?: boolean; isSelected?: boolean }>`
   width: 100%;
   font-size: 13px;
   border-bottom: 1px solid
-    ${props => props.lastItem ? "#00000000" : "#606166"};
+    ${(props) => (props.lastItem ? "#00000000" : "#606166")};
   color: #ffffff;
   user-select: none;
   align-items: center;
   padding: 10px 0px;
   cursor: pointer;
-  background: ${props => props.isSelected ? "#ffffff11" : ""};
+  background: ${(props) => (props.isSelected ? "#ffffff11" : "")};
   :hover {
     background: #ffffff22;
 

+ 8 - 8
dashboard/src/components/porter-form/types.ts

@@ -41,16 +41,16 @@ export interface ResourceListField extends GenericField {
   value: any[];
   context?: {
     config?: {
-      group: string
-      version: string
-      resource: string
-    }
-  },
+      group: string;
+      version: string;
+      resource: string;
+    };
+  };
   settings?: {
     options?: {
-      "resource-button": any,
-    }
-  }
+      "resource-button": any;
+    };
+  };
 }
 
 export interface VeleroBackupField extends GenericField {

+ 2 - 0
dashboard/src/components/repo-selector/ActionConfEditor.tsx

@@ -24,6 +24,7 @@ type Props = {
   setFolderPath: (x: string) => void;
   setSelectedRegistry: (x: any) => void;
   selectedRegistry: any;
+  setBuildConfig: (x: any) => void;
 };
 
 const defaultActionConfig: ActionConfigType = {
@@ -121,6 +122,7 @@ const ActionConfEditor: React.FC<Props> = (props) => {
       folderPath={props.folderPath}
       setSelectedRegistry={props.setSelectedRegistry}
       selectedRegistry={props.selectedRegistry}
+      setBuildConfig={props.setBuildConfig}
     />
   );
 };

+ 499 - 135
dashboard/src/components/repo-selector/ActionDetails.tsx

@@ -1,5 +1,11 @@
-import React, { Component } from "react";
-import styled from "styled-components";
+import React, {
+  Component,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from "react";
+import styled, { keyframes } from "styled-components";
 
 import { integrationList } from "shared/common";
 import { Context } from "shared/Context";
@@ -7,6 +13,10 @@ import api from "shared/api";
 import Loading from "components/Loading";
 import { ActionConfigType } from "../../shared/types";
 import InputRow from "../form-components/InputRow";
+import Selector from "components/Selector";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import SelectRow from "components/form-components/SelectRow";
 
 type PropsType = {
   actionConfig: ActionConfigType | null;
@@ -21,42 +31,64 @@ type PropsType = {
   selectedRegistry: any;
   setDockerfilePath: (x: string) => void;
   setFolderPath: (x: string) => void;
+  setBuildConfig: (x: any) => void;
 };
 
-type StateType = {
-  dockerRepo: string;
-  error: boolean;
-  registries: any[] | null;
-  loading: boolean;
+type Buildpack = {
+  name: string;
+  buildpack: string;
+  config: {
+    [key: string]: string;
+  };
 };
 
-export default class ActionDetails extends Component<PropsType, StateType> {
-  state = {
-    dockerRepo: "",
-    error: false,
-    registries: null as any[] | null,
-    loading: true,
-  };
+type DetectedBuildpack = {
+  name: string;
+  builders: string[];
+  detected: Buildpack[];
+  others: Buildpack[];
+};
+
+type DetectBuildpackResponse = DetectedBuildpack[];
+
+const ActionDetails: React.FC<PropsType> = (props) => {
+  const {
+    actionConfig,
+    branch,
+    dockerfilePath,
+    folderPath,
+    procfilePath,
+    selectedRegistry,
+    setActionConfig,
+    setDockerfilePath,
+    setFolderPath,
+    setProcfilePath,
+    setProcfileProcess,
+    setSelectedRegistry,
+    setBuildConfig,
+  } = props;
+
+  const { currentProject } = useContext(Context);
+  const [registries, setRegistries] = useState<any[]>(null);
+  const [loading, setLoading] = useState(true);
+  const [showBuildpacksConfig, setShowBuildpacksConfig] = useState(false);
+
+  useEffect(() => {
+    const project_id = currentProject.id;
 
-  componentDidMount() {
-    // TODO: Handle custom registry case (unroll repos?)
     api
-      .getProjectRegistries(
-        "<token>",
-        {},
-        { id: this.context.currentProject.id }
-      )
+      .getProjectRegistries("<token>", {}, { id: project_id })
       .then((res: any) => {
-        this.setState({ registries: res.data, loading: false });
+        setRegistries(res.data);
+        setLoading(false);
         if (res.data.length === 1) {
-          this.props.setSelectedRegistry(res.data[0]);
+          setSelectedRegistry(res.data[0]);
         }
       })
       .catch((err: any) => console.log(err));
-  }
+  }, [currentProject]);
 
-  renderIntegrationList = () => {
-    let { loading, registries } = this.state;
+  const renderIntegrationList = () => {
     if (loading) {
       return (
         <LoadingWrapper>
@@ -67,20 +99,19 @@ export default class ActionDetails extends Component<PropsType, StateType> {
 
     return registries.map((registry: any, i: number) => {
       let icon =
-        integrationList[registry.service] &&
-        integrationList[registry.service].icon;
+        integrationList[registry?.service] &&
+        integrationList[registry?.service]?.icon;
+
       if (!icon) {
-        icon = integrationList["dockerhub"].icon;
+        icon = integrationList["dockerhub"]?.icon;
       }
+
       return (
         <RegistryItem
           key={i}
-          isSelected={
-            this.props.selectedRegistry &&
-            registry.id === this.props.selectedRegistry.id
-          }
-          lastItem={i === registries.length - 1}
-          onClick={() => this.props.setSelectedRegistry(registry)}
+          isSelected={selectedRegistry && registry.id === selectedRegistry?.id}
+          lastItem={i === registries?.length - 1}
+          onClick={() => setSelectedRegistry(registry)}
         >
           <img src={icon && icon} />
           {registry.url}
@@ -89,8 +120,7 @@ export default class ActionDetails extends Component<PropsType, StateType> {
     });
   };
 
-  renderRegistrySection = () => {
-    let { registries } = this.state;
+  const renderRegistrySection = () => {
     if (!registries || registries.length === 0 || registries.length === 1) {
       return;
     } else {
@@ -100,104 +130,382 @@ export default class ActionDetails extends Component<PropsType, StateType> {
             Select an Image Destination
             <Required>*</Required>
           </Subtitle>
-          <ExpandedWrapper>{this.renderIntegrationList()}</ExpandedWrapper>
+          <ExpandedWrapper>{renderIntegrationList()}</ExpandedWrapper>
         </>
       );
     }
   };
 
-  render() {
-    return (
-      <>
-        <DarkMatter />
+  return (
+    <>
+      <DarkMatter />
+      <Heading>GitHub Settings</Heading>
+      <InputRow
+        disabled={true}
+        label="Git repository"
+        type="text"
+        width="100%"
+        value={actionConfig?.git_repo}
+      />
+      <InputRow
+        disabled={true}
+        label="Branch"
+        type="text"
+        width="100%"
+        value={props?.branch}
+      />
+      {dockerfilePath && (
         <InputRow
           disabled={true}
-          label="Git Repository"
+          label="Dockerfile path"
           type="text"
           width="100%"
-          value={this.props.actionConfig.git_repo}
+          value={dockerfilePath}
         />
-        <InputRow
-          disabled={true}
-          label="Branch"
-          type="text"
-          width="100%"
-          value={this.props.branch}
-        />
-        {this.props.dockerfilePath && (
-          <InputRow
-            disabled={true}
-            label="Dockerfile Path"
-            type="text"
-            width="100%"
-            value={this.props.dockerfilePath}
+      )}
+      <InputRow
+        disabled={true}
+        label={dockerfilePath ? "Docker build context" : "Application folder"}
+        type="text"
+        width="100%"
+        value={folderPath}
+      />
+      {renderRegistrySection()}
+      {!dockerfilePath && (
+        <>
+          <Heading>
+            <ExpandHeader
+              onClick={() => setShowBuildpacksConfig((prev) => !prev)}
+              isExpanded={showBuildpacksConfig}
+            >
+              Buildpacks Settings
+              <i className="material-icons">arrow_drop_down</i>
+            </ExpandHeader>
+          </Heading>
+          <BuildpackSelection
+            actionConfig={actionConfig}
+            branch={branch}
+            folderPath={folderPath}
+            onChange={(config) => {
+              setBuildConfig(config);
+            }}
+            hide={!showBuildpacksConfig}
           />
+          <Buffer />
+        </>
+      )}
+      <Br />
+
+      <Flex>
+        <BackButton
+          width="140px"
+          onClick={() => {
+            setDockerfilePath(null);
+            setFolderPath(null);
+            setProcfilePath(null);
+            setProcfileProcess(null);
+            setSelectedRegistry(null);
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select Folder
+        </BackButton>
+        {selectedRegistry ? (
+          <StatusWrapper successful={true}>
+            <i className="material-icons">done</i> Source selected
+          </StatusWrapper>
+        ) : (
+          <StatusWrapper>
+            <i className="material-icons">error_outline</i>A connected container
+            registry is required
+          </StatusWrapper>
         )}
-        <InputRow
-          disabled={true}
-          label={
-            this.props.dockerfilePath
-              ? "Docker Build Context"
-              : "Application Folder"
-          }
-          type="text"
+      </Flex>
+    </>
+  );
+};
+
+export default ActionDetails;
+
+const DEFAULT_BUILDER_NAME = "heroku";
+const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
+const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
+
+type BuildConfig = {
+  builder: string;
+  buildpacks: string[];
+  config: null | {
+    [key: string]: string;
+  };
+};
+
+export const BuildpackSelection: React.FC<{
+  actionConfig: ActionConfigType;
+  folderPath: string;
+  branch: string;
+  hide: boolean;
+  onChange: (config: BuildConfig) => void;
+}> = ({ actionConfig, folderPath, branch, hide, onChange }) => {
+  const { currentProject } = useContext(Context);
+
+  const [builders, setBuilders] = useState<DetectedBuildpack[]>(null);
+  const [selectedBuilder, setSelectedBuilder] = useState<string>(null);
+
+  const [stacks, setStacks] = useState<string[]>(null);
+  const [selectedStack, setSelectedStack] = useState<string>(null);
+
+  const [selectedBuildpacks, setSelectedBuildpacks] = useState<Buildpack[]>(
+    null
+  );
+  const [availableBuildpacks, setAvailableBuildpacks] = useState<Buildpack[]>(
+    null
+  );
+
+  useEffect(() => {
+    let buildConfig: BuildConfig = {} as BuildConfig;
+
+    buildConfig.builder = selectedStack;
+    buildConfig.buildpacks = selectedBuildpacks?.map((buildpack) => {
+      return buildpack.buildpack;
+    });
+    if (typeof onChange === "function") {
+      onChange(buildConfig);
+    }
+  }, [selectedBuilder, selectedStack, selectedBuildpacks]);
+
+  useEffect(() => {
+    api
+      .detectBuildpack<DetectBuildpackResponse>(
+        "<token>",
+        {
+          dir: folderPath || ".",
+        },
+        {
+          project_id: currentProject.id,
+          git_repo_id: actionConfig.git_repo_id,
+          kind: "github",
+          owner: actionConfig.git_repo.split("/")[0],
+          name: actionConfig.git_repo.split("/")[1],
+          branch: branch,
+        }
+      )
+      // getMockData()
+      .then(({ data }) => {
+        const builders = data;
+
+        const defaultBuilder = builders.find(
+          (builder) => builder.name.toLowerCase() === DEFAULT_BUILDER_NAME
+        );
+
+        const detectedBuildpacks = defaultBuilder.detected;
+        const availableBuildpacks = defaultBuilder.others;
+        const defaultStack = defaultBuilder.builders.find((stack) => {
+          return (
+            stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK
+          );
+        });
+
+        setBuilders(builders);
+        setSelectedBuilder(defaultBuilder.name.toLowerCase());
+
+        setStacks(defaultBuilder.builders);
+        setSelectedStack(defaultStack);
+
+        setSelectedBuildpacks(detectedBuildpacks);
+        setAvailableBuildpacks(availableBuildpacks);
+      })
+      .catch((err) => {
+        console.error(err);
+      });
+  }, [currentProject, actionConfig]);
+
+  const builderOptions = useMemo(() => {
+    if (!Array.isArray(builders)) {
+      return;
+    }
+
+    return builders.map((builder) => ({
+      label: builder.name,
+      value: builder.name.toLowerCase(),
+    }));
+  }, [builders]);
+
+  const stackOptions = useMemo(() => {
+    if (!Array.isArray(stacks)) {
+      return;
+    }
+
+    return stacks.map((stack) => ({
+      label: stack,
+      value: stack.toLowerCase(),
+    }));
+  }, [stacks]);
+
+  const handleSelectBuilder = (builderName: string) => {
+    const builder = builders.find(
+      (b) => b.name.toLowerCase() === builderName.toLowerCase()
+    );
+    const detectedBuildpacks = builder.detected;
+    const availableBuildpacks = builder.others;
+    const defaultStack = builder.builders.find((stack) => {
+      return stack === DEFAULT_HEROKU_STACK || stack === DEFAULT_PAKETO_STACK;
+    });
+    setSelectedBuilder(builderName);
+    setBuilders(builders);
+    setSelectedBuilder(builderName.toLowerCase());
+
+    setStacks(builder.builders);
+    setSelectedStack(defaultStack);
+
+    setSelectedBuildpacks(detectedBuildpacks);
+    setAvailableBuildpacks(availableBuildpacks);
+  };
+
+  const renderBuildpacksList = (
+    buildpacks: Buildpack[],
+    action: "remove" | "add"
+  ) => {
+    return buildpacks?.map((buildpack) => {
+      const icon = `devicon-${buildpack?.name?.toLowerCase()}-plain colored`;
+
+      return (
+        <StyledCard>
+          <ContentContainer>
+            <Icon className={icon} />
+            <EventInformation>
+              <EventName>{buildpack?.name}</EventName>
+            </EventInformation>
+          </ContentContainer>
+          <ActionContainer>
+            {action === "add" && (
+              <DeleteButton
+                onClick={() => handleAddBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons-outlined">add</span>
+              </DeleteButton>
+            )}
+            {action === "remove" && (
+              <DeleteButton
+                onClick={() => handleRemoveBuildpack(buildpack.buildpack)}
+              >
+                <span className="material-icons">delete</span>
+              </DeleteButton>
+            )}
+          </ActionContainer>
+        </StyledCard>
+      );
+    });
+  };
+
+  const handleRemoveBuildpack = (buildpackToRemove: string) => {
+    setSelectedBuildpacks((selBuildpacks) => {
+      const tmpSelectedBuildpacks = [...selBuildpacks];
+
+      const indexBuildpackToRemove = tmpSelectedBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToRemove
+      );
+      const buildpack = tmpSelectedBuildpacks[indexBuildpackToRemove];
+
+      setAvailableBuildpacks((availableBuildpacks) => [
+        ...availableBuildpacks,
+        buildpack,
+      ]);
+
+      tmpSelectedBuildpacks.splice(indexBuildpackToRemove, 1);
+
+      return [...tmpSelectedBuildpacks];
+    });
+  };
+
+  const handleAddBuildpack = (buildpackToAdd: string) => {
+    setAvailableBuildpacks((avBuildpacks) => {
+      const tmpAvailableBuildpacks = [...avBuildpacks];
+      const indexBuildpackToAdd = tmpAvailableBuildpacks.findIndex(
+        (buildpack) => buildpack.buildpack === buildpackToAdd
+      );
+      const buildpack = tmpAvailableBuildpacks[indexBuildpackToAdd];
+
+      setSelectedBuildpacks((selectedBuildpacks) => [
+        ...selectedBuildpacks,
+        buildpack,
+      ]);
+
+      tmpAvailableBuildpacks.splice(indexBuildpackToAdd, 1);
+      return [...tmpAvailableBuildpacks];
+    });
+  };
+
+  if (hide) {
+    return null;
+  }
+
+  if (!stackOptions?.length || !builderOptions?.length) {
+    return <Loading />;
+  }
+
+  return (
+    <BuildpackConfigurationContainer>
+      <>
+        <SelectRow
+          value={selectedBuilder}
           width="100%"
-          value={this.props.folderPath}
+          options={builderOptions}
+          setActiveValue={(option) => handleSelectBuilder(option)}
+          label="Select a builder"
         />
-        {this.renderRegistrySection()}
-        <Br />
-
-        <Flex>
-          <BackButton
-            width="140px"
-            onClick={() => {
-              this.props.setDockerfilePath(null);
-              this.props.setFolderPath(null);
-              this.props.setProcfilePath(null);
-              this.props.setProcfileProcess(null);
-              this.props.setSelectedRegistry(null);
-            }}
-          >
-            <i className="material-icons">keyboard_backspace</i>
-            Select Folder
-          </BackButton>
-          {
-            // !this.props.procfilePath && !this.props.dockerfilePath ? (
-            //   <StatusWrapper>
-            //     <i className="material-icons">error_outline</i>
-            //     Procfile not detected.
-            //   </StatusWrapper>
-            // ) :
-            this.props.selectedRegistry ? (
-              <StatusWrapper successful={true}>
-                <i className="material-icons">done</i> Source selected
-              </StatusWrapper>
-            ) : (
-              <StatusWrapper>
-                <i className="material-icons">error_outline</i>A connected
-                container registry is required
-              </StatusWrapper>
-            )
-          }
-        </Flex>
+
+        <SelectRow
+          value={selectedStack}
+          width="100%"
+          options={stackOptions}
+          setActiveValue={(option) => setSelectedStack(option)}
+          label="Select your stack"
+        />
+        <Helper>
+          The following buildpacks were automatically detected. You can also
+          manually add/remove buildpacks.
+        </Helper>
+
+        {!!selectedBuildpacks?.length &&
+          renderBuildpacksList(selectedBuildpacks, "remove")}
+
+        {!!availableBuildpacks?.length && (
+          <>
+            <Helper>Available buildpacks:</Helper>
+            {renderBuildpacksList(availableBuildpacks, "add")}
+          </>
+        )}
       </>
-    );
-  }
-}
+    </BuildpackConfigurationContainer>
+  );
+};
 
-ActionDetails.contextType = Context;
+const fadeIn = keyframes`
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+`;
 
-const Highlight = styled.a`
-  color: #949eff;
-  text-decoration: none;
-  margin-left: 5px;
+const ExpandHeader = styled.div<{ isExpanded: boolean }>`
+  display: flex;
+  align-items: center;
   cursor: pointer;
+  > i {
+    margin-left: 10px;
+    transform: ${(props) => (props.isExpanded ? "" : "rotate(180deg)")};
+  }
 `;
 
-const Bold = styled.div`
-  font-weight: 800;
-  color: #ffffff;
-  margin-right: 5px;
+const BuildpackConfigurationContainer = styled.div`
+  animation: ${fadeIn} 0.75s;
+`;
+
+const Buffer = styled.div`
+  width: 100%;
+  height: 8px;
 `;
 
 const Required = styled.div`
@@ -210,19 +518,6 @@ const Subtitle = styled.div`
   margin-top: 21px;
 `;
 
-const SubtitleAlt = styled.div`
-  padding: 11px 0px 16px;
-  font-family: "Work Sans", sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  line-height: 1.6em;
-  display: flex;
-  align-items: center;
-  margin-top: -3px;
-  margin-bottom: -7px;
-  font-weight: 400;
-`;
-
 const RegistryItem = styled.div`
   display: flex;
   width: 100%;
@@ -338,10 +633,6 @@ const BackButton = styled.div`
   }
 `;
 
-const AdvancedHeader = styled.div`
-  margin-top: 15px;
-`;
-
 const Br = styled.div`
   width: 100%;
   height: 1px;
@@ -350,9 +641,82 @@ const Br = styled.div`
 
 const DarkMatter = styled.div`
   width: 100%;
-  margin-bottom: -18px;
+  margin-bottom: -28px;
 `;
 
-const Holder = styled.div`
-  padding: 0px 12px 24px 12px;
+const StyledCard = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border: 1px solid #ffffff00;
+  background: #ffffff08;
+  margin-bottom: 5px;
+  border-radius: 8px;
+  padding: 14px;
+  overflow: hidden;
+  height: 60px;
+  font-size: 13px;
+  animation: ${fadeIn} 0.5s;
+`;
+
+const ContentContainer = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+`;
+
+const Icon = styled.span`
+  font-size: 20px;
+  margin-left: 10px;
+  margin-right: 20px;
+`;
+
+const EventInformation = styled.div`
+  display: flex;
+  flex-direction: column;
+  justify-content: space-around;
+  height: 100%;
+`;
+
+const EventName = styled.div`
+  font-family: "Work Sans", sans-serif;
+  font-weight: 500;
+  color: #ffffff;
+`;
+
+const EventReason = styled.div`
+  font-family: "Work Sans", sans-serif;
+  color: #aaaabb;
+  margin-top: 5px;
+`;
+
+const ActionContainer = styled.div`
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  height: 100%;
+`;
+
+const DeleteButton = styled.button`
+  position: relative;
+  border: none;
+  background: none;
+  color: white;
+  padding: 5px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: 50%;
+  cursor: pointer;
+  color: #aaaabb;
+
+  :hover {
+    background: #ffffff11;
+    border: 1px solid #ffffff44;
+  }
+
+  > span {
+    font-size: 20px;
+  }
 `;

+ 6 - 0
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -317,6 +317,12 @@ export default class ContentsList extends Component<PropsType, StateType> {
                 <Row
                   key={i}
                   onClick={() => {
+                    if (
+                      !this.props.folderPath ||
+                      this.props.folderPath === ""
+                    ) {
+                      this.props.setFolderPath("./");
+                    }
                     this.props.setProcfileProcess(process);
                   }}
                   isLast={processes.length - 1 === i}

+ 40 - 3
dashboard/src/index.html

@@ -42,13 +42,45 @@
     <script>
       window.intercomSettings = {
         app_id: "gq56g49i",
-        custom_launcher_selector: '#intercom_help'
+        custom_launcher_selector: "#intercom_help",
       };
     </script>
 
     <script>
-    // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
-    (function(){var w=window;var ic=w.Intercom;if(typeof ic==="function"){ic('reattach_activator');ic('update',w.intercomSettings);}else{var d=document;var i=function(){i.c(arguments);};i.q=[];i.c=function(args){i.q.push(args);};w.Intercom=i;var l=function(){var s=d.createElement('script');s.type='text/javascript';s.async=true;s.src='https://widget.intercom.io/widget/gq56g49i';var x=d.getElementsByTagName('script')[0];x.parentNode.insertBefore(s,x);};if(document.readyState==='complete'){l();}else if(w.attachEvent){w.attachEvent('onload',l);}else{w.addEventListener('load',l,false);}}})();
+      // We pre-filled your app ID in the widget URL: 'https://widget.intercom.io/widget/gq56g49i'
+      (function () {
+        var w = window;
+        var ic = w.Intercom;
+        if (typeof ic === "function") {
+          ic("reattach_activator");
+          ic("update", w.intercomSettings);
+        } else {
+          var d = document;
+          var i = function () {
+            i.c(arguments);
+          };
+          i.q = [];
+          i.c = function (args) {
+            i.q.push(args);
+          };
+          w.Intercom = i;
+          var l = function () {
+            var s = d.createElement("script");
+            s.type = "text/javascript";
+            s.async = true;
+            s.src = "https://widget.intercom.io/widget/gq56g49i";
+            var x = d.getElementsByTagName("script")[0];
+            x.parentNode.insertBefore(s, x);
+          };
+          if (document.readyState === "complete") {
+            l();
+          } else if (w.attachEvent) {
+            w.attachEvent("onload", l);
+          } else {
+            w.addEventListener("load", l, false);
+          }
+        }
+      })();
     </script>
 
     <script>
@@ -141,6 +173,11 @@
       href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined|Material+Icons+Round"
       rel="stylesheet"
     />
+    <!-- Coding languages icons -->
+    <link
+      rel="stylesheet"
+      href="https://cdn.jsdelivr.net/gh/devicons/devicon@v2.14.0/devicon.min.css"
+    />
   </head>
   <body>
     <div id="output"></div>

+ 2 - 1
dashboard/src/main/home/Home.tsx

@@ -27,6 +27,7 @@ import discordLogo from "../../assets/discord.svg";
 import Onboarding from "./onboarding/Onboarding";
 import ModalHandler from "./ModalHandler";
 import { NewProjectFC } from "./new-project/NewProject";
+import { BuildpackSelection } from "components/repo-selector/ActionDetails";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -391,7 +392,7 @@ class Home extends Component<PropsType, StateType> {
           </>
         )}
 
-        <ViewWrapper>
+        <ViewWrapper id="HomeViewWrapper">
           <Navbar
             logOut={this.props.logOut}
             currentView={this.props.currentRoute} // For form feedback

+ 5 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -11,14 +11,16 @@ import { NamespaceList } from "./NamespaceList";
 import ClusterSettings from "./ClusterSettings";
 import useAuth from "shared/auth/useAuth";
 import Metrics from "./Metrics";
+import EventsTab from "./events/EventsTab";
 
-type TabEnum = "nodes" | "settings" | "namespaces" | "metrics";
+type TabEnum = "nodes" | "settings" | "namespaces" | "metrics" | "events";
 
 const tabOptions: {
   label: string;
   value: TabEnum;
 }[] = [
   { label: "Nodes", value: "nodes" },
+  { label: "Events", value: "events" },
   { label: "Metrics", value: "metrics" },
   { label: "Namespaces", value: "namespaces" },
   { label: "Settings", value: "settings" },
@@ -32,6 +34,8 @@ export const Dashboard: React.FunctionComponent = () => {
   const context = useContext(Context);
   const renderTab = () => {
     switch (currentTab) {
+      case "events":
+        return <EventsTab />;
       case "settings":
         return <ClusterSettings />;
       case "metrics":

+ 215 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/events/EventsTab.tsx

@@ -0,0 +1,215 @@
+import React, { useContext, useEffect, useState } from "react";
+import styled from "styled-components";
+import EventCard from "components/events/EventCard";
+import Loading from "components/Loading";
+import InfiniteScroll from "react-infinite-scroll-component";
+import Dropdown from "components/Dropdown";
+import { useKubeEvents } from "components/events/useEvents";
+import SubEventsList from "components/events/SubEventsList";
+
+const availableResourceTypes = [
+  { label: "Pods", value: "POD" },
+  { label: "HPA", value: "HPA" },
+  { label: "Nodes", value: "NODE" },
+];
+
+const EventsTab = () => {
+  const [resourceType, setResourceType] = useState(availableResourceTypes[0]);
+  const [currentEvent, setCurrentEvent] = useState(null);
+
+  const {
+    isLoading,
+    hasPorterAgent,
+    triggerInstall,
+    kubeEvents,
+    loadMoreEvents,
+    hasMore,
+  } = useKubeEvents({ resourceType: resourceType.value as any });
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  if (!hasPorterAgent) {
+    return (
+      <Placeholder>
+        <div>
+          <Header>We couldn't detect the Porter agent on your cluster</Header>
+          In order to use the events tab, you need to install the Porter agent
+          on your cluster.
+          <InstallPorterAgentButton onClick={() => triggerInstall()}>
+            <i className="material-icons">add</i> Install Porter agent
+          </InstallPorterAgentButton>
+        </div>
+      </Placeholder>
+    );
+  }
+
+  if (currentEvent) {
+    return (
+      <SubEventsList
+        event={currentEvent}
+        clearSelectedEvent={() => setCurrentEvent(null)}
+        enableTopMargin
+      />
+    );
+  }
+
+  return (
+    <EventsPageWrapper>
+      {kubeEvents.length > 0 ? (
+        <>
+          <ControlRow>
+            {/*
+              <Dropdown
+                selectedOption={resourceType}
+                options={availableResourceTypes}
+                onSelect={(o) => setResourceType({ ...o, value: o.value as string })}
+              />
+              */}
+          </ControlRow>
+          <InfiniteScroll
+            dataLength={kubeEvents.length}
+            next={loadMoreEvents}
+            hasMore={hasMore}
+            loader={<h4>Loading...</h4>}
+            scrollableTarget="HomeViewWrapper"
+          >
+            <EventsGrid>
+              {kubeEvents.map((event, i) => {
+                return (
+                  <React.Fragment key={i}>
+                    <EventCard
+                      event={event}
+                      selectEvent={() => setCurrentEvent(event)}
+                    />
+                  </React.Fragment>
+                );
+              })}
+            </EventsGrid>
+          </InfiniteScroll>
+        </>
+      ) : (
+        <Placeholder>
+          <i className="material-icons">search</i>
+          No matching events were found.
+        </Placeholder>
+      )}
+    </EventsPageWrapper>
+  );
+};
+
+export default EventsTab;
+
+const Label = styled.div`
+  color: #ffffff44;
+  margin-right: 8px;
+  font-size: 13px;
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 30px;
+  padding-left: 0px;
+  font-size: 13px;
+`;
+
+const EventsPageWrapper = styled.div`
+  font-size: 13px;
+  padding-bottom: 80px;
+  border-radius: 8px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
+`;
+
+const InstallPorterAgentButton = styled.button`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: "Work Sans", sans-serif;
+  border: none;
+  border-radius: 5px;
+  color: white;
+  height: 35px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-top: 20px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  background: ${(props: { disabled?: boolean }) =>
+    props.disabled ? "#aaaabbee" : "#5561C0"};
+  :hover {
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
+  }
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-weight: 600;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const Placeholder = styled.div`
+  padding: 30px;
+  margin-top: 35px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 50vh;
+  background: #ffffff11;
+  border-radius: 8px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 18px;
+    margin-right: 8px;
+  }
+`;
+
+const Header = styled.div`
+  font-weight: 500;
+  color: #aaaabb;
+  font-size: 16px;
+  margin-bottom: 15px;
+`;

+ 1 - 1
dashboard/src/main/home/cluster-dashboard/env-groups/EnvGroupList.tsx

@@ -82,7 +82,7 @@ export default class EnvGroupList extends Component<PropsType, StateType> {
   }
 
   componentDidUpdate(prevProps: PropsType) {
-    // Ret2: Prevents reload when opening ClusterConfigModal
+    // Prevents reload when opening ClusterConfigModal
     if (
       prevProps.currentCluster !== this.props.currentCluster ||
       prevProps.namespace !== this.props.namespace ||

+ 114 - 111
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -347,12 +347,12 @@ const ExpandedChart: React.FC<Props> = (props) => {
   const renderTabContents = (currentTab: string) => {
     let { setSidebar } = props;
     let chart = currentChart;
-
+    console.log("CONTROLLERS", controllers);
     switch (currentTab) {
       case "metrics":
         return <MetricsSection currentChart={chart} />;
       case "events":
-        return <EventsTab currentChart={chart} />;
+        return <EventsTab controllers={controllers} />;
       case "status":
         if (isLoadingChartData) {
           return (
@@ -384,7 +384,12 @@ const ExpandedChart: React.FC<Props> = (props) => {
             </Placeholder>
           );
         } else {
-          return <StatusSection currentChart={chart} setFullScreenLogs={() => setFullScreenLogs(true)} />;
+          return (
+            <StatusSection
+              currentChart={chart}
+              setFullScreenLogs={() => setFullScreenLogs(true)}
+            />
+          );
         }
       case "settings":
         return (
@@ -663,114 +668,112 @@ const ExpandedChart: React.FC<Props> = (props) => {
 
   return (
     <>
-      { 
-        fullScreenLogs ? (
-          <StatusSection
-            fullscreen={true} 
-            currentChart={currentChart} 
-            setFullScreenLogs={() => setFullScreenLogs(false)}
-          />
-        ) : (
-      <StyledExpandedChart>
-        <HeaderWrapper>
-          <BackButton onClick={props.closeChart}>
-            <BackButtonImg src={backArrow} />
-          </BackButton>
-          <TitleSection
-            icon={currentChart.chart.metadata.icon}
-            iconWidth="33px"
-          >
-            {currentChart.name}
-            <DeploymentType currentChart={currentChart} />
-            <TagWrapper>
-              Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
-            </TagWrapper>
-          </TitleSection>
-
-          {currentChart.chart.metadata.name != "worker" &&
-            currentChart.chart.metadata.name != "job" &&
-            renderUrl()}
-          <InfoWrapper>
-            <StatusIndicator
-              controllers={controllers}
-              status={currentChart.info.status}
-              margin_left={"0px"}
-            />
-            <LastDeployed>
-              <Dot>•</Dot>Last deployed
-              {" " + getReadableDate(currentChart.info.last_deployed)}
-            </LastDeployed>
-          </InfoWrapper>
-        </HeaderWrapper>
-        {deleting ? (
-          <>
-            <LineBreak />
-            <Placeholder>
-              <TextWrap>
-                <Header>
-                  <Spinner src={loadingSrc} /> Deleting "{currentChart.name}"
-                </Header>
-                You will be automatically redirected after deletion is complete.
-              </TextWrap>
-            </Placeholder>
-          </>
-        ) : (
-          <>
-            <RevisionSection
-              showRevisions={showRevisions}
-              toggleShowRevisions={() => {
-                setShowRevisions(!showRevisions);
-              }}
-              chart={currentChart}
-              refreshChart={() => getChartData(currentChart)}
-              setRevision={setRevision}
-              forceRefreshRevisions={forceRefreshRevisions}
-              refreshRevisionsOff={() => setForceRefreshRevisions(false)}
-              status={chartStatus}
-              shouldUpdate={
-                currentChart.latest_version &&
-                currentChart.latest_version !==
-                  currentChart.chart.metadata.version
-              }
-              latestVersion={currentChart.latest_version}
-              upgradeVersion={handleUpgradeVersion}
-            />
-            {(isPreview || leftTabOptions.length > 0) && (
-              <BodyWrapper>
-                <PorterFormWrapper
-                  formData={currentChart.form}
-                  valuesToOverride={{
-                    namespace: props.namespace,
-                    clusterId: currentCluster.id,
-                    currentChart: {
-                      name: currentChart.name,
-                    },
-                  }}
-                  renderTabContents={renderTabContents}
-                  isReadOnly={
-                    imageIsPlaceholder ||
-                    !isAuthorized("application", "", ["get", "update"])
-                  }
-                  onSubmit={onSubmit}
-                  rightTabOptions={rightTabOptions}
-                  leftTabOptions={leftTabOptions}
-                  color={isPreview ? "#f5cb42" : null}
-                  addendum={
-                    <TabButton
-                      onClick={toggleDevOpsMode}
-                      devOpsMode={devOpsMode}
-                    >
-                      <i className="material-icons">offline_bolt</i> DevOps Mode
-                    </TabButton>
-                  }
-                  saveValuesStatus={saveValuesStatus}
-                />
-              </BodyWrapper>
-            )}
-          </>
-        )}
-      </StyledExpandedChart>
-    )}
+      {fullScreenLogs ? (
+        <StatusSection
+          fullscreen={true}
+          currentChart={currentChart}
+          setFullScreenLogs={() => setFullScreenLogs(false)}
+        />
+      ) : (
+        <StyledExpandedChart>
+          <HeaderWrapper>
+            <BackButton onClick={props.closeChart}>
+              <BackButtonImg src={backArrow} />
+            </BackButton>
+            <TitleSection
+              icon={currentChart.chart.metadata.icon}
+              iconWidth="33px"
+            >
+              {currentChart.name}
+              <DeploymentType currentChart={currentChart} />
+              <TagWrapper>
+                Namespace <NamespaceTag>{currentChart.namespace}</NamespaceTag>
+              </TagWrapper>
+            </TitleSection>
+
+            {currentChart.chart.metadata.name != "worker" &&
+              currentChart.chart.metadata.name != "job" &&
+              renderUrl()}
+            <InfoWrapper>
+              <StatusIndicator
+                controllers={controllers}
+                status={currentChart.info.status}
+                margin_left={"0px"}
+              />
+              <LastDeployed>
+                <Dot>•</Dot>Last deployed
+                {" " + getReadableDate(currentChart.info.last_deployed)}
+              </LastDeployed>
+            </InfoWrapper>
+          </HeaderWrapper>
+          {deleting ? (
+            <>
+              <LineBreak />
+              <Placeholder>
+                <TextWrap>
+                  <Header>
+                    <Spinner src={loadingSrc} /> Deleting "{currentChart.name}"
+                  </Header>
+                  You will be automatically redirected after deletion is
+                  complete.
+                </TextWrap>
+              </Placeholder>
+            </>
+          ) : (
+            <>
+              <RevisionSection
+                showRevisions={showRevisions}
+                toggleShowRevisions={() => {
+                  setShowRevisions(!showRevisions);
+                }}
+                chart={currentChart}
+                refreshChart={() => getChartData(currentChart)}
+                setRevision={setRevision}
+                forceRefreshRevisions={forceRefreshRevisions}
+                refreshRevisionsOff={() => setForceRefreshRevisions(false)}
+                status={chartStatus}
+                shouldUpdate={
+                  currentChart.latest_version &&
+                  currentChart.latest_version !==
+                    currentChart.chart.metadata.version
+                }
+                latestVersion={currentChart.latest_version}
+                upgradeVersion={handleUpgradeVersion}
+              />
+              {(isPreview || leftTabOptions.length > 0) && (
+                <BodyWrapper>
+                  <PorterFormWrapper
+                    formData={currentChart.form}
+                    valuesToOverride={{
+                      namespace: props.namespace,
+                      clusterId: currentCluster.id,
+                    }}
+                    renderTabContents={renderTabContents}
+                    isReadOnly={
+                      imageIsPlaceholder ||
+                      !isAuthorized("application", "", ["get", "update"])
+                    }
+                    onSubmit={onSubmit}
+                    rightTabOptions={rightTabOptions}
+                    leftTabOptions={leftTabOptions}
+                    color={isPreview ? "#f5cb42" : null}
+                    addendum={
+                      <TabButton
+                        onClick={toggleDevOpsMode}
+                        devOpsMode={devOpsMode}
+                      >
+                        <i className="material-icons">offline_bolt</i> DevOps
+                        Mode
+                      </TabButton>
+                    }
+                    saveValuesStatus={saveValuesStatus}
+                  />
+                </BodyWrapper>
+              )}
+            </>
+          )}
+        </StyledExpandedChart>
+      )}
     </>
   );
 };

+ 0 - 120
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventCard.tsx

@@ -1,120 +0,0 @@
-import React, { useState } from "react";
-import styled from "styled-components";
-import { Event } from "./EventsTab";
-import Loading from "../../../../../components/Loading";
-
-type CardProps = {
-  event: Event;
-  selectEvent?: () => void;
-  overrideName?: string;
-};
-
-export const getReadableDate = (s: number) => {
-  let ts = new Date(s * 1000);
-  let date = ts.toLocaleDateString();
-  let time = ts.toLocaleTimeString([], {
-    hour: "numeric",
-    minute: "2-digit",
-  });
-  return `${time} ${date}`;
-};
-
-// Rename to Event Card
-const EventCard: React.FunctionComponent<CardProps> = ({
-  event,
-  selectEvent,
-  overrideName,
-}) => {
-  return (
-    <StyledCard onClick={() => selectEvent && selectEvent()}>
-      {event.status == 1 && (
-        <Icon status="normal" className="material-icons-outlined">
-          check
-        </Icon>
-      )}
-      {event.status == 2 && (
-        <Icon className="material-icons-outlined">autorenew</Icon>
-      )}
-      {event.status == 3 && (
-        <Icon status="critical" className="material-icons-outlined">
-          error
-        </Icon>
-      )}
-
-      <InfoWrapper>
-        <EventName>
-          {overrideName ? overrideName : event.name}
-          {event.status == 1 && " successful"}
-          {event.status == 2 && " in progress"}
-          {event.status == 3 && ` failed: ${event.info}`}
-        </EventName>
-        <TimestampContainer>
-          <i className="material-icons-outlined">access_time</i>
-          {getReadableDate(event.time)}
-        </TimestampContainer>
-      </InfoWrapper>
-    </StyledCard>
-  );
-};
-
-export default EventCard;
-
-const StyledCard = styled.div`
-  display: flex;
-  align-items: center;
-  border: 1px solid #ffffff44;
-  background: #ffffff08;
-  margin-bottom: 10px;
-  border-radius: 10px;
-  padding-left: 20px;
-  overflow: hidden;
-  height: 80px;
-  cursor: pointer;
-
-  :hover {
-    background: #ffffff11;
-    border: 1px solid #ffffff66;
-  }
-`;
-
-const Icon = styled.span<{ status?: "critical" | "normal" }>`
-  font-size: 22px;
-  margin-right: 18px;
-  color: ${({ status }) =>
-    status ? (status === "critical" ? "#cc3d42" : "#38a88a") : "#efefef"};
-  animation: ${({ status }) => !status && "rotating 3s linear infinite"};
-  @keyframes rotating {
-    from {
-      transform: rotate(0deg);
-    }
-    to {
-      transform: rotate(360deg);
-    }
-  }
-`;
-
-const InfoWrapper = styled.div`
-  display: flex;
-  flex-direction: column;
-`;
-
-const EventName = styled.div`
-  font-size: 13px;
-  font-family: "Work Sans", sans-serif;
-  font-weight: 500;
-  color: #ffffff;
-`;
-
-const TimestampContainer = styled.div`
-  display: flex;
-  align-items: center;
-  color: #ffffff55;
-  font-size: 13px;
-  margin-top: 8px;
-
-  > i {
-    margin-right: 5px;
-    font-size: 18px;
-    margin-left: -1px;
-  }
-`;

+ 0 - 94
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventDetail.tsx

@@ -1,94 +0,0 @@
-import React, { Fragment } from "react";
-import { EventContainer } from "./EventsTab";
-import TitleSection from "components/TitleSection";
-import EventCard, { getReadableDate } from "./EventCard";
-import styled from "styled-components";
-
-interface Props {
-  container: EventContainer;
-  resetSelection: () => {};
-}
-
-const EventDetail: React.FC<Props> = (props) => {
-  return (
-    <>
-      <Flex>
-        <TitleSection handleNavBack={props.resetSelection}>
-          {props.container.name}
-        </TitleSection>
-        <P>
-          <i className="material-icons-outlined">access_time</i>
-          {getReadableDate(props.container.started_at)}
-        </P>
-      </Flex>
-      <EventsGrid>
-        {props.container.events
-          .slice(0)
-          .reverse()
-          .map((event) => {
-            return (
-              <React.Fragment key={event.index}>
-                <EventCard event={event} />
-              </React.Fragment>
-            );
-          })}
-      </EventsGrid>
-    </>
-  );
-};
-
-export default EventDetail;
-
-const Flex = styled.div`
-  display: flex;
-  align-items: center;
-  margin-bottom: 10px;
-`;
-
-const P = styled.p`
-  display: flex;
-  align-items: center;
-  color: #ffffff44;
-  font-size: 13px;
-  margin-left: 20px;
-  margin-top: 0px;
-
-  > i {
-    margin-right: 5px;
-    font-size: 18px;
-    margin-left: -1px;
-  }
-`;
-
-const BackButton = styled.div`
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  cursor: pointer;
-  font-size: 13px;
-  height: 35px;
-  padding: 5px 16px;
-  padding-right: 15px;
-  border: 1px solid #ffffff55;
-  border-radius: 100px;
-  width: ${(props: { width: string }) => props.width};
-  color: white;
-  background: #ffffff11;
-
-  :hover {
-    background: #ffffff22;
-  }
-
-  > i {
-    color: white;
-    font-size: 16px;
-    margin-right: 6px;
-    margin-left: -2px;
-  }
-`;
-
-const EventsGrid = styled.div`
-  display: grid;
-  grid-row-gap: 15px;
-  grid-template-columns: 1;
-`;

+ 189 - 159
dashboard/src/main/home/cluster-dashboard/expanded-chart/events/EventsTab.tsx

@@ -1,185 +1,227 @@
-import React, { useContext, useEffect, useState } from "react";
+import React, { useEffect, useMemo, useState } from "react";
 import styled from "styled-components";
-
-import loadingSrc from "assets/loading.gif";
-import { Context } from "shared/Context";
-import { ChartType } from "../../../../../shared/types";
-import api from "../../../../../shared/api";
-import EventCard from "./EventCard";
+import EventCard from "components/events/EventCard";
 import Loading from "components/Loading";
-import EventDetail from "./EventDetail";
+import InfiniteScroll from "react-infinite-scroll-component";
+import Dropdown from "components/Dropdown";
+import { useKubeEvents } from "components/events/useEvents";
+import { ChartType } from "shared/types";
+import _, { isEmpty, isObject } from "lodash";
+import SubEventsList from "components/events/SubEventsList";
 
-export type Event = {
-  event_id: string;
-  index: number;
-  info: string;
-  name: string;
-  status: number;
-  time: number;
-};
+const availableResourceTypes = [
+  { label: "Pods", value: "pod" },
+  { label: "HPA", value: "hpa" },
+];
 
-export type EventContainer = {
-  events: Event[];
-  name: string;
-  started_at: number;
-};
+const EventsTab: React.FC<{
+  controllers: Record<string, Record<string, any>>;
+}> = (props) => {
+  const { controllers } = props;
+  const [resourceType, setResourceType] = useState(availableResourceTypes[0]);
+  const [currentEvent, setCurrentEvent] = useState(null);
 
-type Props = {
-  currentChart: ChartType;
-};
+  const [selectedControllerKey, setSelectedControllerKey] = useState(null);
 
-const REFRESH_TIME = 15000;
+  const [hasControllers, setHasControllers] = useState(null);
 
-const EventsTab: React.FunctionComponent<Props> = (props) => {
-  const { currentCluster, currentProject } = useContext(Context);
-  const [isLoading, setIsLoading] = useState(true);
-  const [isError, setIsError] = useState(false);
-  const [shouldRequest, setShouldRequest] = useState(true);
-  const [eventData, setEventData] = useState<EventContainer[]>([]); // most recent event is last
-  const [selectedEvent, setSelectedEvent] = useState<number | null>(null);
+  const controllerOptions = useMemo(() => {
+    if (typeof controllers !== "object") {
+      return [];
+    }
 
-  // sort by time, ensure sequences are monotonically increasing by time, collapse by id
-  const filterData = (data: Event[]) => {
-    data = data.sort((a, b) => a.time - b.time);
+    return Object.entries(controllers).map(([key, value]) => ({
+      label: value?.metadata?.name,
+      value: key,
+    }));
+  }, [controllers]);
 
-    if (data.length == 0) return;
+  const currentControllerOption = useMemo(() => {
+    return (
+      controllerOptions?.find((c) => c.value === selectedControllerKey) ||
+      controllerOptions[0]
+    );
+  }, [selectedControllerKey, controllerOptions]);
 
-    let seq: Event[][] = [];
-    let cur: Event[] = [data[0]];
+  const selectedController = controllers[currentControllerOption?.value];
 
-    for (let i = 1; i < data.length; ++i) {
-      if (data[i].index < data[i - 1].index) {
-        seq.push(cur);
-        cur = [];
-      }
-      cur.push(data[i]);
-    }
-    if (cur) seq.push(cur);
+  const {
+    isLoading,
+    hasPorterAgent,
+    triggerInstall,
+    kubeEvents,
+    loadMoreEvents,
+    hasMore,
+  } = useKubeEvents({
+    resourceType: resourceType.value as any,
+    ownerName: selectedController?.metadata?.name,
+    ownerType: selectedController?.kind,
+    shouldWaitForOwner: true,
+  });
 
-    let ret: EventContainer[] = [];
-    seq.forEach((j) => {
-      j.push({
-        event_id: "",
-        index: 0,
-        info: "",
-        name: "",
-        status: 0,
-        time: 0,
-      });
+  useEffect(() => {
+    let timer: NodeJS.Timeout = null;
 
-      let fin: EventContainer = {
-        events: [],
-        name: "Deployment",
-        started_at: j[0].time,
-      };
-      for (let i = 0; i < j.length - 1; ++i) {
-        if (j[i].event_id != j[i + 1].event_id) {
-          fin.events.push(j[i]);
-        }
+    const checkControllers = (counter = 0) => {
+      if (timer !== null) {
+        clearTimeout(timer);
       }
-      ret.push(fin);
-    });
 
-    setEventData(ret);
-  };
-
-  useEffect(() => {
-    const getData = () => {
-      if (!shouldRequest) return;
-      setShouldRequest(false);
-      api
-        .getReleaseSteps(
-          "<token>",
-          {},
-          {
-            cluster_id: currentCluster.id,
-            namespace: props.currentChart.namespace,
-            id: currentProject.id,
-            name: props.currentChart.name,
-          }
-        )
-        .then((data) => {
-          setIsLoading(false);
-          filterData(data.data);
-        })
-        .catch((err) => {
-          setIsError(true);
-        })
-        .finally(() => {
-          setShouldRequest(true);
-        });
+      if (isEmpty(controllers) && counter === 5) {
+        clearTimeout(timer);
+        setHasControllers(false);
+      } else {
+        if (isEmpty(controllers)) {
+          timer = setTimeout(() => {
+            checkControllers(counter + 1);
+          }, 2000);
+        } else {
+          setHasControllers(true);
+        }
+      }
     };
 
-    getData();
-    const id = window.setInterval(getData, REFRESH_TIME);
+    checkControllers();
 
     return () => {
-      setIsLoading(true);
-      window.clearInterval(id);
+      if (timer !== null) {
+        clearTimeout(timer);
+      }
     };
-  }, [currentProject, currentCluster, props.currentChart]);
+  }, [controllers]);
 
-  if (isError) {
-    return <Placeholder>Error loading events.</Placeholder>;
+  if (isLoading && hasControllers === null) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
   }
 
-  if (isLoading) {
+  if (!hasControllers) {
     return (
       <Placeholder>
-        <Loading />
+        <i className="material-icons">search</i>
+        We coulnd't find any controllers for this application.
       </Placeholder>
     );
   }
 
-  if (eventData.length === 0) {
+  if (!hasPorterAgent) {
     return (
       <Placeholder>
-        <i className="material-icons">category</i>
-        No application events found.
+        <div>
+          <Header>We couldn't detect the Porter agent on your cluster</Header>
+          In order to use the events tab, you need to install the Porter agent
+          on your cluster.
+          <InstallPorterAgentButton onClick={() => triggerInstall()}>
+            <i className="material-icons">add</i> Install Porter agent
+          </InstallPorterAgentButton>
+        </div>
       </Placeholder>
     );
   }
 
-  if (selectedEvent !== null) {
+  if (currentEvent) {
     return (
-      <EventDetail
-        container={eventData[selectedEvent]}
-        resetSelection={() => {
-          setSelectedEvent(null);
-          return null;
-        }}
+      <SubEventsList
+        event={currentEvent}
+        clearSelectedEvent={() => setCurrentEvent(null)}
       />
     );
   }
 
   return (
-    <EventsGrid>
-      {eventData
-        .slice(0)
-        .reverse()
-        .map((dat, i) => {
-          console.log(dat.started_at);
-          return (
-            <React.Fragment key={dat.started_at}>
-              <EventCard
-                event={dat.events[dat.events.length - 1]}
-                selectEvent={() => {
-                  setSelectedEvent(eventData.length - i - 1);
-                }}
-                overrideName={"Deployment"}
+    <EventsPageWrapper>
+      {kubeEvents.length > 0 ? (
+        <>
+          <ControlRow>
+            {/*
+              <Dropdown
+                selectedOption={resourceType}
+                options={availableResourceTypes}
+                onSelect={(o) => setResourceType({ ...o, value: o.value as string })}
               />
-            </React.Fragment>
-          );
-        })}
-    </EventsGrid>
+              */}
+            <Label>Controller -</Label>
+            <Dropdown
+              selectedOption={currentControllerOption}
+              options={controllerOptions}
+              onSelect={(o) => setSelectedControllerKey(o?.value)}
+            />
+          </ControlRow>
+
+          <InfiniteScroll
+            dataLength={kubeEvents.length}
+            next={loadMoreEvents}
+            hasMore={hasMore}
+            loader={<h4>Loading...</h4>}
+            scrollableTarget="HomeViewWrapper"
+          >
+            <EventsGrid>
+              {kubeEvents.map((event, i) => {
+                return (
+                  <React.Fragment key={i}>
+                    <EventCard
+                      event={event as any}
+                      selectEvent={() => {
+                        setCurrentEvent(event);
+                      }}
+                    />
+                  </React.Fragment>
+                );
+              })}
+            </EventsGrid>
+          </InfiniteScroll>
+        </>
+      ) : (
+        <Placeholder>
+          <i className="material-icons">search</i>
+          No matching events were found.
+        </Placeholder>
+      )}
+    </EventsPageWrapper>
   );
 };
 
 export default EventsTab;
 
+const Label = styled.div`
+  color: #ffffff44;
+  margin-right: 8px;
+  font-size: 13px;
+`;
+
+const ControlRow = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 30px;
+  padding-left: 0px;
+  font-size: 13px;
+`;
+
 const EventsPageWrapper = styled.div`
-  margin-top: 35px;
-  padding-bottom: 80px;
+  font-size: 13px;
+  border-radius: 8px;
+  animation: floatIn 0.3s;
+  animation-timing-function: ease-out;
+  animation-fill-mode: forwards;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(10px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const EventsGrid = styled.div`
+  display: grid;
+  grid-row-gap: 15px;
+  grid-template-columns: 1;
 `;
 
 const InstallPorterAgentButton = styled.button`
@@ -191,12 +233,12 @@ const InstallPorterAgentButton = styled.button`
   cursor: pointer;
   font-family: "Work Sans", sans-serif;
   border: none;
-  border-radius: 20px;
+  border-radius: 5px;
   color: white;
   height: 35px;
   padding: 0px 8px;
   padding-bottom: 1px;
-  margin-top: 10px;
+  margin-top: 20px;
   font-weight: 500;
   padding-right: 15px;
   overflow: hidden;
@@ -205,14 +247,11 @@ const InstallPorterAgentButton = styled.button`
   box-shadow: 0 5px 8px 0px #00000010;
   cursor: ${(props: { disabled?: boolean }) =>
     props.disabled ? "not-allowed" : "pointer"};
-
   background: ${(props: { disabled?: boolean }) =>
-    props.disabled ? "#aaaabbee" : "#616FEEcc"};
+    props.disabled ? "#aaaabbee" : "#5561C0"};
   :hover {
-    background: ${(props: { disabled?: boolean }) =>
-      props.disabled ? "" : "#505edddd"};
+    filter: ${(props) => (!props.disabled ? "brightness(120%)" : "")};
   }
-
   > i {
     color: white;
     width: 18px;
@@ -228,18 +267,22 @@ const InstallPorterAgentButton = styled.button`
 `;
 
 const Placeholder = styled.div`
+  padding: 30px;
+  padding-bottom: 40px;
+  font-size: 13px;
+  color: #ffffff44;
+  min-height: 400px;
+  height: 50vh;
+  background: #ffffff08;
+  border-radius: 8px;
   width: 100%;
-  min-height: 300px;
-  height: 40vh;
   display: flex;
   align-items: center;
   justify-content: center;
-  color: #ffffff44;
-  font-size: 14px;
 
   > i {
     font-size: 18px;
-    margin-right: 10px;
+    margin-right: 8px;
   }
 `;
 
@@ -249,16 +292,3 @@ const Header = styled.div`
   font-size: 16px;
   margin-bottom: 15px;
 `;
-
-const Spinner = styled.img`
-  width: 15px;
-  height: 15px;
-  margin-right: 12px;
-  margin-bottom: -2px;
-`;
-
-const EventsGrid = styled.div`
-  display: grid;
-  grid-row-gap: 15px;
-  grid-template-columns: 1;
-`;

+ 25 - 28
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -65,7 +65,9 @@ const StatusSectionFC: React.FunctionComponent<Props> = ({
         setControllers([]);
         setIsLoading(false);
       });
-    return () => (isSubscribed = false);
+    return () => {
+      isSubscribed = false;
+    };
   }, [currentProject, currentCluster, setCurrentError, currentChart]);
 
   const renderLogs = () => {
@@ -133,31 +135,27 @@ const StatusSectionFC: React.FunctionComponent<Props> = ({
 
   return (
     <>
-      {
-        fullscreen ? (
-          <FullScreen>
-            <AbsoluteTitle>
-              <BackButton
-                onClick={setFullScreenLogs}
-              >
-                <i className="material-icons">navigate_before</i>
-              </BackButton>
-              Status ({currentChart.name})
-            </AbsoluteTitle>
-            <FullScreenButton top="70px" onClick={setFullScreenLogs}>
-              <i className="material-icons">close_fullscreen</i>
-            </FullScreenButton>
-            {renderStatusSection()}
-          </FullScreen>
-        ) : (
-          <StyledStatusSection>
-            <FullScreenButton onClick={setFullScreenLogs}>
-              <i className="material-icons">open_in_full</i>
-            </FullScreenButton>
-            {renderStatusSection()}
-          </StyledStatusSection>
-        )
-      }
+      {fullscreen ? (
+        <FullScreen>
+          <AbsoluteTitle>
+            <BackButton onClick={setFullScreenLogs}>
+              <i className="material-icons">navigate_before</i>
+            </BackButton>
+            Status ({currentChart.name})
+          </AbsoluteTitle>
+          <FullScreenButton top="70px" onClick={setFullScreenLogs}>
+            <i className="material-icons">close_fullscreen</i>
+          </FullScreenButton>
+          {renderStatusSection()}
+        </FullScreen>
+      ) : (
+        <StyledStatusSection>
+          <FullScreenButton onClick={setFullScreenLogs}>
+            <i className="material-icons">open_in_full</i>
+          </FullScreenButton>
+          {renderStatusSection()}
+        </StyledStatusSection>
+      )}
     </>
   );
 };
@@ -166,7 +164,7 @@ export default StatusSectionFC;
 
 const FullScreenButton = styled.div<{ top?: string }>`
   position: absolute;
-  top: ${props => props.top || "10px"};
+  top: ${(props) => props.top || "10px"};
   right: 10px;
   width: 24px;
   height: 24px;
@@ -218,7 +216,6 @@ const BackButtonImg = styled.img`
   opacity: 0.75;
 `;
 
-
 const AbsoluteTitle = styled.div`
   position: absolute;
   top: 0px;

+ 3 - 0
dashboard/src/main/home/launch/launch-flow/LaunchFlow.tsx

@@ -64,6 +64,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
   const [folderPath, setFolderPath] = useState(null);
   const [selectedRegistry, setSelectedRegistry] = useState(null);
   const [shouldCreateWorkflow, setShouldCreateWorkflow] = useState(true);
+  const [buildConfig, setBuildConfig] = useState();
 
   const generateRandomName = () => {
     const randomTemplateName = randomWords({ exactly: 3, join: "-" });
@@ -268,6 +269,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           template_version: props.currentTemplate?.currentVersion || "latest",
           name: release_name,
           github_action_config: githubActionConfig,
+          build_config: buildConfig,
         },
         {
           id: currentProject.id,
@@ -328,6 +330,7 @@ const LaunchFlow: React.FC<PropsType> = (props) => {
           setProcfilePath={setProcfilePath}
           selectedRegistry={selectedRegistry}
           setSelectedRegistry={setSelectedRegistry}
+          setBuildConfig={setBuildConfig}
         />
       );
     }

+ 3 - 0
dashboard/src/main/home/launch/launch-flow/SourcePage.tsx

@@ -45,6 +45,7 @@ type PropsType = RouteComponentProps & {
   setFolderPath: (x: string) => void;
   selectedRegistry: any;
   setSelectedRegistry: (x: string) => void;
+  setBuildConfig: (x: any) => void;
 };
 
 type StateType = {};
@@ -144,6 +145,7 @@ class SourcePage extends Component<PropsType, StateType> {
       setFolderPath,
       selectedRegistry,
       setSelectedRegistry,
+      setBuildConfig,
     } = this.props;
     return (
       <StyledSourceBox>
@@ -207,6 +209,7 @@ class SourcePage extends Component<PropsType, StateType> {
           }}
           setSelectedRegistry={setSelectedRegistry}
           selectedRegistry={selectedRegistry}
+          setBuildConfig={setBuildConfig}
         />
         <br />
       </StyledSourceBox>

+ 60 - 48
dashboard/src/main/home/navbar/Help.tsx

@@ -4,8 +4,7 @@ import styled from "styled-components";
 import { Context } from "shared/Context";
 import discordLogo from "../../../assets/discord.svg";
 
-type PropsType = {
-};
+type PropsType = {};
 
 type StateType = {
   showHelpDropdown: boolean;
@@ -13,7 +12,7 @@ type StateType = {
 
 export default class Help extends Component<PropsType, StateType> {
   state = {
-      showHelpDropdown: false,
+    showHelpDropdown: false,
   };
 
   renderHelpDropdown = () => {
@@ -27,28 +26,29 @@ export default class Help extends Component<PropsType, StateType> {
               })
             }
           />
-          <Dropdown
-            dropdownWidth="155px"
-            dropdownMaxHeight="300px"
-          >
-            <Option onClick={()=> {
-                window.open('https://docs.porter.run', '_blank').focus();}
-            }>
-            <i className="material-icons-outlined">book</i>
-                Documentation
+          <Dropdown dropdownWidth="155px" dropdownMaxHeight="300px">
+            <Option
+              onClick={() => {
+                window.open("https://docs.porter.run", "_blank").focus();
+              }}
+            >
+              <i className="material-icons-outlined">book</i>
+              Documentation
             </Option>
-            <Line/>
-            <Option onClick={() => {
-              window.open('https://discord.gg/Vbse9vJtPU', '_blank').focus();
-            }}>
-            <Icon src={discordLogo} />
+            <Line />
+            <Option
+              onClick={() => {
+                window.open("https://discord.gg/Vbse9vJtPU", "_blank").focus();
+              }}
+            >
+              <Icon src={discordLogo} />
               Community
             </Option>
-            <Line/>
-            <Option id={'intercom_help'}>
-            <i className="material-icons-outlined">message</i>
-                Message us
-            </Option>            
+            <Line />
+            <Option id={"intercom_help"}>
+              <i className="material-icons-outlined">message</i>
+              Message us
+            </Option>
           </Dropdown>
         </>
       );
@@ -77,31 +77,43 @@ export default class Help extends Component<PropsType, StateType> {
 Help.contextType = Context;
 
 const Option = styled.div`
-    margin-left: 15px;
-    font-size: 13px;
-    display: flex;
-    align-items: center;
-    justify-content: flex-start;
-    width: 120px;
-    height: 40px;
-    color: #ffffff88;
+  margin-left: 12px;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  width: 120px;
+  height: 40px;
+  color: #ffffff88;
+  cursor: pointer;
+  > i {
+    opacity: 50%;
+    color: white;
+    margin-right: 9px;
+    font-size: 18px;
     cursor: pointer;
+  }
+
+  :hover {
+    color: #ffffff;
+
+    > img {
+      opacity: 100%;
+    }
+
     > i {
-        opacity: 50%;
-        color: white;
-        margin-right: 7px;
-        font-size: 20px;
-        cursor: pointer;
+      opacity: 100%;
     }
-`
+  }
+`;
 
 const Line = styled.div`
-    height: 1px;
-    z-index: 0;
-    left: 0;
-    background: #aaaabb55;
-    width: 100%;
-`
+  height: 1px;
+  z-index: 0;
+  left: 0;
+  background: #aaaabb55;
+  width: 100%;
+`;
 
 const CloseOverlay = styled.div`
   position: fixed;
@@ -203,9 +215,9 @@ const FeedbackButton = styled(NavButton)`
 `;
 
 const Icon = styled.img`
-    margin-left: -2px;
-    height: 25px;
-    width: 25px;
-    opacity: 50%;
-    margin-right: 5px;
-`
+  margin-left: -2px;
+  height: 22px;
+  width: 22px;
+  opacity: 50%;
+  margin-right: 7px;
+`;

+ 11 - 11
dashboard/src/main/home/navbar/Navbar.tsx

@@ -62,7 +62,7 @@ class Navbar extends Component<PropsType, StateType> {
   render() {
     return (
       <StyledNavbar>
-        <Help/>
+        <Help />
         {this.renderFeedbackButton()}
         <NavButton
           selected={this.state.showDropdown}
@@ -245,18 +245,18 @@ const StyledNavbar = styled.div`
 `;
 
 const HelpIcon = styled.div`
-> a {
-  > i {
-    font-size: 18px;
-    margin-left: 8px;
-    margin-top: 2px;
-    color: #8590ff;
-    :hover {
-      color: #aaaabb;
+  > a {
+    > i {
+      font-size: 18px;
+      margin-left: 8px;
+      margin-top: 2px;
+      color: #8590ff;
+      :hover {
+        color: #aaaabb;
+      }
     }
   }
-}
-`
+`;
 
 const NavButton = styled.a`
   display: flex;

+ 2 - 1
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -315,11 +315,12 @@ class Sidebar extends Component<PropsType, StateType> {
           <br />
 
           {this.renderProjectContents()}
-
+          {/*
           <DiscordButton href="https://discord.gg/34n7NN7FJ7" target="_blank">
             <Icon src={discordLogo} />
             Join Our Discord
           </DiscordButton>
+          */}
         </StyledSidebar>
       </>
     );

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

@@ -321,6 +321,7 @@ const deployTemplate = baseApi<
     values?: any;
     name: string;
     github_action_config?: FullActionConfigType;
+    build_config?: any;
   },
   {
     id: number;
@@ -635,6 +636,20 @@ const getJobPods = baseApi<
   return `/api/projects/${id}/clusters/${cluster_id}/namespaces/${namespace}/jobs/${name}/pods`;
 });
 
+const getPodByName = baseApi<
+  {},
+  {
+    project_id: number;
+    cluster_id: number;
+    namespace: string;
+    name: string;
+  }
+>(
+  "GET",
+  ({ project_id, cluster_id, namespace, name }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/pods/${name}`
+);
+
 const getMatchingPods = baseApi<
   {
     namespace: string;
@@ -1132,6 +1147,63 @@ const getOnboardingRegistry = baseApi<
     `/api/projects/${project_id}/registries/${registry_connection_id}`
 );
 
+const detectPorterAgent = baseApi<
+  {},
+  { project_id: number; cluster_id: number }
+>(
+  "GET",
+  ({ project_id, cluster_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/agent/detect`
+);
+
+const installPorterAgent = baseApi<
+  {},
+  { project_id: number; cluster_id: number }
+>(
+  "POST",
+  ({ cluster_id, project_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/agent/install`
+);
+
+const getKubeEvents = baseApi<
+  {
+    skip: number;
+    resource_type: string;
+    owner_type?: string;
+    owner_name?: string;
+  },
+  { project_id: number; cluster_id: number }
+>("GET", ({ project_id, cluster_id }) => {
+  return `/api/projects/${project_id}/clusters/${cluster_id}/kube_events`;
+});
+
+const getKubeEvent = baseApi<
+  {},
+  { project_id: number; cluster_id: number; kube_event_id: number }
+>(
+  "GET",
+  ({ project_id, cluster_id, kube_event_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/kube_events/${kube_event_id}`
+);
+
+const getLogBuckets = baseApi<
+  {},
+  { project_id: number; cluster_id: number; kube_event_id: number }
+>(
+  "GET",
+  ({ project_id, cluster_id, kube_event_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/kube_events/${kube_event_id}/log_buckets`
+);
+
+const getLogBucketLogs = baseApi<
+  { timestamp: number },
+  { project_id: number; cluster_id: number; kube_event_id: number }
+>(
+  "GET",
+  ({ project_id, cluster_id, kube_event_id }) =>
+    `/api/projects/${project_id}/clusters/${cluster_id}/kube_events/${kube_event_id}/logs`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1197,6 +1269,7 @@ export default {
   getJobs,
   getJobStatus,
   getJobPods,
+  getPodByName,
   getMatchingPods,
   getMetrics,
   getNamespaces,
@@ -1249,4 +1322,10 @@ export default {
   saveOnboardingState,
   getOnboardingInfra,
   getOnboardingRegistry,
+  detectPorterAgent,
+  installPorterAgent,
+  getKubeEvents,
+  getKubeEvent,
+  getLogBuckets,
+  getLogBucketLogs,
 };

+ 16 - 0
dashboard/src/shared/types.tsx

@@ -331,3 +331,19 @@ export interface UsageData {
   exceeds: boolean;
   exceeded_since?: string;
 }
+
+export type KubeEvent = {
+  cluster_id: number;
+  event_type: string;
+  id: number;
+  message: string;
+  name: string;
+  namespace: string;
+  owner_name: string;
+  owner_type: string;
+  project_id: number;
+  reason: string;
+  resource_type: string;
+  timestamp: string;
+  sub_events: any[];
+};

+ 4 - 2
docker/Dockerfile

@@ -37,12 +37,14 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
 
 # Webpack build environment
 # -------------------------
-FROM node:lts as build-webpack
+FROM node:16 as build-webpack
 WORKDIR /webpack
 
 COPY ./dashboard ./
 
-RUN npm i
+RUN npm install -g npm@8.1
+
+RUN npm i --legacy-peer-deps
 
 ENV NODE_ENV=production
 

+ 4 - 2
ee/docker/ee.Dockerfile

@@ -38,12 +38,14 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
 
 # Webpack build environment
 # -------------------------
-FROM node:lts as build-webpack
+FROM node:16 as build-webpack
 WORKDIR /webpack
 
 COPY ./dashboard ./
 
-RUN npm i
+RUN npm install -g npm@8.1
+
+RUN npm i --legacy-peer-deps
 
 ENV NODE_ENV=production
 

+ 12 - 19
go.mod

@@ -3,10 +3,9 @@ module github.com/porter-dev/porter
 go 1.16
 
 require (
-	cloud.google.com/go v0.65.0
+	cloud.google.com/go v0.81.0
 	github.com/AlecAivazis/survey/v2 v2.2.9
-	github.com/DATA-DOG/go-sqlmock v1.5.0 // indirect
-	github.com/Masterminds/semver v1.5.0 // indirect
+	github.com/BurntSushi/toml v0.4.1 // indirect
 	github.com/Masterminds/semver/v3 v3.1.1
 	github.com/aws/aws-sdk-go v1.35.4
 	github.com/bradleyfalzon/ghinstallation v1.1.1
@@ -19,11 +18,9 @@ require (
 	github.com/docker/docker v20.10.7+incompatible
 	github.com/docker/docker-credential-helpers v0.6.3
 	github.com/docker/go-connections v0.4.0
-	github.com/fatih/color v1.9.0
+	github.com/fatih/color v1.10.0
 	github.com/getsentry/sentry-go v0.11.0
 	github.com/go-chi/chi v4.1.2+incompatible
-	github.com/go-playground/locales v0.13.0
-	github.com/go-playground/universal-translator v0.17.0
 	github.com/go-playground/validator/v10 v10.3.0
 	github.com/go-redis/redis/v8 v8.11.0
 	github.com/go-test/deep v1.0.7
@@ -38,37 +35,33 @@ require (
 	github.com/gorilla/websocket v1.4.2
 	github.com/hashicorp/golang-lru v0.5.3 // indirect
 	github.com/itchyny/gojq v0.12.1
-	github.com/jinzhu/gorm v1.9.16 // indirect
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
-	github.com/josharian/impl v1.1.0 // indirect
 	github.com/kris-nova/logger v0.0.0-20181127235838-fd0d87064b06
 	github.com/kris-nova/lolgopher v0.0.0-20180921204813-313b3abb0d9b // indirect
-	github.com/mitchellh/mapstructure v1.3.1 // indirect
+	github.com/mattn/go-runewidth v0.0.12 // indirect
 	github.com/moby/moby v20.10.6+incompatible
 	github.com/moby/term v0.0.0-20201216013528-df9cb8a40635
+	github.com/onsi/gomega v1.16.0 // indirect
 	github.com/opencontainers/image-spec v1.0.1
+	github.com/pelletier/go-toml v1.9.4
 	github.com/pkg/errors v0.9.1
 	github.com/rogpeppe/go-internal v1.5.2 // indirect
 	github.com/rs/zerolog v1.20.0
 	github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect
 	github.com/sendgrid/rest v2.6.3+incompatible // indirect
 	github.com/sendgrid/sendgrid-go v3.8.0+incompatible
-	github.com/spf13/cobra v1.1.3
-	github.com/spf13/jwalterweatherman v1.1.0 // indirect
+	github.com/spf13/cobra v1.2.1
 	github.com/spf13/pflag v1.0.5
-	github.com/spf13/viper v1.7.0
+	github.com/spf13/viper v1.8.1
 	github.com/stretchr/testify v1.7.0
 	github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c // indirect
 	golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2
 	golang.org/x/mod v0.5.0 // indirect
-	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
-	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
+	golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect
+	golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602
 	golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c // indirect
-	golang.org/x/tools v0.1.5 // indirect
-	google.golang.org/api v0.30.0
-	google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a
-	google.golang.org/grpc v1.38.0 // indirect
-	gopkg.in/ini.v1 v1.56.0 // indirect
+	google.golang.org/api v0.44.0
+	google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c
 	gopkg.in/segmentio/analytics-go.v3 v3.1.0
 	gopkg.in/yaml.v2 v2.4.0
 	gorm.io/driver/postgres v1.0.2

+ 121 - 50
go.sum

@@ -13,8 +13,13 @@ cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bP
 cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
 cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
 cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
-cloud.google.com/go v0.65.0 h1:Dg9iHVQfrhq82rUNu9ZxUDrJLaxFUe/HlCVaLyRruq8=
 cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
+cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
+cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
+cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
+cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
+cloud.google.com/go v0.81.0 h1:at8Tk2zUz63cLPR0JPWm5vp77pEZmzxEQBEfRKn1VV8=
+cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
 cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
@@ -62,8 +67,9 @@ github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZ
 github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk=
 github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo=
 github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
-github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
+github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
 github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
@@ -99,7 +105,6 @@ github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMo
 github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw=
 github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
-github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc=
 github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
 github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
 github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
@@ -128,9 +133,9 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
 github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
-github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239 h1:kFOfPq6dUM1hTo4JG6LR5AXSUEsOjtdm0kw0FtQtMJA=
 github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
+github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
 github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
 github.com/apex/log v1.9.0 h1:FHtw/xuaM8AgmvDDTI9fiwoAL25Sq2cxojnZICUU8l0=
@@ -168,6 +173,7 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
 github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
 github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
 github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84=
+github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
 github.com/blang/semver v3.5.0+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
 github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
 github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY=
@@ -196,7 +202,6 @@ github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5 h1:7aWHqerlJ41y6FOsEUvknqgXnGmJyJSbjhAWq5pO4F8=
 github.com/chai2010/gettext-go v0.0.0-20160711120539-c6fed771bfd5/go.mod h1:/iP1qXHoty45bqomnu2LM+VVyAEdWN+vtSHGlQgyxbw=
 github.com/charmbracelet/glamour v0.3.0/go.mod h1:TzF0koPZhqq0YVBNL100cPHznAAjVj7fksX2RInwjGw=
 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@@ -213,6 +218,7 @@ github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
 github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
 github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
@@ -249,6 +255,7 @@ github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+1atmu1JpKERPPk=
+github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
@@ -270,8 +277,6 @@ github.com/daviddengcn/go-colortext v0.0.0-20160507010035-511bcaf42ccd/go.mod h1
 github.com/deislabs/oras v0.11.1 h1:oo2J/3vXdcti8cjFi8ghMOkx0OacONxHC8dhJ17NdJ0=
 github.com/deislabs/oras v0.11.1/go.mod h1:39lCtf8Q6WDC7ul9cnyWXONNzKvabEKk+AX+L0ImnQk=
 github.com/denisenkom/go-mssqldb v0.0.0-20191001013358-cfbb681360f0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
-github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM=
-github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
 github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0=
 github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4=
 github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
@@ -329,10 +334,10 @@ github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4s
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
+github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
-github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
-github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
 github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
 github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
 github.com/evanphx/json-patch v4.5.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
@@ -341,11 +346,10 @@ github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLi
 github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwCFad8crR9dcMQWvV9Hvulu6hwUh4tWPJnM=
 github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4=
 github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
-github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8=
 github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc=
 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
-github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s=
-github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
+github.com/fatih/color v1.10.0 h1:s36xzo75JdqLaaWoiEHk767eHiwo0598uUxyfiPkDsg=
+github.com/fatih/color v1.10.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM=
 github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk=
@@ -474,6 +478,7 @@ github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22
 github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
 github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
 github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
 github.com/godror/godror v0.13.3/go.mod h1:2ouUT4kdhUBk7TAkHWD4SN0CdI0pgEQbo8FVHhbSKWg=
 github.com/gofrs/flock v0.7.0/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
 github.com/gofrs/flock v0.8.0 h1:MSdYClljsF3PbENUUEx85nkWfJSGfzYI9yEBZOJz6CY=
@@ -488,7 +493,6 @@ github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5
 github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
-github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
 github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@@ -522,6 +526,7 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD
 github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
 github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
 github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
 github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@@ -538,6 +543,8 @@ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
 github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -561,6 +568,7 @@ github.com/google/gofuzz v1.1.0 h1:Hsa8mG0dQ46ij8Sl2AYJDUv1oA9/d6Vk+3LG99Oe02g=
 github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
 github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
+github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
 github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
 github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
 github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@@ -568,6 +576,10 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf
 github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
 github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
 github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
+github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
+github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
@@ -613,6 +625,7 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de
 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
 github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
 github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
+github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
 github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
 github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
 github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
@@ -650,6 +663,7 @@ github.com/huandu/xstrings v1.3.1 h1:4jgBlKK6tLKFvO8u5pmYjG91cqytmDCDvGh7ECVFfFs
 github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
 github.com/hudl/fargo v1.3.0/go.mod h1:y3CKSmjA+wD2gak7sUSXTAoopbhU08POFhmITJgmKTg=
 github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
+github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
 github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
 github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
 github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
@@ -724,11 +738,8 @@ github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
-github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o=
-github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs=
 github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
 github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
-github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
 github.com/jinzhu/now v1.1.1 h1:g39TucaRWyV3dwDO++eEc6qf8TVIQ/Da48WmqjZ3i7E=
 github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
 github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
@@ -747,16 +758,15 @@ github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd/go.mod h1:MEQrHu
 github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
 github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
-github.com/josharian/impl v1.1.0 h1:gafhg1OFVMq46ifdkBa8wp4hlGogjktjjA5h/2j4+2k=
-github.com/josharian/impl v1.1.0/go.mod h1:SQ6aJMP6xsJpGSD/36IIqrUdigLCYe9bz/9o5AKm6Aw=
 github.com/jpillora/backoff v0.0.0-20180909062703-3050d21c67d7/go.mod h1:2iMrUgbbvHEiQClaW2NsSzMyGHqN+rDFqY705q49KG0=
 github.com/json-iterator/go v0.0.0-20180612202835-f2b4162afba3/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
 github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
 github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
 github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
-github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=
 github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
+github.com/json-iterator/go v1.1.11 h1:uVUAXhF2To8cbw/3xN3pxj6kk7TYKs98NIrTqPlMWAQ=
+github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
 github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
 github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
 github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
@@ -782,6 +792,7 @@ github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgo
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
@@ -808,7 +819,6 @@ github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
 github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
 github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
-github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.10.0 h1:Zx5DJFEYQXio93kgXnQ09fXNiUKsqv4OUEu2UtGcB1E=
@@ -821,8 +831,9 @@ github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
-github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4=
 github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
+github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
+github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
 github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
 github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -835,7 +846,6 @@ github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHef
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
 github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
 github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
-github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
 github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
 github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
 github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@@ -845,7 +855,6 @@ github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd
 github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
-github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
 github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-oci8 v0.0.7/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mNXJwGI=
@@ -853,11 +862,11 @@ github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzp
 github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
 github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
-github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg=
 github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
+github.com/mattn/go-runewidth v0.0.12 h1:Y41i/hVW3Pgwr8gV+J23B9YEY0zxjptBuCWEaxmAOow=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-shellwords v1.0.11/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
 github.com/mattn/go-sqlite3 v1.12.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
-github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus=
 github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
 github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg=
 github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
@@ -889,8 +898,8 @@ github.com/mitchellh/ioprogress v0.0.0-20180201004757-6a23b12fa88e h1:Qa6dnn8Dla
 github.com/mitchellh/ioprogress v0.0.0-20180201004757-6a23b12fa88e/go.mod h1:waEya8ee1Ro/lgxpVhkJI4BVASzkm3UZqkx/cFJiYHM=
 github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
-github.com/mitchellh/mapstructure v1.3.1 h1:cCBH2gTD2K0OtLlv/Y5H01VQCqmlDxz30kS5Y5bqfLA=
-github.com/mitchellh/mapstructure v1.3.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
+github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A=
 github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
 github.com/mitchellh/reflectwalk v1.0.1 h1:FVzMWA5RllMAKIdUSC8mdWo3XtwoecrH79BY70sEEpE=
@@ -955,8 +964,9 @@ github.com/onsi/ginkgo v1.11.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+
 github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
 github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
 github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISqnKUg=
-github.com/onsi/ginkgo v1.16.2 h1:HFB2fbVIlhIfCfOW81bZFbiC/RvnpXSdhbF2/DJr134=
 github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E=
+github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
+github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
 github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
 github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
 github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
@@ -965,8 +975,9 @@ github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7J
 github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
 github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
 github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48=
-github.com/onsi/gomega v1.12.0 h1:p4oGGk2M2UJc0wWN4lHFvIB71lxsh0T/UiKCCgFADY8=
 github.com/onsi/gomega v1.12.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
+github.com/onsi/gomega v1.16.0 h1:6gjqkI8iiRHMvdccRJM8rVKjCWk6ZIm6FTm3ddIe4/c=
+github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
 github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
 github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
 github.com/opencontainers/go-digest v0.0.0-20180430190053-c9281466c8b2/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
@@ -995,8 +1006,10 @@ github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FI
 github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
 github.com/pelletier/go-buffruneio v0.2.0/go.mod h1:JkE26KsDizTr40EUHkXVtNPvgGtbSNq5BcowyYOWdKo=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
-github.com/pelletier/go-toml v1.9.1 h1:a6qW1EVNZWH9WGI6CsYdD8WAylkoXBS5yv0XHlh17Tc=
 github.com/pelletier/go-toml v1.9.1/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
+github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
+github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM=
+github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
 github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
 github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
 github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
@@ -1004,6 +1017,7 @@ github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2 h1:JhzVVoYvbOACxoU
 github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE=
 github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc=
 github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
+github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
 github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -1011,6 +1025,7 @@ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
+github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
 github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -1063,6 +1078,7 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
 github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
+github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
 github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/rogpeppe/go-internal v1.3.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
@@ -1126,8 +1142,9 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k
 github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
-github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
 github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
+github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=
+github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
 github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
@@ -1136,8 +1153,9 @@ github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3
 github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
 github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
 github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI=
-github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M=
 github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
+github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw=
+github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
 github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
@@ -1149,8 +1167,9 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
 github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
-github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM=
 github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg=
+github.com/spf13/viper v1.8.1 h1:Kq1fyeebqsBfbjZj4EL7gj2IO0mMaiyjYUWcUsl2O44=
+github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
 github.com/src-d/gcfg v1.4.0 h1:xXbNR5AlLSA315x2UO+fTSSAXCDf+Ar38/6oyGbDKQ4=
 github.com/src-d/gcfg v1.4.0/go.mod h1:p/UMsR43ujA89BJY9duynAwIpvqEujIH/jFlfL7jWoI=
 github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
@@ -1237,6 +1256,9 @@ go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
 go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
 go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
 go.etcd.io/etcd v0.5.0-alpha.5.0.20200910180754-dd1b699fc489/go.mod h1:yVHk9ub3CSBatqGNg7GRmsnfLWtoW60w4eDYfh7vHDg=
+go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
+go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
+go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
 go.hein.dev/go-version v0.1.0/go.mod h1:WOEm7DWMroRe5GdUgHMvx+Pji5WWIpMuXmK/3foylXs=
 go.mongodb.org/mongo-driver v1.0.3/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=
 go.mongodb.org/mongo-driver v1.1.1/go.mod h1:u7ryQJ+DOzQmeO7zB6MHyr8jkEQvC8vH7qLUO4lqsUM=
@@ -1247,21 +1269,26 @@ go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
-go.opencensus.io v0.22.4 h1:LYy1Hy3MJdrCdMwwzxA/dRok4ejH+RwNGbuoD9fCjto=
 go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
+go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M=
+go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
 go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5 h1:+FNtrFTmVw0YZGpBGX56XDee331t6JAXeK2bcyhLOOc=
 go.starlark.net v0.0.0-20200306205701-8dd3e2ee1dd5/go.mod h1:nmDLcffg48OtT/PSW0Hg7FvpRQsQh5OSqIylirxKC7o=
 go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
 go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
 go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
 go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
+go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
 go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
 go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
 go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
 go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
+go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
 golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -1283,7 +1310,6 @@ golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
-golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200220183623-bac4c82f6975/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -1319,6 +1345,8 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl
 golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
 golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
 golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
+golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
 golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
 golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
 golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
@@ -1328,12 +1356,12 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
+golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.5.0 h1:UG21uOlmZabA4fW5i7ZX6bjw1xELEGg/ZLgZq9auk/Q=
 golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
 golang.org/x/net v0.0.0-20170114055629-f2499483f923/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
 golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1377,22 +1405,32 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R
 golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210224082022-3d97a244fca7/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
 golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20210428140749-89ef3d95e781 h1:DzZ89McO9/gWPsQXS/FVKAlG02ZjaQ6AlZRBimEYOd0=
 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
+golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q=
+golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
-golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 h1:ld7aEMNHoBnnDAX15v1T6z31v8HwR2A9FYOuAhWqkwc=
 golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
+golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 h1:0Ja1LBD+yisY6RWM/BH7TJVXWsSjs2VwBSmvSX4HdBc=
+golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1469,23 +1507,27 @@ golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200831180312-196b9ba8737a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200922070232-aee5d888a860/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210423082822-04245dca01da h1:b3NXsE2LusjYGGjL5bxEVZZORm/YEFFrWFjR8eFrw/c=
+golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
-golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c h1:Lyn7+CqXIiC+LOR9aHD6jDK+hPcmAuCfuXztd1v4w1Q=
 golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
@@ -1571,19 +1613,22 @@ golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roY
 golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
-golang.org/x/tools v0.0.0-20200522201501-cb1345f3a375/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200616133436-c1934b75d054/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
 golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
+golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
 golang.org/x/tools v0.0.0-20200916195026-c9a70fc28ce3/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU=
+golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
+golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
-golang.org/x/tools v0.1.5 h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA=
-golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1611,16 +1656,23 @@ google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/
 google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
 google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
 google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
-google.golang.org/api v0.30.0 h1:yfrXXP61wVuLb0vBcG6qaOoIoqYEzOQS8jum51jkv2w=
 google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
+google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
+google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
+google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
+google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
+google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
+google.golang.org/api v0.44.0 h1:URs6qR1lAxDsqWITsQXI4ZkGiYJ5dHtRNiCpfs2OeKA=
+google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
 google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc=
 google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
+google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
+google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
 google.golang.org/cloud v0.0.0-20151119220103-975617b05ea8/go.mod h1:0H1ncTHf11KCFhTc/+EFRbzSCOZx+VUbRMk55Yv5MYk=
 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
 google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@@ -1646,6 +1698,7 @@ google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfG
 google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
+google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
 google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
@@ -1653,9 +1706,20 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc
 google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20201022181438-0ff5f38871d5/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
-google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a h1:pOwg4OoaRYScjmR4LlLgdtnyoHYTSAVhhqe5uPdpII8=
+google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
+google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c h1:wtujag7C+4D6KMoulW9YauvK2lgdvCMS260jsqqBXr0=
+google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
 google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
@@ -1674,7 +1738,13 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
 google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
 google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
 google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
 google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
+google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
+google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
+google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
+google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 google.golang.org/grpc v1.38.0 h1:/9BgsAsa5nWe26HqOlvlgJnqBuktYOLCgjCPqsa56W0=
 google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
@@ -1713,8 +1783,8 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
 gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
 gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/ini.v1 v1.56.0 h1:DPMeDvGTM54DXbPkVIZsp19fp/I2K7zwA/itHYHKo8Y=
-gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ini.v1 v1.62.0 h1:duBzk771uxoUuOlyRLkHsygud9+5lrlGjdFBb4mSKDU=
+gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
 gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
 gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
@@ -1734,6 +1804,7 @@ gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRN
 gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
 gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

+ 101 - 0
internal/integrations/buildpacks/go.go

@@ -0,0 +1,101 @@
+package buildpacks
+
+import (
+	"sync"
+
+	"github.com/google/go-github/github"
+)
+
+type goRuntime struct {
+	wg sync.WaitGroup
+}
+
+func NewGoRuntime() Runtime {
+	return &goRuntime{}
+}
+
+func (runtime *goRuntime) detectMod(results chan struct {
+	string
+	bool
+}, directoryContent []*github.RepositoryContent) {
+	goModFound := false
+	for i := 0; i < len(directoryContent); i++ {
+		name := directoryContent[i].GetName()
+		if name == "go.mod" {
+			goModFound = true
+			break
+		}
+	}
+	if goModFound {
+		results <- struct {
+			string
+			bool
+		}{mod, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *goRuntime) detectDep(results chan struct {
+	string
+	bool
+}, directoryContent []*github.RepositoryContent) {
+	gopkgFound := false
+	vendorFound := false
+	for i := 0; i < len(directoryContent); i++ {
+		name := directoryContent[i].GetName()
+		if name == "Gopkg.toml" {
+			gopkgFound = true
+		} else if name == "vendor" && directoryContent[i].GetType() == "dir" {
+			vendorFound = true
+		}
+		if gopkgFound && vendorFound {
+			break
+		}
+	}
+	if gopkgFound && vendorFound {
+		results <- struct {
+			string
+			bool
+		}{dep, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *goRuntime) Detect(
+	client *github.Client,
+	directoryContent []*github.RepositoryContent,
+	owner, name, path string,
+	repoContentOptions github.RepositoryContentGetOptions,
+	paketo, heroku *BuilderInfo,
+) error {
+	results := make(chan struct {
+		string
+		bool
+	}, 2)
+
+	runtime.wg.Add(2)
+	go runtime.detectMod(results, directoryContent)
+	go runtime.detectDep(results, directoryContent)
+	runtime.wg.Wait()
+	close(results)
+
+	paketoBuildpackInfo := BuildpackInfo{
+		Name:      "Go",
+		Buildpack: "gcr.io/paketo-buildpacks/go",
+	}
+	herokuBuildpackInfo := BuildpackInfo{
+		Name:      "Go",
+		Buildpack: "heroku/go",
+	}
+
+	if len(results) == 0 {
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return nil
+	}
+
+	paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+	heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
+
+	return nil
+}

+ 339 - 0
internal/integrations/buildpacks/nodejs.go

@@ -0,0 +1,339 @@
+package buildpacks
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"strings"
+	"sync"
+
+	"github.com/Masterminds/semver/v3"
+	"github.com/google/go-github/github"
+)
+
+var (
+	lts = map[string]int{
+		"argon":   4,
+		"boron":   6,
+		"carbon":  8,
+		"dubnium": 10,
+	}
+)
+
+type nodejsRuntime struct {
+	wg sync.WaitGroup
+}
+
+func NewNodeRuntime() Runtime {
+	return &nodejsRuntime{}
+}
+
+func (runtime *nodejsRuntime) detectYarn(results chan struct {
+	string
+	bool
+}, directoryContent []*github.RepositoryContent) {
+	yarnLockFound := false
+	packageJSONFound := false
+	for i := 0; i < len(directoryContent); i++ {
+		name := directoryContent[i].GetName()
+		if name == "yarn.lock" {
+			yarnLockFound = true
+		} else if name == "package.json" {
+			packageJSONFound = true
+		}
+		if yarnLockFound && packageJSONFound {
+			break
+		}
+	}
+	if yarnLockFound && packageJSONFound {
+		results <- struct {
+			string
+			bool
+		}{yarn, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *nodejsRuntime) detectNPM(results chan struct {
+	string
+	bool
+}, directoryContent []*github.RepositoryContent) {
+	packageJSONFound := false
+	for i := 0; i < len(directoryContent); i++ {
+		name := directoryContent[i].GetName()
+		if name == "package.json" {
+			packageJSONFound = true
+			break
+		}
+	}
+	if packageJSONFound {
+		results <- struct {
+			string
+			bool
+		}{npm, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *nodejsRuntime) detectStandalone(results chan struct {
+	string
+	bool
+}, directoryContent []*github.RepositoryContent) {
+	jsFileFound := false
+	for i := 0; i < len(directoryContent); i++ {
+		name := directoryContent[i].GetName()
+		if name == "server.js" || name == "app.js" || name == "main.js" || name == "index.js" {
+			jsFileFound = true
+			break
+		}
+	}
+	if jsFileFound {
+		results <- struct {
+			string
+			bool
+		}{standalone, true}
+	}
+	runtime.wg.Done()
+}
+
+// copied directly from https://github.com/paketo-buildpacks/node-engine/blob/main/nvmrc_parser.go
+func validateNvmrc(content string) (string, error) {
+	content = strings.TrimSpace(strings.ToLower(content))
+
+	if content == "lts/*" || content == "node" {
+		return content, nil
+	}
+
+	for key := range lts {
+		if content == strings.ToLower("lts/"+key) {
+			return content, nil
+		}
+	}
+
+	content = strings.TrimPrefix(content, "v")
+
+	if _, err := semver.NewConstraint(content); err != nil {
+		return "", fmt.Errorf("invalid version constraint specified in .nvmrc: %q", content)
+	}
+
+	return content, nil
+}
+
+// copied directly from https://github.com/paketo-buildpacks/node-engine/blob/main/nvmrc_parser.go
+func formatNvmrcContent(version string) string {
+	if version == "node" {
+		return "*"
+	}
+
+	if strings.HasPrefix(version, "lts") {
+		ltsName := strings.SplitN(version, "/", 2)[1]
+		if ltsName == "*" {
+			var maxVersion int
+			for _, versionValue := range lts {
+				if maxVersion < versionValue {
+					maxVersion = versionValue
+				}
+			}
+
+			return fmt.Sprintf("%d.*", maxVersion)
+		}
+
+		return fmt.Sprintf("%d.*", lts[ltsName])
+	}
+
+	return version
+}
+
+// copied directly from https://github.com/paketo-buildpacks/node-engine/blob/main/node_version_parser.go
+func validateNodeVersion(content string) (string, error) {
+	content = strings.TrimSpace(strings.ToLower(content))
+
+	content = strings.TrimPrefix(content, "v")
+
+	if _, err := semver.NewConstraint(content); err != nil {
+		return "", fmt.Errorf("invalid version constraint specified in .node-version: %q", content)
+	}
+
+	return content, nil
+}
+
+func (runtime *nodejsRuntime) Detect(
+	client *github.Client,
+	directoryContent []*github.RepositoryContent,
+	owner, name, path string,
+	repoContentOptions github.RepositoryContentGetOptions,
+	paketo, heroku *BuilderInfo,
+) error {
+	results := make(chan struct {
+		string
+		bool
+	}, 3)
+
+	runtime.wg.Add(3)
+	go runtime.detectYarn(results, directoryContent)
+	go runtime.detectNPM(results, directoryContent)
+	go runtime.detectStandalone(results, directoryContent)
+	runtime.wg.Wait()
+	close(results)
+
+	paketoBuildpackInfo := BuildpackInfo{
+		Name:      "NodeJS",
+		Buildpack: "gcr.io/paketo-buildpacks/nodejs",
+	}
+	herokuBuildpackInfo := BuildpackInfo{
+		Name:      "NodeJS",
+		Buildpack: "heroku/nodejs",
+	}
+
+	if len(results) == 0 {
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return nil
+	}
+
+	foundYarn := false
+	foundNPM := false
+	foundStandalone := false
+	for result := range results {
+		if result.string == yarn {
+			foundYarn = true
+		} else if result.string == npm {
+			foundNPM = true
+		} else if result.string == standalone {
+			foundStandalone = true
+		}
+	}
+
+	if foundYarn || foundNPM {
+		// it is safe to assume that the project contains a package.json
+		fileContent, _, _, err := client.Repositories.GetContents(
+			context.Background(),
+			owner,
+			name,
+			fmt.Sprintf("%s/package.json", path),
+			&repoContentOptions,
+		)
+		if err != nil {
+			paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+			heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+			return fmt.Errorf("error fetching contents of package.json: %v", err)
+		}
+		var packageJSON struct {
+			Scripts map[string]string `json:"scripts"`
+			Engines struct {
+				Node string `json:"node"`
+			} `json:"engines"`
+		}
+
+		data, err := fileContent.GetContent()
+		if err != nil {
+			paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+			heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+			return fmt.Errorf("error calling GetContent() on package.json: %v", err)
+		}
+		err = json.NewDecoder(strings.NewReader(data)).Decode(&packageJSON)
+		if err != nil {
+			paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+			heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+			return fmt.Errorf("error decoding package.json contents to struct: %v", err)
+		}
+
+		if packageJSON.Engines.Node == "" {
+			// we should now check for the node engine version in .nvmrc and then .node-version
+			nvmrcFound := false
+			nodeVersionFound := false
+			for i := 0; i < len(directoryContent); i++ {
+				name := directoryContent[i].GetName()
+				if name == ".nvmrc" {
+					nvmrcFound = true
+				} else if name == ".node-version" {
+					nodeVersionFound = true
+				}
+			}
+
+			if nvmrcFound {
+				// copy exact behavior of https://github.com/paketo-buildpacks/node-engine/blob/main/nvmrc_parser.go
+				fileContent, _, _, err = client.Repositories.GetContents(
+					context.Background(),
+					owner,
+					name,
+					fmt.Sprintf("%s/.nvmrc", path),
+					&repoContentOptions,
+				)
+				if err != nil {
+					paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+					heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+					return fmt.Errorf("error fetching contents of .nvmrc: %v", err)
+				}
+				data, err = fileContent.GetContent()
+				if err != nil {
+					paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+					heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+					return fmt.Errorf("error calling GetContent() on .nvmrc: %v", err)
+				}
+				nvmrcVersion, err := validateNvmrc(data)
+				if err != nil {
+					paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+					heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+					return fmt.Errorf("error validating .nvmrc: %v", err)
+				}
+				nvmrcVersion = formatNvmrcContent(nvmrcVersion)
+
+				if nvmrcVersion != "*" {
+					packageJSON.Engines.Node = data
+				}
+			}
+
+			if packageJSON.Engines.Node == "" && nodeVersionFound {
+				// copy exact behavior of https://github.com/paketo-buildpacks/node-engine/blob/main/node_version_parser.go
+				fileContent, _, _, err = client.Repositories.GetContents(
+					context.Background(),
+					owner,
+					name,
+					fmt.Sprintf("%s/.node-version", path),
+					&repoContentOptions,
+				)
+				if err != nil {
+					paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+					heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+					return fmt.Errorf("error fetching contents of .node-version: %v", err)
+				}
+				data, err = fileContent.GetContent()
+				if err != nil {
+					paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+					heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+					return fmt.Errorf("error calling GetContent() on .node-version: %v", err)
+				}
+				nodeVersion, err := validateNodeVersion(data)
+				if err != nil {
+					paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+					heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+					return fmt.Errorf("error validating .node-version: %v", err)
+				}
+				if nodeVersion != "" {
+					packageJSON.Engines.Node = nodeVersion
+				}
+			}
+		}
+
+		if packageJSON.Engines.Node == "" {
+			// use the default node engine version from https://github.com/paketo-buildpacks/node-engine/blob/main/buildpack.toml
+			packageJSON.Engines.Node = "16.*.*"
+		}
+
+		paketoBuildpackInfo.Config = make(map[string]interface{})
+		paketoBuildpackInfo.Config["scripts"] = packageJSON.Scripts
+		paketoBuildpackInfo.Config["node_engine"] = packageJSON.Engines.Node
+		paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+
+		herokuBuildpackInfo.Config = make(map[string]interface{})
+		herokuBuildpackInfo.Config["scripts"] = packageJSON.Scripts
+		herokuBuildpackInfo.Config["node_engine"] = packageJSON.Engines.Node
+		heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
+	} else if foundStandalone {
+		paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+		heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
+	}
+
+	return nil
+}

+ 149 - 0
internal/integrations/buildpacks/python.go

@@ -0,0 +1,149 @@
+package buildpacks
+
+import (
+	"strings"
+	"sync"
+
+	"github.com/google/go-github/github"
+)
+
+type pythonRuntime struct {
+	wg sync.WaitGroup
+}
+
+func NewPythonRuntime() Runtime {
+	return &pythonRuntime{}
+}
+
+func (runtime *pythonRuntime) detectPipenv(results chan struct {
+	string
+	bool
+}, directoryContent []*github.RepositoryContent) {
+	pipfileFound := false
+	pipfileLockFound := false
+	for i := 0; i < len(directoryContent); i++ {
+		name := directoryContent[i].GetName()
+		if name == "Pipfile" {
+			pipfileFound = true
+		} else if name == "Pipfile.lock" {
+			pipfileLockFound = true
+		}
+		if pipfileFound && pipfileLockFound {
+			break
+		}
+	}
+	if pipfileFound && pipfileLockFound {
+		results <- struct {
+			string
+			bool
+		}{pipenv, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *pythonRuntime) detectPip(results chan struct {
+	string
+	bool
+}, directoryContent []*github.RepositoryContent) {
+	requirementsTxtFound := false
+	for i := 0; i < len(directoryContent); i++ {
+		name := directoryContent[i].GetName()
+		if name == "requirements.txt" {
+			requirementsTxtFound = true
+		}
+	}
+	if requirementsTxtFound {
+		results <- struct {
+			string
+			bool
+		}{pip, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *pythonRuntime) detectConda(results chan struct {
+	string
+	bool
+}, directoryContent []*github.RepositoryContent) {
+	environmentFound := false
+	packageListFound := false
+	for i := 0; i < len(directoryContent); i++ {
+		name := directoryContent[i].GetName()
+		if name == "environment.yml" {
+			environmentFound = true
+			break
+		} else if name == "package-list.txt" {
+			packageListFound = true
+			break
+		}
+	}
+	if environmentFound || packageListFound {
+		results <- struct {
+			string
+			bool
+		}{conda, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *pythonRuntime) detectStandalone(results chan struct {
+	string
+	bool
+}, directoryContent []*github.RepositoryContent) {
+	pyFound := false
+	for i := 0; i < len(directoryContent); i++ {
+		name := directoryContent[i].GetName()
+		if strings.HasSuffix(name, ".py") {
+			pyFound = true
+			break
+		}
+	}
+	if pyFound {
+		results <- struct {
+			string
+			bool
+		}{standalone, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *pythonRuntime) Detect(
+	client *github.Client,
+	directoryContent []*github.RepositoryContent,
+	owner, name, path string,
+	repoContentOptions github.RepositoryContentGetOptions,
+	paketo, heroku *BuilderInfo,
+) error {
+	results := make(chan struct {
+		string
+		bool
+	}, 4)
+
+	runtime.wg.Add(4)
+	go runtime.detectPipenv(results, directoryContent)
+	go runtime.detectPip(results, directoryContent)
+	go runtime.detectConda(results, directoryContent)
+	go runtime.detectStandalone(results, directoryContent)
+	runtime.wg.Wait()
+	close(results)
+
+	paketoBuildpackInfo := BuildpackInfo{
+		Name:      "Python",
+		Buildpack: "gcr.io/paketo-buildpacks/python",
+	}
+	herokuBuildpackInfo := BuildpackInfo{
+		Name:      "Python",
+		Buildpack: "heroku/python",
+	}
+
+	if len(results) == 0 {
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return nil
+	}
+
+	paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+	heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
+
+	return nil
+}

+ 280 - 0
internal/integrations/buildpacks/ruby.go

@@ -0,0 +1,280 @@
+package buildpacks
+
+import (
+	"bufio"
+	"context"
+	"fmt"
+	"regexp"
+	"strings"
+	"sync"
+
+	"github.com/google/go-github/github"
+)
+
+type rubyRuntime struct {
+	wg sync.WaitGroup
+}
+
+func NewRubyRuntime() Runtime {
+	return &rubyRuntime{}
+}
+
+func (runtime *rubyRuntime) detectPuma(gemfileContent string, results chan struct {
+	string
+	bool
+}) {
+	pumaFound := false
+	quotes := `["']`
+	pumaRe := regexp.MustCompile(fmt.Sprintf(`^\s*gem %spuma%s`, quotes, quotes))
+	scanner := bufio.NewScanner(strings.NewReader(gemfileContent))
+	for scanner.Scan() {
+		line := []byte(scanner.Text())
+		if pumaRe.Match(line) {
+			pumaFound = true
+			break
+		}
+	}
+	if pumaFound {
+		results <- struct {
+			string
+			bool
+		}{puma, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *rubyRuntime) detectThin(gemfileContent string, results chan struct {
+	string
+	bool
+}) {
+	thinFound := false
+	quotes := `["']`
+	thinRe := regexp.MustCompile(fmt.Sprintf(`^\s*gem %sthin%s`, quotes, quotes))
+	scanner := bufio.NewScanner(strings.NewReader(gemfileContent))
+	for scanner.Scan() {
+		line := []byte(scanner.Text())
+		if thinRe.Match(line) {
+			thinFound = true
+			break
+		}
+	}
+	if thinFound {
+		results <- struct {
+			string
+			bool
+		}{thin, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *rubyRuntime) detectUnicorn(gemfileContent string, results chan struct {
+	string
+	bool
+}) {
+	unicornFound := false
+	quotes := `["']`
+	unicornRe := regexp.MustCompile(fmt.Sprintf(`^\s*gem %sunicorn%s`, quotes, quotes))
+	scanner := bufio.NewScanner(strings.NewReader(gemfileContent))
+	for scanner.Scan() {
+		line := []byte(scanner.Text())
+		if unicornRe.Match(line) {
+			unicornFound = true
+			break
+		}
+	}
+	if unicornFound {
+		results <- struct {
+			string
+			bool
+		}{unicorn, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *rubyRuntime) detectPassenger(gemfileContent string, results chan struct {
+	string
+	bool
+}) {
+	passengerFound := false
+	quotes := `["']`
+	passengerRe := regexp.MustCompile(fmt.Sprintf(`^\s*gem %spassenger%s`, quotes, quotes))
+	scanner := bufio.NewScanner(strings.NewReader(gemfileContent))
+	for scanner.Scan() {
+		line := []byte(scanner.Text())
+		if passengerRe.Match(line) {
+			passengerFound = true
+			break
+		}
+	}
+	if passengerFound {
+		results <- struct {
+			string
+			bool
+		}{passenger, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *rubyRuntime) detectRackup(
+	client *github.Client, owner, name string,
+	repoContentOptions github.RepositoryContentGetOptions, results chan struct {
+		string
+		bool
+	},
+) {
+	fileContent, _, _, err := client.Repositories.GetContents(context.Background(),
+		owner, name, "Gemfile.lock", &repoContentOptions)
+	if err != nil {
+		runtime.wg.Done()
+		return
+	}
+	gemfileLockContent, err := fileContent.GetContent()
+	if err != nil {
+		runtime.wg.Done()
+		return
+	}
+
+	rackFound := false
+	scanner := bufio.NewScanner(strings.NewReader(gemfileLockContent))
+	for scanner.Scan() {
+		if strings.TrimSpace(scanner.Text()) == "GEM" {
+			for scanner.Scan() {
+				if strings.Contains(scanner.Text(), "rack") {
+					rackFound = true
+					break
+				}
+			}
+		}
+	}
+	if rackFound {
+		results <- struct {
+			string
+			bool
+		}{rackup, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *rubyRuntime) detectRake(gemfileContent string, results chan struct {
+	string
+	bool
+}) {
+	rakeFound := false
+	quotes := `["']`
+	rakeRe := regexp.MustCompile(fmt.Sprintf(`^\s*gem %srake%s`, quotes, quotes))
+	scanner := bufio.NewScanner(strings.NewReader(gemfileContent))
+	for scanner.Scan() {
+		line := []byte(scanner.Text())
+		if rakeRe.Match(line) {
+			rakeFound = true
+			break
+		}
+	}
+	if rakeFound {
+		results <- struct {
+			string
+			bool
+		}{rake, true}
+	}
+	runtime.wg.Done()
+}
+
+func (runtime *rubyRuntime) Detect(
+	client *github.Client,
+	directoryContent []*github.RepositoryContent,
+	owner, name, path string,
+	repoContentOptions github.RepositoryContentGetOptions,
+	paketo, heroku *BuilderInfo,
+) error {
+	gemfileFound := false
+	gemfileLockFound := false
+	configRuFound := false
+	rakefileFound := false
+	for i := range directoryContent {
+		name := directoryContent[i].GetName()
+		if name == "Gemfile" {
+			gemfileFound = true
+		} else if name == "Gemfile.lock" {
+			gemfileLockFound = true
+		} else if name == "config.ru" {
+			configRuFound = true
+		} else if name == "Rakefile" || name == "Rakefile.rb" || name == "rakefile" || name == "rakefile.rb" {
+			rakefileFound = true
+		}
+	}
+
+	paketoBuildpackInfo := BuildpackInfo{
+		Name:      "Ruby",
+		Buildpack: "gcr.io/paketo-buildpacks/ruby",
+	}
+	herokuBuildpackInfo := BuildpackInfo{
+		Name:      "Ruby",
+		Buildpack: "heroku/ruby",
+	}
+
+	if !gemfileFound {
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return nil
+	}
+
+	fileContent, _, _, err := client.Repositories.GetContents(context.Background(), owner, name, "Gemfile", &repoContentOptions)
+	if err != nil {
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return fmt.Errorf("error fetching contents of Gemfile for %s/%s: %v", owner, name, err)
+	}
+	gemfileContent, err := fileContent.GetContent()
+	if err != nil {
+		paketo.Others = append(paketo.Others, paketoBuildpackInfo)
+		heroku.Others = append(heroku.Others, herokuBuildpackInfo)
+		return fmt.Errorf("error calling GetContent() on Gemfile for %s/%s: %v", owner, name, err)
+	}
+
+	count := 6
+	if !configRuFound {
+		// unicorn needs config.ru
+		count -= 1
+		if !gemfileLockFound {
+			// rackup needs one of Gemfile.lock or config.ru
+			count -= 1
+		}
+	}
+	if !rakefileFound {
+		count -= 1
+	}
+	results := make(chan struct {
+		string
+		bool
+	}, count)
+
+	runtime.wg.Add(count)
+	go runtime.detectPuma(gemfileContent, results)
+	go runtime.detectThin(gemfileContent, results)
+	if configRuFound {
+		{
+			// FIXME: find a better, more readable way of doing this
+			results <- struct {
+				string
+				bool
+			}{rackup, true}
+			runtime.wg.Done()
+		}
+
+		go runtime.detectUnicorn(gemfileContent, results)
+	}
+	go runtime.detectPassenger(gemfileContent, results)
+	if !configRuFound && gemfileLockFound {
+		go runtime.detectRackup(client, owner, name, repoContentOptions, results)
+	}
+	if rakefileFound {
+		go runtime.detectRake(gemfileContent, results)
+	}
+	runtime.wg.Wait()
+	close(results)
+
+	paketo.Detected = append(paketo.Detected, paketoBuildpackInfo)
+	heroku.Detected = append(heroku.Detected, herokuBuildpackInfo)
+
+	return nil
+}

+ 69 - 0
internal/integrations/buildpacks/shared.go

@@ -0,0 +1,69 @@
+package buildpacks
+
+import (
+	"github.com/google/go-github/github"
+)
+
+const (
+	// NodeJS
+	yarn = "yarn"
+	npm  = "npm"
+
+	// Go
+	mod = "mod"
+	dep = "dep"
+
+	// Python
+	pipenv = "pipenv"
+	pip    = "pip"
+	conda  = "conda"
+
+	// Ruby
+	puma      = "puma"
+	thin      = "thin"
+	unicorn   = "unicorn"
+	passenger = "passenger"
+	rackup    = "rackup"
+	rake      = "rake"
+
+	// Common
+	standalone = "standalone"
+
+	// Builders
+	PaketoBuilder = "paketo"
+	HerokuBuilder = "heroku"
+)
+
+type BuildpackInfo struct {
+	Name      string                 `json:"name"`
+	Buildpack string                 `json:"buildpack"`
+	Config    map[string]interface{} `json:"config"`
+}
+
+type BuilderInfo struct {
+	Name     string          `json:"name"`
+	Builders []string        `json:"builders"`
+	Detected []BuildpackInfo `json:"detected"`
+	Others   []BuildpackInfo `json:"others"`
+}
+
+type Runtime interface {
+	Detect(
+		*github.Client, // github client to pull contents of files
+		[]*github.RepositoryContent, // the root folder structure of the git repo
+		string, // owner
+		string, // name
+		string, // path
+		github.RepositoryContentGetOptions, // SHA, branch or tag
+		*BuilderInfo, // paketo
+		*BuilderInfo, // heroku
+	) error
+}
+
+// Runtimes is a list of all API runtimes
+var Runtimes = []Runtime{
+	NewGoRuntime(),
+	NewNodeRuntime(),
+	NewPythonRuntime(),
+	NewRubyRuntime(),
+}

+ 65 - 30
internal/integrations/slack/notifier.go

@@ -19,8 +19,9 @@ type Notifier interface {
 type DeploymentStatus string
 
 const (
-	StatusDeployed string = "deployed"
-	StatusFailed   string = "failed"
+	StatusHelmDeployed DeploymentStatus = "helm_deployed"
+	StatusPodCrashed   DeploymentStatus = "pod_crashed"
+	StatusHelmFailed   DeploymentStatus = "helm_failed"
 )
 
 type NotifyOpts struct {
@@ -34,7 +35,7 @@ type NotifyOpts struct {
 	ClusterName string
 
 	// Status is the current status of the deployment.
-	Status string
+	Status DeploymentStatus
 
 	// Info is any additional information about this status, such as an error message if
 	// the deployment failed.
@@ -82,38 +83,29 @@ func (s *SlackNotifier) Notify(opts *NotifyOpts) error {
 		if !s.Config.Enabled {
 			return nil
 		}
-		if opts.Status == StatusDeployed && !s.Config.Success {
+		if opts.Status == StatusHelmDeployed && !s.Config.Success {
 			return nil
 		}
-		if opts.Status == StatusFailed && !s.Config.Failure {
+		if opts.Status == StatusPodCrashed && !s.Config.Failure {
+			return nil
+		}
+		if opts.Status == StatusHelmFailed && !s.Config.Failure {
 			return nil
 		}
-	}
-
-	blocks := []*SlackBlock{
-		getMessageBlock(opts),
-		getDividerBlock(),
-		getMarkdownBlock(fmt.Sprintf("*Name:* %s", "`"+opts.Name+"`")),
-		getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+opts.Namespace+"`")),
-		getMarkdownBlock(fmt.Sprintf("*Version:* %d", opts.Version)),
 	}
 
 	// we create a basic payload as a fallback if the detailed payload with "info" fails, due to
 	// marshaling errors on the Slack API side.
-	basicSlackPayload := &SlackPayload{
-		Blocks: blocks,
-	}
-
-	infoBlock := getInfoBlock(opts)
-
-	if infoBlock != nil {
-		blocks = append(blocks, infoBlock)
-	}
+	blocks, basicBlocks := getSlackBlocks(opts)
 
 	slackPayload := &SlackPayload{
 		Blocks: blocks,
 	}
 
+	basicSlackPayload := &SlackPayload{
+		Blocks: basicBlocks,
+	}
+
 	basicPayload, err := json.Marshal(basicSlackPayload)
 
 	if err != nil {
@@ -143,6 +135,37 @@ func (s *SlackNotifier) Notify(opts *NotifyOpts) error {
 	return nil
 }
 
+func getSlackBlocks(opts *NotifyOpts) ([]*SlackBlock, []*SlackBlock) {
+	res := []*SlackBlock{}
+
+	if opts.Status == StatusHelmDeployed || opts.Status == StatusHelmFailed {
+		res = append(res, getHelmMessageBlock(opts))
+	} else if opts.Status == StatusPodCrashed {
+		res = append(res, getPodCrashedMessageBlock(opts))
+	}
+
+	res = append(
+		res,
+		getDividerBlock(),
+		getMarkdownBlock(fmt.Sprintf("*Name:* %s", "`"+opts.Name+"`")),
+		getMarkdownBlock(fmt.Sprintf("*Namespace:* %s", "`"+opts.Namespace+"`")),
+	)
+
+	if opts.Status == StatusHelmDeployed || opts.Status == StatusHelmFailed {
+		res = append(res, getMarkdownBlock(fmt.Sprintf("*Version:* %d", opts.Version)))
+	}
+
+	basicRes := res
+
+	infoBlock := getInfoBlock(opts)
+
+	if infoBlock != nil {
+		res = append(res, infoBlock)
+	}
+
+	return res, basicRes
+}
+
 func getDividerBlock() *SlackBlock {
 	return &SlackBlock{
 		Type: "divider",
@@ -159,24 +182,36 @@ func getMarkdownBlock(md string) *SlackBlock {
 	}
 }
 
-func getMessageBlock(opts *NotifyOpts) *SlackBlock {
+func getHelmMessageBlock(opts *NotifyOpts) *SlackBlock {
 	var md string
 
 	switch opts.Status {
-	case StatusDeployed:
-		md = getSuccessMessage(opts)
-	case StatusFailed:
-		md = getFailedMessage(opts)
+	case StatusHelmDeployed:
+		md = getHelmSuccessMessage(opts)
+	case StatusHelmFailed:
+		md = getHelmFailedMessage(opts)
 	}
 
 	return getMarkdownBlock(md)
 }
 
+func getPodCrashedMessageBlock(opts *NotifyOpts) *SlackBlock {
+	md := fmt.Sprintf(
+		":x: Your application %s crashed on Porter. <%s|View the application.>",
+		"`"+opts.Name+"`",
+		opts.URL,
+	)
+
+	return getMarkdownBlock(md)
+}
+
 func getInfoBlock(opts *NotifyOpts) *SlackBlock {
 	var md string
 
 	switch opts.Status {
-	case StatusFailed:
+	case StatusHelmFailed:
+		md = getFailedInfoMessage(opts)
+	case StatusPodCrashed:
 		md = getFailedInfoMessage(opts)
 	default:
 		return nil
@@ -185,7 +220,7 @@ func getInfoBlock(opts *NotifyOpts) *SlackBlock {
 	return getMarkdownBlock(md)
 }
 
-func getSuccessMessage(opts *NotifyOpts) string {
+func getHelmSuccessMessage(opts *NotifyOpts) string {
 	return fmt.Sprintf(
 		":rocket: Your application %s was successfully updated on Porter! <%s|View the new release.>",
 		"`"+opts.Name+"`",
@@ -193,7 +228,7 @@ func getSuccessMessage(opts *NotifyOpts) string {
 	)
 }
 
-func getFailedMessage(opts *NotifyOpts) string {
+func getHelmFailedMessage(opts *NotifyOpts) string {
 	return fmt.Sprintf(
 		":x: Your application %s failed to deploy on Porter. <%s|View the status here.>",
 		"`"+opts.Name+"`",

+ 45 - 0
internal/kubernetes/agent.go

@@ -267,6 +267,17 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 
 // CreateNamespace creates a namespace with the given name.
 func (a *Agent) CreateNamespace(name string) (*v1.Namespace, error) {
+	// check if namespace exists
+	checkNS, err := a.Clientset.CoreV1().Namespaces().Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+
+	if err == nil && checkNS != nil {
+		return checkNS, nil
+	}
+
 	namespace := v1.Namespace{
 		ObjectMeta: metav1.ObjectMeta{
 			Name: name,
@@ -289,6 +300,20 @@ func (a *Agent) DeleteNamespace(name string) error {
 	)
 }
 
+func (a *Agent) GetPorterAgent() (*appsv1.Deployment, error) {
+	depl, err := a.Clientset.AppsV1().Deployments("porter-agent-system").Get(
+		context.TODO(),
+		"porter-agent-controller-manager",
+		metav1.GetOptions{},
+	)
+
+	if err != nil && errors.IsNotFound(err) {
+		return nil, IsNotFoundError
+	}
+
+	return depl, err
+}
+
 // ListJobsByLabel lists jobs in a namespace matching a label
 type Label struct {
 	Key string
@@ -493,6 +518,26 @@ func (a *Agent) GetPodsByLabel(selector string, namespace string) (*v1.PodList,
 	)
 }
 
+// GetPodByName retrieves a single instance of pod with given name
+func (a *Agent) GetPodByName(name string, namespace string) (*v1.Pod, error) {
+	// Get pod by name
+	pod, err := a.Clientset.CoreV1().Pods(namespace).Get(
+		context.TODO(),
+		name,
+		metav1.GetOptions{},
+	)
+
+	if err != nil && errors.IsNotFound(err) {
+		return nil, IsNotFoundError
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	return pod, nil
+}
+
 // DeletePod deletes a pod by name and namespace
 func (a *Agent) DeletePod(namespace string, name string) error {
 	err := a.Clientset.CoreV1().Pods(namespace).Delete(

+ 124 - 0
internal/kubernetes/porter_agent/logs.go

@@ -0,0 +1,124 @@
+package porter_agent
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+
+	v1 "k8s.io/api/core/v1"
+	"k8s.io/client-go/kubernetes"
+
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// returns the agent service
+func GetAgentService(clientset kubernetes.Interface) (*v1.Service, error) {
+	return clientset.CoreV1().Services("porter-agent-system").Get(
+		context.TODO(),
+		"porter-agent-controller-manager",
+		metav1.GetOptions{},
+	)
+}
+
+type SimpleIngress struct {
+	Name      string `json:"name"`
+	Namespace string `json:"namespace"`
+}
+
+type LogPathOpts struct {
+	Timestamp int
+	Pod       string
+	Namespace string
+}
+
+func GetLogsFromPorterAgent(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	opts *LogPathOpts,
+) (*AgentLogsResp, error) {
+	if len(service.Spec.Ports) == 0 {
+		return nil, fmt.Errorf("agent service has no exposed ports to query")
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		fmt.Sprintf("/pod/%s/ns/%s/logbucket/%d", opts.Pod, opts.Namespace, opts.Timestamp),
+		nil,
+	)
+
+	rawQuery, err := resp.DoRaw(context.TODO())
+
+	if err != nil {
+		return nil, err
+	}
+
+	return parseLogQuery(rawQuery)
+}
+
+type LogBucketPathOpts struct {
+	Pod       string
+	Namespace string
+}
+
+func GetLogBucketsFromPorterAgent(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+	opts *LogBucketPathOpts,
+) (*AgentLogBucketsResp, error) {
+	if len(service.Spec.Ports) == 0 {
+		return nil, fmt.Errorf("agent service has no exposed ports to query")
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		fmt.Sprintf("/pod/%s/ns/%s/logbucket", opts.Pod, opts.Namespace),
+		nil,
+	)
+
+	rawQuery, err := resp.DoRaw(context.TODO())
+
+	if err != nil {
+		return nil, err
+	}
+
+	return parseLogBucketsQuery(rawQuery)
+}
+
+type AgentLogsResp struct {
+	Logs          []string `json:"logs"`
+	MatchedBucket string   `json:"matchedBucket"`
+	Error         string   `json:"error"`
+}
+
+func parseLogQuery(rawQuery []byte) (*AgentLogsResp, error) {
+	resp := &AgentLogsResp{}
+
+	err := json.Unmarshal(rawQuery, resp)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp, nil
+}
+
+type AgentLogBucketsResp struct {
+	AvailableBuckets []string `json:"availableLogBuckets"`
+	Error            string   `json:"error"`
+}
+
+func parseLogBucketsQuery(rawQuery []byte) (*AgentLogBucketsResp, error) {
+	resp := &AgentLogBucketsResp{}
+
+	err := json.Unmarshal(rawQuery, resp)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp, nil
+}

+ 25 - 0
internal/models/build_config.go

@@ -0,0 +1,25 @@
+package models
+
+import (
+	"strings"
+
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+type BuildConfig struct {
+	gorm.Model
+
+	Name       string `json:"name"`
+	Builder    string `json:"builder"`
+	Buildpacks string `json:"buildpacks"`
+	Config     []byte `json:"config"`
+}
+
+func (conf *BuildConfig) ToBuildConfigType() *types.BuildConfig {
+	return &types.BuildConfig{
+		Builder:    conf.Builder,
+		Buildpacks: strings.Split(conf.Buildpacks, ","),
+		Config:     conf.Config,
+	}
+}

+ 77 - 0
internal/models/kube_events.go

@@ -0,0 +1,77 @@
+package models
+
+import (
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
+
+// KubeEvent model refers to a type of event from a Kubernetes cluster
+type KubeEvent struct {
+	gorm.Model
+
+	ProjectID uint
+	ClusterID uint
+
+	// The name of the referenced kube object
+	Name string
+
+	// The kube resource type, such as "pod", "hpa", or "node"
+	ResourceType string
+
+	// (optional) The owner reference type and name, which can be used to filter events by
+	// controller
+	OwnerType string
+	OwnerName string
+
+	// (optional) the namespace of the event, if namespaceable
+	Namespace string
+
+	// The "subevents" attached to the event. These are a grouped collection of events that belong
+	// to the same object.
+	SubEvents []KubeSubEvent
+}
+
+type KubeSubEvent struct {
+	gorm.Model
+
+	KubeEventID uint
+	Message     string
+	Reason      string
+	Timestamp   time.Time
+
+	// The event type, such as "critical" or "normal"
+	EventType types.KubeEventType
+}
+
+func (k *KubeSubEvent) ToKubeSubEventType() *types.KubeSubEvent {
+	return &types.KubeSubEvent{
+		Message:   k.Message,
+		Reason:    k.Reason,
+		Timestamp: k.Timestamp,
+		EventType: k.EventType,
+	}
+}
+
+func (k *KubeEvent) ToKubeEventType() *types.KubeEvent {
+	subEvents := make([]*types.KubeSubEvent, 0)
+
+	for _, subEvent := range k.SubEvents {
+		subEvents = append(subEvents, subEvent.ToKubeSubEventType())
+	}
+
+	return &types.KubeEvent{
+		CreatedAt:    k.CreatedAt,
+		UpdatedAt:    k.UpdatedAt,
+		ID:           k.ID,
+		ProjectID:    k.ProjectID,
+		ClusterID:    k.ClusterID,
+		ResourceType: k.ResourceType,
+		Name:         k.Name,
+		Namespace:    k.Namespace,
+		OwnerType:    k.OwnerType,
+		OwnerName:    k.OwnerName,
+		SubEvents:    subEvents,
+	}
+}

+ 19 - 3
internal/models/notification.go

@@ -1,6 +1,8 @@
 package models
 
 import (
+	"time"
+
 	"github.com/porter-dev/porter/api/types"
 	"gorm.io/gorm"
 )
@@ -12,12 +14,26 @@ type NotificationConfig struct {
 
 	Success bool
 	Failure bool
+
+	LastNotifiedTime time.Time
+	NotifLimit       string
 }
 
 func (conf *NotificationConfig) ToNotificationConfigType() *types.NotificationConfig {
 	return &types.NotificationConfig{
-		Enabled: conf.Enabled,
-		Success: conf.Success,
-		Failure: conf.Failure,
+		Enabled:    conf.Enabled,
+		Success:    conf.Success,
+		Failure:    conf.Failure,
+		NotifLimit: conf.NotifLimit,
 	}
 }
+
+func (conf *NotificationConfig) ShouldNotify() bool {
+	// check the last notified time against the notification limit
+	return conf.LastNotifiedTime.Before(notifLimitToTime(conf.NotifLimit))
+}
+
+func notifLimitToTime(notifTime string) time.Time {
+	// TODO: compute a time that's not just 5 min
+	return time.Now().Add(-10 * time.Minute)
+}

+ 1 - 0
internal/models/release.go

@@ -24,6 +24,7 @@ type Release struct {
 	GitActionConfig    *GitActionConfig `json:"git_action_config"`
 	EventContainer     uint
 	NotificationConfig uint
+	BuildConfig        uint
 }
 
 func (r *Release) ToReleaseType() *types.PorterRelease {

+ 10 - 0
internal/repository/build_config.go

@@ -0,0 +1,10 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// BuildConfigRepository represents the set of queries on the BuildConfig model
+type BuildConfigRepository interface {
+	CreateBuildConfig(*models.BuildConfig) (*models.BuildConfig, error)
+	UpdateBuildConfig(*models.BuildConfig) (*models.BuildConfig, error)
+	GetBuildConfig(uint) (*models.BuildConfig, error)
+}

+ 18 - 2
internal/repository/event.go

@@ -1,8 +1,11 @@
 package repository
 
-import "github.com/porter-dev/porter/internal/models"
+import (
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
 
-type EventRepository interface {
+type BuildEventRepository interface {
 	CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error)
 	CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error)
 	ReadEventsByContainerID(id uint) ([]*models.SubEvent, error)
@@ -10,3 +13,16 @@ type EventRepository interface {
 	ReadSubEvent(id uint) (*models.SubEvent, error)
 	AppendEvent(container *models.EventContainer, event *models.SubEvent) error
 }
+
+type KubeEventRepository interface {
+	CreateEvent(event *models.KubeEvent) (*models.KubeEvent, error)
+	AppendSubEvent(event *models.KubeEvent, subEvent *models.KubeSubEvent) error
+	ReadEvent(id uint, projID uint, clusterID uint) (*models.KubeEvent, error)
+	ReadEventByGroup(projID uint, clusterID uint, opts *types.GroupOptions) (*models.KubeEvent, error)
+	ListEventsByProjectID(
+		projectID uint,
+		clusterID uint,
+		opts *types.ListKubeEventRequest,
+	) ([]*models.KubeEvent, int64, error)
+	DeleteEvent(id uint) error
+}

+ 48 - 0
internal/repository/gorm/build_config.go

@@ -0,0 +1,48 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// BuildConfigRepository uses gorm.DB for querying the database
+type BuildConfigRepository struct {
+	db *gorm.DB
+}
+
+// NewBuildConfigRepository returns a BuildConfigRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewBuildConfigRepository(db *gorm.DB) repository.BuildConfigRepository {
+	return &BuildConfigRepository{db}
+}
+
+// CreateBuildConfig creates a new build config for a release
+func (repo *BuildConfigRepository) CreateBuildConfig(bc *models.BuildConfig) (*models.BuildConfig, error) {
+	if err := repo.db.Create(bc).Error; err != nil {
+		return nil, err
+	}
+
+	return bc, nil
+}
+
+// UpdateBuildConfig updates a build config
+func (repo *BuildConfigRepository) UpdateBuildConfig(bc *models.BuildConfig) (*models.BuildConfig, error) {
+	if err := repo.db.Save(bc).Error; err != nil {
+		return nil, err
+	}
+
+	return bc, nil
+}
+
+// GetBuildConfig returns a BuildConfig with the specified id
+func (repo *BuildConfigRepository) GetBuildConfig(id uint) (*models.BuildConfig, error) {
+	bc := &models.BuildConfig{}
+
+	if err := repo.db.First(bc, id).Error; err != nil {
+		return nil, err
+	}
+
+	return bc, nil
+}

+ 248 - 11
internal/repository/gorm/event.go

@@ -1,37 +1,42 @@
 package gorm
 
 import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 )
 
-// EventRepository holds both EventContainer and SubEvent models
-type EventRepository struct {
+// BuildEventRepository holds both EventContainer and SubEvent models
+type BuildEventRepository struct {
 	db *gorm.DB
 }
 
-// NewEventRepository returns a EventRepository which uses
+// NewBuildEventRepository returns a BuildEventRepository which uses
 // gorm.DB for querying the database
-func NewEventRepository(db *gorm.DB) repository.EventRepository {
-	return &EventRepository{db}
+func NewBuildEventRepository(db *gorm.DB) repository.BuildEventRepository {
+	return &BuildEventRepository{db}
 }
 
-func (repo EventRepository) CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error) {
+func (repo BuildEventRepository) CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error) {
 	if err := repo.db.Create(am).Error; err != nil {
 		return nil, err
 	}
 	return am, nil
 }
 
-func (repo EventRepository) CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error) {
+func (repo BuildEventRepository) CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error) {
 	if err := repo.db.Create(am).Error; err != nil {
 		return nil, err
 	}
 	return am, nil
 }
 
-func (repo EventRepository) ReadEventsByContainerID(id uint) ([]*models.SubEvent, error) {
+func (repo BuildEventRepository) ReadEventsByContainerID(id uint) ([]*models.SubEvent, error) {
 	var events []*models.SubEvent
 	if err := repo.db.Where("event_container_id = ?", id).Find(&events).Error; err != nil {
 		return nil, err
@@ -39,7 +44,7 @@ func (repo EventRepository) ReadEventsByContainerID(id uint) ([]*models.SubEvent
 	return events, nil
 }
 
-func (repo EventRepository) ReadEventContainer(id uint) (*models.EventContainer, error) {
+func (repo BuildEventRepository) ReadEventContainer(id uint) (*models.EventContainer, error) {
 	container := &models.EventContainer{}
 	if err := repo.db.Where("id = ?", id).First(&container).Error; err != nil {
 		return nil, err
@@ -47,7 +52,7 @@ func (repo EventRepository) ReadEventContainer(id uint) (*models.EventContainer,
 	return container, nil
 }
 
-func (repo EventRepository) ReadSubEvent(id uint) (*models.SubEvent, error) {
+func (repo BuildEventRepository) ReadSubEvent(id uint) (*models.SubEvent, error) {
 	event := &models.SubEvent{}
 	if err := repo.db.Where("id = ?", id).First(&event).Error; err != nil {
 		return nil, err
@@ -57,7 +62,239 @@ func (repo EventRepository) ReadSubEvent(id uint) (*models.SubEvent, error) {
 
 // AppendEvent will check if subevent with same (id, index) already exists
 // if yes, overrite it, otherwise make a new subevent
-func (repo EventRepository) AppendEvent(container *models.EventContainer, event *models.SubEvent) error {
+func (repo BuildEventRepository) AppendEvent(container *models.EventContainer, event *models.SubEvent) error {
 	event.EventContainerID = container.ID
 	return repo.db.Create(event).Error
 }
+
+// KubeEventRepository uses gorm.DB for querying the database
+type KubeEventRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewKubeEventRepository returns an KubeEventRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewKubeEventRepository(db *gorm.DB, key *[32]byte) repository.KubeEventRepository {
+	return &KubeEventRepository{db, key}
+}
+
+// CreateEvent creates a new kube auth mechanism
+func (repo *KubeEventRepository) CreateEvent(
+	event *models.KubeEvent,
+) (*models.KubeEvent, error) {
+	// read the count of the events in the DB
+	query := repo.db.Where("project_id = ? AND cluster_id = ?", event.ProjectID, event.ClusterID)
+
+	var count int64
+
+	if err := query.Model([]*models.KubeEvent{}).Count(&count).Error; err != nil {
+		return nil, err
+	}
+
+	fmt.Println("COUNT IS", event.Name, count)
+
+	// if the count is greater than 500, remove the lowest-order event to implement a
+	// basic fixed-length buffer
+	if count >= 500 {
+		// first, delete the matching sub events
+		err := repo.db.Debug().Exec(`
+		  DELETE FROM kube_sub_events 
+		  WHERE kube_event_id IN (
+			SELECT id FROM kube_events k2 WHERE (k2.project_id = ? AND k2.cluster_id = ?) AND k2.id NOT IN (
+			  SELECT id FROM kube_events k3 WHERE (k3.project_id = ? AND k3.cluster_id = ?) ORDER BY k3.updated_at desc, k3.id desc LIMIT 499
+			)
+		  )
+		`, event.ProjectID, event.ClusterID, event.ProjectID, event.ClusterID).Error
+
+		if err != nil {
+			return nil, err
+		}
+
+		// then, delete the matching events
+		err = repo.db.Debug().Exec(`
+		  DELETE FROM kube_events 
+		  WHERE (project_id = ? AND cluster_id = ?) AND id NOT IN (
+			SELECT id FROM kube_events k2 WHERE (k2.project_id = ? AND k2.cluster_id = ?) ORDER BY k2.updated_at desc, k2.id desc LIMIT 499
+		  )
+		`, event.ProjectID, event.ClusterID, event.ProjectID, event.ClusterID).Error
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	if err := repo.db.Create(event).Error; err != nil {
+		return nil, err
+	}
+
+	return event, nil
+}
+
+// ReadEvent finds an event by id
+func (repo *KubeEventRepository) ReadEvent(
+	id, projID, clusterID uint,
+) (*models.KubeEvent, error) {
+	event := &models.KubeEvent{}
+
+	if err := repo.db.Preload("SubEvents").Where(
+		"id = ? AND project_id = ? AND cluster_id = ?",
+		id,
+		projID,
+		clusterID,
+	).First(&event).Error; err != nil {
+		return nil, err
+	}
+
+	return event, nil
+}
+
+// ReadEventByGroup finds an event by a set of options which group events together
+func (repo *KubeEventRepository) ReadEventByGroup(
+	projID uint,
+	clusterID uint,
+	opts *types.GroupOptions,
+) (*models.KubeEvent, error) {
+	event := &models.KubeEvent{}
+
+	query := repo.db.Preload("SubEvents").
+		Where("project_id = ? AND cluster_id = ? AND name = ? AND LOWER(resource_type) = LOWER(?)", projID, clusterID, opts.Name, opts.ResourceType)
+
+	// construct query for timestamp
+	query = query.Where(
+		"updated_at >= ?", opts.ThresholdTime,
+	)
+
+	if opts.Namespace != "" {
+		query = query.Where(
+			"namespace = ?",
+			strings.ToLower(opts.Namespace),
+		)
+	}
+
+	if err := query.First(&event).Error; err != nil {
+		return nil, err
+	}
+
+	return event, nil
+}
+
+// ListEventsByProjectID finds all events for a given project id
+// with the given options
+func (repo *KubeEventRepository) ListEventsByProjectID(
+	projectID uint,
+	clusterID uint,
+	opts *types.ListKubeEventRequest,
+) ([]*models.KubeEvent, int64, error) {
+	listOpts := opts
+
+	if listOpts.Limit == 0 {
+		listOpts.Limit = 50
+	}
+
+	events := []*models.KubeEvent{}
+
+	// preload the subevents
+	query := repo.db.Preload("SubEvents").Where("project_id = ? AND cluster_id = ?", projectID, clusterID)
+
+	if listOpts.OwnerName != "" && listOpts.OwnerType != "" {
+		query = query.Where(
+			"LOWER(owner_name) = LOWER(?) AND LOWER(owner_type) = LOWER(?)",
+			listOpts.OwnerName,
+			listOpts.OwnerType,
+		)
+	}
+
+	if listOpts.ResourceType != "" {
+		query = query.Where(
+			"LOWER(resource_type) = LOWER(?)",
+			listOpts.ResourceType,
+		)
+	}
+
+	// get the count before limit and offset
+	var count int64
+
+	if err := query.Model([]*models.KubeEvent{}).Count(&count).Error; err != nil {
+		return nil, 0, err
+	}
+
+	query = query.Order("updated_at desc").Order("id desc").Limit(listOpts.Limit).Offset(listOpts.Skip)
+
+	if err := query.Find(&events).Error; err != nil {
+		return nil, 0, err
+	}
+
+	return events, count, nil
+}
+
+// AppendSubEvent will add a subevent to an existing event
+func (repo *KubeEventRepository) AppendSubEvent(event *models.KubeEvent, subEvent *models.KubeSubEvent) error {
+	subEvent.KubeEventID = event.ID
+
+	var count int64
+
+	query := repo.db.Where("kube_event_id = ?", event.ID)
+
+	if err := query.Model([]*models.KubeSubEvent{}).Count(&count).Error; err != nil {
+		return err
+	}
+
+	fmt.Println("COUNT IS (subevents)", event.Name, count)
+
+	// if the count is greater than 20, remove the lowest-order events to implement a
+	// basic fixed-length buffer
+	if count >= 20 {
+		err := repo.db.Exec(`
+			  DELETE FROM kube_sub_events 
+			  WHERE kube_event_id = ? AND 
+			  id NOT IN (
+				SELECT id FROM kube_sub_events k2 WHERE k2.kube_event_id = ? ORDER BY k2.updated_at desc, k2.id desc LIMIT 19
+			  )
+			`, event.ID, event.ID).Error
+
+		if err != nil {
+			return err
+		}
+	}
+
+	// we construct a shallow copy here that just populates the primary key, because otherwise gorm
+	// attempts to write subevents that have already been written via the association.
+	shallowCopy := &models.KubeEvent{
+		Model: gorm.Model{
+			ID: event.ID,
+		},
+	}
+
+	if err := repo.db.Model(shallowCopy).Association("SubEvents").Append(subEvent); err != nil {
+		return err
+	}
+
+	// only update the updated_at field for the event
+	if err := repo.db.Model(shallowCopy).Update("updated_at", time.Now()).Error; err != nil {
+		return err
+	}
+
+	event.SubEvents = append(event.SubEvents, shallowCopy.SubEvents...)
+	event.UpdatedAt = shallowCopy.UpdatedAt
+
+	return nil
+}
+
+// DeleteEvent deletes an event by ID
+func (repo *KubeEventRepository) DeleteEvent(
+	id uint,
+) error {
+	return deleteEventPermanently(id, repo.db)
+}
+
+func deleteEventPermanently(id uint, db *gorm.DB) error {
+	// delete all subevents first
+	if err := db.Unscoped().Where("kube_event_id = ?", id).Delete(&models.KubeSubEvent{}).Error; err != nil {
+		return err
+	}
+
+	// delete event
+	return db.Preload("SubEvents").Unscoped().Where("id = ?", id).Delete(&models.KubeEvent{}).Error
+}

+ 201 - 0
internal/repository/gorm/event_test.go

@@ -0,0 +1,201 @@
+package gorm_test
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+func TestCreateKubeEvent(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_event.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	defer cleanup(tester, t)
+
+	event := &models.KubeEvent{
+		ProjectID: tester.initProjects[0].Model.ID,
+		ClusterID: tester.initClusters[0].Model.ID,
+		Name:      "pod-example-1",
+		Namespace: "default",
+	}
+
+	copyKubeEvent := *event
+
+	event, err := tester.repo.KubeEvent().CreateEvent(event)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// append a sub event as well
+	subEvent := &models.KubeSubEvent{
+		EventType: "pod",
+		Message:   "Pod killed",
+		Reason:    "OOM: memory limit exceeded",
+		Timestamp: time.Now(),
+	}
+
+	copySubEvent := *subEvent
+	copySubEvent.KubeEventID = 1
+
+	err = tester.repo.KubeEvent().AppendSubEvent(event, subEvent)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	copyKubeEvent.SubEvents = []models.KubeSubEvent{copySubEvent}
+
+	event, err = tester.repo.KubeEvent().ReadEvent(event.Model.ID, 1, 1)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1 and name is "ecr"
+	if event.Model.ID != 1 {
+		t.Errorf("incorrect registry ID: expected %d, got %d\n", 1, event.Model.ID)
+	}
+
+	event.Model = gorm.Model{}
+	event.SubEvents[0].Model = gorm.Model{}
+
+	if diff := deep.Equal(event, &copyKubeEvent); diff != nil {
+		t.Errorf("events not equal:")
+		t.Error(diff)
+	}
+}
+
+func TestReadKubeEventsByGroup(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_read_event_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initKubeEvents(tester, t)
+	defer cleanup(tester, t)
+
+	event, err := tester.repo.KubeEvent().ReadEventByGroup(
+		tester.initProjects[0].Model.ID,
+		tester.initClusters[0].Model.ID,
+		&types.GroupOptions{
+			Name:          "pod-example-1",
+			Namespace:     "default",
+			ResourceType:  "pod",
+			ThresholdTime: time.Now().Add(-15 * time.Minute),
+		},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	expKubeEvent := tester.initKubeEvents[1]
+
+	if diff := deep.Equal(expKubeEvent, event); diff != nil {
+		t.Errorf("incorrect events")
+		t.Error(diff)
+	}
+}
+
+func TestListKubeEventsByProjectIDWithLimit(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_list_events_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initKubeEvents(tester, t)
+	defer cleanup(tester, t)
+
+	testListKubeEventsByProjectID(tester, t, 1, true, &types.ListKubeEventRequest{
+		Limit:        10,
+		ResourceType: "node",
+	}, tester.initKubeEvents[50:60])
+}
+
+func TestListKubeEventsByProjectIDWithSkip(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_list_events_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initKubeEvents(tester, t)
+	defer cleanup(tester, t)
+
+	testListKubeEventsByProjectID(tester, t, 1, true, &types.ListKubeEventRequest{
+		Limit: 25,
+		Skip:  10,
+	}, tester.initKubeEvents[10:35])
+}
+
+func TestDeleteKubeEvents(t *testing.T) {
+	suffix, _ := repository.GenerateRandomBytes(4)
+
+	tester := &tester{
+		dbFileName: fmt.Sprintf("./porter_delete_events_%s.db", suffix),
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	initKubeEvents(tester, t)
+	defer cleanup(tester, t)
+
+	// delete a specific event and then test list again
+	err := tester.repo.KubeEvent().DeleteEvent(11)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	testListKubeEventsByProjectID(tester, t, 1, true, &types.ListKubeEventRequest{
+		Limit: 25,
+		Skip:  10,
+	}, tester.initKubeEvents[11:36])
+}
+
+func testListKubeEventsByProjectID(tester *tester, t *testing.T, clusterID uint, decrypt bool, opts *types.ListKubeEventRequest, expKubeEvents []*models.KubeEvent) {
+	t.Helper()
+
+	events, _, err := tester.repo.KubeEvent().ListEventsByProjectID(
+		tester.initProjects[0].Model.ID,
+		clusterID,
+		opts,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure data is correct
+	if len(events) != len(expKubeEvents) {
+		t.Fatalf("length of events incorrect: expected %d, got %d\n", len(expKubeEvents), len(events))
+	}
+
+	if diff := deep.Equal(expKubeEvents, events); diff != nil {
+		t.Errorf("incorrect events")
+		t.Error(diff)
+	}
+}

+ 87 - 19
internal/repository/gorm/helpers_test.go

@@ -1,6 +1,7 @@
 package gorm_test
 
 import (
+	"fmt"
 	"os"
 	"testing"
 	"time"
@@ -15,25 +16,26 @@ import (
 )
 
 type tester struct {
-	repo         repository.Repository
-	key          *[32]byte
-	dbFileName   string
-	initUsers    []*models.User
-	initProjects []*models.Project
-	initGRs      []*models.GitRepo
-	initRegs     []*models.Registry
-	initClusters []*models.Cluster
-	initHRs      []*models.HelmRepo
-	initInfras   []*models.Infra
-	initReleases []*models.Release
-	initInvites  []*models.Invite
-	initCCs      []*models.ClusterCandidate
-	initKIs      []*ints.KubeIntegration
-	initBasics   []*ints.BasicIntegration
-	initOIDCs    []*ints.OIDCIntegration
-	initOAuths   []*ints.OAuthIntegration
-	initGCPs     []*ints.GCPIntegration
-	initAWSs     []*ints.AWSIntegration
+	repo           repository.Repository
+	key            *[32]byte
+	dbFileName     string
+	initUsers      []*models.User
+	initProjects   []*models.Project
+	initGRs        []*models.GitRepo
+	initRegs       []*models.Registry
+	initClusters   []*models.Cluster
+	initHRs        []*models.HelmRepo
+	initInfras     []*models.Infra
+	initReleases   []*models.Release
+	initInvites    []*models.Invite
+	initKubeEvents []*models.KubeEvent
+	initCCs        []*models.ClusterCandidate
+	initKIs        []*ints.KubeIntegration
+	initBasics     []*ints.BasicIntegration
+	initOIDCs      []*ints.OIDCIntegration
+	initOAuths     []*ints.OAuthIntegration
+	initGCPs       []*ints.GCPIntegration
+	initAWSs       []*ints.AWSIntegration
 }
 
 func setupTestEnv(tester *tester, t *testing.T) {
@@ -64,6 +66,8 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.Infra{},
 		&models.GitActionConfig{},
 		&models.Invite{},
+		&models.KubeEvent{},
+		&models.KubeSubEvent{},
 		&models.Onboarding{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
@@ -548,3 +552,67 @@ func initRelease(tester *tester, t *testing.T) {
 
 	tester.initReleases = append(tester.initReleases, release)
 }
+
+func initKubeEvents(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	initEvents := make([]*models.KubeEvent, 0)
+
+	// init 100 events for testing purposes
+	for i := 0; i < 100; i++ {
+		refType := "pod"
+
+		if i >= 50 {
+			refType = "node"
+		}
+
+		event := &models.KubeEvent{
+			ProjectID:    tester.initProjects[0].Model.ID,
+			ClusterID:    tester.initClusters[0].Model.ID,
+			Name:         fmt.Sprintf("%s-example-%d", refType, i),
+			Namespace:    "default",
+			ResourceType: refType,
+		}
+
+		event, err := tester.repo.KubeEvent().CreateEvent(event)
+
+		if err != nil {
+			t.Fatalf("%v\n", err)
+		}
+
+		// append a sub event as well
+		subEvent := &models.KubeSubEvent{
+			EventType: "pod",
+			Message:   "Pod killed",
+			Reason:    "OOM: memory limit exceeded",
+		}
+
+		err = tester.repo.KubeEvent().AppendSubEvent(event, subEvent)
+
+		if err != nil {
+			t.Fatalf("%v\n", err)
+		}
+
+		initEvents = append(initEvents, event)
+	}
+
+	for i := 99; i >= 0; i-- {
+		subEvent := &models.KubeSubEvent{
+			EventType: "pod",
+			Message:   "Pod killed",
+			Reason:    "OOM: memory limit exceeded",
+		}
+
+		err := tester.repo.KubeEvent().AppendSubEvent(initEvents[i], subEvent)
+
+		if err != nil {
+			t.Fatalf("%v\n", err)
+		}
+	}
+
+	tester.initKubeEvents = initEvents
+}

+ 3 - 0
internal/repository/gorm/migrate.go

@@ -29,10 +29,13 @@ func AutoMigrate(db *gorm.DB) error {
 		&models.NotificationConfig{},
 		&models.EventContainer{},
 		&models.SubEvent{},
+		&models.KubeEvent{},
+		&models.KubeSubEvent{},
 		&models.ProjectUsage{},
 		&models.ProjectUsageCache{},
 		&models.Onboarding{},
 		&models.CredentialsExchangeToken{},
+		&models.BuildConfig{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 16 - 4
internal/repository/gorm/repository.go

@@ -31,10 +31,12 @@ type GormRepository struct {
 	githubAppOAuthIntegration repository.GithubAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
 	notificationConfig        repository.NotificationConfigRepository
-	event                     repository.EventRepository
+	buildEvent                repository.BuildEventRepository
+	kubeEvent                 repository.KubeEventRepository
 	projectUsage              repository.ProjectUsageRepository
 	onboarding                repository.ProjectOnboardingRepository
 	ceToken                   repository.CredentialsExchangeTokenRepository
+	buildConfig               repository.BuildConfigRepository
 }
 
 func (t *GormRepository) User() repository.UserRepository {
@@ -133,8 +135,12 @@ func (t *GormRepository) NotificationConfig() repository.NotificationConfigRepos
 	return t.notificationConfig
 }
 
-func (t *GormRepository) Event() repository.EventRepository {
-	return t.event
+func (t *GormRepository) BuildEvent() repository.BuildEventRepository {
+	return t.buildEvent
+}
+
+func (t *GormRepository) KubeEvent() repository.KubeEventRepository {
+	return t.kubeEvent
 }
 
 func (t *GormRepository) ProjectUsage() repository.ProjectUsageRepository {
@@ -149,6 +155,10 @@ func (t *GormRepository) CredentialsExchangeToken() repository.CredentialsExchan
 	return t.ceToken
 }
 
+func (t *GormRepository) BuildConfig() repository.BuildConfigRepository {
+	return t.buildConfig
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.CredentialStorage) repository.Repository {
@@ -177,9 +187,11 @@ func NewRepository(db *gorm.DB, key *[32]byte, storageBackend credentials.Creden
 		githubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(db),
 		slackIntegration:          NewSlackIntegrationRepository(db, key),
 		notificationConfig:        NewNotificationConfigRepository(db),
-		event:                     NewEventRepository(db),
+		buildEvent:                NewBuildEventRepository(db),
+		kubeEvent:                 NewKubeEventRepository(db, key),
 		projectUsage:              NewProjectUsageRepository(db),
 		onboarding:                NewProjectOnboardingRepository(db),
 		ceToken:                   NewCredentialsExchangeTokenRepository(db),
+		buildConfig:               NewBuildConfigRepository(db),
 	}
 }

+ 3 - 1
internal/repository/repository.go

@@ -25,8 +25,10 @@ type Repository interface {
 	GithubAppOAuthIntegration() GithubAppOAuthIntegrationRepository
 	SlackIntegration() SlackIntegrationRepository
 	NotificationConfig() NotificationConfigRepository
-	Event() EventRepository
+	BuildEvent() BuildEventRepository
+	KubeEvent() KubeEventRepository
 	ProjectUsage() ProjectUsageRepository
 	Onboarding() ProjectOnboardingRepository
 	CredentialsExchangeToken() CredentialsExchangeTokenRepository
+	BuildConfig() BuildConfigRepository
 }

+ 48 - 0
internal/repository/test/build_config.go

@@ -0,0 +1,48 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+type BuildConfigRepository struct {
+	canQuery     bool
+	buildConfigs []*models.BuildConfig
+}
+
+func NewBuildConfigRepository(canQuery bool) repository.BuildConfigRepository {
+	return &BuildConfigRepository{canQuery, []*models.BuildConfig{}}
+}
+
+func (repo *BuildConfigRepository) CreateBuildConfig(
+	bc *models.BuildConfig,
+) (*models.BuildConfig, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	repo.buildConfigs = append(repo.buildConfigs, bc)
+	bc.ID = uint(len(repo.buildConfigs))
+
+	return bc, nil
+}
+
+func (repo *BuildConfigRepository) UpdateBuildConfig(bc *models.BuildConfig) (*models.BuildConfig, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	// TODO
+	return bc, nil
+}
+
+func (repo *BuildConfigRepository) GetBuildConfig(id uint) (*models.BuildConfig, error) {
+	if !repo.canQuery {
+		return nil, errors.New("cannot write database")
+	}
+
+	// TODO
+	return nil, nil
+}

+ 48 - 9
internal/repository/test/event.go

@@ -1,36 +1,75 @@
 package test
 
 import (
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 )
 
-type EventRepository struct{}
+type BuildEventRepository struct{}
 
-func NewEventRepository(canQuery bool) repository.EventRepository {
-	return &EventRepository{}
+func NewBuildEventRepository(canQuery bool) repository.BuildEventRepository {
+	return &BuildEventRepository{}
 }
 
-func (n *EventRepository) CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error) {
+func (n *BuildEventRepository) CreateEventContainer(am *models.EventContainer) (*models.EventContainer, error) {
 	panic("not implemented") // TODO: Implement
 }
 
-func (n *EventRepository) CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error) {
+func (n *BuildEventRepository) CreateSubEvent(am *models.SubEvent) (*models.SubEvent, error) {
 	panic("not implemented") // TODO: Implement
 }
 
-func (n *EventRepository) ReadEventsByContainerID(id uint) ([]*models.SubEvent, error) {
+func (n *BuildEventRepository) ReadEventsByContainerID(id uint) ([]*models.SubEvent, error) {
 	panic("not implemented") // TODO: Implement
 }
 
-func (n *EventRepository) ReadEventContainer(id uint) (*models.EventContainer, error) {
+func (n *BuildEventRepository) ReadEventContainer(id uint) (*models.EventContainer, error) {
 	panic("not implemented") // TODO: Implement
 }
 
-func (n *EventRepository) ReadSubEvent(id uint) (*models.SubEvent, error) {
+func (n *BuildEventRepository) ReadSubEvent(id uint) (*models.SubEvent, error) {
 	panic("not implemented") // TODO: Implement
 }
 
-func (n *EventRepository) AppendEvent(container *models.EventContainer, event *models.SubEvent) error {
+func (n *BuildEventRepository) AppendEvent(container *models.EventContainer, event *models.SubEvent) error {
+	panic("not implemented") // TODO: Implement
+}
+
+type KubeEventRepository struct{}
+
+func NewKubeEventRepository(canQuery bool) repository.KubeEventRepository {
+	return &KubeEventRepository{}
+}
+
+func (n *KubeEventRepository) CreateEvent(event *models.KubeEvent) (*models.KubeEvent, error) {
+	panic("not implemented") // TODO: Implement
+}
+
+func (n *KubeEventRepository) ReadEvent(id uint, projID uint, clusterID uint) (*models.KubeEvent, error) {
+	panic("not implemented") // TODO: Implement
+}
+
+func (n *KubeEventRepository) ReadEventByGroup(
+	projID uint,
+	clusterID uint,
+	opts *types.GroupOptions,
+) (*models.KubeEvent, error) {
+	panic("not implemented") // TODO: Implement
+}
+
+func (n *KubeEventRepository) ListEventsByProjectID(
+	projectID uint,
+	clusterID uint,
+	opts *types.ListKubeEventRequest,
+) ([]*models.KubeEvent, int64, error) {
+	panic("not implemented") // TODO: Implement
+}
+
+func (n *KubeEventRepository) AppendSubEvent(event *models.KubeEvent, subEvent *models.KubeSubEvent) error {
+	panic("not implemented") // TODO: Implement
+}
+
+func (n *KubeEventRepository) DeleteEvent(id uint) error {
 	panic("not implemented") // TODO: Implement
 }

+ 16 - 4
internal/repository/test/repository.go

@@ -29,10 +29,12 @@ type TestRepository struct {
 	githubAppOAuthIntegration repository.GithubAppOAuthIntegrationRepository
 	slackIntegration          repository.SlackIntegrationRepository
 	notificationConfig        repository.NotificationConfigRepository
-	event                     repository.EventRepository
+	buildEvent                repository.BuildEventRepository
+	kubeEvent                 repository.KubeEventRepository
 	projectUsage              repository.ProjectUsageRepository
 	onboarding                repository.ProjectOnboardingRepository
 	ceToken                   repository.CredentialsExchangeTokenRepository
+	buildConfig               repository.BuildConfigRepository
 }
 
 func (t *TestRepository) User() repository.UserRepository {
@@ -131,8 +133,12 @@ func (t *TestRepository) NotificationConfig() repository.NotificationConfigRepos
 	return t.notificationConfig
 }
 
-func (t *TestRepository) Event() repository.EventRepository {
-	return t.event
+func (t *TestRepository) BuildEvent() repository.BuildEventRepository {
+	return t.buildEvent
+}
+
+func (t *TestRepository) KubeEvent() repository.KubeEventRepository {
+	return t.kubeEvent
 }
 
 func (t *TestRepository) ProjectUsage() repository.ProjectUsageRepository {
@@ -147,6 +153,10 @@ func (t *TestRepository) CredentialsExchangeToken() repository.CredentialsExchan
 	return t.ceToken
 }
 
+func (t *TestRepository) BuildConfig() repository.BuildConfigRepository {
+	return t.buildConfig
+}
+
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
@@ -175,9 +185,11 @@ func NewRepository(canQuery bool, failingMethods ...string) repository.Repositor
 		githubAppOAuthIntegration: NewGithubAppOAuthIntegrationRepository(canQuery),
 		slackIntegration:          NewSlackIntegrationRepository(canQuery),
 		notificationConfig:        NewNotificationConfigRepository(canQuery),
-		event:                     NewEventRepository(canQuery),
+		buildEvent:                NewBuildEventRepository(canQuery),
+		kubeEvent:                 NewKubeEventRepository(canQuery),
 		projectUsage:              NewProjectUsageRepository(canQuery),
 		onboarding:                NewProjectOnboardingRepository(canQuery),
 		ceToken:                   NewCredentialsExchangeTokenRepository(canQuery),
+		buildConfig:               NewBuildConfigRepository(canQuery),
 	}
 }

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff