Ctrl K
ComunidadGuías…

api/forms/forms.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package forms

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/url"
	"regexp"
	"strings"

	"github.com/coroot/coroot/clickhouse"
	"github.com/coroot/coroot/config"
	"github.com/coroot/coroot/db"
	"github.com/coroot/coroot/model"
	"github.com/coroot/coroot/notifications"
	"github.com/coroot/coroot/prom"
	"github.com/coroot/coroot/timeseries"
	"github.com/coroot/coroot/utils"
	"github.com/prometheus/prometheus/promql/parser"
)

var (
	promqlParser = parser.NewParser(parser.Options{})

	ErrInvalidForm = errors.New("invalid form")

	slugRe  = regexp.MustCompile("^[-_0-9a-z]{3,}$")
	emailRe = regexp.MustCompile(`^[^@\r\n\t\f\v ]+@[^@\r\n\t\f\v ]+\.[a-z]+$`)
)

func ptrBool(v bool) *bool {
	return &v
}

func boolValue(v *bool) bool {
	return v != nil && *v
}

type Form interface {
	Valid() bool
}

func ReadAndValidate(r *http.Request, f Form) error {
	if err := utils.ReadJson(r, f); err != nil {
		return err
	}
	if !f.Valid() {
		return ErrInvalidForm
	}
	return nil
}

type ProjectForm struct {
	Name           string   `json:"name"`
	MemberProjects []string `json:"member_projects"`
}

func (f *ProjectForm) Valid() bool {
	if !slugRe.MatchString(f.Name) {
		return false
	}
	for _, mp := range f.MemberProjects {
		if !slugRe.MatchString(mp) {
			return false
		}
	}
	return true
}

type ApiKeyForm struct {
	Action string `json:"action"`
	db.ApiKey
}

func (f *ApiKeyForm) Valid() bool {
	return true
}

type DashboardForm struct {
	Action string `json:"action"`
	db.Dashboard
}

func (f *DashboardForm) Valid() bool {
	return f.Name != ""
}

type CheckConfigForm struct {
	Configs []*model.CheckConfigSimple `json:"configs"`
}

func (f *CheckConfigForm) Valid() bool {
	return true
}

func validateCustomQuery(query string) error {
	if _, err := promqlParser.ParseExpr(strings.ReplaceAll(query, "$RANGE", "1m")); err != nil {
		return fmt.Errorf("invalid query: %s", err)
	}
	return nil
}

type CheckConfigSLOAvailabilityForm struct {
	Configs []model.CheckConfigSLOAvailability `json:"configs"`
	Default bool                               `json:"default"`
}

func (f *CheckConfigSLOAvailabilityForm) Valid() bool {
	for _, c := range f.Configs {
		if c.Custom && (c.TotalRequestsQuery == "" || c.FailedRequestsQuery == "") {
			return false
		}
	}
	return true
}

func (f *CheckConfigSLOAvailabilityForm) Validate() error {
	for _, c := range f.Configs {
		if !c.Custom {
			continue
		}
		for _, q := range []string{c.Total(), c.Failed()} {
			if err := validateCustomQuery(q); err != nil {
				return err
			}
		}
	}
	return nil
}

type CheckConfigSLOLatencyForm struct {
	Configs []model.CheckConfigSLOLatency `json:"configs"`
	Default bool                          `json:"default"`
}

func (f *CheckConfigSLOLatencyForm) Valid() bool {
	for _, c := range f.Configs {
		if c.Custom && (c.HistogramQuery == "" || c.ObjectiveBucket <= 0) {
			return false
		}
	}
	return true
}

func (f *CheckConfigSLOLatencyForm) Validate() error {
	for _, c := range f.Configs {
		if !c.Custom {
			continue
		}
		if err := validateCustomQuery(c.Histogram()); err != nil {
			return err
		}
	}
	return nil
}

type ApplicationCategoryForm struct {
	Action string                    `json:"action"`
	Id     model.ApplicationCategory `json:"id"`
	db.ApplicationCategory
	Test *struct {
		Incident   *db.ApplicationCategoryNotificationDestinations `json:"incident,omitempty"`
		Deployment *db.ApplicationCategoryNotificationDestinations `json:"deployment,omitempty"`
	} `json:"test,omitempty"`
}

func (f *ApplicationCategoryForm) Valid() bool {
	if f.Test != nil {
		return true
	}
	if !slugRe.MatchString(string(f.Name)) {
		return false
	}
	customPatterns := strings.Fields(f.CustomPatterns)
	if !utils.GlobValidate(customPatterns) {
		return false
	}
	for _, p := range customPatterns {
		if strings.Count(p, "/") != 1 || strings.Index(p, "/") < 1 {
			return false
		}
	}
	return true
}

func (f *ApplicationCategoryForm) SendTestNotification(ctx context.Context, project *db.Project) error {
	if f.Test == nil {
		return nil
	}
	integrations := project.Settings.Integrations
	var client notifications.NotificationClient
	switch {
	case f.Test.Incident != nil:
		if slack := f.Test.Incident.Slack; slack != nil && integrations.Slack != nil {
			client = notifications.NewSlack(integrations.Slack.Token, cmp.Or(slack.Channel, integrations.Slack.DefaultChannel))
		}
		if teams := f.Test.Incident.Teams; teams != nil && integrations.Teams != nil {
			client = notifications.NewTeams(integrations.Teams.GetWebhookUrl(teams.Channel))
		}
		if pagerduty := f.Test.Incident.Pagerduty; pagerduty != nil && integrations.Pagerduty != nil {
			client = notifications.NewPagerduty(integrations.Pagerduty.IntegrationKey)
		}
		if opsgenie := f.Test.Incident.Opsgenie; opsgenie != nil && integrations.Opsgenie != nil {
			client = notifications.NewOpsgenie(integrations.Opsgenie.ApiKey, integrations.Opsgenie.EUInstance)
		}
		if webhook := f.Test.Incident.Webhook; webhook != nil && integrations.Webhook != nil {
			client = notifications.NewWebhook(integrations.Webhook)
		}
		if client != nil {
			return client.SendIncident(ctx, integrations.BaseUrl, testIncidentNotification(project))
		}
	case f.Test.Deployment != nil:
		if slack := f.Test.Deployment.Slack; slack != nil && integrations.Slack != nil {
			client = notifications.NewSlack(integrations.Slack.Token, cmp.Or(slack.Channel, integrations.Slack.DefaultChannel))
		}
		if teams := f.Test.Deployment.Teams; teams != nil && integrations.Teams != nil {
			client = notifications.NewTeams(integrations.Teams.GetWebhookUrl(teams.Channel))
		}
		if webhook := f.Test.Deployment.Webhook; webhook != nil && integrations.Webhook != nil {
			client = notifications.NewWebhook(integrations.Webhook)
		}
		if client != nil {
			return client.SendDeployment(ctx, project, testDeploymentNotification(project))
		}
	}
	return nil
}

type CustomApplicationForm struct {
	Name    string `json:"name"`
	NewName string `json:"new_name"`

	InstancePatternsStr string `json:"instance_patterns"`
	InstancePatterns    []string
}

func (f *CustomApplicationForm) Valid() bool {
	if !slugRe.MatchString(f.NewName) {
		return false
	}
	f.InstancePatterns = strings.Fields(f.InstancePatternsStr)
	if !utils.GlobValidate(f.InstancePatterns) {
		return false
	}
	return true
}

type CustomCloudPricingForm struct {
	db.CustomCloudPricing
}

func (f *CustomCloudPricingForm) Valid() bool {
	if f.PerCPUCore <= 0 || f.PerMemoryGb <= 0 {
		return false
	}
	return true
}

type ApplicationInstrumentationForm struct {
	model.ApplicationInstrumentation
}

func (f *ApplicationInstrumentationForm) Valid() bool {
	return f.Port != ""
}

type ApplicationSettingsProfilingForm struct {
	model.ApplicationSettingsProfiling
}

func (f *ApplicationSettingsProfilingForm) Valid() bool {
	return true
}

type ApplicationSettingsTracingForm struct {
	model.ApplicationSettingsTracing
}

func (f *ApplicationSettingsTracingForm) Valid() bool {
	return true
}

type ApplicationSettingsLogsForm struct {
	model.ApplicationSettingsLogs
}

func (f *ApplicationSettingsLogsForm) Valid() bool {
	return true
}

type ApplicationSettingsRisksForm struct {
	Action string        `json:"action"`
	Key    model.RiskKey `json:"key"`
	Reason string        `json:"reason"`
}

func (f *ApplicationSettingsRisksForm) Valid() bool {
	return true
}

type IntegrationsForm struct {
	BaseUrl string `json:"base_url"`
}

func (f *IntegrationsForm) Valid() bool {
	if _, err := url.Parse(f.BaseUrl); err != nil || f.BaseUrl == "" {
		return false
	}
	f.BaseUrl = strings.TrimRight(f.BaseUrl, "/")
	return true
}

type IntegrationForm interface {
	Form
	Get(project *db.Project, masked bool)
	Update(ctx context.Context, project *db.Project, remove bool) error
	Test(ctx context.Context, project *db.Project) error
}

func NewIntegrationForm(t db.IntegrationType, globalClickHouse *db.IntegrationClickhouse, globalPrometheus *db.IntegrationPrometheus) IntegrationForm {
	switch t {
	case db.IntegrationTypePrometheus:
		return &IntegrationFormPrometheus{global: globalPrometheus, globalClickHouse: globalClickHouse}
	case db.IntegrationTypeClickhouse:
		return &IntegrationFormClickhouse{global: globalClickHouse}
	case db.IntegrationTypeAWS:
		return &IntegrationFormAWS{}
	case db.IntegrationTypeSlack:
		return &IntegrationFormSlack{}
	case db.IntegrationTypeTeams:
		return &IntegrationFormTeams{}
	case db.IntegrationTypePagerduty:
		return &IntegrationFormPagerduty{}
	case db.IntegrationTypeOpsgenie:
		return &IntegrationFormOpsgenie{}
	case db.IntegrationTypeWebhook:
		return &IntegrationFormWebhook{}
	}
	return nil
}

type IntegrationFormPrometheus struct {
	db.IntegrationPrometheus
	global           *db.IntegrationPrometheus
	globalClickHouse *db.IntegrationClickhouse
}

func (f *IntegrationFormPrometheus) Valid() bool {
	if _, err := url.Parse(f.IntegrationPrometheus.Url); err != nil {
		return false
	}
	if f.RemoteWriteUrl != "" {
		if _, err := url.Parse(f.IntegrationPrometheus.RemoteWriteUrl); err != nil {
			return false
		}
	}
	if !config.IsPrometheusSelectorValid(f.IntegrationPrometheus.ExtraSelector) {
		return false
	}
	var validHeaders []utils.Header
	for _, h := range f.CustomHeaders {
		if h.Valid() {
			validHeaders = append(validHeaders, h)
		}
	}
	f.CustomHeaders = validHeaders
	return true
}

func (f *IntegrationFormPrometheus) Get(project *db.Project, masked bool) {
	cfg := project.PrometheusConfig(f.global)
	f.IntegrationPrometheus = *cfg
	if cfg.Url == "" {
		return
	}
	if masked {
		f.Url = "http://<hidden>"
		if f.BasicAuth != nil {
			f.BasicAuth.User = "<hidden>"
			f.BasicAuth.Password = "<hidden>"
		}
		for i := range f.CustomHeaders {
			f.CustomHeaders[i].Value = "<hidden>"
		}
		if f.RemoteWriteUrl != "" {
			f.RemoteWriteUrl = "<hidden>"
		}
	}
}

func (f *IntegrationFormPrometheus) Update(ctx context.Context, project *db.Project, remove bool) error {
	if f.global != nil {
		return errors.New("global Prometheus configuration is used and cannot be changed")
	}
	if err := f.Test(ctx, project); err != nil {
		return err
	}
	project.Prometheus = f.IntegrationPrometheus
	return nil
}

func (f *IntegrationFormPrometheus) Test(ctx context.Context, project *db.Project) error {
	client, err := prom.NewClient(&f.IntegrationPrometheus, project.ClickHouseConfig(f.globalClickHouse))
	if err != nil {
		return err
	}
	defer client.Close()
	if err = client.Ping(ctx); err != nil {
		return err
	}
	return nil
}

type IntegrationFormClickhouse struct {
	db.IntegrationClickhouse
	global *db.IntegrationClickhouse
}

func (f *IntegrationFormClickhouse) Valid() bool {
	if _, _, err := net.SplitHostPort(f.Addr); err != nil {
		return false
	}
	return true
}

func (f *IntegrationFormClickhouse) Get(project *db.Project, masked bool) {
	cfg := project.ClickHouseConfig(f.global)
	if cfg != nil {
		f.IntegrationClickhouse = *cfg
	}
	if f.Protocol == "" {
		f.Protocol = "native"
	}
	if f.Database == "" {
		f.Database = "default"
	}
	if f.Auth.User == "" {
		f.Auth.User = "default"
	}
	if masked {
		f.Addr = "<hidden>"
		f.Auth.User = "<hidden>"
		f.Auth.Password = "<hidden>"
	}
}

func (f *IntegrationFormClickhouse) Update(ctx context.Context, project *db.Project, remove bool) error {
	if f.global != nil {
		return errors.New("global ClickHouse configuration is used and cannot be changed")
	}
	cfg := &f.IntegrationClickhouse
	if remove {
		cfg = nil
	} else {
		if err := f.Test(ctx, project); err != nil {
			return err
		}
	}
	project.Settings.Integrations.Clickhouse = cfg
	return nil
}

func (f *IntegrationFormClickhouse) Test(ctx context.Context, project *db.Project) error {
	client, err := clickhouse.NewClient(&f.IntegrationClickhouse, project)
	if err != nil {
		return err
	}
	if err = client.Ping(ctx); err != nil {
		return err
	}
	return nil
}

type IntegrationFormAWS struct {
	db.IntegrationAWS
}

func (f *IntegrationFormAWS) Valid() bool {
	return (f.AccessKeyID == "") == (f.SecretAccessKey == "")
}

func (f *IntegrationFormAWS) Get(project *db.Project, masked bool) {
	cfg := project.Settings.Integrations.AWS
	if cfg != nil {
		f.IntegrationAWS = *cfg
	}
	if masked {
		f.AccessKeyID = "<hidden>"
		f.SecretAccessKey = "<hidden>"
	}
}

func (f *IntegrationFormAWS) Update(ctx context.Context, project *db.Project, remove bool) error {
	cfg := &f.IntegrationAWS
	if remove {
		cfg = nil
	} else {
		if err := f.Test(ctx, project); err != nil {
			return err
		}
	}
	project.Settings.Integrations.AWS = cfg
	return nil
}

func (f *IntegrationFormAWS) Test(ctx context.Context, project *db.Project) error {
	return nil
}

type IntegrationFormSlack struct {
	db.IntegrationSlack
}

func (f *IntegrationFormSlack) Valid() bool {
	if err := f.Validate(); err != nil {
		return false
	}
	return true
}

func (f *IntegrationFormSlack) Get(project *db.Project, masked bool) {
	cfg := project.Settings.Integrations.Slack
	if cfg == nil {
		f.Incidents = true
		f.Deployments = true
		f.Alerts = ptrBool(true)
		return
	}
	f.IntegrationSlack = *cfg
	if masked {
		f.Token = "<hidden>"
	}
}

func (f *IntegrationFormSlack) Update(ctx context.Context, project *db.Project, remove bool) error {
	cfg := &f.IntegrationSlack
	if remove {
		cfg = nil
	}
	project.Settings.Integrations.Slack = cfg
	return nil
}

func (f *IntegrationFormSlack) Test(ctx context.Context, project *db.Project) error {
	baseUrl := project.Settings.Integrations.BaseUrl
	client := notifications.NewSlack(f.Token, f.DefaultChannel)
	if err := client.SendIncident(ctx, baseUrl, testIncidentNotification(project)); err != nil {
		return err
	}
	if boolValue(f.Alerts) {
		return client.SendAlert(ctx, baseUrl, testAlertNotification(project))
	}
	return nil
}

type IntegrationFormTeams struct {
	db.IntegrationTeams
}

func (f *IntegrationFormTeams) Valid() bool {
	if len(f.GetChannels()) == 0 {
		return true
	}
	if err := f.Validate(); err != nil {
		return false
	}
	return true
}

func (f *IntegrationFormTeams) Get(project *db.Project, masked bool) {
	cfg := project.Settings.Integrations.Teams
	if cfg == nil {
		f.Incidents = true
		f.Deployments = true
		f.Alerts = ptrBool(true)
		f.Channels = []db.IntegrationTeamsChannel{{Name: db.TeamsDefaultChannelName}}
		f.DefaultChannel = db.TeamsDefaultChannelName
		return
	}
	f.IntegrationTeams = *cfg
	f.WebhookUrl = ""
	f.DefaultChannel = cfg.GetDefaultChannel()
	channels := cfg.GetChannels()
	f.Channels = make([]db.IntegrationTeamsChannel, len(channels))
	for i, ch := range channels {
		f.Channels[i] = ch
		if masked {
			f.Channels[i].WebhookUrl = "<hidden>"
		}
	}
}

func (f *IntegrationFormTeams) Update(ctx context.Context, project *db.Project, remove bool) error {
	cfg := &f.IntegrationTeams
	if remove || len(f.GetChannels()) == 0 {
		cfg = nil
	}
	project.Settings.Integrations.Teams = cfg
	return nil
}

func (f *IntegrationFormTeams) Test(ctx context.Context, project *db.Project) error {
	webhookUrl := f.GetWebhookUrl("")
	if webhookUrl == "" {
		return errors.New("no channels configured")
	}
	baseUrl := project.Settings.Integrations.BaseUrl
	client := notifications.NewTeams(webhookUrl)
	if err := client.SendIncident(ctx, baseUrl, testIncidentNotification(project)); err != nil {
		return err
	}
	if boolValue(f.Alerts) {
		return client.SendAlert(ctx, baseUrl, testAlertNotification(project))
	}
	return nil
}

type IntegrationFormPagerduty struct {
	db.IntegrationPagerduty
}

func (f *IntegrationFormPagerduty) Valid() bool {
	if err := f.Validate(); err != nil {
		return false
	}
	return true
}

func (f *IntegrationFormPagerduty) Get(project *db.Project, masked bool) {
	cfg := project.Settings.Integrations.Pagerduty
	if cfg == nil {
		f.Incidents = true
		f.Alerts = ptrBool(true)
		return
	}
	f.IntegrationPagerduty = *cfg
	if masked {
		f.IntegrationKey = "<hidden>"
	}
}

func (f *IntegrationFormPagerduty) Update(ctx context.Context, project *db.Project, remove bool) error {
	cfg := &f.IntegrationPagerduty
	if remove {
		cfg = nil
	}
	project.Settings.Integrations.Pagerduty = cfg
	return nil
}

func (f *IntegrationFormPagerduty) Test(ctx context.Context, project *db.Project) error {
	return notifications.NewPagerduty(f.IntegrationKey).SendIncident(ctx, project.Settings.Integrations.BaseUrl, testIncidentNotification(project))
}

type IntegrationFormOpsgenie struct {
	db.IntegrationOpsgenie
}

func (f *IntegrationFormOpsgenie) Valid() bool {
	if err := f.Validate(); err != nil {
		return false
	}
	return true
}

func (f *IntegrationFormOpsgenie) Get(project *db.Project, masked bool) {
	cfg := project.Settings.Integrations.Opsgenie
	if cfg == nil {
		f.Incidents = true
		f.Alerts = ptrBool(true)
		return
	}
	f.IntegrationOpsgenie = *cfg
	if masked {
		f.ApiKey = "<hidden>"
	}
}

func (f *IntegrationFormOpsgenie) Update(ctx context.Context, project *db.Project, remove bool) error {
	cfg := &f.IntegrationOpsgenie
	if remove {
		cfg = nil
	}
	project.Settings.Integrations.Opsgenie = cfg
	return nil
}

func (f *IntegrationFormOpsgenie) Test(ctx context.Context, project *db.Project) error {
	return notifications.NewOpsgenie(f.ApiKey, f.EUInstance).SendIncident(ctx, project.Settings.Integrations.BaseUrl, testIncidentNotification(project))
}

type IntegrationFormWebhook struct {
	db.IntegrationWebhook
}

func (f *IntegrationFormWebhook) Valid() bool {
	if err := f.Validate(); err != nil {
		return false
	}
	return true
}

func (f *IntegrationFormWebhook) Get(project *db.Project, masked bool) {
	cfg := project.Settings.Integrations.Webhook
	if cfg == nil {
		f.Incidents = true
		f.Deployments = true
		f.Alerts = ptrBool(true)
		return
	}
	f.IntegrationWebhook = *cfg
	if masked {
		f.Url = "<hidden>"
	}
}

func (f *IntegrationFormWebhook) Update(ctx context.Context, project *db.Project, remove bool) error {
	cfg := &f.IntegrationWebhook
	if remove {
		cfg = nil
	}
	project.Settings.Integrations.Webhook = cfg
	return nil
}

func (f *IntegrationFormWebhook) Test(ctx context.Context, project *db.Project) error {
	cfg := &f.IntegrationWebhook
	wh := notifications.NewWebhook(cfg)
	if cfg.Incidents {
		err := wh.SendIncident(ctx, project.Settings.Integrations.BaseUrl, testIncidentNotification(project))
		if err != nil {
			return err
		}
	}
	if cfg.Deployments {
		err := wh.SendDeployment(ctx, project, testDeploymentNotification(project))
		if err != nil {
			return err
		}
	}
	if boolValue(cfg.Alerts) {
		err := wh.SendAlert(ctx, project.Settings.Integrations.BaseUrl, testAlertNotification(project))
		if err != nil {
			return err
		}
	}
	return nil
}

func testIncidentNotification(project *db.Project) *db.IncidentNotification {
	return &db.IncidentNotification{
		ProjectId:     project.Id,
		ApplicationId: model.NewApplicationId(string(project.Id), "default", model.ApplicationKindDeployment, "fake-app"),
		IncidentKey:   "123ab456",
		Status:        model.CRITICAL,
		Details: &db.IncidentNotificationDetails{
			Reports: []db.IncidentNotificationDetailsReport{
				{Name: model.AuditReportNetwork, Check: model.Checks.NetworkRTT.Title, Message: "high network latency to 2 upstream services"},
				{Name: model.AuditReportLogs, Check: model.Checks.LogErrors.Title, Message: "1206 errors occurred"},
			},
		},
	}
}

func testAlertNotification(project *db.Project) *db.AlertNotification {
	return &db.AlertNotification{
		ProjectId:     project.Id,
		AlertId:       "123ab456",
		ApplicationId: model.NewApplicationId(string(project.Id), "default", model.ApplicationKindDeployment, "fake-app"),
		Status:        model.WARNING,
		Timestamp:     timeseries.Now(),
		Details: &db.AlertNotificationDetails{
			ProjectName: project.Name,
			RuleName:    "Log errors",
			Severity:    model.WARNING.String(),
			Summary:     "1206 errors occurred",
			Details: []model.AlertDetail{
				{Name: "Sample", Value: "connection refused", Code: true},
			},
		},
	}
}

func testDeploymentNotification(project *db.Project) model.ApplicationDeploymentStatus {
	return model.ApplicationDeploymentStatus{
		Status: model.OK,
		State:  model.ApplicationDeploymentStateSummary,
		Summary: []model.ApplicationDeploymentSummary{
			{Report: model.AuditReportSLO, Ok: false, Message: "Availability: 87% (objective: 99%)"},
			{Report: model.AuditReportCPU, Ok: false, Message: "CPU usage: +21% (+$37/mo) compared to the previous deployment"},
			{Report: model.AuditReportCPU, Ok: true, Message: "Memory: looks like the memory leak has been fixed"},
		},
		Deployment: &model.ApplicationDeployment{
			ApplicationId: model.NewApplicationId(string(project.Id), "default", model.ApplicationKindDeployment, "fake-app"),
			Name:          "123ab456",
			StartedAt:     timeseries.Now().Add(-model.ApplicationDeploymentMinLifetime),
			Details:       &model.ApplicationDeploymentDetails{ContainerImages: []string{"app:v1.8.2"}},
			Notifications: &model.ApplicationDeploymentNotifications{},
		},
	}
}