initial commit

This commit is contained in:
Denis-Cosmin Nutiu 2022-11-03 22:04:14 +02:00
commit 17476be23c
26 changed files with 1789 additions and 0 deletions

19
.gitignore vendored Normal file
View 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
View 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
View file

@ -0,0 +1,2 @@
test:
go test ./...

9
alert/alert.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}