commit 17476be23cadcbece770272fcaf447be149fad70 Author: dnutiu Date: Thu Nov 3 22:04:14 2022 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24d2452 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# IDE +.idea + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test +hotalert + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7a1f90f --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +test: + go test ./... diff --git a/alert/alert.go b/alert/alert.go new file mode 100644 index 0000000..1cc9fac --- /dev/null +++ b/alert/alert.go @@ -0,0 +1,9 @@ +package alert + +import "context" + +// Alerter is an interface for implementing alerts on various channels +type Alerter interface { + // PostAlert posts the alert with the given message. + PostAlert(ctx context.Context, matchedKeywords []string) +} diff --git a/alert/discord.go b/alert/discord.go new file mode 100644 index 0000000..09447b3 --- /dev/null +++ b/alert/discord.go @@ -0,0 +1,83 @@ +package alert + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "hotalert/logging" + "net/http" + "strings" +) + +// DiscordWebhookAlerter is a struct that implements alerting on Discord via webhooks. +type DiscordWebhookAlerter struct { + // webhook is the Discord webhook URL. + webhook string + // messageTemplate is the message that is going to be posted when the alert conditions match. + messageTemplate string + // HttpClient is the http client used when executing requests. + HttpClient *http.Client +} + +// DiscordWebhookAlerterOptions are the options for the DiscordWebhookAlerter +type DiscordWebhookAlerterOptions struct { + // Webhook is the discord webhook. + Webhook string `mapstructure:"webhook"` + // MessageTemplate is the message template that is going to be posted. + MessageTemplate string `mapstructure:"message"` +} + +// Validate validates the DiscordWebhookAlerterOptions, returns an error on invalid options. +func (o *DiscordWebhookAlerterOptions) Validate() error { + if o.Webhook == "" || o.MessageTemplate == "" { + return errors.New("invalid configuration for webhook_discord") + } + if !strings.Contains(o.Webhook, "http://") && !strings.Contains(o.Webhook, "https://") { + return errors.New(fmt.Sprintf("invalid webhook schema for %s", o.Webhook)) + } + return nil +} + +// NewDiscordWebhookAlerter returns a new DiscordWebhookAlerter instance. +func NewDiscordWebhookAlerter(options DiscordWebhookAlerterOptions) (*DiscordWebhookAlerter, error) { + if err := options.Validate(); err != nil { + return nil, err + } + + return &DiscordWebhookAlerter{ + webhook: options.Webhook, + messageTemplate: options.MessageTemplate, + HttpClient: http.DefaultClient, + }, nil +} + +// PostAlert posts the alert on Discord via webhooks. +func (d *DiscordWebhookAlerter) PostAlert(ctx context.Context, matchedKeywords []string) { + alertMessage := strings.Replace(d.messageTemplate, "$keywords", strings.Join(matchedKeywords, ","), -1) + var postBody = map[string]interface{}{ + "content": alertMessage, + "embeds": nil, + "attachments": nil, + } + + postBodyBytes, err := json.Marshal(postBody) + if err != nil { + logging.SugaredLogger.Errorf("Failed to marshall postBody: %v", err) + return + } + + postRequest, err := http.NewRequest("POST", d.webhook, bytes.NewBuffer(postBodyBytes)) + if err != nil { + logging.SugaredLogger.Errorf("Failed to create alert request.") + return + } + postRequest.Header["Content-Type"] = []string{"application/json"} + _, err = d.HttpClient.Do(postRequest.WithContext(ctx)) + if err != nil { + logging.SugaredLogger.Errorf("Failed to post alert to discord!") + return + } + logging.SugaredLogger.Infof("Alert posted:\nBEGIN\n%s\nEND", alertMessage) +} diff --git a/alert/discord_test.go b/alert/discord_test.go new file mode 100644 index 0000000..077e135 --- /dev/null +++ b/alert/discord_test.go @@ -0,0 +1,76 @@ +package alert + +import ( + "context" + "fmt" + "github.com/stretchr/testify/assert" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func Test_DiscordWebhookAlerterOptions_Validate(t *testing.T) { + var tests = []struct { + Webhook string + MessageTemplate string + IsValid bool + }{ + { + "", + "", + false, + }, + { + "", + "asdasd", + false, + }, + { + "asdasd", + "asdasd", + false, + }, + { + "http://example.com", + "The template", + true, + }, + { + "https://example.com", + "The template", + true, + }, + } + + for ti, tv := range tests { + t.Run(fmt.Sprintf("test_%d", ti), func(t *testing.T) { + opts := DiscordWebhookAlerterOptions{Webhook: tv.Webhook, MessageTemplate: tv.MessageTemplate} + err := opts.Validate() + if tv.IsValid { + assert.Nil(t, err) + } else { + assert.Error(t, err) + } + }) + } +} + +func Test_DiscordWebhookAlerter_PostAlert(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestBody, _ := io.ReadAll(r.Body) + assert.Equal(t, "{\"attachments\":null,\"content\":\"test matched,second\",\"embeds\":null}", string(requestBody)) + assert.Equal(t, "application/json", r.Header["Content-Type"][0]) + })) + defer ts.Close() + + client := ts.Client() + alerter, err := NewDiscordWebhookAlerter(DiscordWebhookAlerterOptions{ + Webhook: ts.URL, + MessageTemplate: "test $keywords", + }) + assert.NoError(t, err) + alerter.HttpClient = client + + alerter.PostAlert(context.Background(), []string{"matched", "second"}) +} diff --git a/alert/dummy.go b/alert/dummy.go new file mode 100644 index 0000000..5be85e1 --- /dev/null +++ b/alert/dummy.go @@ -0,0 +1,19 @@ +package alert + +import ( + "context" + "hotalert/logging" +) + +// DummyAlerter is an Alerter that does nothing. It is used when no actual alerter is available. +type DummyAlerter struct { +} + +// NewDummyAlerter returns a new instance of DummyAlerter. +func NewDummyAlerter() *DummyAlerter { + return &DummyAlerter{} +} + +func (d DummyAlerter) PostAlert(ctx context.Context, matchedKeywords []string) { + logging.SugaredLogger.Infof("DummyAlert: %v - %v", ctx, matchedKeywords) +} diff --git a/alert/dummy_test.go b/alert/dummy_test.go new file mode 100644 index 0000000..17987c8 --- /dev/null +++ b/alert/dummy_test.go @@ -0,0 +1,10 @@ +package alert + +import ( + "context" + "testing" +) + +func TestDummyAlerter_PostAlert(t *testing.T) { + NewDummyAlerter().PostAlert(context.TODO(), []string{"demo"}) +} diff --git a/alert/factory.go b/alert/factory.go new file mode 100644 index 0000000..dc41838 --- /dev/null +++ b/alert/factory.go @@ -0,0 +1,17 @@ +package alert + +import ( + "errors" + "fmt" +) + +// NewAlerter builds Alerted function given the alerter name and options. +func NewAlerter(name string, options map[string]interface{}) (Alerter, error) { + if name == "webhook_discord" { + return NewDiscordWebhookAlerter(DiscordWebhookAlerterOptions{ + Webhook: options["webhook"].(string), + MessageTemplate: options["message"].(string), + }) + } + return nil, errors.New(fmt.Sprintf("invalid alerter name %s", name)) +} diff --git a/alert/factory_test.go b/alert/factory_test.go new file mode 100644 index 0000000..681c574 --- /dev/null +++ b/alert/factory_test.go @@ -0,0 +1,48 @@ +package alert + +import "testing" +import "github.com/stretchr/testify/assert" + +func TestNewAlerter(t *testing.T) { + var tests = []struct { + TestName string + AlerterName string + AlerterOptions map[string]interface{} + ExpectedType interface{} + ShouldError bool + }{ + { + TestName: "Webhook Discord", + AlerterName: "webhook_discord", + AlerterOptions: map[string]interface{}{ + "webhook": "https://webhook.test", + "message": "The Message is fine.", + }, + ExpectedType: &DiscordWebhookAlerter{}, + ShouldError: false, + }, + { + TestName: "Webhook Discord Error", + AlerterName: "webhook_discord", + AlerterOptions: map[string]interface{}{ + "webhook": "", + "message": "The Message is fine.", + }, + ExpectedType: &DiscordWebhookAlerter{}, + ShouldError: true, + }, + } + + for _, tv := range tests { + t.Run(tv.TestName, func(t *testing.T) { + alerter, err := NewAlerter(tv.AlerterName, tv.AlerterOptions) + if !tv.ShouldError { + assert.Nil(t, err) + assert.IsType(t, tv.ExpectedType, alerter) + } else { + assert.NotNil(t, err) + assert.Nil(t, alerter) + } + }) + } +} diff --git a/cmd/directory.go b/cmd/directory.go new file mode 100644 index 0000000..2aa8b8e --- /dev/null +++ b/cmd/directory.go @@ -0,0 +1,14 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "hotalert/logging" +) + +var directoryCmd = &cobra.Command{ + Use: "directory", + Short: "execute each yaml file from a directory", + Run: func(cmd *cobra.Command, args []string) { + logging.SugaredLogger.Fatal("not implemented") + }, +} diff --git a/cmd/file.go b/cmd/file.go new file mode 100644 index 0000000..692942a --- /dev/null +++ b/cmd/file.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "hotalert/logging" + "hotalert/task" + "hotalert/task/target" + "hotalert/workload" + "os" + "sync" +) + +// The file command executes a tasks from a single file only. +var fileCmd = &cobra.Command{ + Use: "file", + Short: "execute tasks from a single file", + Run: func(cmd *cobra.Command, args []string) { + var isRunning = true + var fileName = args[0] + data, err := os.ReadFile(fileName) + if err != nil { + logging.SugaredLogger.Fatalf("Failed to read file %s exiting!", fileName) + return + } + workload, err := workload.FromYamlContent(data) + if err != nil { + logging.SugaredLogger.Fatalf("Failed to parse file %s exiting!", fileName) + return + } + + var waitGroup = sync.WaitGroup{} + var defaultExecutor = task.NewDefaultExecutor(target.ScrapeWebTask) + taskResultChan := defaultExecutor.Start() + defer defaultExecutor.Shutdown() + + // Function that logs task results and marks the task executed in the waitGroup + go func() { + for isRunning { + select { + case result := <-taskResultChan: + waitGroup.Done() + if result.Error() != nil { + logging.SugaredLogger.Errorf("Failed to execute task %v got: %s", result.InitialTask, result.Error()) + } + } + } + }() + + // Add tasks + waitGroup.Add(workload.GetTasksLen()) + for _, task := range workload.GetTasks() { + defaultExecutor.AddTask(task) + } + + // Wait for tasks to be executed + waitGroup.Wait() + + // Turn off logging goroutine + isRunning = false + + logging.SugaredLogger.Infof("Done") + }, +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..2a4fddc --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,16 @@ +package cmd + +import "github.com/spf13/cobra" + +var RootCmd = &cobra.Command{ + Use: "hotalert", + Args: cobra.ExactArgs(1), + Short: "Hotalert is a command line tool that for task execution and configuration.", + Long: `Hotalert is a command line tool that for task execution and configuration. Tasks and alerts are defined +in yaml files and the program parses the files, executes the tasks and emits alerts when the tasks conditions are met. `, +} + +func init() { + RootCmd.AddCommand(fileCmd) + RootCmd.AddCommand(directoryCmd) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d21e5e0 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module hotalert + +go 1.18 + +require ( + github.com/spf13/cobra v1.6.0 + github.com/stretchr/testify v1.8.0 + go.uber.org/zap v1.23.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.0.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + go.uber.org/atomic v1.7.0 // indirect + go.uber.org/multierr v1.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b9a9ef2 --- /dev/null +++ b/go.sum @@ -0,0 +1,33 @@ +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= +github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.6.0 h1:42a0n6jwCot1pUmomAp4T7DeMD+20LFv4Q54pxLf2LI= +github.com/spf13/cobra v1.6.0/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI= +go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= +go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= +go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY= +go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/logging/logging.go b/logging/logging.go new file mode 100644 index 0000000..ccc0759 --- /dev/null +++ b/logging/logging.go @@ -0,0 +1,140 @@ +package logging + +import ( + "fmt" + "strings" +) + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type LogOption struct { + name string + value string +} + +// SugaredLogger is the Zap SugaredLogger for use withing the harvester. +var SugaredLogger *zap.SugaredLogger + +func getZapLevel(level string) zapcore.Level { + loweredLevel := strings.ToLower(level) + if loweredLevel == "info" { + return zapcore.InfoLevel + } + if loweredLevel == "warn" || loweredLevel == "warning" { + return zapcore.WarnLevel + } + if loweredLevel == "error" { + return zapcore.ErrorLevel + } + if loweredLevel == "fatal" { + return zapcore.FatalLevel + } + if loweredLevel == "debug" { + return zapcore.DebugLevel + } + + panic(fmt.Sprintf("Invalid logging level %s.", level)) +} + +// LogFilePath returns a LogFilePath LogOption with the given path value. +//goland:noinspection GoUnusedExportedFunction +func LogFilePath(pathValue string) LogOption { + return LogOption{ + name: "LogFilePath", + value: pathValue, + } +} + +// GetLogOption retrieves a LogOption by name from a log options slice. Returns the option if found or null otherwise. +func GetLogOption(logOptions []LogOption, optionName string) *LogOption { + for _, v := range logOptions { + if v.name == optionName { + return &v + } + } + return nil +} + +// InitLoggingWithParams initialises SugaredLogger with params. +func InitLoggingWithParams(logLevel string, logType string, logFilePath ...LogOption) { + outputPaths := make([]string, 0, 2) + errOutputPaths := make([]string, 0, 2) + + // Build console or file logger based on type. + for _, logType := range strings.Split(logType, ",") { + if logType == "console" { + outputPaths = append(outputPaths, "stdout") + errOutputPaths = append(errOutputPaths, "stderr") + } else if logType == "file" { + logPath := "" + logPathOption := GetLogOption(logFilePath, "LogFilePath") + if logPathOption != nil { + logPath = logPathOption.name + } + + outputPaths = append(outputPaths, logPath) + errOutputPaths = append(errOutputPaths, logPath) + } + } + + zapProduction := zap.Config{ + Level: zap.NewAtomicLevelAt(getZapLevel(logLevel)), + Development: false, + Encoding: "console", + EncoderConfig: zapcore.EncoderConfig{ + // Keys can be anything except the empty string. + TimeKey: "time", + LevelKey: "level", + NameKey: "name", + CallerKey: "caller", + MessageKey: "message", + StacktraceKey: "stacktrace", + LineEnding: zapcore.DefaultLineEnding, + EncodeLevel: zapcore.CapitalLevelEncoder, + EncodeTime: zapcore.ISO8601TimeEncoder, + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + }, + OutputPaths: outputPaths, + ErrorOutputPaths: errOutputPaths, + } + UpdateLogger(zapProduction) +} + +// UpdateLogger updates the logger's configuration. +func UpdateLogger(cfg zap.Config) { + logger, _ := cfg.Build() + SugaredLogger = logger.Sugar() +} + +// LeveledSugaredLogger is an adapter for adapting the SugaredLogger to LeveledLogger interface. +type LeveledSugaredLogger struct { +} + +// Error is a wrapper over SugaredLogger's Errorf +func (a *LeveledSugaredLogger) Error(msg string, keysAndValues ...interface{}) { + SugaredLogger.Errorf(msg, keysAndValues) +} + +// Info is a wrapper over SugaredLogger's Infof +func (a *LeveledSugaredLogger) Info(msg string, keysAndValues ...interface{}) { + SugaredLogger.Infof(msg, keysAndValues) +} + +// Debug is a wrapper over SugaredLogger's Debugf +func (a *LeveledSugaredLogger) Debug(msg string, keysAndValues ...interface{}) { + SugaredLogger.Debugf(msg, keysAndValues) +} + +// Warn is a wrapper over SugaredLogger's Warnf +func (a *LeveledSugaredLogger) Warn(msg string, keysAndValues ...interface{}) { + SugaredLogger.Warnf(msg, keysAndValues) +} + +// init initialises logging with default values. +func init() { + InitLoggingWithParams("info", "console") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..e982ef4 --- /dev/null +++ b/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "hotalert/cmd" + "hotalert/logging" +) + +func main() { + logging.InitLoggingWithParams("info", "console") + err := cmd.RootCmd.Execute() + if err != nil { + logging.SugaredLogger.Fatal(err) + } +} diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..92fd56a --- /dev/null +++ b/readme.md @@ -0,0 +1,42 @@ +# Hot Alert + +## Introduction +Hotalert is a command line tool that for task execution and configuration. Tasks and alerts are defined +in yaml files and the program parses the files, executes the tasks and emits alerts when the tasks conditions are met. + +For example if you want to send a notification to your mobile phone when a keyword is found on a website you can use the +`webhook_discord` alerter and the `scrape_web` task. + +Example: + +```yaml +tasks: + - options: + url: [...] + keywords: ["Episode 10", "Episode 11"] + timeout: 10 + alerter: "webhook_discord" + task: "scrape_web" +alerts: + webhook_discord: + webhook: https://discord.com/api/webhooks/[...] + # $keywords can be used as a placeholder in the message, and it will be replaced with the actual keywords. + message: "Hi, the keyword(s) `$keywords` was found on [...]" +``` + +### Development + +To build the program for Linux under Linux use the following command: + +```bash +GOOS=linux GOARCH=arm64 go build +``` + +If you're using Windows use: +```bash +$env:GOOS = "linux" +$env:GOARCH = "arm" +$env:GOARM = 5 + +go build -o hotalert . +``` \ No newline at end of file diff --git a/task/executor.go b/task/executor.go new file mode 100644 index 0000000..097fabc --- /dev/null +++ b/task/executor.go @@ -0,0 +1,118 @@ +package task + +import ( + "errors" + "fmt" + "sync" +) + +// Executor is an interface for implementing task executors. +type Executor interface { + // AddTask adds a task to the task executor. + AddTask(task *Task) + // Start starts the scrapper and returns a Result receive-only channel. + Start() <-chan *Result + // Shutdown shuts down the scrapper. It will block until the Executor was shut down. + Shutdown() +} + +// ExecutionFn is a type definition for a function that executes the task and returns an error. +type ExecutionFn func(task *Task) error + +// DefaultExecutor is a TaskExecutor with the default implementation. +// The tasks are executed directly on the machine. +type DefaultExecutor struct { + // TaskExecutionFn is the function that executes the task. + TaskExecutionFn ExecutionFn + // workerGroup is a waiting group for worker goroutines. + workerGroup *sync.WaitGroup + // numberOfWorkerGoroutines is the number of working goroutines. + numberOfWorkerGoroutines int + // taskResultChan is a receive only channel for task results. + taskResultChan chan *Result + // taskChan is a channel for tasks + taskChan chan *Task + // quinChan is a channel for sending the quit command to worker goroutines. + quinChan chan int +} + +// TODO: Add support for per task execution functions + +// NewDefaultExecutor returns a new instance of DefaultExecutor. +func NewDefaultExecutor(fn ExecutionFn) *DefaultExecutor { + ws := &DefaultExecutor{ + TaskExecutionFn: fn, + workerGroup: &sync.WaitGroup{}, + taskResultChan: make(chan *Result, 50), + taskChan: make(chan *Task, 50), + numberOfWorkerGoroutines: 5, + } + ws.quinChan = make(chan int, ws.numberOfWorkerGoroutines) + return ws +} + +// AddTask adds a task to the DefaultExecutor queue. +func (ws *DefaultExecutor) AddTask(task *Task) { + ws.taskChan <- task +} + +// executeTask executes the given task using TaskExecutionFn +func (ws *DefaultExecutor) executeTask(task *Task) error { + var taskErr error = nil + // Execute task and set panics as errors in taskResult. + func() { + defer func() { + if r := recover(); r != nil { + taskErr = errors.New(fmt.Sprintf("panic: %s", r)) + } + }() + err := ws.TaskExecutionFn(task) + if err != nil { + taskErr = err + } + }() + return taskErr +} + +// workerGoroutine waits for tasks and executes them. +// After the task is executed it forwards the result, including errors and panics to the task Result channel. +func (ws *DefaultExecutor) workerGoroutine() { + defer ws.workerGroup.Done() + for { + select { + case task := <-ws.taskChan: + var taskResult = NewResult(task) + taskResult.error = ws.executeTask(task) + + // Forward TaskResult to channel. + ws.taskResultChan <- taskResult + case <-ws.quinChan: + // Quit + return + } + } +} + +// Start starts the DefaultExecutor. +// Start returns a receive only channel with task Result. +func (ws *DefaultExecutor) Start() <-chan *Result { + // Start worker goroutines. + for i := 0; i < ws.numberOfWorkerGoroutines; i++ { + ws.workerGroup.Add(1) + go ws.workerGoroutine() + } + return ws.taskResultChan +} + +// Shutdown shuts down the DefaultExecutor. +// Shutdown blocks till the DefaultExecutor has shutdown. +func (ws *DefaultExecutor) Shutdown() { + // Shutdown all worker goroutines + for i := 0; i < ws.numberOfWorkerGoroutines; i++ { + ws.quinChan <- 1 + } + ws.workerGroup.Wait() + close(ws.taskChan) + close(ws.taskResultChan) + close(ws.quinChan) +} diff --git a/task/executor_test.go b/task/executor_test.go new file mode 100644 index 0000000..a35311d --- /dev/null +++ b/task/executor_test.go @@ -0,0 +1,50 @@ +package task + +import ( + "errors" + "github.com/stretchr/testify/assert" + "hotalert/alert" + "testing" +) + +func Test_DefaultExecutor(t *testing.T) { + // Setup + var taskCounter = 0 + defaultExecutor := NewDefaultExecutor(func(task *Task) error { + // First task is successful, others return error. + if taskCounter > 0 { + return errors.New("test") + } + taskCounter += 1 + return nil + }) + taskResultsChan := defaultExecutor.Start() + + // Test + var task1 = &Task{ + Timeout: 0, + Alerter: alert.NewDummyAlerter(), + Callback: nil, + } + var task2 = &Task{ + Timeout: 0, + Alerter: alert.NewDummyAlerter(), + Callback: nil, + } + + defaultExecutor.AddTask(task1) + defaultExecutor.AddTask(task2) + + // Assert results + assert.Equal(t, &Result{ + InitialTask: task1, + error: nil, + }, <-taskResultsChan) + assert.Equal(t, &Result{ + InitialTask: task2, + error: errors.New("test"), + }, <-taskResultsChan) + + // Clean-up + defaultExecutor.Shutdown() +} diff --git a/task/target/scrape.go b/task/target/scrape.go new file mode 100644 index 0000000..e862a69 --- /dev/null +++ b/task/target/scrape.go @@ -0,0 +1,81 @@ +package target + +import ( + "context" + "errors" + "fmt" + "hotalert/logging" + "hotalert/task" + "io/ioutil" + "net/http" + "strings" +) + +// ScrapeWebTask scraps the web page given the task. +func ScrapeWebTask(task *task.Task) error { + // Parse options + targetUrl, ok := task.Options["url"].(string) + if !ok { + logging.SugaredLogger.Errorf("Invalid task parameter url %v", targetUrl) + return errors.New(fmt.Sprintf("Invalid task parameter url %v", targetUrl)) + } + keywords, ok := task.Options["keywords"] + if !ok { + logging.SugaredLogger.Errorf("Invalid task parameter keywords %v", keywords) + return errors.New(fmt.Sprintf("Invalid parameter keywords %v", keywords)) + } + + // Create a context with timeout specific to task. + ctx, cancel := context.WithTimeout(context.Background(), task.Timeout) + defer cancel() + + // Create a request with timeout. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetUrl, nil) + if err != nil { + logging.SugaredLogger.Errorf("failed to build http request: %s", err) + return err + } + + // Execute request + resp, err := http.DefaultClient.Do(req) + if err != nil { + logging.SugaredLogger.Errorf("Failed to scrap page: %s", err) + return err + } else { + if resp.StatusCode == 200 { + pageBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + logging.SugaredLogger.Errorf("Failed to read response from page. %s", err) + return err + } + pageBodyStr := string(pageBody) + + // Search for matched keywords and save them. + var matchedKeywords = make([]string, 0, 10) + keywordsLst, ok := keywords.([]any) + if !ok { + logging.SugaredLogger.Errorf("Invalid task parameter keywords %v", keywords) + return errors.New(fmt.Sprintf("Invalid parameter keywords %v", keywords)) + } + for _, value := range keywordsLst { + valueStr, ok := value.(string) + if !ok { + logging.SugaredLogger.Errorf("Invalid value in task keywords, not a string %v", valueStr) + return errors.New(fmt.Sprintf("Invalid value in task keywords, not a string %v", valueStr)) + } + if strings.Contains(pageBodyStr, valueStr) { + matchedKeywords = append(matchedKeywords, valueStr) + } + } + + // If we have matched keywords post an alert. + if len(matchedKeywords) > 0 { + task.Alerter.PostAlert(context.Background(), matchedKeywords) + } + } else { + logging.SugaredLogger.Errorf("Failed to query website, status code %d", resp.StatusCode) + return errors.New(fmt.Sprintf("Failed to query website, status code %d", resp.StatusCode)) + } + } + return nil +} diff --git a/task/target/scrape_test.go b/task/target/scrape_test.go new file mode 100644 index 0000000..0d15b71 --- /dev/null +++ b/task/target/scrape_test.go @@ -0,0 +1,124 @@ +package target + +import ( + "github.com/stretchr/testify/assert" + "hotalert/alert" + "hotalert/task" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestScrapeWebTask(t *testing.T) { + var tests = []struct { + TestName string + Task task.Task + StartServer bool + ServerFunc http.HandlerFunc + ExpectedError bool + }{ + { + "ServerNotOnline", + task.Task{ + Options: task.Options{ + "keywords": []string{"keyword"}, + }, + Timeout: 10 * time.Second, + Alerter: alert.NewDummyAlerter(), + Callback: nil, + }, + false, + func(writer http.ResponseWriter, request *http.Request) { + _, _ = writer.Write([]byte("ok")) + }, + true, + }, + { + "TestOk", + task.Task{ + Options: task.Options{ + "keywords": []string{"keyword"}, + }, + Timeout: 10 * time.Second, + Alerter: alert.NewDummyAlerter(), + Callback: nil, + }, + true, + func(writer http.ResponseWriter, request *http.Request) { + _, _ = writer.Write([]byte("ok")) + }, + false, + }, + { + "TestInvalidKeywords", + task.Task{ + Options: task.Options{ + "keywords": nil, + }, + Timeout: 10 * time.Second, + Alerter: alert.NewDummyAlerter(), + Callback: nil, + }, + true, + func(writer http.ResponseWriter, request *http.Request) { + _, _ = writer.Write([]byte("ok")) + }, + true, + }, + { + "TestBadResponse", + task.Task{ + Options: task.Options{ + "keywords": []string{"keyword"}, + }, + Timeout: 10 * time.Second, + Alerter: alert.NewDummyAlerter(), + Callback: nil, + }, + true, + func(writer http.ResponseWriter, request *http.Request) { + writer.WriteHeader(500) + _, _ = writer.Write([]byte("nok")) + }, + true, + }, + { + "TestTimeout", + task.Task{ + Options: task.Options{ + "keywords": []string{"keyword"}, + }, + Timeout: 0, + Alerter: alert.NewDummyAlerter(), + Callback: nil, + }, + true, + func(writer http.ResponseWriter, request *http.Request) { + _, _ = writer.Write([]byte("ok")) + }, + true, + }, + } + + for _, tv := range tests { + t.Run(tv.TestName, func(t *testing.T) { + testHttpServer := httptest.NewServer(tv.ServerFunc) + if tv.StartServer { + defer testHttpServer.Close() + } else { + testHttpServer.Close() + } + + tv.Task.Options["url"] = testHttpServer.URL + + err := ScrapeWebTask(&tv.Task) + if tv.ExpectedError { + assert.Error(t, err) + } else { + assert.Nil(t, err) + } + }) + } + +} diff --git a/task/task.go b/task/task.go new file mode 100644 index 0000000..0d48a68 --- /dev/null +++ b/task/task.go @@ -0,0 +1,60 @@ +package task + +import ( + "fmt" + "hotalert/alert" + "time" +) + +// Callback represents a callback function that is called after task completion +type Callback func(result *Result) + +// Options represents the options available for a task. +type Options map[string]any + +// Task represents the context of a task. +type Task struct { + // Options are the option given to the task. + Options Options `mapstructure:"options"` + // Timeout is the timeout for the task. + Timeout time.Duration `mapstructure:"timeout"` + // Alerter is the alerter that will be called when task is completed. + Alerter alert.Alerter `mapstructure:"alerter"` + // Callback is an optional function that will be called when task is completed. (Not implemented) + Callback *Callback +} + +// NewTask returns a new task instance. +func NewTask(options Options, alerter alert.Alerter) *Task { + if alerter == nil { + panic(fmt.Sprintf("Alerter cannot be nil")) + } + return &Task{ + Options: options, + Timeout: 10 * time.Second, + Alerter: alerter, + Callback: nil, + } +} + +// Result represents the result of a task. +type Result struct { + // InitialTask is the original Task for which the Result is given. + InitialTask *Task + // error is the error of the task. + error error +} + +// NewResult represents the result of a task. +func NewResult(task *Task) *Result { + return &Result{ + InitialTask: task, + error: nil, + } +} + +// Error returns the error encountered during the execution of the task. +// Error returns null if the task had no errors and was completed. +func (r *Result) Error() error { + return r.error +} diff --git a/task/task_test.go b/task/task_test.go new file mode 100644 index 0000000..58d5544 --- /dev/null +++ b/task/task_test.go @@ -0,0 +1,40 @@ +package task + +import ( + "github.com/stretchr/testify/assert" + "hotalert/alert" + "testing" + "time" +) + +func Test_NewTask(t *testing.T) { + var task = NewTask(Options{ + "option": "true", + }, alert.NewDummyAlerter()) + assert.Equal(t, Task{ + Options: Options{ + "option": "true", + }, + Timeout: 10 * time.Second, + Alerter: alert.NewDummyAlerter(), + Callback: nil, + }, *task) +} + +func Test_NewResult(t *testing.T) { + var task = NewTask(Options{ + "option": "true", + }, alert.NewDummyAlerter()) + var result = NewResult(task) + assert.Equal(t, Result{ + InitialTask: &Task{ + Options: Options{ + "option": "true", + }, + Timeout: 10 * time.Second, + Alerter: alert.NewDummyAlerter(), + Callback: nil, + }, + error: nil, + }, *result) +} diff --git a/workload/workload.go b/workload/workload.go new file mode 100644 index 0000000..25f1836 --- /dev/null +++ b/workload/workload.go @@ -0,0 +1,155 @@ +package workload + +import ( + "errors" + "fmt" + "gopkg.in/yaml.v3" + "hotalert/alert" + "hotalert/logging" + "hotalert/task" + "time" +) + +// Workload represents a workload for the HotAlert program. +type Workload struct { + tasksList []*task.Task + alerterMap map[string]alert.Alerter +} + +// NewWorkload returns a new Workload given the workload data. +func NewWorkload(workloadData map[string]any) (*Workload, error) { + var workload Workload + var err error + + // Two important keys from here on: alert and tasks + err = workload.buildAlerterMap(workloadData) + if err != nil { + return nil, errors.New(fmt.Sprintf("failed build alert contents: %s", err)) + } + err = workload.buildTasksArray(workloadData) + if err != nil { + return nil, errors.New(fmt.Sprintf("failed to build tasks contents: %s", err)) + } + + return &workload, nil +} + +// GetTasks returns the tasks assigned to the workload. +func (p *Workload) GetTasks() []*task.Task { + return p.tasksList +} + +// GetTasksLen returns the number of the tasks assigned to the workload. +func (p *Workload) GetTasksLen() int { + return len(p.tasksList) +} + +// buildAlerterMap parses the alert section from the given workload data and creates alerter components. +// On failure, it returns an error. +func (p *Workload) buildAlerterMap(workloadData map[string]any) error { + p.alerterMap = make(map[string]alert.Alerter) + + alertContents, ok := workloadData["alerts"] + if !ok { + return errors.New("key 'alerts' does not exists in workload") + } + alertContentsMap, ok := alertContents.(map[string]any) + if !ok { + return errors.New("key 'alert' is not a map type") + } + + // Parse the map and build alerts on the way. + for key, values := range alertContentsMap { + valuesMap, ok := values.(map[string]any) + if !ok { + return errors.New(fmt.Sprintf("alert section '%s' does no contain a map", key)) + } + + alerter, err := alert.NewAlerter(key, valuesMap) + if err != nil { + return err + } + if p.alerterMap[key] != nil { + return errors.New(fmt.Sprintf("alert section '%s' is a duplicate", key)) + } + p.alerterMap[key] = alerter + } + + return nil +} + +// buildTasksArray parses the tasks section from the given workload data and creates task components. +// On failure, it returns an error. +func (p *Workload) buildTasksArray(workloadData map[string]any) error { + p.tasksList = make([]*task.Task, 0, 10) + + // Figure out tasks types safely. + taskContents, ok := workloadData["tasks"] + if !ok { + return errors.New("key 'tasks' does not exists in workload") + } + + taskContentsArray, ok := taskContents.([]any) + if !ok { + return errors.New("key 'tasks' is not an array") + } + + // Iterate through task array + for i, taskEntryRaw := range taskContentsArray { + taskEntry, ok := taskEntryRaw.(map[string]any) + if !ok { + logging.SugaredLogger.Errorf("error parsing entry %d in tasks array: not a valid map type", i) + continue + } + + taskOptions, ok := taskEntry["options"].(map[string]any) + if !ok { + logging.SugaredLogger.Errorf("error parsing entry %d in tasks array: options is not a valid map type", i) + continue + } + + // Build task + tempTask := task.NewTask(taskOptions, alert.DummyAlerter{}) + + // Timeout (optional) + taskTimeout, ok := taskEntry["timeout"].(int) + if ok { + tempTask.Timeout = time.Duration(taskTimeout) * time.Second + } + + // Alerter + taskAlerter, ok := taskEntry["alerter"].(string) + if ok { + alerter, ok := p.alerterMap[taskAlerter] + if !ok { + logging.SugaredLogger.Errorf("error parsing entry %d in tasks array: invalid alerter", i) + continue + } + tempTask.Alerter = alerter + } else { + logging.SugaredLogger.Errorf("error parsing entry %d in tasks array: invalid alerter", i) + continue + } + + p.tasksList = append(p.tasksList, tempTask) + } + + if len(p.tasksList) == 0 { + return errors.New("tasks list is empty or parsing has failed") + } + + return nil +} + +// FromYamlContent returns a new Workload given a yaml workload data definition. +func FromYamlContent(contents []byte) (*Workload, error) { + var workloadData map[string]any + + err := yaml.Unmarshal(contents, &workloadData) + if err != nil { + newError := errors.New(fmt.Sprintf("failed to unmarshal yaml contents %s", err)) + return nil, newError + } + + return NewWorkload(workloadData) +} diff --git a/workload/workload_test.go b/workload/workload_test.go new file mode 100644 index 0000000..8cc3e2d --- /dev/null +++ b/workload/workload_test.go @@ -0,0 +1,336 @@ +package workload + +import ( + "github.com/stretchr/testify/assert" + "hotalert/task" + "testing" + "time" +) + +func Test_FromYamlContent(t *testing.T) { + var fileContents = ` +tasks: + - options: + url: https://jobs.eu + keywords: ["Software Engineer, Backend"] + timeout: 10 + alerter: "webhook_discord" + - options: + url: https://jobs.ro + keywords: ["Software Engineer, Front-End", "Software Architect"] + extra_int: 80 + extra_float: 80.2 + extra_bool: True + timeout: 15 + alerter: "webhook_discord" +alerts: + webhook_discord: + webhook: https://webhook.url.com + # $keywords can be used as a placeholder in the message, and it will be replaced with the actual keywords. + message: "Hi, the keyword $keywords was found on page!" +` + + currentWorkload, err := FromYamlContent([]byte(fileContents)) + + // General tests + assert.NoError(t, err) + assert.Len(t, currentWorkload.tasksList, 2) + assert.Len(t, currentWorkload.alerterMap, 1) + + // Test alerter. + alerter := currentWorkload.alerterMap["webhook_discord"] + for _, taskEntry := range currentWorkload.tasksList { + assert.Equal(t, alerter, taskEntry.Alerter) + } + + // Test timeout + assert.Equal(t, 10*time.Second, currentWorkload.tasksList[0].Timeout) + assert.Equal(t, 15*time.Second, currentWorkload.tasksList[1].Timeout) + + // Test Options + assert.Equal(t, task.Options{ + "url": "https://jobs.eu", + "keywords": []any{"Software Engineer, Backend"}, + }, currentWorkload.tasksList[0].Options) + assert.Equal(t, task.Options{ + "url": "https://jobs.ro", + "keywords": []any{"Software Engineer, Front-End", "Software Architect"}, + "extra_int": 80, + "extra_float": 80.2, + "extra_bool": true, + }, currentWorkload.tasksList[1].Options) +} + +func Test_GetTasks(t *testing.T) { + var fileContents = ` +tasks: + - options: + url: https://jobs.eu + keywords: ["Software Engineer, Backend"] + timeout: 10 + alerter: "webhook_discord" + - options: + url: https://jobs.ro + keywords: ["Software Engineer, Front-End", "Software Architect"] + extra_int: 80 + extra_float: 80.2 + extra_bool: True + timeout: 15 + alerter: "webhook_discord" +alerts: + webhook_discord: + webhook: https://webhook.url.com + # $keywords can be used as a placeholder in the message, and it will be replaced with the actual keywords. + message: "Hi, the keyword $keywords was found on page!" +` + + currentWorkload, err := FromYamlContent([]byte(fileContents)) + assert.NoError(t, err) + + tasks := currentWorkload.GetTasks() + assert.Len(t, tasks, 2) + assert.IsType(t, []*task.Task{}, tasks) +} + +func Test_GetTasksLen(t *testing.T) { + var fileContents = ` +tasks: + - options: + url: https://jobs.eu + keywords: ["Software Engineer, Backend"] + timeout: 10 + alerter: "webhook_discord" + - options: + url: https://jobs.ro + keywords: ["Software Engineer, Front-End", "Software Architect"] + extra_int: 80 + extra_float: 80.2 + extra_bool: True + timeout: 15 + alerter: "webhook_discord" +alerts: + webhook_discord: + webhook: https://webhook.url.com + # $keywords can be used as a placeholder in the message, and it will be replaced with the actual keywords. + message: "Hi, the keyword $keywords was found on page!" +` + + currentWorkload, err := FromYamlContent([]byte(fileContents)) + assert.NoError(t, err) + assert.Equal(t, 2, currentWorkload.GetTasksLen()) +} + +var testAlertsKeyNotExistsContents = ` +tasks: + - options: + url: https://jobs.eu + keywords: ["Software Engineer, Backend"] + timeout: 10 + alerter: "webhook_discord" + - options: + url: https://jobs.ro + keywords: ["Software Engineer, Front-End", "Software Architect"] + extra_int: 80 + extra_float: 80.2 + extra_bool: True + timeout: 15 + alerter: "webhook_discord" +alertx: + webhook_discord: + webhook: https://webhook.url.com + # $keywords can be used as a placeholder in the message, and it will be replaced with the actual keywords. + message: "Hi, the keyword $keywords was found on page!" +` + +var testAlertsKeyNotMapType = ` +tasks: + - options: + url: https://jobs.eu + keywords: ["Software Engineer, Backend"] + timeout: 10 + alerter: "webhook_discord" + - options: + url: https://jobs.ro + keywords: ["Software Engineer, Front-End", "Software Architect"] + extra_int: 80 + extra_float: 80.2 + extra_bool: True + timeout: 15 + alerter: "webhook_discord" +alerts: "I'm just a string please don't hurt me." +` + +var testAlertsKeyDuplicated = ` +tasks: + - options: + url: https://jobs.eu + keywords: ["Software Engineer, Backend"] + timeout: 10 + alerter: "webhook_discord" + - options: + url: https://jobs.ro + keywords: ["Software Engineer, Front-End", "Software Architect"] + extra_int: 80 + extra_float: 80.2 + extra_bool: True + timeout: 15 + alerter: "webhook_discord" +alerts: + webhook_discord: + webhook: https://webhook.url.com + # $keywords can be used as a placeholder in the message, and it will be replaced with the actual keywords. + message: "Hi, the keyword $keywords was found on page!" +alerts: + webhook_discord: + webhook: https://webhook.url.com + # $keywords can be used as a placeholder in the message, and it will be replaced with the actual keywords. + message: "Hi, the keyword $keywords was found on page!" +` + +var testAlertsUnknownAlerter = ` +tasks: + - options: + url: https://jobs.eu + keywords: ["Software Engineer, Backend"] + timeout: 10 + alerter: "webhook_discord" + - options: + url: https://jobs.ro + keywords: ["Software Engineer, Front-End", "Software Architect"] + extra_int: 80 + extra_float: 80.2 + extra_bool: True + timeout: 15 + alerter: "webhook_discord" +alerts: + imcoolalerter: + cool: true +` + +var testTasksDoesNotExists = ` +alerts: + webhook_discord: + webhook: https://webhook.url.com + # $keywords can be used as a placeholder in the message, and it will be replaced with the actual keywords. + message: "Hi, the keyword $keywords was found on page!" +` + +var testTasksIsNotAnArray = ` +tasks: "Many, very much tasks." +alerts: + webhook_discord: + webhook: https://webhook.url.com + # $keywords can be used as a placeholder in the message, and it will be replaced with the actual keywords. + message: "Hi, the keyword $keywords was found on page!" +` + +var testTasksTaskIsNotAMap = ` +tasks: + - task: "cool task" +alerts: + webhook_discord: + webhook: https://webhook.url.com + # $keywords can be used as a placeholder in the message, and it will be replaced with the actual keywords. + message: "Hi, the keyword $keywords was found on page!" +` + +var testTasksTaskHasInvalidAlerter = ` +tasks: + - options: + url: https://jobs.eu + keywords: ["Software Engineer, Backend"] + timeout: 10 + alerter: "imaacoolalerter" +alerts: + webhook_discord: + webhook: https://webhook.url.com + # $keywords can be used as a placeholder in the message, and it will be replaced with the actual keywords. + message: "Hi, the keyword $keywords was found on page!" +` + +var testTasksListEmpty = ` +tasks: [] +alerts: + webhook_discord: + webhook: https://webhook.url.com + # $keywords can be used as a placeholder in the message, and it will be replaced with the actual keywords. + message: "Hi, the keyword $keywords was found on page!" +` + +func Test_FromYamlContent_Errors(t *testing.T) { + var tests = []struct { + TestName string + TestFileContents string + }{ + { + "AlertsKeyDoesNotExists", + testAlertsKeyNotExistsContents, + }, + { + "testAlertsKeyNotMapType", + testAlertsKeyNotMapType, + }, + { + "testAlertsKeyDuplicated", + testAlertsKeyDuplicated, + }, + { + "testAlertsUnknownAlerter", + testAlertsUnknownAlerter, + }, + { + "testTasksDoesNotExists", + testTasksDoesNotExists, + }, + { + "testTasksIsNotAnArray", + testTasksIsNotAnArray, + }, + { + "testTasksTaskIsNotAMap", + testTasksTaskIsNotAMap, + }, + { + "testTasksListEmpty", + testTasksListEmpty, + }, + { + "testTasksTaskHasInvalidAlerter", + testTasksTaskHasInvalidAlerter, + }, + } + + for _, tv := range tests { + t.Run(tv.TestName, func(t *testing.T) { + currentWorkload, err := FromYamlContent([]byte(tv.TestFileContents)) + assert.Nil(t, currentWorkload) + assert.Error(t, err) + }) + } +} + +var testTasksTaskHasInvalidAlerter2 = ` +tasks: + - options: + url: https://jobs.eu + keywords: ["Software Engineer, Backend"] + timeout: 10 + alerter: "imaacoolalerter" + - options: + url: https://jobs.eu + keywords: ["Software Engineer, Backend"] + timeout: 10 + alerter: "webhook_discord" +alerts: + webhook_discord: + webhook: https://webhook.url.com + # $keywords can be used as a placeholder in the message, and it will be replaced with the actual keywords. + message: "Hi, the keyword $keywords was found on page!" +` + +func Test_FromYamlContent_InvalidAlerterForTask(t *testing.T) { + currentWorkload, err := FromYamlContent([]byte(testTasksTaskHasInvalidAlerter2)) + assert.NoError(t, err) + assert.NotNil(t, currentWorkload) + assert.Len(t, currentWorkload.tasksList, 1) +}