api/forms/forms.go
Rama main · réplica de lectura del repositorio original.
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{},
},
}
}