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