initial commit
This commit is contained in:
commit
17476be23c
26 changed files with 1789 additions and 0 deletions
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
|
@ -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/
|
201
LICENSE
Normal file
201
LICENSE
Normal file
|
@ -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.
|
2
Makefile
Normal file
2
Makefile
Normal file
|
@ -0,0 +1,2 @@
|
|||
test:
|
||||
go test ./...
|
9
alert/alert.go
Normal file
9
alert/alert.go
Normal file
|
@ -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)
|
||||
}
|
83
alert/discord.go
Normal file
83
alert/discord.go
Normal file
|
@ -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)
|
||||
}
|
76
alert/discord_test.go
Normal file
76
alert/discord_test.go
Normal file
|
@ -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"})
|
||||
}
|
19
alert/dummy.go
Normal file
19
alert/dummy.go
Normal file
|
@ -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)
|
||||
}
|
10
alert/dummy_test.go
Normal file
10
alert/dummy_test.go
Normal file
|
@ -0,0 +1,10 @@
|
|||
package alert
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDummyAlerter_PostAlert(t *testing.T) {
|
||||
NewDummyAlerter().PostAlert(context.TODO(), []string{"demo"})
|
||||
}
|
17
alert/factory.go
Normal file
17
alert/factory.go
Normal file
|
@ -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))
|
||||
}
|
48
alert/factory_test.go
Normal file
48
alert/factory_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
14
cmd/directory.go
Normal file
14
cmd/directory.go
Normal file
|
@ -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")
|
||||
},
|
||||
}
|
63
cmd/file.go
Normal file
63
cmd/file.go
Normal file
|
@ -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")
|
||||
},
|
||||
}
|
16
cmd/root.go
Normal file
16
cmd/root.go
Normal file
|
@ -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)
|
||||
}
|
19
go.mod
Normal file
19
go.mod
Normal file
|
@ -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
|
||||
)
|
33
go.sum
Normal file
33
go.sum
Normal file
|
@ -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=
|
140
logging/logging.go
Normal file
140
logging/logging.go
Normal file
|
@ -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")
|
||||
}
|
14
main.go
Normal file
14
main.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
42
readme.md
Normal file
42
readme.md
Normal file
|
@ -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 .
|
||||
```
|
118
task/executor.go
Normal file
118
task/executor.go
Normal file
|
@ -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)
|
||||
}
|
50
task/executor_test.go
Normal file
50
task/executor_test.go
Normal file
|
@ -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()
|
||||
}
|
81
task/target/scrape.go
Normal file
81
task/target/scrape.go
Normal file
|
@ -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
|
||||
}
|
124
task/target/scrape_test.go
Normal file
124
task/target/scrape_test.go
Normal file
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
60
task/task.go
Normal file
60
task/task.go
Normal file
|
@ -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
|
||||
}
|
40
task/task_test.go
Normal file
40
task/task_test.go
Normal file
|
@ -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)
|
||||
}
|
155
workload/workload.go
Normal file
155
workload/workload.go
Normal file
|
@ -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)
|
||||
}
|
336
workload/workload_test.go
Normal file
336
workload/workload_test.go
Normal file
|
@ -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)
|
||||
}
|
Loading…
Reference in a new issue