diff --git a/Dockerfile b/Dockerfile index 4ba18b6..676e6df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,8 +26,10 @@ RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o ma # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details FROM gcr.io/distroless/static:nonroot +ENV APP_ENV=production WORKDIR / COPY --from=builder /workspace/manager . +COPY --from=builder /workspace/internal/webserver/static /static USER 65532:65532 ENTRYPOINT ["/manager"] diff --git a/README.md b/README.md index c3f423c..77dbf1c 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ They are described in the following table: | `--leader-elect` | Enable leader election for controller manager | `false` | | `--metrics-secure` | If set the metrics endpoint is served securely | `false` | | `--enable-http2` | If set, HTTP/2 will be enabled for the metrirs | `false` | +| `--webserver-address` | Webserver listen address.
0 disables the webserver | `0` | ## Examples diff --git a/cmd/main.go b/cmd/main.go index 40835cb..b63e66d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -17,6 +17,7 @@ limitations under the License. package main import ( + "context" "crypto/tls" "flag" "os" @@ -39,6 +40,7 @@ import ( "prosimcorp.com/SearchRuler/internal/controller" "prosimcorp.com/SearchRuler/internal/globals" "prosimcorp.com/SearchRuler/internal/pools" + "prosimcorp.com/SearchRuler/internal/webserver" // +kubebuilder:scaffold:imports ) @@ -71,6 +73,7 @@ func main() { var probeAddr string var secureMetrics bool var enableHTTP2 bool + var webserverAddr string var tlsOpts []func(*tls.Config) flag.StringVar(&metricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. "+ "Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") @@ -82,6 +85,8 @@ func main() { "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") + flag.StringVar(&webserverAddr, "webserver-address", "0", + "The address the webserver will bind to. Use :8443 for HTTPS or :8080 for HTTP or leave as 0 to disable the webserver.") opts := zap.Options{ Development: true, } @@ -157,6 +162,13 @@ func main() { os.Exit(1) } + if webserverAddr != "0" { + // Create webserver for the application + go func() { + webserver.RunWebserver(context.TODO(), webserverAddr, RulesPool) + }() + } + // Create and store raw Kubernetes clients from client-go // They are used by kubebuilder non-related processess and controllers globals.Application.KubeRawClient, globals.Application.KubeRawCoreClient, err = globals.NewKubernetesClient() diff --git a/go.mod b/go.mod index 0139c3a..9f07105 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.22.0 require ( github.com/BurntSushi/toml v1.4.0 github.com/Masterminds/sprig v2.22.0+incompatible + github.com/gofiber/fiber/v2 v2.52.5 + github.com/gofiber/template/html/v2 v2.1.2 github.com/onsi/ginkgo/v2 v2.19.0 github.com/onsi/gomega v1.33.1 github.com/tidwall/gjson v1.18.0 @@ -19,6 +21,7 @@ require ( require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect + github.com/andybalholm/brotli v1.0.5 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -38,6 +41,8 @@ require ( github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.22.4 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/gofiber/template v1.8.3 // indirect + github.com/gofiber/utils v1.1.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -53,7 +58,11 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -64,11 +73,15 @@ require ( github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect go.opentelemetry.io/otel v1.28.0 // indirect diff --git a/go.sum b/go.sum index 7b3b28a..e14007d 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3Q github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4B6AGu/h5Sxe66HYVdqdGu2l9Iebqhi/AEoA= @@ -52,6 +54,14 @@ github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogB github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= +github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc= +github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= +github.com/gofiber/template/html/v2 v2.1.2 h1:wkK/mYJ3nIhongTkG3t0QgV4ADdgOYJYVSAF2AHnh8Y= +github.com/gofiber/template/html/v2 v2.1.2/go.mod h1:E98Z/FzvpaSib06aWEgYk6GXNf3ctoyaJH8yW5ay5ak= +github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= +github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -86,6 +96,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -95,6 +107,13 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= @@ -123,6 +142,8 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +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/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -149,6 +170,12 @@ github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JT github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -200,6 +227,8 @@ golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= diff --git a/internal/controller/queryconnector_sync.go b/internal/controller/queryconnector_sync.go index 30c50bd..67120bd 100644 --- a/internal/controller/queryconnector_sync.go +++ b/internal/controller/queryconnector_sync.go @@ -34,7 +34,7 @@ func (r *QueryConnectorReconciler) Sync(ctx context.Context, eventType watch.Eve // If the eventType is Deleted, remove the credentials from the pool // In other cases get the credentials from the secret and add them to the pool if eventType == watch.Deleted { - credentialsKey := fmt.Sprintf("%s/%s", resource.Namespace, resource.Name) + credentialsKey := fmt.Sprintf("%s_%s", resource.Namespace, resource.Name) r.CredentialsPool.Delete(credentialsKey) return nil } @@ -66,7 +66,7 @@ func (r *QueryConnectorReconciler) Sync(ctx context.Context, eventType watch.Eve } // Save credentials in the credentials pool - key := fmt.Sprintf("%s/%s", resource.Namespace, resource.Name) + key := fmt.Sprintf("%s_%s", resource.Namespace, resource.Name) r.CredentialsPool.Set(key, &pools.Credentials{ Username: username, Password: password, diff --git a/internal/controller/searchrule_status.go b/internal/controller/searchrule_status.go index 7808e7d..064dc0c 100644 --- a/internal/controller/searchrule_status.go +++ b/internal/controller/searchrule_status.go @@ -67,17 +67,6 @@ func (r *SearchRuleReconciler) UpdateConditionAlertFiring(searchRule *v1alpha1.S globals.UpdateCondition(&searchRule.Status.Conditions, condition) } -// UpdateConditionNoCredsFound updates the status of the SearchRule resource with alert resolved condition -func (r *SearchRuleReconciler) UpdateConditionAlertResolved(searchRule *v1alpha1.SearchRule) { - - // Create the new condition with the alert resolved status - condition := globals.NewCondition(globals.ConditionTypeState, metav1.ConditionTrue, - globals.ConditionReasonAlertResolved, globals.ConditionReasonAlertResolvedMessage) - - // Update the status of the SearchRule resource - globals.UpdateCondition(&searchRule.Status.Conditions, condition) -} - // UpdateStateAlertPendingFiring updates the status of the SearchRule resource with alert pending firing condition func (r *SearchRuleReconciler) UpdateStateAlertPendingFiring(searchRule *v1alpha1.SearchRule) { diff --git a/internal/controller/searchrule_sync.go b/internal/controller/searchrule_sync.go index e40f5f5..791164d 100644 --- a/internal/controller/searchrule_sync.go +++ b/internal/controller/searchrule_sync.go @@ -45,10 +45,10 @@ import ( const ( // Rule states - ruleHealthyState = "Healthy" + ruleNormalState = "Normal" ruleFiringState = "Firing" rulePendingFiringState = "PendingFiring" - rulePendingResolvedState = "PendingResolved" + rulePendingResolvedState = "PendingResolving" // Conditions conditionGreaterThan = "greaterThan" @@ -81,7 +81,7 @@ func (r *SearchRuleReconciler) Sync(ctx context.Context, eventType watch.EventTy // If the eventType is Deleted, remove the rule from the rules pool and from the alerts pool // In other cases, execute Sync logic if eventType == watch.Deleted { - key := fmt.Sprintf("%s/%s", resource.Namespace, resource.Name) + key := fmt.Sprintf("%s_%s", resource.Namespace, resource.Name) r.RulesPool.Delete(key) r.AlertsPool.Delete(key) return nil @@ -105,7 +105,7 @@ func (r *SearchRuleReconciler) Sync(ctx context.Context, eventType watch.EventTy // Get credentials for QueryConnector attached if defined if !reflect.ValueOf(QueryConnectorResource.Spec.Credentials).IsZero() { - key := fmt.Sprintf("%s/%s", resource.Namespace, QueryConnectorResource.Name) + key := fmt.Sprintf("%s_%s", resource.Namespace, QueryConnectorResource.Name) queryConnectorCreds, credsExists = r.QueryConnectorCredentialsPool.Get(key) if !credsExists { r.UpdateConditionNoCredsFound(resource) @@ -113,6 +113,13 @@ func (r *SearchRuleReconciler) Sync(ctx context.Context, eventType watch.EventTy } } + // Get `for` duration for the rules firing. When rule is firing during this for time, + // then the rule is really ocurring and must be an alert + forDuration, err := time.ParseDuration(resource.Spec.Condition.For) + if err != nil { + return fmt.Errorf(ForValueParseErrorMessage, err) + } + // Check if query is defined in the resource if resource.Spec.Elasticsearch.Query == nil && resource.Spec.Elasticsearch.QueryJSON == "" { r.UpdateConditionNoQueryFound(resource) @@ -223,32 +230,37 @@ func (r *SearchRuleReconciler) Sync(ctx context.Context, eventType watch.EventTy ) } - // Get ruleKey for the pool / and get rule from the pool if exists + // Get ruleKey for the pool _ and get rule from the pool if exists // If not, create a default skeleton rule and save it to the pool - ruleKey := fmt.Sprintf("%s/%s", resource.Namespace, resource.Name) + ruleKey := fmt.Sprintf("%s_%s", resource.Namespace, resource.Name) rule, ruleInPool := r.RulesPool.Get(ruleKey) if !ruleInPool { // Initialize rule with default values rule = &pools.Rule{ + SearchRule: *resource, FiringTime: time.Time{}, - State: ruleHealthyState, + State: ruleNormalState, ResolvingTime: time.Time{}, + Value: conditionValue.Float(), } r.RulesPool.Set(ruleKey, rule) } - // Get `for` duration for the rules firing. When rule is firing during this for time, - // then the rule is really ocurring and must be an alert - forDuration, err := time.ParseDuration(resource.Spec.Condition.For) - if err != nil { - return fmt.Errorf(ForValueParseErrorMessage, err) + // Check if resource is sync with the pool + if !reflect.DeepEqual(rule.SearchRule, *resource) { + rule.SearchRule = *resource + r.RulesPool.Set(ruleKey, rule) } + // Set the current value of the condition to the rule + rule.Value = conditionValue.Float() + r.RulesPool.Set(ruleKey, rule) + // If rule is firing right now if firing { // If rule is not set as firing in the pool, set start fireTime and state PendingFiring - if rule.State == ruleHealthyState || rule.State == rulePendingResolvedState { + if rule.State == ruleNormalState || rule.State == rulePendingResolvedState { rule.FiringTime = time.Now() rule.State = rulePendingFiringState r.RulesPool.Set(ruleKey, rule) @@ -260,7 +272,7 @@ func (r *SearchRuleReconciler) Sync(ctx context.Context, eventType watch.EventTy r.RulesPool.Set(ruleKey, rule) // Add alert to the pool with the value, the object and the rulerAction name which will trigger the alert - alertKey := fmt.Sprintf("%s/%s", resource.Namespace, resource.Name) + alertKey := fmt.Sprintf("%s_%s", resource.Namespace, resource.Name) r.AlertsPool.Set(alertKey, &pools.Alert{ RulerActionName: resource.Spec.ActionRef.Name, SearchRule: *resource, @@ -297,7 +309,7 @@ func (r *SearchRuleReconciler) Sync(ctx context.Context, eventType watch.EventTy } // If alert is not firing right now and it is not in healthy state - if !firing && rule.State != ruleHealthyState { + if !firing && rule.State != ruleNormalState { // If rule is not marked as resolving in the pool, change state to PendingResolved and set resolvingTime now if rule.State != rulePendingResolvedState { @@ -310,21 +322,23 @@ func (r *SearchRuleReconciler) Sync(ctx context.Context, eventType watch.EventTy if time.Since(rule.ResolvingTime) > forDuration { // Remove alert from the pool - alertKey := fmt.Sprintf("%s/%s", resource.Namespace, resource.Name) + alertKey := fmt.Sprintf("%s_%s", resource.Namespace, resource.Name) r.AlertsPool.Delete(alertKey) // Restore rule to default values rule = &pools.Rule{ FiringTime: time.Time{}, - State: ruleHealthyState, + State: ruleNormalState, ResolvingTime: time.Time{}, + SearchRule: *resource, + Value: conditionValue.Float(), } r.RulesPool.Set(ruleKey, rule) // Log and update the AlertStatus to Resolved - r.UpdateConditionAlertResolved(resource) + r.UpdateStateNormal(resource) logger.Info(fmt.Sprintf( - "Rule %s is in resolved state. Current value is %v", + "Rule %s is in normal state. Current value is %v", resource.Name, conditionValue, )) diff --git a/internal/pools/rules.go b/internal/pools/rules.go index 7deb297..74dd93b 100644 --- a/internal/pools/rules.go +++ b/internal/pools/rules.go @@ -19,13 +19,17 @@ package pools import ( "sync" "time" + + "prosimcorp.com/SearchRuler/api/v1alpha1" ) // Rule type Rule struct { + SearchRule v1alpha1.SearchRule FiringTime time.Time ResolvingTime time.Time State string + Value float64 } // RulesStore diff --git a/internal/webserver/static/public/logo.png b/internal/webserver/static/public/logo.png new file mode 100644 index 0000000..650b469 Binary files /dev/null and b/internal/webserver/static/public/logo.png differ diff --git a/internal/webserver/static/public/styles.css b/internal/webserver/static/public/styles.css new file mode 100644 index 0000000..be7e9a2 --- /dev/null +++ b/internal/webserver/static/public/styles.css @@ -0,0 +1,115 @@ +body { + font-family: 'Roboto', Arial, sans-serif; + margin: 0; + padding: 0; + background: linear-gradient(135deg, #e3f2fd, #f9f9f9); + color: #333; +} +header { + background: linear-gradient(90deg, #1e3c72, #2a5298); + color: white; + padding: 15px 20px; + display: flex; + align-items: center; + justify-content: flex-start; + box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1); +} +header img { + max-height: 40px; + margin-right: 15px; + cursor: pointer; +} +header h1 { + font-family: 'Poppins', Arial, sans-serif; + font-size: 22px; + font-weight: 600; + margin: 0; +} +.container { + max-width: 900px; + margin: 20px auto; + background-color: white; + box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.1); + border-radius: 8px; + padding: 20px; +} +table { + width: 95%; + margin: 30px auto; + border-collapse: collapse; + box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.1); + background-color: white; + overflow: hidden; + border-radius: 8px; +} +th, td { + text-align: left; + padding: 12px; +} +th { + background: linear-gradient(90deg, #1e3c72, #2a5298); + color: white; + font-size: 16px; +} +tr:nth-child(even) { + background-color: #f9f9f9; +} +tr:nth-child(odd) { + background-color: #ffffff; +} +tr:hover { + background-color: #e3f2fd; +} +.bold { + font-weight: bold; +} +.pending { + color: #ff9800; + font-weight: bold; +} +.firing { + color: #f44336; + font-weight: bold; +} +.normal { + color: #4caf50; + font-weight: bold; +} +.manifest { + background-color: #f8f8f8; + padding: 10px; + border-radius: 5px; + white-space: pre-wrap; + overflow-x: auto; + font-family: "Courier New", monospace; +} +a.back { + display: inline-block; + margin-top: 20px; + color: #1e3c72; + text-decoration: none; + font-weight: bold; +} +a.back:hover { + color: #2a5298; +} +.btn-details { + display: inline-block; + padding: 5px 10px; + color: white; + background-color: #007bff; + border-radius: 4px; + text-decoration: none; + font-size: 14px; +} +.btn-details:hover { + background-color: #0056b3; +} +@media (max-width: 768px) { + table { + width: 100%; + } + th, td { + font-size: 14px; + } +} \ No newline at end of file diff --git a/internal/webserver/static/templates/rule_detail.html b/internal/webserver/static/templates/rule_detail.html new file mode 100644 index 0000000..84031f1 --- /dev/null +++ b/internal/webserver/static/templates/rule_detail.html @@ -0,0 +1,90 @@ + + + + + + Rule Status + + + + + +
+ Logo +

Rule Detail

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldValue
Rule{{ .Key }}
Name{{ .Rule.SearchRule.Name }}
Namespace{{ .Rule.SearchRule.Namespace }}
State + {{if or (eq .Rule.State "PendingFiring") (eq .Rule.State "PendingResolving")}}{{ .Rule.State }}{{end}} + {{if eq .Rule.State "Firing"}}{{ .Rule.State }}{{end}} + {{if eq .Rule.State "Normal"}}{{ .Rule.State }}{{end}} +
FiringTime{{if eq .Rule.FiringTime.String "0001-01-01 00:00:00 +0000 UTC"}}-{{else}}{{ .Rule.FiringTime.Format "2006-01-02 15:04:05"}}{{end}}
ResolvingTime{{if eq .Rule.ResolvingTime.String "0001-01-01 00:00:00 +0000 UTC"}}-{{else}}{{ .Rule.ResolvingTime.Format "2006-01-02 15:04:05"}}{{end}}
ConditionField{{ .Rule.SearchRule.Spec.Elasticsearch.ConditionField }}
Current value{{ .Rule.Value }}
Description{{ .Rule.SearchRule.Spec.Description }}
QueryConnector{{ .Rule.SearchRule.Spec.QueryConnectorRef.Name }}
Index{{ .Rule.SearchRule.Spec.Elasticsearch.Index }}
CheckInterval{{ .Rule.SearchRule.Spec.CheckInterval }}
+

Query:

+
+
{{ printf "%s" .Rule.SearchRule.Spec.Elasticsearch.Query.Raw }}
+
+

Condition:

+
+
{{ printf "%s" .Condition }}
+
+

ActionRef:

+
+
{{ printf "%s" .ActionRef }}
+
+ ← Return to global rules +
+ + diff --git a/internal/webserver/static/templates/rules.html b/internal/webserver/static/templates/rules.html new file mode 100644 index 0000000..1b8745a --- /dev/null +++ b/internal/webserver/static/templates/rules.html @@ -0,0 +1,57 @@ + + + + + + Rules + + + + + +
+ Logo +

Rules Dashboard

+
+ + + + + + + + + + + + + {{range $key, $value := .Rules}} + + + + + + + + + {{end}} + +
RulenameFiringTimeResolvingTimeDescriptionCurrent ValueState
{{ $key }} + {{if eq $value.FiringTime.String "0001-01-01 00:00:00 +0000 UTC"}} + - + {{else}} + {{ $value.FiringTime.Format "2006-01-02 15:04:05"}} + {{end}} + + {{if eq $value.ResolvingTime.String "0001-01-01 00:00:00 +0000 UTC"}} + - + {{else}} + {{ $value.ResolvingTime.Format "2006-01-02 15:04:05"}} + {{end}} + {{ $value.SearchRule.Spec.Description }}{{ $value.Value }} + {{if or (eq $value.State "PendingFiring") (eq $value.State "PendingResolving")}}{{ $value.State }}{{end}} + {{if eq $value.State "Firing"}}{{ $value.State }}{{end}} + {{if eq $value.State "Normal"}}{{ $value.State }}{{end}} +
+ + diff --git a/internal/webserver/webserver.go b/internal/webserver/webserver.go new file mode 100644 index 0000000..354cb58 --- /dev/null +++ b/internal/webserver/webserver.go @@ -0,0 +1,149 @@ +package webserver + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + + "prosimcorp.com/SearchRuler/internal/pools" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/yaml" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/template/html/v2" +) + +// states is a map of the states of the rules and their respective status used for +// the rules API endpoint +var ( + states = map[string]string{ + "PendingFiring": "pending", + "PendingResolving": "pending", + "Firing": "firing", + "Normal": "resolved", + } +) + +// RunWebserver starts a webserver that serves the rule pages +func RunWebserver(ctx context.Context, webserverAddr string, rulesPool *pools.RulesStore) error { + logger := log.FromContext(ctx) + + logger.Info(fmt.Sprintf("Starting webserver in %s", webserverAddr)) + + // Get the path of templates folder with the HTML files + _, b, _, _ := runtime.Caller(0) + basePath := filepath.Dir(b) + + // If the environment is production, use the executable path as basePath + if os.Getenv("APP_ENV") == "production" { + // For production environments + execPath, err := os.Executable() + if err != nil { + return fmt.Errorf("error getting the path of the executable: %w", err) + } + basePath = filepath.Dir(execPath) + } + + // Usar la ruta del ejecutable como basePath + templatePath := filepath.Join(basePath, "static/templates") + publicPath := filepath.Join(basePath, "static/public") + + // Create a new Fiber app with the HTML template engine + engine := html.New(templatePath, ".html") + app := fiber.New(fiber.Config{ + Views: engine, + }) + + // Define the routes + // "/" redirets to "/rules" + app.Get("/", func(c *fiber.Ctx) error { + return c.Redirect("/rules") + }) + app.Get("/rules", getRules(rulesPool)) + app.Get("/api/rules", getRulesJSON(rulesPool)) + app.Get("/rules/:key", getRule(rulesPool)) + app.Static("/static", publicPath) + + // Start the webserver + if err := app.Listen(webserverAddr); err != nil { + return err + } + + return nil +} + +// getRule returns a handler function that renders the rule detail page +func getRule(rulesPool *pools.RulesStore) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + key := c.Params("key") + + // Get the rule from the pool + rule, exists := rulesPool.Get(key) + if !exists { + return c.Status(fiber.StatusNotFound).SendString("Rule not found") + } + + // Parse the YAML fields + actionRef, err := yaml.Marshal(rule.SearchRule.Spec.ActionRef) + if err != nil { + actionRef = []byte("Error serializing ActionRef") + } + condition, err := yaml.Marshal(rule.SearchRule.Spec.Condition) + if err != nil { + condition = []byte("Error serializing Condition") + } + + // Render the rule detail page + return c.Render("rule_detail", fiber.Map{ + "Key": key, + "Rule": rule, + "Condition": condition, + "ActionRef": actionRef, + }) + } +} + +// getRules returns a handler function that renders the rules page +func getRules(rulesPool *pools.RulesStore) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + return c.Render("rules", fiber.Map{ + "Rules": rulesPool.Store, + }) + } +} + +// getRulesJSON returns a handler function that returns the rules in JSON format +func getRulesJSON(rulesPool *pools.RulesStore) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + + alerts := []map[string]interface{}{} + + for key, value := range rulesPool.Store { + alert := map[string]interface{}{ + "labels": map[string]string{ + "alertname": key, + "namespace": value.SearchRule.Namespace, + }, + "annotations": map[string]string{ + "description": value.SearchRule.Spec.Description, + "summary": value.SearchRule.Spec.Description, + }, + "state": states[value.State], + "activeAt": func() string { + if value.FiringTime.IsZero() { + return "" + } + return value.FiringTime.String() + }(), + } + + alerts = append(alerts, alert) + } + + return c.JSON(map[string]interface{}{ + "alerts": alerts, + }) + } +}