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://" if f.BasicAuth != nil { f.BasicAuth.User = "" f.BasicAuth.Password = "" } for i := range f.CustomHeaders { f.CustomHeaders[i].Value = "" } if f.RemoteWriteUrl != "" { f.RemoteWriteUrl = "" } } } 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 = "" f.Auth.User = "" f.Auth.Password = "" } } 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 = "" f.SecretAccessKey = "" } } 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 = "" } } 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 = "" } } } 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 = "" } } 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 = "" } } 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 = "" } } 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{}, }, } }