diff --git a/.golangci.yml b/.golangci.yml index 6f5d185..62aa991 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,7 @@ run: - deadline: 1m + timeout: 5m + skip-files: + - '(.+)_test\.go' linters: disable-all: false diff --git a/README.md b/README.md index 3681cfc..4b9da5d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,34 @@ # check_cloud_aws -Icinga check plugin to check Amazon AWS resources. At the moment the check supports EC2 instances, Cloudfront and S3 -Buckets. +An Icinga check plugin to check Amazon AWS resources. ## Usage +### Health Status + +A general status based on the RSS feed on the AWS Health Page + +``` +Usage: + check_cloud_aws status [flags] + +Flags: + -u, --url string The AWS Status Page URL (default "https://status.aws.amazon.com") + -s, --service string The AWS Service to check (default "ec2") + -h, --help help for status +``` + +``` +check_cloud_aws status --service cloudfront +OK - Service cloudfront is operating normally + +check_cloud_aws --region us-west-1 status --service cloudwatch +WARNING - Information available for cloudwatch in us-west-1 + +check_cloud_aws --region eu-west-1 status +CRITICAL - Service disruption for ec2 in eu-west-1 +``` + ### EC2 - Instances When one of the states is non-ok, or a machine is stopped, the check will alert. @@ -152,7 +176,7 @@ Global Flags: ```` ```` -$ check_cloud_aws cloudfront +$ check_cloud_aws cloudfront CRITICAL - Found 2 Distributions - critical 1 - warning 1 [WARNING] E32127W2BLH4SR status=InProgress enabled=true diff --git a/cmd/root.go b/cmd/root.go index e92d101..6be3d0d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -15,7 +15,7 @@ var ( var rootCmd = &cobra.Command{ Use: "check_cloud_aws", - Short: "Icinga check plugin to check Amazon EC2 instances", + Short: "An Icinga check plugin to check Amazon Web Services", PersistentPreRun: func(cmd *cobra.Command, args []string) { go check.HandleTimeout(Timeout) }, diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..41ee3a6 --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,183 @@ +package cmd + +import ( + "encoding/xml" + "fmt" + "github.com/NETWAYS/check_cloud_aws/internal/status" + "github.com/NETWAYS/go-check" + "github.com/spf13/cobra" + "net/http" + "net/url" + "strings" +) + +// To store the CLI parameters +type StatusConfig struct { + Url string + Service string +} + +var cliStatusConfig StatusConfig + +func contains(s string, list []string) bool { + // Tiny helper to see if a string is in a list of strings + for _, elem := range list { + if s == elem { + return true + } + } + + return false +} + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Checks the status of AWS services", + Example: ` + check_cloud_aws status --service cloudfront + OK - Service cloudfront is operating normally + + check_cloud_aws --region us-west-1 status --service cloudwatch + WARNING - Information available for cloudwatch in us-west-1 + + check_cloud_aws --region eu-west-1 status + CRITICAL - Service disruption for ec2 in eu-west-1 + + check_cloud_aws --region "" status iam + CRITICAL - WARNING - Information available for iam (Global)`, + Run: func(cmd *cobra.Command, args []string) { + var ( + feed status.Rss + rc int + output string + feedUrl string + ) + + // These services don't require a region + // Hint: This list might not be extensive + var globalServices = []string{"route53", + "route53domainregistration", + "route53apprecoverycontroller", + "chime", + "health", + "import-export", + "iam", + "awsiotdevicemanagement", + "marketplace", + "apipricing", + "awswaf", + "trustedadvisor", + "supportcenter", + "resourcegroups", + "organizations", + "management-console", + "awsiotdevicemanagement", + "account", + "interregionvpcpeering", + "cloudfront", + "billingconsole", + "chatbot"} + + // Creating an client and connecting to the RSS Feed + // Access the AWS Health Dashboard at health.aws.amazon.com directly, + // since the AWS Go SDK just supports Personal Dashboards + c := &http.Client{} + + if Region == "" && !contains(cliStatusConfig.Service, globalServices) { + check.ExitError(fmt.Errorf("Region required for regional services")) + } + + // Using + concatenation since the JoinPath will add / inbetween + if Region == "" && contains(cliStatusConfig.Service, globalServices) { + feedUrl, _ = url.JoinPath(cliStatusConfig.Url, "/rss/", cliStatusConfig.Service+".rss") + // Just for the output later + Region = "Global" + } else { + feedUrl, _ = url.JoinPath(cliStatusConfig.Url, "/rss/", cliStatusConfig.Service+"-"+Region+".rss") + } + + resp, err := c.Get(feedUrl) + + if err != nil { + check.ExitError(err) + } + + if resp.StatusCode != http.StatusOK { + check.ExitError(fmt.Errorf("Could not get %s - Error: %d", feedUrl, resp.StatusCode)) + } + + defer resp.Body.Close() + err = xml.NewDecoder(resp.Body).Decode(&feed) + + if err != nil { + check.ExitError(err) + } + + // Exit if there are no events + if len(feed.Channel.Items) == 0 { + rc = check.OK + output = fmt.Sprintf("No events for %s (%s)", cliStatusConfig.Service, Region) + check.ExitRaw(rc, output) + } + + rc = check.Unknown + output = "Status unknown" + + // Get the latest event + item := strings.Split(feed.Channel.Items[0].Title, ":") + + // If we couldn't split the title + if len(item) < 2 { + output = "Could not determine status." + check.ExitRaw(rc, output, feed.Channel.Items[0].Title) + } + + event := item[0] + details := item[1] + + if strings.Contains(event, "Service disruption") { + // Service disruptions are Critical + rc = check.Critical + output = fmt.Sprintf("Service disruption for %s (%s)", cliStatusConfig.Service, Region) + } + + if strings.Contains(event, "Performance issue") { + // Performance issues are Warnings + rc = check.Warning + output = fmt.Sprintf("Performance issues for %s (%s)", cliStatusConfig.Service, Region) + } + + if strings.Contains(event, "Informational message") { + // There will be no new item if an information is resolved, + // we need to check if the info is resolved. + if strings.Contains(details, "[RESOLVED]") { + rc = check.OK + output = fmt.Sprintf("Event resolved for %s (%s)", cliStatusConfig.Service, Region) + } else { + // An information that should be checked by someone + rc = check.Warning + output = fmt.Sprintf("Information available for %s (%s)", cliStatusConfig.Service, Region) + } + } + + if strings.Contains(event, "Service is operating normally") { + rc = check.OK + output = fmt.Sprintf("Service %s is operating normally (%s)", cliStatusConfig.Service, Region) + } + + check.ExitRaw(rc, output) + }, +} + +func init() { + rootCmd.AddCommand(statusCmd) + + fs := statusCmd.Flags() + + fs.StringVarP(&cliStatusConfig.Url, "url", "u", "https://status.aws.amazon.com", + "The AWS Status Page URL") + fs.StringVarP(&cliStatusConfig.Service, "service", "s", "ec2", + "The AWS Service to check") + + fs.SortFlags = false +} diff --git a/cmd/status_test.go b/cmd/status_test.go new file mode 100644 index 0000000..944fd53 --- /dev/null +++ b/cmd/status_test.go @@ -0,0 +1,255 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "os/exec" + "strings" + "testing" +) + +func TestStatus_ConnectionRefused(t *testing.T) { + + cmd := exec.Command("go", "run", "../main.go", "status", "--region", "foobar", "--url", "https://localhost") + out, _ := cmd.CombinedOutput() + + actual := string(out) + expected := "UNKNOWN - Get \"https://localhost" + + if !strings.Contains(actual, expected) { + t.Error("\nActual: ", actual, "\nExpected: ", expected) + } +} + +type StatusTest struct { + name string + server *httptest.Server + args []string + expected string +} + +func TestStatusCmd(t *testing.T) { + tests := []StatusTest{ + { + name: "status-ok", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + <![CDATA[Amazon Simple Storage Service (Foobar) Service Status]]> + http://httptest.localhost/ + en-us + Mon, 02 Jan 2023 00:05:49 PST + AWS Service Health Dashboard RSS Generator + + 5 + + <![CDATA[Nothing so split into Slice]]> + + +`)) + })), + args: []string{"run", "../main.go", "status", "--region", "eu-foobar-1"}, + expected: "UNKNOWN - Could not determine status. Nothing so split into Slice", + }, + { + name: "status-ok", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + <![CDATA[Amazon Simple Storage Service (Foobar) Service Status]]> + http://httptest.localhost/ + en-us + Mon, 02 Jan 2023 00:05:49 PST + AWS Service Health Dashboard RSS Generator + + 5 + + <![CDATA[Informational message: [RESOLVED] Elevated request error rate using the PUT object]]> + http://httptest.localhost/ + Fri, 24 Jul 2015 11:54:38 PDT + http://httptest.localhost/1234 + + + <![CDATA[Informational message: [RESOLVED] Foobar Event]]> + http://httptest.localhost/ + Fri, 20 Jul 2015 11:54:38 PDT + http://httptest.localhost/1234 + + +`)) + })), + args: []string{"run", "../main.go", "status", "--region", "eu-foobar-1"}, + expected: "OK - Event resolved for ec2 (eu-foobar-1)", + }, + { + name: "status-ok-no-items", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + <![CDATA[Amazon Simple Storage Service (Foobar) Service Status]]> + http://httptest.localhost/ + en-us + Mon, 02 Jan 2023 00:05:49 PST + AWS Service Health Dashboard RSS Generator + + 5 + +`)) + })), + args: []string{"run", "../main.go", "status", "--region", "eu-foobar-1"}, + expected: "OK - No events for ec2 (eu-foobar-1)", + }, + { + name: "status-ok-normal", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + <![CDATA[Amazon Simple Storage Service (Foobar) Service Status]]> + http://httptest.localhost/ + en-us + Mon, 02 Jan 2023 00:05:49 PST + AWS Service Health Dashboard RSS Generator + + 5 + + <![CDATA[Service is operating normally: Nothing to see move along]]> + http://httptest.localhost/ + Fri, 24 Jul 2015 11:54:38 PDT + http://httptest.localhost/1234 + + +`)) + })), + args: []string{"run", "../main.go", "status", "--region", "eu-foobar-1"}, + expected: "OK - Service ec2 is operating normally (eu-foobar-1)", + }, + { + name: "status-warning", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + <![CDATA[Amazon Simple Storage Service (Foobar) Service Status]]> + http://httptest.localhost/ + en-us + Mon, 02 Jan 2023 00:05:49 PST + AWS Service Health Dashboard RSS Generator + + 5 + + <![CDATA[Performance issues: Slow news day]]> + http://httptest.localhost/ + Fri, 24 Jul 2015 11:54:38 PDT + http://httptest.localhost/1234 + + +`)) + })), + args: []string{"run", "../main.go", "status", "--region", "eu-foobar-1"}, + expected: "WARNING - Performance issues for ec2 (eu-foobar-1)", + }, + { + name: "status-critical", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + <![CDATA[Amazon Simple Storage Service (Foobar) Service Status]]> + http://httptest.localhost/ + en-us + Mon, 02 Jan 2023 00:05:49 PST + AWS Service Health Dashboard RSS Generator + + 5 + + <![CDATA[Service disruption: Oh no!]]> + http://httptest.localhost/ + Fri, 24 Jul 2015 11:54:38 PDT + http://httptest.localhost/1234 + + +`)) + })), + args: []string{"run", "../main.go", "status", "--region", "eu-foobar-1"}, + expected: "CRITICAL - Service disruption for ec2 (eu-foobar-1)", + }, + { + name: "status-informational", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + <![CDATA[Amazon Simple Storage Service (Foobar) Service Status]]> + http://httptest.localhost/ + en-us + Mon, 02 Jan 2023 00:05:49 PST + AWS Service Health Dashboard RSS Generator + + 5 + + <![CDATA[Informational message: Foobar Event is unresolved]]> + http://httptest.localhost/ + Fri, 24 Jul 2015 11:54:38 PDT + http://httptest.localhost/1234 + + +`)) + })), + args: []string{"run", "../main.go", "status", "--region", "eu-foobar-1"}, + expected: "WARNING - Information available for ec2 (eu-foobar-1)", + }, + { + name: "status-global-service", + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(` + + + <![CDATA[Amazon Simple Storage Service (Foobar) Service Status]]> + http://httptest.localhost/ + en-us + Mon, 02 Jan 2023 00:05:49 PST + AWS Service Health Dashboard RSS Generator + + 5 + + <![CDATA[Informational message: Foobar Event is unresolved]]> + http://httptest.localhost/ + Fri, 24 Jul 2015 11:54:38 PDT + http://httptest.localhost/1234 + + +`)) + })), + args: []string{"run", "../main.go", "status", "-s", "iam", "--region", ""}, + expected: "WARNING - Information available for iam (Global)", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + defer test.server.Close() + + cmd := exec.Command("go", append(test.args, "--url", test.server.URL)...) + out, _ := cmd.CombinedOutput() + + actual := string(out) + + if !strings.Contains(actual, test.expected) { + t.Error("\nActual: ", actual, "\nExpected: ", test.expected) + } + + }) + } +} diff --git a/internal/status/rss.go b/internal/status/rss.go new file mode 100644 index 0000000..3734e2d --- /dev/null +++ b/internal/status/rss.go @@ -0,0 +1,14 @@ +package status + +type Rss struct { + Channel struct { + Title string `xml:"title"` + Link string `xml:"link"` + Desc string `xml:"description"` + Items []Item `xml:"item"` + } `xml:"channel"` +} + +type Item struct { + Title string `xml:"title"` +}