Procházet zdrojové kódy

add initial internal admin tool for porter instances

Alexander Belanger před 3 roky
rodič
revize
62aa6d70a5

+ 2 - 0
.gitignore

@@ -68,3 +68,5 @@ terraform.rc
 .vscode
 
 tmp
+
+./bin/admin

+ 527 - 0
cmd/admin/health.go

@@ -0,0 +1,527 @@
+//go:build ee
+// +build ee
+
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	v2 "github.com/porter-dev/porter/internal/kubernetes/porter_agent/v2"
+	"github.com/porter-dev/porter/internal/kubernetes/prometheus"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/notifier"
+	"github.com/spf13/cobra"
+	"k8s.io/apimachinery/pkg/api/errors"
+)
+
+type ClusterPrometheusData struct {
+	ProjectName string
+	ProjectID   uint
+
+	ClusterID   uint
+	ClusterName string
+
+	CanQueryCluster    bool
+	HasPrometheus      bool
+	CanQueryPrometheus bool
+
+	FailureMessage string
+}
+
+type ClusterPorterAgentData struct {
+	ProjectName string
+	ProjectID   uint
+
+	ClusterID   uint
+	ClusterName string
+
+	CanQueryCluster     bool
+	HasPorterAgent      bool
+	CanQueryPorterAgent bool
+
+	FailureMessage string
+}
+
+var prometheusClusterData map[uint]ClusterPrometheusData
+var porterAgentClusterData map[uint]ClusterPorterAgentData
+var shouldSendEmail bool
+
+var healthCmd = &cobra.Command{
+	Use:   "health",
+	Short: "Checks the health of various components",
+}
+
+var healthPrometheusCmd = &cobra.Command{
+	Use:   "prometheus",
+	Short: "Checks the health of Prometheus instances",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := runHealthPrometheus()
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var healthPorterAgentCmd = &cobra.Command{
+	Use:   "porter-agent",
+	Short: "Checks the health of porter-agent instances",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := runHealthPorterAgent()
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	adminCmd.AddCommand(healthCmd)
+
+	healthCmd.PersistentFlags().BoolVarP(
+		&shouldSendEmail,
+		"email",
+		"e",
+		true,
+		"specify if digest email should be sent",
+	)
+
+	healthCmd.AddCommand(healthPrometheusCmd)
+	healthCmd.AddCommand(healthPorterAgentCmd)
+}
+
+func runHealthPrometheus() error {
+	prometheusClusterData = make(map[uint]ClusterPrometheusData)
+
+	err := iterateProjects(IterateProjectsSelector{
+		NotFreeTier: true,
+	}, prometheusProjectIterator)
+
+	if err != nil {
+		return err
+	}
+
+	var numClusterUnreachable uint = 0
+	var numPrometheusDoesNotExist uint = 0
+	var numPrometheusUnqueryable uint = 0
+	var workingInstances uint = 0
+
+	for _, data := range prometheusClusterData {
+		if !data.CanQueryPrometheus {
+			logPrometheusError(data)
+		}
+
+		if !data.CanQueryCluster {
+			numClusterUnreachable++
+		} else if !data.HasPrometheus {
+			numPrometheusDoesNotExist++
+		} else if !data.CanQueryPrometheus {
+			numPrometheusUnqueryable++
+		} else {
+			workingInstances++
+		}
+	}
+
+	fmt.Println("instances with cluster unreachable:", numClusterUnreachable)
+	fmt.Println("instances where prometheus does not exist:", numPrometheusDoesNotExist)
+	fmt.Println("instances where prometheus is unqueryable:", numPrometheusUnqueryable)
+	fmt.Println("working instances:", workingInstances)
+
+	if shouldSendEmail {
+		if notifyEmail == "" {
+			return fmt.Errorf("could not send email: NOTIFY_EMAIL is not defined")
+		}
+
+		sendPrometheusDigestEmail()
+	}
+
+	return nil
+}
+
+func sendPrometheusDigestEmail() {
+	text := "Prometheus summary results:\n"
+	text += fmt.Sprintf("Total clusters scanned: %d\n", len(prometheusClusterData))
+
+	text += "Clusters which do not have Prometheus installed:\n"
+	var numNoPrometheus uint = 0
+
+	for _, data := range prometheusClusterData {
+		if data.CanQueryCluster && !data.HasPrometheus {
+			text += fmt.Sprintf(
+				"Project: %s (%d), Cluster: %s (%d)\n",
+				data.ProjectName, data.ProjectID, data.ClusterName, data.ClusterID,
+			)
+			numNoPrometheus++
+		}
+	}
+
+	text += fmt.Sprintf("Total: %d\n", numNoPrometheus)
+
+	text += "\n\n"
+
+	text += "Clusters which have a failing Prometheus instance:\n"
+	var numFailing uint = 0
+
+	for _, data := range prometheusClusterData {
+		if data.CanQueryCluster && !data.CanQueryPrometheus {
+			text += fmt.Sprintf(
+				"Project: %s (%d), Cluster: %s (%d). Prometheus could not be queried: %s\n",
+				data.ProjectName, data.ProjectID, data.ClusterName, data.ClusterID, data.FailureMessage,
+			)
+
+			numFailing++
+		}
+	}
+
+	text += fmt.Sprintf("Total: %d\n", numFailing)
+
+	userNotifier.SendTextEmail(&notifier.SendTextEmailOpts{
+		Email:   notifyEmail,
+		Text:    text,
+		Subject: fmt.Sprintf("[%s] Prometheus health check results", envName),
+	})
+}
+
+func runHealthPorterAgent() error {
+	porterAgentClusterData = make(map[uint]ClusterPorterAgentData)
+
+	err := iterateProjects(IterateProjectsSelector{
+		NotFreeTier: true,
+	}, porterAgentProjectIterator)
+
+	if err != nil {
+		return err
+	}
+
+	var numClusterUnreachable uint = 0
+	var numPorterAgentDoesNotExist uint = 0
+	var numPorterAgentUnqueryable uint = 0
+	var workingInstances uint = 0
+
+	for _, data := range porterAgentClusterData {
+		if !data.CanQueryPorterAgent {
+			logPorterAgentError(data)
+		}
+
+		if !data.CanQueryCluster {
+			numClusterUnreachable++
+		} else if !data.HasPorterAgent {
+			numPorterAgentDoesNotExist++
+		} else if !data.CanQueryPorterAgent {
+			numPorterAgentUnqueryable++
+		} else {
+			workingInstances++
+		}
+	}
+
+	fmt.Println("instances with cluster unreachable:", numClusterUnreachable)
+	fmt.Println("instances where porter-agent does not exist:", numPorterAgentDoesNotExist)
+	fmt.Println("instances where porter-agent is unqueryable:", numPorterAgentUnqueryable)
+	fmt.Println("working instances:", workingInstances)
+
+	if shouldSendEmail {
+		if notifyEmail == "" {
+			return fmt.Errorf("could not send email: NOTIFY_EMAIL is not defined")
+		}
+
+		sendPorterAgentDigestEmail()
+	}
+
+	return nil
+}
+
+func sendPorterAgentDigestEmail() {
+	text := "Porter-agent summary results:\n\n"
+	text += fmt.Sprintf("Total clusters scanned: %d\n\n", len(porterAgentClusterData))
+
+	text += "Clusters which do not have porter-agent installed:\n"
+	var numNoPorterAgent uint = 0
+
+	for _, data := range porterAgentClusterData {
+		if data.CanQueryCluster && !data.HasPorterAgent {
+			text += fmt.Sprintf(
+				"Project: %s (%d), Cluster: %s (%d)\n",
+				data.ProjectName, data.ProjectID, data.ClusterName, data.ClusterID,
+			)
+
+			numNoPorterAgent++
+		}
+	}
+
+	text += fmt.Sprintf("Total: %d\n", numNoPorterAgent)
+
+	text += "\n\n"
+
+	text += "Clusters which have a failing porter-agent instance:\n"
+	var numFailing uint = 0
+
+	for _, data := range porterAgentClusterData {
+		if data.CanQueryCluster && !data.CanQueryPorterAgent {
+			text += fmt.Sprintf(
+				"Project: %s (%d), Cluster: %s (%d). Porter-agent could not be queried: %s\n",
+				data.ProjectName, data.ProjectID, data.ClusterName, data.ClusterID, data.FailureMessage,
+			)
+
+			numFailing++
+		}
+	}
+
+	text += fmt.Sprintf("Total: %d\n", numFailing)
+
+	userNotifier.SendTextEmail(&notifier.SendTextEmailOpts{
+		Email:   notifyEmail,
+		Text:    text,
+		Subject: fmt.Sprintf("[%s] Porter-agent health check results", envName),
+	})
+}
+
+func prometheusProjectIterator(project *models.Project) error {
+	clusters, err := repo.Cluster().ListClustersByProjectID(project.ID)
+
+	if err != nil {
+		return err
+	}
+
+	for _, cluster := range clusters {
+		ooc := &kubernetes.OutOfClusterConfig{
+			Cluster:                   cluster,
+			Repo:                      repo,
+			DigitalOceanOAuth:         doConf,
+			AllowInClusterConnections: false,
+		}
+
+		agent, err := kubernetes.GetAgentOutOfClusterConfig(ooc)
+
+		if err != nil {
+			addPrometheusClusterError(project, cluster, fmt.Sprintf("could not get agent: %s", err))
+
+			continue
+		}
+
+		promSvc, exists, err := prometheus.GetPrometheusService(agent.Clientset)
+
+		if err != nil {
+			addPrometheusClusterError(project, cluster, err.Error())
+
+			continue
+		}
+
+		if !exists {
+			addPrometheusNotFoundError(project, cluster)
+
+			continue
+		}
+
+		// query a metric
+		err = prometheus.TestQueryPrometheus(agent.Clientset, promSvc)
+
+		if err != nil {
+			addPrometheusUnqueryableError(project, cluster, err.Error())
+
+			continue
+		}
+
+		addPrometheusQueryable(project, cluster)
+	}
+
+	return nil
+}
+
+func addPrometheusClusterError(project *models.Project, cluster *models.Cluster, message string) {
+	prometheusClusterData[cluster.ID] = ClusterPrometheusData{
+		ProjectName:        project.Name,
+		ProjectID:          cluster.ProjectID,
+		ClusterID:          cluster.ID,
+		ClusterName:        cluster.Name,
+		CanQueryCluster:    false,
+		HasPrometheus:      false,
+		CanQueryPrometheus: false,
+		FailureMessage:     message,
+	}
+}
+
+func addPrometheusNotFoundError(project *models.Project, cluster *models.Cluster) {
+	prometheusClusterData[cluster.ID] = ClusterPrometheusData{
+		ProjectName:        project.Name,
+		ProjectID:          cluster.ProjectID,
+		ClusterID:          cluster.ID,
+		ClusterName:        cluster.Name,
+		CanQueryCluster:    true,
+		HasPrometheus:      false,
+		CanQueryPrometheus: false,
+		FailureMessage:     "Prometheus was not found",
+	}
+}
+
+func addPrometheusUnqueryableError(project *models.Project, cluster *models.Cluster, message string) {
+	prometheusClusterData[cluster.ID] = ClusterPrometheusData{
+		ProjectName:        project.Name,
+		ProjectID:          cluster.ProjectID,
+		ClusterID:          cluster.ID,
+		ClusterName:        cluster.Name,
+		CanQueryCluster:    true,
+		HasPrometheus:      true,
+		CanQueryPrometheus: false,
+		FailureMessage:     fmt.Sprintf("Prometheus was found, but could not be queried (it's probably crashing): %s", message),
+	}
+}
+
+func addPrometheusQueryable(project *models.Project, cluster *models.Cluster) {
+	prometheusClusterData[cluster.ID] = ClusterPrometheusData{
+		ProjectName:        project.Name,
+		ProjectID:          cluster.ProjectID,
+		ClusterID:          cluster.ID,
+		ClusterName:        cluster.Name,
+		CanQueryCluster:    true,
+		HasPrometheus:      true,
+		CanQueryPrometheus: true,
+	}
+}
+
+func logPrometheusError(data ClusterPrometheusData) {
+	if !data.CanQueryCluster {
+		fmt.Printf(
+			"Project: %s (%d), Cluster: %s (%d). Cluster could not be queried: %s\n\n",
+			data.ProjectName, data.ProjectID, data.ClusterName, data.ClusterID, data.FailureMessage,
+		)
+
+		return
+	} else if !data.HasPrometheus {
+		fmt.Printf(
+			"Project: %s (%d), Cluster: %s (%d). Prometheus was not found\n\n",
+			data.ProjectName, data.ProjectID, data.ClusterName, data.ClusterID,
+		)
+
+		return
+	}
+
+	fmt.Printf(
+		"Project: %s (%d), Cluster: %s (%d). Prometheus could not be queried: %s\n\n",
+		data.ProjectName, data.ProjectID, data.ClusterName, data.ClusterID, data.FailureMessage,
+	)
+}
+
+func porterAgentProjectIterator(project *models.Project) error {
+	clusters, err := repo.Cluster().ListClustersByProjectID(project.ID)
+
+	if err != nil {
+		return err
+	}
+
+	for _, cluster := range clusters {
+		ooc := &kubernetes.OutOfClusterConfig{
+			Cluster:                   cluster,
+			Repo:                      repo,
+			DigitalOceanOAuth:         doConf,
+			AllowInClusterConnections: false,
+		}
+
+		agent, err := kubernetes.GetAgentOutOfClusterConfig(ooc)
+
+		if err != nil {
+			addPorterAgentClusterError(project, cluster, fmt.Sprintf("could not get agent: %s", err))
+
+			continue
+		}
+
+		agentSvc, err := v2.GetAgentService(agent.Clientset)
+
+		if err != nil {
+			if errors.IsNotFound(err) {
+				addPorterAgentNotFoundError(project, cluster)
+			} else if err != nil {
+				addPorterAgentClusterError(project, cluster, err.Error())
+			}
+
+			continue
+		}
+
+		_, err = v2.GetAllIncidents(agent.Clientset, agentSvc)
+
+		if err != nil {
+			addPorterAgentUnqueryableError(project, cluster, err.Error())
+
+			continue
+		}
+
+		addPorterAgentQueryable(project, cluster)
+	}
+
+	return nil
+}
+
+func addPorterAgentClusterError(project *models.Project, cluster *models.Cluster, message string) {
+	porterAgentClusterData[cluster.ID] = ClusterPorterAgentData{
+		ProjectName:         project.Name,
+		ProjectID:           cluster.ProjectID,
+		ClusterID:           cluster.ID,
+		ClusterName:         cluster.Name,
+		CanQueryCluster:     false,
+		HasPorterAgent:      false,
+		CanQueryPorterAgent: false,
+		FailureMessage:      message,
+	}
+}
+
+func addPorterAgentNotFoundError(project *models.Project, cluster *models.Cluster) {
+	porterAgentClusterData[cluster.ID] = ClusterPorterAgentData{
+		ProjectName:         project.Name,
+		ProjectID:           cluster.ProjectID,
+		ClusterID:           cluster.ID,
+		ClusterName:         cluster.Name,
+		CanQueryCluster:     true,
+		HasPorterAgent:      false,
+		CanQueryPorterAgent: false,
+		FailureMessage:      "Prometheus was not found",
+	}
+}
+
+func addPorterAgentUnqueryableError(project *models.Project, cluster *models.Cluster, message string) {
+	porterAgentClusterData[cluster.ID] = ClusterPorterAgentData{
+		ProjectName:         project.Name,
+		ProjectID:           cluster.ProjectID,
+		ClusterID:           cluster.ID,
+		ClusterName:         cluster.Name,
+		CanQueryCluster:     true,
+		HasPorterAgent:      true,
+		CanQueryPorterAgent: false,
+		FailureMessage:      fmt.Sprintf("Prometheus was found, but could not be queried (it's probably crashing): %s", message),
+	}
+}
+
+func addPorterAgentQueryable(project *models.Project, cluster *models.Cluster) {
+	porterAgentClusterData[cluster.ID] = ClusterPorterAgentData{
+		ProjectName:         project.Name,
+		ProjectID:           cluster.ProjectID,
+		ClusterID:           cluster.ID,
+		ClusterName:         cluster.Name,
+		CanQueryCluster:     true,
+		HasPorterAgent:      true,
+		CanQueryPorterAgent: true,
+	}
+}
+
+func logPorterAgentError(data ClusterPorterAgentData) {
+	if !data.CanQueryCluster {
+		fmt.Printf(
+			"Project: %s (%d), Cluster: %s (%d). Cluster could not be queried: %s\n\n",
+			data.ProjectName, data.ProjectID, data.ClusterName, data.ClusterID, data.FailureMessage,
+		)
+
+		return
+	} else if !data.HasPorterAgent {
+		fmt.Printf(
+			"Project: %s (%d), Cluster: %s (%d). Porter-agent was not found\n\n",
+			data.ProjectName, data.ProjectID, data.ClusterName, data.ClusterID,
+		)
+
+		return
+	}
+
+	fmt.Printf(
+		"Project: %s (%d), Cluster: %s (%d). Porter-agent could not be queried: %s\n\n",
+		data.ProjectName, data.ProjectID, data.ClusterName, data.ClusterID, data.FailureMessage,
+	)
+}

+ 102 - 0
cmd/admin/helpers.go

@@ -0,0 +1,102 @@
+//go:build ee
+// +build ee
+
+package main
+
+import (
+	"fmt"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type projectIteratorMethod func(project *models.Project) error
+
+type clusterIteratorMethod func(cluster *models.Cluster) error
+
+const stepSize = 100
+
+type IterateProjectsSelector struct {
+	NotFreeTier bool
+}
+
+func iterateProjects(opts IterateProjectsSelector, fn projectIteratorMethod) error {
+	// get count of model
+	var count int64
+
+	if err := db.Model(&models.Project{}).Count(&count).Error; err != nil {
+		return err
+	}
+
+	// iterate (count / stepSize) + 1 times using Limit and Offset
+	for i := 0; i < (int(count)/stepSize)+1; i++ {
+		projects := []*models.Project{}
+
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&projects).Error; err != nil {
+			return err
+		}
+
+		for _, proj := range projects {
+			// if there are conditions, check the conditions and skip if match
+			if opts.NotFreeTier {
+				// query for the project usage
+				if usage, err := repo.ProjectUsage().ReadProjectUsage(proj.ID); err != nil || isFreeTier(usage) {
+					continue
+				}
+			}
+
+			fmt.Println("iterating on project:", proj.Name)
+
+			err := fn(proj)
+
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}
+
+func isFreeTier(projUsage *models.ProjectUsage) bool {
+	return types.BasicPlan.Clusters == projUsage.Clusters &&
+		types.BasicPlan.Users == projUsage.Users &&
+		types.BasicPlan.ResourceCPU == projUsage.ResourceCPU &&
+		types.BasicPlan.ResourceMemory == projUsage.ResourceMemory
+}
+
+func iterateClusters(fn clusterIteratorMethod) error {
+	// get count of model
+	var count int64
+
+	if err := db.Model(&models.Cluster{}).Count(&count).Error; err != nil {
+		return err
+	}
+
+	// iterate (count / stepSize) + 1 times using Limit and Offset
+	for i := 0; i < (int(count)/stepSize)+1; i++ {
+		clusters := []*models.Cluster{}
+
+		if err := db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&clusters).Error; err != nil {
+			return err
+		}
+
+		for _, clusterSimple := range clusters {
+			cluster, err := repo.Cluster().ReadCluster(clusterSimple.ProjectID, clusterSimple.ID)
+
+			if err != nil {
+				return err
+			}
+
+			fmt.Println("iterating on cluster:", cluster.Name)
+
+			err = fn(cluster)
+
+			if err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}

+ 107 - 0
cmd/admin/main.go

@@ -0,0 +1,107 @@
+//go:build ee
+// +build ee
+
+package main
+
+import (
+	"os"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/api/server/shared/config/envloader"
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/notifier"
+	"github.com/porter-dev/porter/internal/notifier/sendgrid"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/credentials"
+	pgorm "github.com/porter-dev/porter/internal/repository/gorm"
+	"github.com/spf13/cobra"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+
+	"github.com/porter-dev/porter/ee/integrations/vault"
+
+	lr "github.com/porter-dev/porter/pkg/logger"
+)
+
+var db *gorm.DB
+var repo repository.Repository
+var doConf *oauth2.Config
+var userNotifier notifier.UserNotifier
+var envName string
+var notifyEmail string
+
+func main() {
+	notifyEmail = os.Getenv("NOTIFY_EMAIL")
+
+	// initialize the database
+	logger := lr.NewConsole(true)
+
+	envConf, err := envloader.FromEnv()
+
+	if err != nil {
+		logger.Fatal().Err(err).Msg("could not load env conf")
+		return
+	}
+
+	db, err = adapter.New(envConf.DBConf)
+
+	if err != nil {
+		logger.Fatal().Err(err).Msg("could not connect to the database")
+		return
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte(envConf.DBConf.EncryptionKey) {
+		key[i] = b
+	}
+
+	var credBackend credentials.CredentialStorage
+
+	if envConf.DBConf.VaultAPIKey != "" && envConf.DBConf.VaultServerURL != "" && envConf.DBConf.VaultPrefix != "" {
+		credBackend = vault.NewClient(
+			envConf.DBConf.VaultServerURL,
+			envConf.DBConf.VaultAPIKey,
+			envConf.DBConf.VaultPrefix,
+		)
+	}
+
+	repo = pgorm.NewRepository(db, &key, credBackend)
+
+	if envConf.ServerConf.DOClientID != "" && envConf.ServerConf.DOClientSecret != "" {
+		doConf = oauth.NewDigitalOceanClient(&oauth.Config{
+			ClientID:     envConf.ServerConf.DOClientID,
+			ClientSecret: envConf.ServerConf.DOClientSecret,
+			Scopes:       []string{"read", "write"},
+			BaseURL:      envConf.ServerConf.ServerURL,
+		})
+	}
+
+	userNotifier = &notifier.EmptyUserNotifier{}
+
+	if envConf.ServerConf.SendgridAPIKey != "" && envConf.ServerConf.SendgridSenderEmail != "" {
+		userNotifier = sendgrid.NewUserNotifier(&sendgrid.Client{
+			APIKey:      envConf.ServerConf.SendgridAPIKey,
+			SenderEmail: envConf.ServerConf.SendgridSenderEmail,
+		})
+	}
+
+	envName = envConf.ServerConf.InstanceName
+
+	if envName == "" {
+		envName = "test"
+	}
+
+	// call the admin CLI command with the database connection
+	if err := adminCmd.Execute(); err != nil {
+		color.New(color.FgRed).Println(err)
+		os.Exit(1)
+	}
+}
+
+// adminCmd represents the base command when called without any subcommands
+var adminCmd = &cobra.Command{
+	Use:   "admin",
+	Short: "Admin command-line tool for managing a Porter instance.",
+}

+ 38 - 0
internal/kubernetes/prometheus/metrics.go

@@ -5,6 +5,7 @@ import (
 	"encoding/json"
 	"fmt"
 	"strings"
+	"time"
 
 	v1 "k8s.io/api/core/v1"
 	"k8s.io/client-go/kubernetes"
@@ -99,6 +100,43 @@ func GetIngressesWithNGINXAnnotation(clientset kubernetes.Interface) ([]SimpleIn
 	return res, nil
 }
 
+func TestQueryPrometheus(
+	clientset kubernetes.Interface,
+	service *v1.Service,
+) error {
+	now := time.Now()
+	prev := now.Add(-10 * time.Minute)
+
+	queryParams := map[string]string{
+		"start": fmt.Sprintf("%d", uint(now.Unix())),
+		"end":   fmt.Sprintf("%d", uint(prev.Unix())),
+	}
+
+	resp := clientset.CoreV1().Services(service.Namespace).ProxyGet(
+		"http",
+		service.Name,
+		fmt.Sprintf("%d", service.Spec.Ports[0].Port),
+		"/api/v1/label/__name__/values",
+		queryParams,
+	)
+
+	rawQuery, err := resp.DoRaw(context.TODO())
+
+	if err != nil {
+		return fmt.Errorf("could not query prometheus: %v", err)
+	}
+
+	rawQueryObj := &promRawValuesQuery{}
+
+	json.Unmarshal(rawQuery, rawQueryObj)
+
+	if rawQueryObj.Status != "success" {
+		return fmt.Errorf("could not query prometheus: label query was not successful")
+	}
+
+	return nil
+}
+
 type QueryOpts struct {
 	Metric     string   `schema:"metric"`
 	ShouldSum  bool     `schema:"shouldsum"`

+ 10 - 0
internal/notifier/notifier.go

@@ -22,11 +22,17 @@ type SendProjectInviteEmailOpts struct {
 	ProjectOwnerEmail string
 }
 
+type SendTextEmailOpts struct {
+	Email   string
+	Subject string
+	Text    string
+}
 type UserNotifier interface {
 	SendPasswordResetEmail(opts *SendPasswordResetEmailOpts) error
 	SendGithubRelinkEmail(opts *SendGithubRelinkEmailOpts) error
 	SendEmailVerification(opts *SendEmailVerificationOpts) error
 	SendProjectInviteEmail(opts *SendProjectInviteEmailOpts) error
+	SendTextEmail(opts *SendTextEmailOpts) error
 }
 
 type EmptyUserNotifier struct{}
@@ -46,3 +52,7 @@ func (e *EmptyUserNotifier) SendEmailVerification(opts *SendEmailVerificationOpt
 func (e *EmptyUserNotifier) SendProjectInviteEmail(opts *SendProjectInviteEmailOpts) error {
 	return nil
 }
+
+func (e *EmptyUserNotifier) SendTextEmail(opts *SendTextEmailOpts) error {
+	return nil
+}

+ 34 - 0
internal/notifier/sendgrid/sendgrid.go

@@ -151,3 +151,37 @@ func (s *UserNotifier) SendProjectInviteEmail(opts *notifier.SendProjectInviteEm
 
 	return err
 }
+
+func (s *UserNotifier) SendTextEmail(opts *notifier.SendTextEmailOpts) error {
+	request := sendgrid.GetRequest(s.client.APIKey, "/v3/mail/send", "https://api.sendgrid.com")
+	request.Method = "POST"
+
+	sgMail := &mail.SGMailV3{
+		Content: []*mail.Content{
+			{
+				Type:  "text/plain",
+				Value: opts.Text,
+			},
+		},
+		Personalizations: []*mail.Personalization{
+			{
+				Subject: opts.Subject,
+				To: []*mail.Email{
+					{
+						Address: opts.Email,
+					},
+				},
+			},
+		},
+		From: &mail.Email{
+			Address: s.client.SenderEmail,
+			Name:    "Porter",
+		},
+	}
+
+	request.Body = mail.GetRequestBody(sgMail)
+
+	_, err := sendgrid.API(request)
+
+	return err
+}

+ 16 - 0
scripts/dev-environment/RunAdminDev.sh

@@ -0,0 +1,16 @@
+#!/bin/bash
+
+go build -tags ee -o ./bin/admin ./cmd/admin 
+
+# Load env variables for backend
+if [[ -e ./docker/.env ]]
+then
+  set -a # automatically export all variables
+  source ./docker/.env
+  set +a
+else 
+  echo "Couldn't find any backend env variables, exiting process"
+  exit
+fi
+
+./bin/admin "$@"

+ 36 - 0
services/admin_container/Dockerfile

@@ -0,0 +1,36 @@
+# syntax=docker/dockerfile:1.1.7-experimental
+
+# Base Go environment
+# -------------------
+FROM golang:1.18-alpine as base
+WORKDIR /porter
+
+RUN apk update && apk add --no-cache gcc musl-dev git
+
+COPY go.mod go.sum ./
+COPY /cmd ./cmd
+COPY /internal ./internal
+COPY /api ./api
+COPY /ee ./ee
+COPY /pkg ./pkg
+
+RUN --mount=type=cache,target=$GOPATH/pkg/mod \
+    go mod download
+
+# Go build environment
+# --------------------
+FROM base AS build-go
+
+ARG version=production
+
+RUN --mount=type=cache,target=/root/.cache/go-build \
+    --mount=type=cache,target=$GOPATH/pkg/mod \
+    go build -ldflags '-w -s' -a -tags ee -o ./bin/admin ./cmd/admin
+
+# Deployment environment
+# ----------------------
+FROM alpine
+RUN apk update
+
+COPY --from=build-go /porter/bin/admin /porter/
+ENTRYPOINT [ "/porter/admin" ]