├── .circleci └── config.yml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── USAGE.md ├── actions ├── action.go ├── all │ └── all.go ├── exec │ ├── action.go │ ├── action_unix_test.go │ └── doc.go ├── http │ ├── action.go │ ├── action_test.go │ └── doc.go └── mail │ ├── action.go │ ├── action_test.go │ ├── doc.go │ └── server_test.go ├── cmd └── goma │ ├── command.go │ ├── config.go │ ├── config_test.go │ └── main.go ├── create.go ├── create_test.go ├── filters ├── all │ └── all.go ├── average │ ├── doc.go │ ├── filter.go │ └── filter_test.go └── filter.go ├── go.mod ├── go.sum ├── goma.png ├── handle_list.go ├── handle_monitor.go ├── handle_register.go ├── handle_verbosity.go ├── handler.go ├── monitor ├── doc.go ├── error.go ├── monitor.go └── registry.go ├── probes ├── all │ └── all.go ├── exec │ ├── doc.go │ ├── probe.go │ ├── probe_test.go │ └── probe_unix_test.go ├── http │ ├── doc.go │ ├── probe.go │ └── probe_test.go ├── mysql │ ├── doc.go │ ├── probe.go │ └── probe_test.go └── probe.go ├── sample.toml ├── server.go └── util.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: ghcr.io/cybozu/golang:1.20-jammy 6 | working_directory: /work 7 | steps: 8 | - checkout 9 | - run: test -z "$(gofmt -s -l . | grep -v '^vendor' | tee /dev/stderr)" 10 | - run: golint -set_exit_status . 11 | - run: go build ./... 12 | - run: go test -race -v ./... 13 | - run: go vet ./... 14 | 15 | workflows: 16 | version: 2 17 | main: 18 | jobs: 19 | - build 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Editors 7 | *~ 8 | .*.swp 9 | .#* 10 | \#*# 11 | 12 | # Folders 13 | _obj 14 | _test 15 | 16 | # Architecture specific extensions/prefixes 17 | *.[568vq] 18 | [568vq].out 19 | 20 | *.cgo1.go 21 | *.cgo2.c 22 | _cgo_defun.c 23 | _cgo_gotypes.go 24 | _cgo_export.* 25 | 26 | _testmain.go 27 | 28 | *.exe 29 | *.test 30 | *.prof 31 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - dupl 4 | - goconst 5 | - gofmt 6 | - golint 7 | - typecheck 8 | - unparam 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [Unreleased] 6 | 7 | ## [1.0.2] - 2018-11-16 8 | - Handle renaming of cybozu-go/cmd to [cybozu-go/well][well] 9 | - Introduce support for Go modules 10 | 11 | ## [1.0.1] - 2016-08-24 12 | ### Changed 13 | - Fix for cybozu-go/cmd v1.1.0. 14 | 15 | ## [1.0.0] - 2016-08-22 16 | ### Added 17 | - goma now adopts [github.com/cybozu-go/cmd][cmd] framework. 18 | As a result, commands implement [the common spec][spec]. 19 | - [actions/exec] new parameter "debug" to log outputs on failure. 20 | 21 | [well]: https://github.com/cybozu-go/well 22 | [cmd]: https://github.com/cybozu-go/cmd 23 | [spec]: https://github.com/cybozu-go/cmd/blob/master/README.md#specifications 24 | [Unreleased]: https://github.com/cybozu-go/goma/compare/v1.0.2...HEAD 25 | [1.0.2]: https://github.com/cybozu-go/goma/compare/v1.0.0...v1.0.1 26 | [1.0.1]: https://github.com/cybozu-go/goma/compare/v1.0.0...v1.0.1 27 | [1.0.0]: https://github.com/cybozu-go/goma/compare/v0.1...v1.0.0 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Thank you for contributing goma! Here are few guidelines. 2 | 3 | Issues / Bugs 4 | ------------- 5 | 6 | If you think you found a bug, open an issue and supply the 7 | minimum configuration that triggers the bug reproducibly. 8 | 9 | Pull requests 10 | ------------- 11 | 12 | New plugins should be accompanied with enough tests. 13 | Bug fixes should update or add tests to cover that bug. 14 | 15 | Codes must be formatted by [goimports][]. Please configure 16 | your editor to run it automatically. 17 | 18 | Pull requests will be tested on travis-ci with [golint][] and 19 | [go vet][govet]. Please run them on your local machine before 20 | submission. 21 | 22 | By submitting the code, you agree to license your code under 23 | the [MIT][] license. 24 | 25 | [gofmt]: https://golang.org/cmd/gofmt/ 26 | [goimports]: https://godoc.org/golang.org/x/tools/cmd/goimports 27 | [golint]: https://github.com/golang/lint 28 | [govet]: https://golang.org/cmd/vet/ 29 | [MIT]: https://opensource.org/licenses/MIT 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Cybozu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Goma 2 | ==== 3 | 4 | [![GitHub release](https://img.shields.io/github/release/cybozu-go/goma.svg?maxAge=60)][releases] 5 | [![GoDoc](https://godoc.org/github.com/cybozu-go/goma?status.svg)][godoc] 6 | [![CircleCI](https://circleci.com/gh/cybozu-go/goma.svg?style=svg)](https://circleci.com/gh/cybozu-go/goma) 7 | [![Go Report Card](https://goreportcard.com/badge/github.com/cybozu-go/goma)](https://goreportcard.com/report/github.com/cybozu-go/goma) 8 | 9 | Goma is: 10 | 11 | * Japanese name of sesame seeds, ![Goma image](goma.png) and 12 | * an extensible monitoring agent written in Go, described here. 13 | 14 | Abstract 15 | -------- 16 | 17 | Goma is a general purpose monitoring server/client. It can run 18 | multiple monitoring processes concurrently in a single server process. 19 | 20 | Basically, goma does active (not passive) monitoring to objects like 21 | web sites or local OS, and kicks actions on failure and/or recovery. 22 | 23 | Monitor processes are loaded from configuration files from a directory 24 | at start up, and can be added/started/stopped/removed dynamically via 25 | command-line and REST API. 26 | 27 | Goma is designed with [DevOps][] in mind. Developers can define 28 | and add monitors for their programs easily by putting a rule file 29 | to the configuration directory or by REST API. Monitoring rules 30 | and actions can be configured flexibly as goma can run arbitrary 31 | commands for them. 32 | 33 | ### What goma is not 34 | 35 | goma is *not* designed for metrics collection. 36 | Use other tools such as Zabbix for that purpose. 37 | 38 | Architecture 39 | ------------ 40 | 41 | goma can run multiple independent **monitors** in a single process. 42 | 43 | A monitor consists of a **probe**, one or more **actions**, and optionally 44 | a **filter**. A monitor probes something periodically, and kick actions 45 | for failure when the probe, or filtered result of the probe, reports 46 | failures. The monitor kicks actions for recovery when the probe or 47 | filtered result of the probe reports recovery from failures. 48 | 49 | A probe checks a system and report its status as a floating point number. 50 | *All probes have timeouts*; if a probe cannot return a value before 51 | the timeout, goma will cancel the probe. 52 | 53 | A filter manipulates probe outputs; for example, a filter can produce 54 | moving average of probe outputs. 55 | 56 | An action implements actions on failures and recoveries. 57 | 58 | goma comes with a set of probes, actions, and filters. New probes, 59 | actions, and filters can be added as compiled-in plugins. 60 | 61 | **Pull requests to add new plugins are welcome!** 62 | 63 | Usage 64 | ----- 65 | 66 | Read [USAGE.md](USAGE.md) for details. 67 | 68 | Install 69 | ------- 70 | 71 | The latest officially supported Go version is recommended. 72 | 73 | ``` 74 | go install github.com/cybozu-go/goma/cmd/goma@latest 75 | ``` 76 | 77 | License 78 | ------- 79 | 80 | [MIT][] 81 | 82 | [releases]: https://github.com/cybozu-go/goma/releases 83 | [godoc]: https://godoc.org/github.com/cybozu-go/goma 84 | [DevOps]: https://en.wikipedia.org/wiki/DevOps 85 | [MIT]: https://opensource.org/licenses/MIT 86 | -------------------------------------------------------------------------------- /USAGE.md: -------------------------------------------------------------------------------- 1 | User Guide 2 | ========== 3 | 4 | Table of contents: 5 | 6 | * [Running goma agent](#agent) 7 | * [Client commands](#client) 8 | * [Defining monitors](#define) 9 | * [Probes](#probes) 10 | * [Filters](#filters) 11 | * [Actions](#actions) 12 | * [Security](#security) 13 | * [REST API](#api) 14 | 15 | Running goma agent 16 | ------------------------------------ 17 | 18 | `goma serve` starts monitoring agent (server). 19 | `goma` does not provide so-called *daemon* mode. 20 | Please use [systemd][] or [upstart][] to run it in the background. 21 | 22 | At startup, `goma` will load [TOML][] configuration files from a 23 | directory (default is `/usr/local/etc/goma`). Each file can define 24 | multiple monitors as described below. 25 | 26 | Client commands 27 | ---------------------------------- 28 | 29 | `goma` works as clients for commands other than "serve". 30 | 31 | By default, `goma` connects to the agent running on "localhost:3838". 32 | Use `-s` option to change the address. 33 | 34 | * list 35 | 36 | `goma list` lists all registered monitors. 37 | 38 | * show 39 | 40 | `goma show ID` show the status of a monitor for ID. 41 | The ID can be identified by list command. 42 | 43 | * start 44 | 45 | `goma start ID` starts the monitor for ID. 46 | 47 | * stop 48 | 49 | `goma stop ID` stops the monitor for ID. 50 | 51 | * register 52 | 53 | `goma register FILE` loads monitor definitions from a TOML file, 54 | register them into the agent, then starts the new monitors. 55 | 56 | If FILE is "-", definitions are read from stdin. 57 | 58 | * unregister 59 | 60 | `goma unregister ID` stops and unregister the monitor for ID. 61 | 62 | * verbosity 63 | 64 | `goma verbosity LEVEL` changes the logging threshold. 65 | Available levels are: `debug`, `info`, `warn`, `error`, `critical` 66 | 67 | `goma verbosity` queries the current logging threshold. 68 | 69 | Defining monitors 70 | ------------------------------------ 71 | 72 | Monitors can be defined in a TOML file like this: 73 | 74 | ``` 75 | [[monitor]] 76 | name = "monitor1" 77 | interval = 10 78 | timeout = 1 79 | min = 0.0 80 | max = 0.3 81 | 82 | [monitor.probe] 83 | type = "exec" 84 | command = "/some/probe/cmd" 85 | 86 | [monitor.filter] 87 | type = "average" 88 | 89 | [[monitor.actions]] 90 | type = "mail" 91 | from = "no-reply@example.org" 92 | to = ["alert@example.org"] 93 | ``` 94 | 95 | | Key | Type | Default | Required | Description | 96 | | --- | ---- | ------: | -------- | ----------- | 97 | | `name` | string | | Yes | Descriptive monitor name. | 98 | | `interval` | int | 60 | No | Interval seconds between probes. | 99 | | `timeout` | int | 59 | No | Timeout seconds for a probe. | 100 | | `min` | float | 0.0 | No | The minimum of the normal probe output. | 101 | | `max` | float | 0.0 | No | The maximum of the normal probe output. | 102 | | `probe` | table | | Yes | Probe properties. See below. | 103 | | `filter` | table | | No | Filter properties. See below. | 104 | | `actions` | list of table | | Yes | List of action properties. See below. | 105 | 106 | See [annotated sample file](sample.toml). 107 | 108 | Probes 109 | ------------------------- 110 | 111 | See GoDoc for construction parameters: 112 | 113 | * [exec](https://godoc.org/github.com/cybozu-go/goma/probes/exec) 114 | * [http](https://godoc.org/github.com/cybozu-go/goma/probes/http) 115 | * [mysql](https://godoc.org/github.com/cybozu-go/goma/probes/mysql) 116 | 117 | Filters 118 | --------------------------- 119 | 120 | See GoDoc for construction parameters: 121 | 122 | * [average](https://godoc.org/github.com/cybozu-go/goma/filters/average) 123 | 124 | Actions 125 | --------------------------- 126 | 127 | See GoDoc for construction parameters: 128 | 129 | * [exec](https://godoc.org/github.com/cybozu-go/goma/actions/exec) 130 | * [http](https://godoc.org/github.com/cybozu-go/goma/actions/http) 131 | * [mail](https://godoc.org/github.com/cybozu-go/goma/actions/mail) 132 | 133 | Security 134 | ----------------------------- 135 | 136 | Care must be taken on which address and user will goma run. 137 | 138 | Since goma can add dynamically a monitor to execute arbitrary commands, 139 | it is strongly discouraged to run goma as root (super user), or listen 140 | on any address. 141 | 142 | By default, goma listens on "localhost:3838" so that only local users 143 | can access its REST API. You may change the address to, say, ":3838" 144 | to accept requests from remote hosts. Use firewalls like [iptables][], 145 | [ufw][], or [firewalld][] to restrict access. 146 | 147 | Strongly recommended is to create a user solely for goma, and run goma 148 | as that user. To escalate privileges for some probes, `sudo` with 149 | properly configured `/etc/sudoers` can be used. 150 | 151 | REST API 152 | ------------------------ 153 | 154 | ### /list 155 | 156 | GET will return a list of monitor status objects in JSON: 157 | 158 | ```javascript 159 | [ 160 | {"id": "0", "name": "monitor1", "running": true, "failing": false}, 161 | ... 162 | ] 163 | ``` 164 | 165 | ### /register 166 | 167 | POST will create and start a new monitor. 168 | The request content-type must be `application/json`. 169 | 170 | The request body is a JSON object just like TOML monitor table: 171 | 172 | ```javascript 173 | { 174 | "name": "monitor1", 175 | "interval": 10, 176 | "timeout": 1, 177 | "min": 0, 178 | "max": 0.3, 179 | "probe": { 180 | "type": "exec", 181 | "command": "/some/probe/cmd" 182 | }, 183 | "filter": { 184 | "type": "average" 185 | }, 186 | "actions": [ 187 | { 188 | "type": "exec", 189 | "command": "/some/action/cmd" 190 | }, 191 | ... 192 | ] 193 | } 194 | ``` 195 | 196 | ### /monitor/ID 197 | 198 | GET returns monitor status for the given ID. 199 | The response is a JSON object: 200 | 201 | ```javascript 202 | { 203 | "id": "0", 204 | "name": "monitor1", 205 | "running": true, 206 | "failing": false 207 | } 208 | ``` 209 | 210 | DELETE will stop and unregister the monitor. 211 | 212 | POST can stop or start the monitor. 213 | The request content-type should be `text/plain`. 214 | The request body shall be either `start` or `stop`. 215 | 216 | ### /verbosity 217 | 218 | GET will return the current verbosity. 219 | Possible values are: `critical`, `error`, `warning`, `info`, and `debug`. 220 | 221 | PUT or POST will modify the verbosity as given by the request body. 222 | The request content-type should be `text/plain`. 223 | The request body shall be the new verbosity level string such as "error". 224 | 225 | [systemd]: https://www.freedesktop.org/wiki/Software/systemd/ 226 | [upstart]: http://upstart.ubuntu.com/ 227 | [TOML]: https://github.com/toml-lang/toml 228 | [iptables]: https://en.wikipedia.org/wiki/Iptables 229 | [ufw]: https://wiki.ubuntu.com/UncomplicatedFirewall 230 | [firewalld]: https://fedoraproject.org/wiki/FirewallD 231 | -------------------------------------------------------------------------------- /actions/action.go: -------------------------------------------------------------------------------- 1 | // Package actions provides API to implement goma actions. 2 | package actions 3 | 4 | import ( 5 | "errors" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // Actor is the interface for actions. 11 | type Actor interface { 12 | // Init is called when goma starts monitoring. 13 | // 14 | // name is the monitor name. 15 | // Non-nil error is logged, and STOPS the monitor. 16 | Init(name string) error 17 | 18 | // Fail is called when a probe is start failing. 19 | // 20 | // name is the monitor name. 21 | // v is the returned value from the probe (or a value from the filter). 22 | // Non-nil error is logged, but will not stop the monitor. 23 | Fail(name string, v float64) error 24 | 25 | // Recover is called when a probe is recovered from failure. 26 | // 27 | // name is the monitor name. 28 | // d is the failure duration. 29 | // Non-nil error is logged, but will not stop the monitor. 30 | // 31 | // Note that this may not always be called if goma is stopped 32 | // during failure. Init is the good place to correct such status. 33 | Recover(name string, d time.Duration) error 34 | 35 | // String returns a descriptive string for this action. 36 | String() string 37 | } 38 | 39 | // Constructor is a function to create an action. 40 | // 41 | // params are configuration options for the action. 42 | type Constructor func(params map[string]interface{}) (Actor, error) 43 | 44 | // Errors for actions. 45 | var ( 46 | ErrNotFound = errors.New("action not found") 47 | ) 48 | 49 | var ( 50 | registryLock = new(sync.Mutex) 51 | registry = make(map[string]Constructor) 52 | ) 53 | 54 | // Register registers a constructor of a kind of probes. 55 | func Register(name string, ctor Constructor) { 56 | registryLock.Lock() 57 | defer registryLock.Unlock() 58 | 59 | if _, ok := registry[name]; ok { 60 | panic("duplicate action entry: " + name) 61 | } 62 | 63 | registry[name] = ctor 64 | } 65 | 66 | // Construct constructs a named action. 67 | // This function is used internally in goma. 68 | func Construct(name string, params map[string]interface{}) (Actor, error) { 69 | registryLock.Lock() 70 | ctor, ok := registry[name] 71 | registryLock.Unlock() 72 | 73 | if !ok { 74 | return nil, ErrNotFound 75 | } 76 | 77 | return ctor(params) 78 | } 79 | -------------------------------------------------------------------------------- /actions/all/all.go: -------------------------------------------------------------------------------- 1 | // Package all import all actions to be compiled-in. 2 | package all 3 | 4 | import ( 5 | // import all actions 6 | _ "github.com/cybozu-go/goma/actions/exec" 7 | _ "github.com/cybozu-go/goma/actions/http" 8 | _ "github.com/cybozu-go/goma/actions/mail" 9 | ) 10 | -------------------------------------------------------------------------------- /actions/exec/action.go: -------------------------------------------------------------------------------- 1 | package exec 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "sort" 9 | "strings" 10 | "time" 11 | 12 | "github.com/cybozu-go/goma" 13 | "github.com/cybozu-go/goma/actions" 14 | "github.com/cybozu-go/log" 15 | ) 16 | 17 | const ( 18 | eventInit = "init" 19 | eventFail = "fail" 20 | eventRecover = "recover" 21 | 22 | envMonitor = "GOMA_MONITOR" 23 | envEvent = "GOMA_EVENT" 24 | envValue = "GOMA_VALUE" 25 | envDuration = "GOMA_DURATION" 26 | envVersion = "GOMA_VERSION" 27 | ) 28 | 29 | type action struct { 30 | command string 31 | args []string 32 | env []string 33 | timeout time.Duration 34 | debug bool 35 | } 36 | 37 | func mergeEnv(env, bgenv []string) (merged []string) { 38 | m := make(map[string]string) 39 | for _, e := range bgenv { 40 | m[strings.SplitN(e, "=", 2)[0]] = e 41 | } 42 | for _, e := range env { 43 | m[strings.SplitN(e, "=", 2)[0]] = e 44 | } 45 | for _, v := range m { 46 | merged = append(merged, v) 47 | } 48 | sort.Strings(merged) 49 | return 50 | } 51 | 52 | func (a *action) run(env []string) error { 53 | var cmd *exec.Cmd 54 | if a.timeout == 0 { 55 | cmd = exec.Command(a.command, a.args...) 56 | } else { 57 | ctx, cancel := context.WithTimeout(context.Background(), a.timeout) 58 | defer cancel() 59 | cmd = exec.CommandContext(ctx, a.command, a.args...) 60 | } 61 | cmd.Dir = "/" 62 | cmd.Env = mergeEnv(env, a.env) 63 | 64 | if a.debug { 65 | out, err := cmd.CombinedOutput() 66 | log.Error("action:exec debug", map[string]interface{}{ 67 | "output": out, 68 | "error": err.Error(), 69 | }) 70 | return err 71 | } 72 | return cmd.Run() 73 | } 74 | 75 | func (a *action) Init(name string) error { 76 | env := []string{ 77 | fmt.Sprintf("%s=%s", envMonitor, name), 78 | fmt.Sprintf("%s=%s", envVersion, goma.Version), 79 | fmt.Sprintf("%s=%s", envEvent, eventInit), 80 | } 81 | return a.run(env) 82 | } 83 | 84 | func (a *action) Fail(name string, v float64) error { 85 | env := []string{ 86 | fmt.Sprintf("%s=%s", envMonitor, name), 87 | fmt.Sprintf("%s=%s", envVersion, goma.Version), 88 | fmt.Sprintf("%s=%s", envEvent, eventFail), 89 | fmt.Sprintf("%s=%g", envValue, v), // %g suppresses trailing zeroes. 90 | } 91 | return a.run(env) 92 | } 93 | 94 | func (a *action) Recover(name string, d time.Duration) error { 95 | env := []string{ 96 | fmt.Sprintf("%s=%s", envMonitor, name), 97 | fmt.Sprintf("%s=%s", envVersion, goma.Version), 98 | fmt.Sprintf("%s=%s", envEvent, eventRecover), 99 | fmt.Sprintf("%s=%d", envDuration, int(d.Seconds())), 100 | } 101 | return a.run(env) 102 | } 103 | 104 | func (a *action) String() string { 105 | return "action:exec:" + a.command 106 | } 107 | 108 | func construct(params map[string]interface{}) (actions.Actor, error) { 109 | command, err := goma.GetString("command", params) 110 | if err != nil { 111 | return nil, err 112 | } 113 | args, err := goma.GetStringList("args", params) 114 | if err != nil && err != goma.ErrNoKey { 115 | return nil, err 116 | } 117 | env, err := goma.GetStringList("env", params) 118 | switch err { 119 | case nil: 120 | env = mergeEnv(env, os.Environ()) 121 | case goma.ErrNoKey: 122 | env = os.Environ() 123 | default: 124 | return nil, err 125 | } 126 | timeout, err := goma.GetInt("timeout", params) 127 | if err != nil && err != goma.ErrNoKey { 128 | return nil, err 129 | } 130 | debug, err := goma.GetBool("debug", params) 131 | if err != nil && err != goma.ErrNoKey { 132 | return nil, err 133 | } 134 | 135 | return &action{ 136 | command: command, 137 | args: args, 138 | env: env, 139 | timeout: time.Duration(timeout) * time.Second, 140 | debug: debug, 141 | }, nil 142 | } 143 | 144 | func init() { 145 | actions.Register("exec", construct) 146 | } 147 | -------------------------------------------------------------------------------- /actions/exec/action_unix_test.go: -------------------------------------------------------------------------------- 1 | //go:build !nacl && !plan9 && !windows 2 | // +build !nacl,!plan9,!windows 3 | 4 | package exec 5 | 6 | import ( 7 | "testing" 8 | "time" 9 | 10 | "github.com/cybozu-go/goma" 11 | ) 12 | 13 | func TestConstruct(t *testing.T) { 14 | t.Parallel() 15 | 16 | _, err := construct(nil) 17 | if err != goma.ErrNoKey { 18 | t.Error(`err != goma.ErrNoKey`) 19 | } 20 | 21 | a, err := construct(map[string]interface{}{ 22 | "command": "sh", 23 | "args": []interface{}{"-u", "-c", ` 24 | echo GOMA_MONITOR=$GOMA_MONITOR 25 | echo GOMA_VERSION=$GOMA_VERSION 26 | if [ "$GOMA_EVENT" != "init" ]; then exit 1; fi 27 | `}, 28 | }) 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | 33 | if err := a.Init("monitor1"); err != nil { 34 | t.Error(err) 35 | } 36 | } 37 | 38 | func TestFail(t *testing.T) { 39 | t.Parallel() 40 | 41 | a, err := construct(map[string]interface{}{ 42 | "command": "sh", 43 | "args": []interface{}{"-u", "-c", ` 44 | echo GOMA_VALUE=$GOMA_VALUE 45 | if [ "$GOMA_EVENT" != "fail" ]; then exit 1; fi 46 | if [ "$GOMA_VALUE" != "0.1" ]; then exit 1; fi 47 | `}, 48 | }) 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | 53 | if err := a.Fail("monitor1", 0.1); err != nil { 54 | t.Error(err) 55 | } 56 | } 57 | 58 | func TestRecover(t *testing.T) { 59 | t.Parallel() 60 | 61 | a, err := construct(map[string]interface{}{ 62 | "command": "sh", 63 | "args": []interface{}{"-u", "-c", ` 64 | echo GOMA_DURATION=$GOMA_DURATION 65 | if [ "$GOMA_EVENT" != "recover" ]; then exit 1; fi 66 | if [ "$GOMA_DURATION" != "39" ]; then exit 1; fi 67 | `}, 68 | }) 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | 73 | if err := a.Recover("monitor1", 39280*time.Millisecond); err != nil { 74 | t.Error(err) 75 | } 76 | } 77 | 78 | func TestEnv(t *testing.T) { 79 | t.Parallel() 80 | 81 | a, err := construct(map[string]interface{}{ 82 | "command": "sh", 83 | "args": []interface{}{"-u", "-c", ` 84 | echo TEST_ENV1=$TEST_ENV1 85 | if [ "$TEST_ENV1" != "test1" ]; then exit 1; fi 86 | `}, 87 | "env": []interface{}{"TEST_ENV1=test1"}, 88 | }) 89 | if err != nil { 90 | t.Fatal(err) 91 | } 92 | 93 | if err := a.Fail("monitor1", 0); err != nil { 94 | t.Error(err) 95 | } 96 | } 97 | 98 | func TestTimeout(t *testing.T) { 99 | t.Parallel() 100 | 101 | a, err := construct(map[string]interface{}{ 102 | "command": "sleep", 103 | "args": []interface{}{"10"}, 104 | "timeout": 1, 105 | }) 106 | if err != nil { 107 | t.Fatal(err) 108 | } 109 | 110 | if err := a.Init("monitor1"); err == nil { 111 | t.Error("err must not be nil") 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /actions/exec/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package exec implements "exec" action type that runs arbitrary commands. 3 | 4 | Monitor information are passed by environment variables: 5 | 6 | Name Description 7 | GOMA_MOINTOR The name of the monitor. 8 | GOMA_EVENT Event name. One of "init", "fail" or "recover". 9 | GOMA_VALUE The probe(filter) value. Available on failure. 10 | GOMA_DURATION Failure duration in seconds. Available on recovery. 11 | GOMA_VERSION Goma version such as "0.1". 12 | 13 | The constructor takes these parameters: 14 | 15 | Name Type Default Description 16 | command string The command to run. Required. 17 | args []string nil Arguments for the command. 18 | env []string nil Environment variables. See os.Environ. 19 | timeout int 0 Timeout seconds for command execution. 20 | Zero disables timeout. 21 | debug bool false If true, command outputs are logged on failure. 22 | */ 23 | package exec 24 | -------------------------------------------------------------------------------- /actions/http/action.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "strconv" 11 | "strings" 12 | "time" 13 | 14 | "github.com/cybozu-go/goma" 15 | "github.com/cybozu-go/goma/actions" 16 | ) 17 | 18 | const ( 19 | defaultTimeout = 30 20 | ) 21 | 22 | var ( 23 | client = &http.Client{} 24 | ) 25 | 26 | type action struct { 27 | urlInit *url.URL 28 | urlFail *url.URL 29 | urlRecover *url.URL 30 | method string 31 | header map[string]string 32 | params map[string]string 33 | timeout time.Duration 34 | } 35 | 36 | func processResponse(u *url.URL, resp *http.Response) error { 37 | defer func() { 38 | io.Copy(io.Discard, resp.Body) 39 | resp.Body.Close() 40 | }() 41 | 42 | if 200 <= resp.StatusCode && resp.StatusCode < 300 { 43 | return nil 44 | } 45 | return fmt.Errorf("action:http:%s %s", u.String(), resp.Status) 46 | } 47 | 48 | func (a *action) request(u *url.URL, params map[string]string) error { 49 | tu := *u 50 | values := tu.Query() 51 | for k, v := range a.params { 52 | values.Set(k, v) 53 | } 54 | for k, v := range params { 55 | values.Set(k, v) 56 | } 57 | hname, err := os.Hostname() 58 | if err != nil { 59 | return err 60 | } 61 | values.Set("host", hname) 62 | values.Set("version", goma.Version) 63 | data := values.Encode() 64 | 65 | header := make(http.Header) 66 | for k, v := range a.header { 67 | header.Set(k, v) 68 | } 69 | 70 | var body io.ReadCloser 71 | var length int64 72 | if a.method == http.MethodGet { 73 | tu.RawQuery = data 74 | } else { 75 | header.Set("Content-Type", "application/x-www-form-urlencoded") 76 | length = int64(len(data)) 77 | body = io.NopCloser(strings.NewReader(data)) 78 | } 79 | req := &http.Request{ 80 | Method: a.method, 81 | URL: &tu, 82 | Proto: "HTTP/1.1", 83 | ProtoMajor: 1, 84 | ProtoMinor: 1, 85 | Header: header, 86 | Body: body, 87 | ContentLength: length, 88 | Host: u.Host, 89 | } 90 | 91 | if a.timeout > 0 { 92 | ctx, cancel := context.WithTimeout(context.Background(), a.timeout) 93 | defer cancel() 94 | req = req.WithContext(ctx) 95 | } 96 | 97 | resp, err := client.Do(req) 98 | if err != nil { 99 | return err 100 | } 101 | return processResponse(u, resp) 102 | } 103 | 104 | func (a *action) Init(name string) error { 105 | if a.urlInit == nil { 106 | return nil 107 | } 108 | params := make(map[string]string) 109 | for k, v := range a.params { 110 | params[k] = v 111 | } 112 | params["monitor"] = name 113 | params["event"] = "init" 114 | return a.request(a.urlInit, params) 115 | } 116 | 117 | func (a *action) Fail(name string, v float64) error { 118 | if a.urlFail == nil { 119 | return nil 120 | } 121 | params := make(map[string]string) 122 | for k, v := range a.params { 123 | params[k] = v 124 | } 125 | params["monitor"] = name 126 | params["event"] = "fail" 127 | params["value"] = fmt.Sprintf("%g", v) // %g suppresses trailing zeroes. 128 | return a.request(a.urlFail, params) 129 | } 130 | 131 | func (a *action) Recover(name string, d time.Duration) error { 132 | if a.urlRecover == nil { 133 | return nil 134 | } 135 | params := make(map[string]string) 136 | for k, v := range a.params { 137 | params[k] = v 138 | } 139 | params["monitor"] = name 140 | params["event"] = "recover" 141 | params["duration"] = strconv.Itoa(int(d.Seconds())) 142 | return a.request(a.urlRecover, params) 143 | } 144 | 145 | func (a *action) String() string { 146 | return fmt.Sprintf("action:http:%s:%s:%s", 147 | a.urlInit, a.urlFail, a.urlRecover) 148 | } 149 | 150 | func construct(params map[string]interface{}) (actions.Actor, error) { 151 | var uI, uF, uR *url.URL 152 | urlInit, err := goma.GetString("url_init", params) 153 | switch err { 154 | case nil: 155 | uI, err = url.Parse(urlInit) 156 | if err != nil { 157 | return nil, err 158 | } 159 | case goma.ErrNoKey: 160 | default: 161 | return nil, err 162 | } 163 | urlFail, err := goma.GetString("url_fail", params) 164 | switch err { 165 | case nil: 166 | uF, err = url.Parse(urlFail) 167 | if err != nil { 168 | return nil, err 169 | } 170 | case goma.ErrNoKey: 171 | default: 172 | return nil, err 173 | } 174 | urlRecover, err := goma.GetString("url_recover", params) 175 | switch err { 176 | case nil: 177 | uR, err = url.Parse(urlRecover) 178 | if err != nil { 179 | return nil, err 180 | } 181 | case goma.ErrNoKey: 182 | default: 183 | return nil, err 184 | } 185 | 186 | method, err := goma.GetString("method", params) 187 | switch err { 188 | case nil: 189 | case goma.ErrNoKey: 190 | method = http.MethodGet 191 | default: 192 | return nil, err 193 | } 194 | agent, err := goma.GetString("agent", params) 195 | switch err { 196 | case nil: 197 | case goma.ErrNoKey: 198 | agent = "goma/" + goma.Version 199 | default: 200 | return nil, err 201 | } 202 | header, err := goma.GetStringMap("header", params) 203 | switch err { 204 | case nil: 205 | case goma.ErrNoKey: 206 | header = map[string]string{"User-Agent": agent} 207 | default: 208 | return nil, err 209 | } 210 | formParams, err := goma.GetStringMap("params", params) 211 | if err != nil && err != goma.ErrNoKey { 212 | return nil, err 213 | } 214 | timeout, err := goma.GetInt("timeout", params) 215 | switch err { 216 | case nil: 217 | case goma.ErrNoKey: 218 | timeout = defaultTimeout 219 | default: 220 | return nil, err 221 | } 222 | 223 | return &action{ 224 | urlInit: uI, 225 | urlFail: uF, 226 | urlRecover: uR, 227 | method: method, 228 | header: header, 229 | params: formParams, 230 | timeout: time.Duration(timeout) * time.Second, 231 | }, nil 232 | } 233 | 234 | func init() { 235 | actions.Register("http", construct) 236 | } 237 | -------------------------------------------------------------------------------- /actions/http/action_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "net" 10 | "net/http" 11 | "os" 12 | "strconv" 13 | "strings" 14 | "testing" 15 | "time" 16 | 17 | "github.com/cybozu-go/goma" 18 | ) 19 | 20 | const ( 21 | testAddress = "localhost:13839" 22 | testUserAgent = "testUserAgent" 23 | testHeaderName = "X-Goma-Test" 24 | testHeaderValue = "gomagoma" 25 | testParamName = "test1" 26 | testParamValue = "parapara" 27 | ) 28 | 29 | func checkRequest(r *http.Request, method, event string) error { 30 | if r.Method != method { 31 | return fmt.Errorf("bad method: %s", r.Method) 32 | } 33 | if r.FormValue("monitor") != "monitor1" { 34 | return fmt.Errorf("bad monitor: %s", r.FormValue("monitor")) 35 | } 36 | hname, _ := os.Hostname() 37 | if r.FormValue("host") != hname { 38 | return fmt.Errorf("bad host: %s", r.FormValue("host")) 39 | } 40 | if r.FormValue("event") != event { 41 | return fmt.Errorf("bad event: %s", r.FormValue("event")) 42 | } 43 | if r.FormValue("version") != goma.Version { 44 | return fmt.Errorf("bad version: %s", r.FormValue("version")) 45 | } 46 | return nil 47 | } 48 | 49 | func serve(l net.Listener) { 50 | router := http.NewServeMux() 51 | router.HandleFunc("/init", func(w http.ResponseWriter, r *http.Request) { 52 | if err := checkRequest(r, http.MethodGet, "init"); err != nil { 53 | http.Error(w, err.Error(), http.StatusBadRequest) 54 | return 55 | } 56 | }) 57 | router.HandleFunc("/fail", func(w http.ResponseWriter, r *http.Request) { 58 | if err := checkRequest(r, http.MethodGet, "fail"); err != nil { 59 | http.Error(w, err.Error(), http.StatusBadRequest) 60 | return 61 | } 62 | if r.FormValue("value") != "0.2" { 63 | http.Error(w, `r.FormValue("value") != "0.2"`, 64 | http.StatusBadRequest) 65 | return 66 | } 67 | }) 68 | router.HandleFunc("/recover", func(w http.ResponseWriter, r *http.Request) { 69 | if err := checkRequest(r, http.MethodGet, "recover"); err != nil { 70 | http.Error(w, err.Error(), http.StatusBadRequest) 71 | return 72 | } 73 | if r.FormValue("duration") != "39" { 74 | http.Error(w, `r.FormValue("duration") != "39"`, 75 | http.StatusBadRequest) 76 | return 77 | } 78 | }) 79 | router.HandleFunc("/post", func(w http.ResponseWriter, r *http.Request) { 80 | if err := checkRequest(r, http.MethodPost, "init"); err != nil { 81 | http.Error(w, err.Error(), http.StatusBadRequest) 82 | return 83 | } 84 | }) 85 | router.HandleFunc("/params", func(w http.ResponseWriter, r *http.Request) { 86 | if err := checkRequest(r, http.MethodGet, "init"); err != nil { 87 | http.Error(w, err.Error(), http.StatusBadRequest) 88 | return 89 | } 90 | if r.FormValue(testParamName) != testParamValue { 91 | http.Error(w, `r.FormValue(testParamName) != testParamValue`, 92 | http.StatusBadRequest) 93 | return 94 | } 95 | }) 96 | router.HandleFunc("/500", func(w http.ResponseWriter, r *http.Request) { 97 | http.Error(w, "500", http.StatusInternalServerError) 98 | }) 99 | router.HandleFunc("/header", func(w http.ResponseWriter, r *http.Request) { 100 | if r.Header.Get(testHeaderName) != testHeaderValue { 101 | http.Error(w, "Bad header", http.StatusBadRequest) 102 | } 103 | }) 104 | router.HandleFunc("/ua", func(w http.ResponseWriter, r *http.Request) { 105 | if r.Header.Get("User-Agent") != testUserAgent { 106 | http.Error(w, "Bad User Agent", http.StatusBadRequest) 107 | } 108 | }) 109 | router.HandleFunc("/sleep/", func(w http.ResponseWriter, r *http.Request) { 110 | t := strings.Split(r.URL.Path, "/") 111 | i, err := strconv.Atoi(t[len(t)-1]) 112 | if err != nil { 113 | http.Error(w, err.Error(), http.StatusBadRequest) 114 | return 115 | } 116 | time.Sleep(time.Duration(i) * time.Second) 117 | }) 118 | 119 | s := &http.Server{ 120 | Handler: router, 121 | } 122 | s.Serve(l) 123 | } 124 | 125 | func TestMain(m *testing.M) { 126 | flag.Parse() 127 | 128 | l, err := net.Listen("tcp", testAddress) 129 | if err != nil { 130 | log.Fatal(err) 131 | } 132 | go serve(l) 133 | os.Exit(m.Run()) 134 | } 135 | 136 | func makeURL(path ...string) string { 137 | return fmt.Sprintf("http://%s/%s", testAddress, strings.Join(path, "/")) 138 | } 139 | 140 | func TestConstruct(t *testing.T) { 141 | t.Parallel() 142 | 143 | _, err := construct(map[string]interface{}{ 144 | "url_init": true, 145 | }) 146 | if err != goma.ErrInvalidType { 147 | t.Error(`err != goma.ErrInvalidType`) 148 | } 149 | 150 | a, err := construct(nil) 151 | if err != nil { 152 | t.Fatal(err) 153 | } 154 | 155 | if err := a.Init("hoge"); err != nil { 156 | t.Error(err) 157 | } 158 | } 159 | 160 | func TestBasic(t *testing.T) { 161 | t.Parallel() 162 | 163 | a, err := construct(map[string]interface{}{ 164 | "url_init": makeURL("init"), 165 | "url_fail": makeURL("fail"), 166 | "url_recover": makeURL("recover"), 167 | }) 168 | if err != nil { 169 | t.Fatal(err) 170 | } 171 | 172 | if err := a.Init("monitor1"); err != nil { 173 | t.Error(err) 174 | } 175 | if err := a.Fail("monitor1", 0.2); err != nil { 176 | t.Error(err) 177 | } 178 | if err := a.Recover("monitor1", 39120*time.Millisecond); err != nil { 179 | t.Error(err) 180 | } 181 | } 182 | 183 | func TestError(t *testing.T) { 184 | t.Parallel() 185 | 186 | a, err := construct(map[string]interface{}{ 187 | "url_init": makeURL("500"), 188 | }) 189 | if err != nil { 190 | t.Fatal(err) 191 | } 192 | 193 | if err := a.Init("monitor1"); err == nil { 194 | t.Error("500 error is expected") 195 | } 196 | } 197 | 198 | func TestPost(t *testing.T) { 199 | t.Parallel() 200 | 201 | a, err := construct(map[string]interface{}{ 202 | "url_init": makeURL("post"), 203 | "method": http.MethodPost, 204 | }) 205 | if err != nil { 206 | t.Fatal(err) 207 | } 208 | 209 | if err := a.Init("monitor1"); err != nil { 210 | t.Error(err) 211 | } 212 | } 213 | 214 | func TestAgent(t *testing.T) { 215 | t.Parallel() 216 | 217 | a, err := construct(map[string]interface{}{ 218 | "url_init": makeURL("ua"), 219 | "agent": testUserAgent, 220 | }) 221 | if err != nil { 222 | t.Fatal(err) 223 | } 224 | 225 | if err := a.Init("monitor1"); err != nil { 226 | t.Error(err) 227 | } 228 | } 229 | 230 | func TestHeader(t *testing.T) { 231 | t.Parallel() 232 | 233 | a, err := construct(map[string]interface{}{ 234 | "url_init": makeURL("header"), 235 | "header": map[string]interface{}{ 236 | testHeaderName: testHeaderValue, 237 | }, 238 | }) 239 | if err != nil { 240 | t.Fatal(err) 241 | } 242 | 243 | if err := a.Init("monitor1"); err != nil { 244 | t.Error(err) 245 | } 246 | } 247 | 248 | func TestParams(t *testing.T) { 249 | t.Parallel() 250 | 251 | a, err := construct(map[string]interface{}{ 252 | "url_init": makeURL("params"), 253 | "params": map[string]interface{}{ 254 | testParamName: testParamValue, 255 | }, 256 | }) 257 | if err != nil { 258 | t.Fatal(err) 259 | } 260 | 261 | if err := a.Init("monitor1"); err != nil { 262 | t.Error(err) 263 | } 264 | } 265 | 266 | func TestTimeout(t *testing.T) { 267 | t.Parallel() 268 | 269 | a, err := construct(map[string]interface{}{ 270 | "url_init": makeURL("sleep", "10"), 271 | "timeout": 1, 272 | }) 273 | if err != nil { 274 | t.Fatal(err) 275 | } 276 | 277 | if err := a.Init("monitor1"); !errors.Is(err, context.DeadlineExceeded) { 278 | t.Errorf("expected %v, got %v", context.DeadlineExceeded, err) 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /actions/http/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package http implements "http" action type that send events to HTTP(S) server. 3 | 4 | GET or POST form variables are automatically appended as follows: 5 | 6 | Name Description 7 | monitor The monitor name. 8 | host Hostname where goma server is running. 9 | event One of "init", "fail", or "recover". 10 | value The probe(filter) value. Appended on failure. 11 | duration Failure duration in seconds. Appended on recovery. 12 | version Goma version such as "0.1". 13 | 14 | The constructor takes these parameters: 15 | 16 | Name Type Default Description 17 | url_init string URL to access on monitor startup. Optional. 18 | url_fail string URL to access on monitor failure. Optional. 19 | url_recover string URL to access on monitor recovery. Optional. 20 | method string GET HTTP method to use. 21 | agent string goma/0.1 User-Agent string. 22 | header map[string]string nil HTTP headers. 23 | params map[string]string nil Additional form parameters. 24 | timeout int 30 Timeout seconds for requests. 25 | Zero means the default timeout. 26 | 27 | If URL is not given for an event type, no request is sent for the event. 28 | 29 | Proxy can be specified through environment variables. 30 | See net.http.ProxyFromEnvironment for details. 31 | 32 | Basic authentication can be used by embedding user:password in URLs. 33 | */ 34 | package http 35 | -------------------------------------------------------------------------------- /actions/mail/action.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net" 8 | "net/mail" 9 | "os" 10 | "regexp" 11 | "strconv" 12 | "text/template" 13 | "time" 14 | 15 | "github.com/cybozu-go/goma" 16 | "github.com/cybozu-go/goma/actions" 17 | gomail "gopkg.in/gomail.v2" 18 | ) 19 | 20 | const ( 21 | // DefaultSubject is a text/template for Subject header. 22 | DefaultSubject = `alert from {{ .Monitor }} on {{ .Host }}` 23 | 24 | // DefaultBody is a text/template for mail message body. 25 | DefaultBody = `Monitor: {{ .Monitor }} 26 | Host: {{ .Host }} 27 | Date: {{ .Date }} 28 | Event: {{ .Event }} 29 | Value: {{printf "%g" .Value}} 30 | Duration: {{ .Duration }} 31 | Version: {{ .Version }} 32 | ` 33 | defaultServer = "localhost:25" 34 | ) 35 | 36 | type tplParams struct { 37 | Monitor string 38 | Host string 39 | Date time.Time 40 | Event string 41 | Value float64 42 | Duration int 43 | Version string 44 | } 45 | 46 | var ( 47 | tplSubject = template.Must(template.New("subject").Parse(DefaultSubject)) 48 | tplBody = template.Must(template.New("body").Parse(DefaultBody)) 49 | 50 | headerPattern = regexp.MustCompile(`(?i)^X-[a-z0-9-]+$`) 51 | ) 52 | 53 | type action struct { 54 | from *mail.Address 55 | to []*mail.Address 56 | initTo []*mail.Address 57 | failTo []*mail.Address 58 | recoverTo []*mail.Address 59 | subject *template.Template 60 | body *template.Template 61 | server string 62 | user string 63 | password string 64 | header map[string]string 65 | bcc bool 66 | } 67 | 68 | func (a *action) send(params *tplParams, altTo []*mail.Address) error { 69 | l := len(a.to) + len(altTo) 70 | if l == 0 { 71 | return nil 72 | } 73 | to := make([]*mail.Address, 0, l) 74 | to = append(to, a.to...) 75 | to = append(to, altTo...) 76 | 77 | hname, err := os.Hostname() 78 | if err != nil { 79 | return err 80 | } 81 | params.Host = hname 82 | params.Date = time.Now() 83 | params.Version = goma.Version 84 | 85 | msg := gomail.NewMessage(gomail.SetCharset("utf-8")) 86 | msg.SetAddressHeader("From", a.from.Address, a.from.Name) 87 | sto := make([]string, 0, len(to)) 88 | for _, t := range to { 89 | sto = append(sto, msg.FormatAddress(t.Address, t.Name)) 90 | } 91 | rcptHeader := "To" 92 | if a.bcc { 93 | rcptHeader = "Bcc" 94 | } 95 | msg.SetHeader(rcptHeader, sto...) 96 | sbj := new(bytes.Buffer) 97 | if err := a.subject.Execute(sbj, params); err != nil { 98 | return err 99 | } 100 | msg.SetHeader("Subject", sbj.String()) 101 | msg.SetDateHeader("Date", params.Date) 102 | for k, v := range a.header { 103 | msg.SetHeader(k, v) 104 | } 105 | 106 | body := new(bytes.Buffer) 107 | if err := a.body.Execute(body, params); err != nil { 108 | return err 109 | } 110 | msg.SetBody("text/plain", body.String()) 111 | 112 | host, port, _ := net.SplitHostPort(a.server) 113 | nport, _ := strconv.Atoi(port) 114 | switch port { 115 | case "smtp", "mail": 116 | nport = 25 117 | case "submission": 118 | nport = 587 119 | case "urd", "ssmtp", "smtps": 120 | nport = 465 121 | } 122 | d := gomail.NewDialer(host, nport, a.user, a.password) 123 | return d.DialAndSend(msg) 124 | } 125 | 126 | func (a *action) Init(name string) error { 127 | params := &tplParams{ 128 | Monitor: name, 129 | Event: "init", 130 | } 131 | return a.send(params, a.initTo) 132 | } 133 | 134 | func (a *action) Fail(name string, v float64) error { 135 | params := &tplParams{ 136 | Monitor: name, 137 | Event: "fail", 138 | Value: v, 139 | } 140 | return a.send(params, a.failTo) 141 | } 142 | 143 | func (a *action) Recover(name string, d time.Duration) error { 144 | params := &tplParams{ 145 | Monitor: name, 146 | Event: "recover", 147 | Duration: int(d.Seconds()), 148 | } 149 | return a.send(params, a.recoverTo) 150 | } 151 | 152 | func (a *action) String() string { 153 | return "action:mail" 154 | } 155 | 156 | func getAddressList(name string, params map[string]interface{}) ([]*mail.Address, error) { 157 | l, err := goma.GetStringList(name, params) 158 | switch err { 159 | case nil: 160 | la := make([]*mail.Address, 0, len(l)) 161 | for _, t := range l { 162 | a, err := mail.ParseAddress(t) 163 | if err != nil { 164 | return nil, err 165 | } 166 | la = append(la, a) 167 | } 168 | return la, nil 169 | case goma.ErrNoKey: 170 | return nil, nil 171 | default: 172 | return nil, err 173 | } 174 | } 175 | 176 | func construct(params map[string]interface{}) (actions.Actor, error) { 177 | fromString, err := goma.GetString("from", params) 178 | if err != nil { 179 | return nil, err 180 | } 181 | from, err := mail.ParseAddress(fromString) 182 | if err != nil { 183 | return nil, err 184 | } 185 | 186 | to, err := getAddressList("to", params) 187 | if err != nil { 188 | return nil, err 189 | } 190 | initTo, err := getAddressList("init_to", params) 191 | if err != nil { 192 | return nil, err 193 | } 194 | failTo, err := getAddressList("fail_to", params) 195 | if err != nil { 196 | return nil, err 197 | } 198 | recoverTo, err := getAddressList("recover_to", params) 199 | if err != nil { 200 | return nil, err 201 | } 202 | 203 | subject := tplSubject 204 | subjectString, err := goma.GetString("subject", params) 205 | switch err { 206 | case nil: 207 | tpl, err := template.New("subject").Parse(subjectString) 208 | if err != nil { 209 | return nil, err 210 | } 211 | if err := tpl.Execute(io.Discard, &tplParams{}); err != nil { 212 | return nil, err 213 | } 214 | subject = tpl 215 | case goma.ErrNoKey: 216 | default: 217 | return nil, err 218 | } 219 | 220 | body := tplBody 221 | bodyString, err := goma.GetString("body", params) 222 | switch err { 223 | case nil: 224 | tpl, err := template.New("body").Parse(bodyString) 225 | if err != nil { 226 | return nil, err 227 | } 228 | if err := tpl.Execute(io.Discard, &tplParams{}); err != nil { 229 | return nil, err 230 | } 231 | body = tpl 232 | case goma.ErrNoKey: 233 | default: 234 | return nil, err 235 | } 236 | 237 | server, err := goma.GetString("server", params) 238 | switch err { 239 | case nil: 240 | if _, _, err := net.SplitHostPort(server); err != nil { 241 | return nil, err 242 | } 243 | case goma.ErrNoKey: 244 | server = defaultServer 245 | default: 246 | return nil, err 247 | } 248 | 249 | user, err := goma.GetString("user", params) 250 | if err != nil && err != goma.ErrNoKey { 251 | return nil, err 252 | } 253 | password, err := goma.GetString("password", params) 254 | if err != nil && err != goma.ErrNoKey { 255 | return nil, err 256 | } 257 | 258 | header, err := goma.GetStringMap("header", params) 259 | if err != nil && err != goma.ErrNoKey { 260 | return nil, err 261 | } 262 | for k := range header { 263 | if !headerPattern.MatchString(k) { 264 | return nil, fmt.Errorf("invalid header: %s", k) 265 | } 266 | } 267 | 268 | bcc, err := goma.GetBool("bcc", params) 269 | if err != nil && err != goma.ErrNoKey { 270 | return nil, err 271 | } 272 | 273 | return &action{ 274 | from: from, 275 | to: to, 276 | initTo: initTo, 277 | failTo: failTo, 278 | recoverTo: recoverTo, 279 | subject: subject, 280 | body: body, 281 | server: server, 282 | user: user, 283 | password: password, 284 | header: header, 285 | bcc: bcc, 286 | }, nil 287 | } 288 | 289 | func init() { 290 | actions.Register("mail", construct) 291 | } 292 | -------------------------------------------------------------------------------- /actions/mail/action_test.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "flag" 6 | "io" 7 | "log" 8 | "net" 9 | "net/mail" 10 | "os" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "github.com/cybozu-go/goma" 16 | ) 17 | 18 | const ( 19 | testAddress = "localhost:13840" 20 | ) 21 | 22 | var ( 23 | chServer <-chan *maildata 24 | 25 | envFrom = os.Getenv("TEST_MAIL_FROM") 26 | envTo = os.Getenv("TEST_MAIL_TO") 27 | envServer = os.Getenv("TEST_MAIL_HOST") 28 | envUser = os.Getenv("TEST_MAIL_USER") 29 | envPassword = os.Getenv("TEST_MAIL_PASSWORD") 30 | ) 31 | 32 | func TestMain(m *testing.M) { 33 | flag.Parse() 34 | 35 | l, err := net.Listen("tcp", testAddress) 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | s, ch := newServer(10) 40 | chServer = ch 41 | go func() { 42 | s.serve(l) 43 | }() 44 | os.Exit(m.Run()) 45 | } 46 | 47 | func TestHeaderPattern(t *testing.T) { 48 | t.Parallel() 49 | 50 | if headerPattern.MatchString("X-hoge fuga") { 51 | t.Error("X-hoge fuga") 52 | } 53 | if headerPattern.MatchString("hoge-fuga") { 54 | t.Error("hoge-fuga") 55 | } 56 | if headerPattern.MatchString("X-hoge:") { 57 | t.Error("X-hoge:") 58 | } 59 | if !headerPattern.MatchString("X-123-Hoge-fuga") { 60 | t.Error("X-123-Hoge-fuga") 61 | } 62 | if !headerPattern.MatchString("x-123-hoge-fuga") { 63 | t.Error("x-123-hoge-fuga") 64 | } 65 | } 66 | 67 | func TestConstruct(t *testing.T) { 68 | t.Parallel() 69 | 70 | _, err := construct(nil) 71 | if err != goma.ErrNoKey { 72 | t.Error(`err != goma.ErrNoKey`) 73 | } 74 | 75 | // Invalid mail address 76 | _, err = construct(map[string]interface{}{ 77 | "from": "3383829289298289222 28923982398 383892389 292398", 78 | }) 79 | if err == nil { 80 | t.Error("from should be invalid") 81 | } 82 | 83 | // Invalid header 84 | _, err = construct(map[string]interface{}{ 85 | "from": "Hirotaka Yamamoto ", 86 | "header": map[string]interface{}{ 87 | "Reply-To": "reply@example.org", 88 | }, 89 | }) 90 | if err == nil { 91 | t.Error("header should be invalid") 92 | } 93 | 94 | a, err := construct(map[string]interface{}{ 95 | "from": "Hirotaka Yamamoto ", 96 | }) 97 | if err != nil { 98 | t.Fatal(err) 99 | } 100 | if a.(*action).from.Address != "ymmt@example.org" { 101 | t.Error(`a.from.Address != "ymmt@example.org"`) 102 | } 103 | if a.(*action).server != defaultServer { 104 | t.Error(`a.(*action).server != defaultServer`) 105 | } 106 | 107 | a, err = construct(map[string]interface{}{ 108 | "from": "Hirotaka Yamamoto ", 109 | "to": []interface{}{ 110 | "Hirotaka Yamamoto ", 111 | "ymmt2@example.org", 112 | }, 113 | }) 114 | if err != nil { 115 | t.Fatal(err) 116 | } 117 | if a.(*action).to[1].Address != "ymmt2@example.org" { 118 | t.Error(`a.to[1].Address != "ymmt2@example.org"`) 119 | } 120 | 121 | a, err = construct(map[string]interface{}{ 122 | "from": "Hirotaka Yamamoto ", 123 | "init_to": []interface{}{ 124 | "Hirotaka Yamamoto ", 125 | "ymmt2@example.org", 126 | }, 127 | }) 128 | if err != nil { 129 | t.Fatal(err) 130 | } 131 | if len(a.(*action).to) != 0 { 132 | t.Error(`len(a.(*action).to) != 0`) 133 | } 134 | if a.(*action).initTo[1].Address != "ymmt2@example.org" { 135 | t.Error(`a.initTo[1].Address != "ymmt2@example.org"`) 136 | } 137 | 138 | a, err = construct(map[string]interface{}{ 139 | "from": "Hirotaka Yamamoto ", 140 | "fail_to": []interface{}{ 141 | "Hirotaka Yamamoto ", 142 | "ymmt2@example.org", 143 | }, 144 | }) 145 | if err != nil { 146 | t.Fatal(err) 147 | } 148 | if len(a.(*action).to) != 0 { 149 | t.Error(`len(a.(*action).to) != 0`) 150 | } 151 | if a.(*action).failTo[1].Address != "ymmt2@example.org" { 152 | t.Error(`a.failTo[1].Address != "ymmt2@example.org"`) 153 | } 154 | 155 | a, err = construct(map[string]interface{}{ 156 | "from": "Hirotaka Yamamoto ", 157 | "recover_to": []interface{}{ 158 | "Hirotaka Yamamoto ", 159 | "ymmt2@example.org", 160 | }, 161 | }) 162 | if err != nil { 163 | t.Fatal(err) 164 | } 165 | if len(a.(*action).to) != 0 { 166 | t.Error(`len(a.(*action).to) != 0`) 167 | } 168 | if a.(*action).recoverTo[1].Address != "ymmt2@example.org" { 169 | t.Error(`a.recoverTo[1].Address != "ymmt2@example.org"`) 170 | } 171 | } 172 | 173 | func TestInitMail(t *testing.T) { 174 | a, err := construct(map[string]interface{}{ 175 | "from": "Hirotaka Yamamoto ", 176 | "to": []interface{}{ 177 | "Hirotaka Yamamoto ", 178 | "ymmt2@example.org", 179 | }, 180 | "init_to": []interface{}{ 181 | "kazu@example.org", 182 | }, 183 | "server": testAddress, 184 | }) 185 | if err != nil { 186 | t.Error(err) 187 | } 188 | 189 | err = a.Init("monitor1") 190 | if err != nil { 191 | t.Fatal(err) 192 | } 193 | 194 | data := <-chServer 195 | if data.from != "ymmt@example.org" { 196 | t.Error(`data.from != "ymmt@example.org"`) 197 | } 198 | if len(data.to) != 3 { 199 | t.Error(`len(data.to) != 3`) 200 | } 201 | msg, err := mail.ReadMessage(strings.NewReader(data.data)) 202 | if err != nil { 203 | t.Error(err) 204 | } 205 | if msg.Header.Get("From") != `"Hirotaka Yamamoto" ` { 206 | t.Error("?", msg.Header.Get("From")) 207 | } 208 | al, err := msg.Header.AddressList("To") 209 | if err != nil { 210 | t.Error(err) 211 | } 212 | if len(al) != 3 { 213 | t.Error(`len(al) != 3`) 214 | } 215 | _, err = msg.Header.Date() 216 | if err != nil { 217 | t.Error(err) 218 | } 219 | if !strings.Contains(msg.Header.Get("Subject"), "monitor1") { 220 | t.Error(`!strings.Contains(msg.Header.Get("Subject"), "monitor1")`) 221 | } 222 | body, err := io.ReadAll(msg.Body) 223 | if err != nil { 224 | t.Error(err) 225 | } 226 | if !bytes.Contains(body, []byte("Event: init")) { 227 | t.Error(`!bytes.Contains(body, []byte("Event: init"))`) 228 | } 229 | } 230 | 231 | func TestFailMail(t *testing.T) { 232 | a, err := construct(map[string]interface{}{ 233 | "from": "Hirotaka Yamamoto ", 234 | "fail_to": []interface{}{ 235 | "kazu@example.org", 236 | }, 237 | "server": testAddress, 238 | }) 239 | if err != nil { 240 | t.Error(err) 241 | } 242 | 243 | err = a.Fail("monitor1", 123.45) 244 | if err != nil { 245 | t.Fatal(err) 246 | } 247 | 248 | data := <-chServer 249 | if data.from != "ymmt@example.org" { 250 | t.Error(`data.from != "ymmt@example.org"`) 251 | } 252 | if len(data.to) != 1 { 253 | t.Error(`len(data.to) != 1`) 254 | } 255 | msg, err := mail.ReadMessage(strings.NewReader(data.data)) 256 | if err != nil { 257 | t.Error(err) 258 | } 259 | al, err := msg.Header.AddressList("To") 260 | if err != nil { 261 | t.Error(err) 262 | } 263 | if len(al) != 1 { 264 | t.Error(`len(al) != 1`) 265 | } 266 | body, err := io.ReadAll(msg.Body) 267 | if err != nil { 268 | t.Error(err) 269 | } 270 | if !bytes.Contains(body, []byte("Event: fail")) { 271 | t.Error(`!bytes.Contains(body, []byte("Event: fail"))`) 272 | } 273 | if !bytes.Contains(body, []byte("Value: 123.45")) { 274 | t.Error(`!bytes.Contains(body, []byte("Value: 123.45"))`) 275 | } 276 | } 277 | 278 | func TestRecoverMail(t *testing.T) { 279 | a, err := construct(map[string]interface{}{ 280 | "from": "Hirotaka Yamamoto ", 281 | "to": []interface{}{ 282 | "hogefuga@example.org", 283 | "abc@example.org", 284 | }, 285 | "fail_to": []interface{}{ 286 | "kazu@example.org", 287 | }, 288 | "server": testAddress, 289 | }) 290 | if err != nil { 291 | t.Error(err) 292 | } 293 | 294 | err = a.Recover("monitor1", 39120*time.Millisecond) 295 | if err != nil { 296 | t.Fatal(err) 297 | } 298 | 299 | data := <-chServer 300 | if data.from != "ymmt@example.org" { 301 | t.Error(`data.from != "ymmt@example.org"`) 302 | } 303 | if len(data.to) != 2 { 304 | t.Error(`len(data.to) != 2`) 305 | } 306 | msg, err := mail.ReadMessage(strings.NewReader(data.data)) 307 | if err != nil { 308 | t.Error(err) 309 | } 310 | al, err := msg.Header.AddressList("To") 311 | if err != nil { 312 | t.Error(err) 313 | } 314 | if len(al) != 2 { 315 | t.Error(`len(al) != 2`) 316 | } 317 | body, err := io.ReadAll(msg.Body) 318 | if err != nil { 319 | t.Error(err) 320 | } 321 | if !bytes.Contains(body, []byte("Event: recover")) { 322 | t.Error(`!bytes.Contains(body, []byte("Event: recover"))`) 323 | } 324 | if !bytes.Contains(body, []byte("Duration: 39")) { 325 | t.Error(`!bytes.Contains(body, []byte("Duration: 39"))`) 326 | } 327 | } 328 | 329 | func TestBcc(t *testing.T) { 330 | a, err := construct(map[string]interface{}{ 331 | "from": "Hirotaka Yamamoto ", 332 | "to": []interface{}{ 333 | "hogefuga@example.org", 334 | "abc@example.org", 335 | }, 336 | "bcc": true, 337 | "server": testAddress, 338 | }) 339 | if err != nil { 340 | t.Error(err) 341 | } 342 | 343 | err = a.Init("monitor1") 344 | if err != nil { 345 | t.Fatal(err) 346 | } 347 | 348 | data := <-chServer 349 | msg, err := mail.ReadMessage(strings.NewReader(data.data)) 350 | if err != nil { 351 | t.Error(err) 352 | } 353 | if len(msg.Header.Get("To")) != 0 { 354 | t.Error(`len(msg.Header.Get("To")) != 0`) 355 | } 356 | if len(msg.Header.Get("Bcc")) != 0 { 357 | t.Error(`len(msg.Header.Get("Bcc")) != 0`) 358 | } 359 | } 360 | 361 | func TestSubject(t *testing.T) { 362 | _, err := construct(map[string]interface{}{ 363 | "from": "Hirotaka Yamamoto ", 364 | "to": []interface{}{ 365 | "hogefuga@example.org", 366 | "abc@example.org", 367 | }, 368 | "subject": `test subject "{{ .NoSuchKey }}"`, 369 | "server": testAddress, 370 | }) 371 | if err == nil { 372 | t.Error("subject is not a valid template") 373 | } 374 | 375 | a, err := construct(map[string]interface{}{ 376 | "from": "Hirotaka Yamamoto ", 377 | "to": []interface{}{ 378 | "hogefuga@example.org", 379 | "abc@example.org", 380 | }, 381 | "subject": `test subject "{{ .Event }}"`, 382 | "server": testAddress, 383 | }) 384 | if err != nil { 385 | t.Error(err) 386 | } 387 | 388 | err = a.Init("monitor1") 389 | if err != nil { 390 | t.Fatal(err) 391 | } 392 | 393 | data := <-chServer 394 | msg, err := mail.ReadMessage(strings.NewReader(data.data)) 395 | if err != nil { 396 | t.Error(err) 397 | } 398 | if msg.Header.Get("Subject") != `test subject "init"` { 399 | t.Error("msg.Header.Get(\"Subject\") != `test subject \"init\"`") 400 | } 401 | } 402 | 403 | func TestBody(t *testing.T) { 404 | _, err := construct(map[string]interface{}{ 405 | "from": "Hirotaka Yamamoto ", 406 | "to": []interface{}{ 407 | "hogefuga@example.org", 408 | "abc@example.org", 409 | }, 410 | "body": `test body "{{ .NoSuchKey }}"`, 411 | "server": testAddress, 412 | }) 413 | if err == nil { 414 | t.Error("body is not a valid template") 415 | } 416 | 417 | a, err := construct(map[string]interface{}{ 418 | "from": "Hirotaka Yamamoto ", 419 | "to": []interface{}{ 420 | "hogefuga@example.org", 421 | "abc@example.org", 422 | }, 423 | "body": `test body "{{ .Event }}"`, 424 | "server": testAddress, 425 | }) 426 | if err != nil { 427 | t.Error(err) 428 | } 429 | 430 | err = a.Init("monitor1") 431 | if err != nil { 432 | t.Fatal(err) 433 | } 434 | 435 | data := <-chServer 436 | msg, err := mail.ReadMessage(strings.NewReader(data.data)) 437 | if err != nil { 438 | t.Error(err) 439 | } 440 | body, err := io.ReadAll(msg.Body) 441 | if err != nil { 442 | t.Error(err) 443 | } 444 | if !bytes.Contains(body, []byte(`test body "init"`)) { 445 | t.Error("!bytes.Contains(body, []byte(`test body \"init\"`))") 446 | } 447 | } 448 | 449 | func TestHeader(t *testing.T) { 450 | _, err := construct(map[string]interface{}{ 451 | "from": "Hirotaka Yamamoto ", 452 | "to": []interface{}{ 453 | "hogefuga@example.org", 454 | "abc@example.org", 455 | }, 456 | "header": map[string]interface{}{ 457 | "Hoge": "Fuga", 458 | }, 459 | "server": testAddress, 460 | }) 461 | if err == nil { 462 | t.Error("header should be invalid") 463 | } 464 | 465 | a, err := construct(map[string]interface{}{ 466 | "from": "Hirotaka Yamamoto ", 467 | "to": []interface{}{ 468 | "hogefuga@example.org", 469 | "abc@example.org", 470 | }, 471 | "header": map[string]interface{}{ 472 | "X-Hoge": "Fuga", 473 | }, 474 | "server": testAddress, 475 | }) 476 | if err != nil { 477 | t.Error(err) 478 | } 479 | 480 | err = a.Init("monitor1") 481 | if err != nil { 482 | t.Fatal(err) 483 | } 484 | 485 | data := <-chServer 486 | msg, err := mail.ReadMessage(strings.NewReader(data.data)) 487 | if err != nil { 488 | t.Error(err) 489 | } 490 | if msg.Header.Get("X-Hoge") != "Fuga" { 491 | t.Error(`msg.Header.Get("X-Hoge") != "Fuga"`) 492 | } 493 | } 494 | 495 | func TestExternalServer(t *testing.T) { 496 | t.Parallel() 497 | 498 | if len(envFrom) == 0 || len(envTo) == 0 || len(envServer) == 0 { 499 | t.Skip() 500 | } 501 | 502 | a, err := construct(map[string]interface{}{ 503 | "from": envFrom, 504 | "to": []interface{}{envTo}, 505 | "server": envServer, 506 | "user": envUser, 507 | "password": envPassword, 508 | }) 509 | if err != nil { 510 | t.Error(err) 511 | } 512 | 513 | err = a.Init("monitor1") 514 | if err != nil { 515 | t.Fatal(err) 516 | } 517 | } 518 | -------------------------------------------------------------------------------- /actions/mail/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package mail implements "mail" action type that send mails. 3 | 4 | The mail body and subject can be customized by text/template: 5 | https://golang.org/pkg/text/template/ 6 | 7 | The template is rendered with this struct: 8 | 9 | struct { 10 | Monitor string // The monitor name. 11 | Host string // The hostname where goma server is running. 12 | Time time.Time // The time of the event. 13 | Event string // One of "init", "fail", or "recover". 14 | Value float64 // The probe(filter) value. Set on failure. 15 | Duration int // Failure duration in seconds. Set on recovery. 16 | Version string // Goma version such as "0.1". 17 | } 18 | 19 | The constructor takes these parameters: 20 | 21 | Name Type Default Description 22 | from string Sender mail address. Required. 23 | to []string nil Destination mail addresses. 24 | init_to []string nil Addresses for "init". 25 | fail_to []string nil Addresses for "fail". 26 | recover_to []string nil Addresses for "recover". 27 | subject string (See source) Subject template. 28 | body string (See source) Mail body template. 29 | server string localhost:25 SMTP server address. 30 | user string SMTP auth user. Optional. 31 | password string SMTP auth password. Optional. 32 | header map[string]string nil Extra headers. 33 | bcc bool false If true, suppress To header. 34 | 35 | If no destination address is given for an event, mail is not sent. 36 | For example, mail is not sent on "init" event if both to and init_to are nil. 37 | 38 | Extra headers must begin with "X-" for security reasons. 39 | */ 40 | package mail 41 | -------------------------------------------------------------------------------- /actions/mail/server_test.go: -------------------------------------------------------------------------------- 1 | // mockup SMTP server. 2 | // 3 | // Only for testing purpose. Do not work for concurrent access. 4 | 5 | package mail 6 | 7 | import ( 8 | "io" 9 | "net" 10 | "net/textproto" 11 | "regexp" 12 | "strings" 13 | ) 14 | 15 | var ( 16 | pathPattern = regexp.MustCompile(`<(?:[^>:]+:)?([^>]+@[^>]+)>`) 17 | ) 18 | 19 | type maildata struct { 20 | from string 21 | to []string 22 | data string 23 | } 24 | 25 | type server struct { 26 | ch chan<- *maildata 27 | } 28 | 29 | func newServer(capacity int) (*server, <-chan *maildata) { 30 | ch := make(chan *maildata, capacity) 31 | return &server{ 32 | ch: ch, 33 | }, ch 34 | } 35 | 36 | func (s *server) listenAndServe(network, addr string) error { 37 | l, err := net.Listen(network, addr) 38 | if err != nil { 39 | return err 40 | } 41 | return s.serve(l) 42 | } 43 | 44 | func (s *server) serve(l net.Listener) error { 45 | for { 46 | conn, err := l.Accept() 47 | if err != nil { 48 | return err 49 | } 50 | // don't go. 51 | s.process(conn) 52 | } 53 | } 54 | 55 | func (s *server) process(c net.Conn) { 56 | tc := textproto.NewConn(c) 57 | defer tc.Close() 58 | 59 | doneHello := false 60 | data := new(maildata) // len(data.from) > 0 means in-transaction. 61 | 62 | reply := func(code int, msg string, cnt bool) error { 63 | delim := " " 64 | if cnt { 65 | delim = "-" 66 | } 67 | return tc.Writer.PrintfLine("%d%s%s", code, delim, msg) 68 | } 69 | 70 | if reply(220, "localhost goma test mail server", false) != nil { 71 | return 72 | } 73 | 74 | for { 75 | l, err := tc.Reader.ReadLine() 76 | if err != nil { 77 | return 78 | } 79 | ul := strings.ToUpper(l) 80 | 81 | switch { 82 | case ul == "NOOP" || strings.HasPrefix(ul, "NOOP "): 83 | if reply(250, "OK", false) != nil { 84 | return 85 | } 86 | case ul == "HELP" || strings.HasPrefix(ul, "HELP "): 87 | if reply(250, "Supported commands: EHLO HELO MAIL RCPT DATA RSET NOOP QUIT VRFY", false) != nil { 88 | return 89 | } 90 | case ul == "QUIT": 91 | reply(221, "OK", false) 92 | return 93 | case strings.HasPrefix(ul, "VRFY "): 94 | if reply(252, "cannot verify, but accept anyway", false) != nil { 95 | return 96 | } 97 | case ul == "RSET": 98 | data = new(maildata) 99 | if reply(250, "OK", false) != nil { 100 | return 101 | } 102 | case strings.HasPrefix(ul, "EHLO "): 103 | if doneHello { 104 | if reply(503, "Duplicate HELO/EHLO", false) != nil { 105 | return 106 | } 107 | continue 108 | } 109 | if reply(250, "localhost greets you", true) != nil { 110 | return 111 | } 112 | if reply(250, "8BITMIME", true) != nil { 113 | return 114 | } 115 | if reply(250, "HELP", false) != nil { 116 | return 117 | } 118 | doneHello = true 119 | case strings.HasPrefix(ul, "HELO "): 120 | if doneHello { 121 | if reply(503, "Duplicate HELO/EHLO", false) != nil { 122 | return 123 | } 124 | continue 125 | } 126 | if reply(250, "localhost", false) != nil { 127 | return 128 | } 129 | doneHello = true 130 | case strings.HasPrefix(ul, "MAIL FROM:"): 131 | if len(data.from) > 0 { 132 | if reply(503, "nested MAIL command", false) != nil { 133 | return 134 | } 135 | continue 136 | } 137 | m := pathPattern.FindStringSubmatch(l) 138 | if len(m) != 2 { 139 | if reply(501, "Syntax: MAIL FROM:
", false) != nil { 140 | return 141 | } 142 | continue 143 | } 144 | data.from = m[1] 145 | if reply(250, "OK", false) != nil { 146 | return 147 | } 148 | case strings.HasPrefix(ul, "RCPT TO:"): 149 | if len(data.from) == 0 { 150 | if reply(503, "need MAIL first", false) != nil { 151 | return 152 | } 153 | continue 154 | } 155 | m := pathPattern.FindStringSubmatch(l) 156 | if len(m) != 2 { 157 | if reply(501, "Syntax: RCPT TO:
", false) != nil { 158 | return 159 | } 160 | continue 161 | } 162 | data.to = append(data.to, m[1]) 163 | if reply(250, "OK", false) != nil { 164 | return 165 | } 166 | case ul == "DATA": 167 | if len(data.from) == 0 { 168 | if reply(503, "need MAIL first", false) != nil { 169 | return 170 | } 171 | continue 172 | } 173 | if len(data.to) == 0 { 174 | if reply(503, "need RCPT first", false) != nil { 175 | return 176 | } 177 | continue 178 | } 179 | if reply(354, "End data with .", false) != nil { 180 | return 181 | } 182 | t, err := io.ReadAll(tc.Reader.DotReader()) 183 | if err != nil { 184 | return 185 | } 186 | data.data = string(t) 187 | s.ch <- data 188 | data = new(maildata) 189 | if reply(250, "OK", false) != nil { 190 | return 191 | } 192 | default: 193 | if reply(500, "unknown command", false) != nil { 194 | return 195 | } 196 | } 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /cmd/goma/command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os" 11 | "strings" 12 | 13 | "github.com/cybozu-go/goma" 14 | "github.com/gorilla/mux" 15 | ) 16 | 17 | func newRequest(method, path string, body io.Reader) *http.Request { 18 | url := fmt.Sprintf("http://%s%s", *listenAddr, path) 19 | req, err := http.NewRequest(method, url, body) 20 | if err != nil { 21 | panic(err) 22 | } 23 | req.Header.Set(goma.VersionHeader, goma.Version) 24 | return req 25 | } 26 | 27 | func readResponse(resp *http.Response) ([]byte, error) { 28 | defer resp.Body.Close() 29 | 30 | data, err := io.ReadAll(resp.Body) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | if resp.StatusCode != http.StatusOK { 36 | fmt.Fprintln(os.Stderr, "Server error:", resp.Status) 37 | return nil, errors.New(string(data)) 38 | } 39 | 40 | return data, nil 41 | } 42 | 43 | func cmdList(r *mux.Router, args []string) error { 44 | client := &http.Client{} 45 | url, err := r.Get("list").URL() 46 | if err != nil { 47 | return err 48 | } 49 | 50 | resp, err := client.Do(newRequest(http.MethodGet, url.Path, nil)) 51 | if err != nil { 52 | return err 53 | } 54 | data, err := readResponse(resp) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | var l goma.List 60 | if err := json.Unmarshal(data, &l); err != nil { 61 | return err 62 | } 63 | 64 | fmt.Printf("%-8s %-32s Running Failing\n", "ID", "Name") 65 | for _, i := range l { 66 | fmt.Printf("%-8d %-32s %-7v %v\n", 67 | i.ID, i.Name, i.Running, i.Failing) 68 | } 69 | return nil 70 | } 71 | 72 | func cmdRegister(r *mux.Router, args []string) error { 73 | if len(args) != 1 { 74 | return errors.New("wrong number of arguments") 75 | } 76 | 77 | defs, err := loadTOML(args[0]) 78 | if err != nil { 79 | return err 80 | } 81 | 82 | client := &http.Client{} 83 | url, err := r.Get("register").URL() 84 | if err != nil { 85 | return err 86 | } 87 | 88 | for _, md := range defs { 89 | data, err := json.Marshal(md) 90 | if err != nil { 91 | return err 92 | } 93 | req := newRequest(http.MethodPost, url.Path, bytes.NewBuffer(data)) 94 | req.Header.Set("Content-Type", "application/json; charset=utf-8") 95 | resp, err := client.Do(req) 96 | if err != nil { 97 | return err 98 | } 99 | data2, err := readResponse(resp) 100 | if err != nil { 101 | return err 102 | } 103 | id := string(data2) 104 | fmt.Printf("%s is registered and started as monitor id=%s\n", 105 | md.Name, id) 106 | } 107 | 108 | return nil 109 | } 110 | 111 | func cmdShow(r *mux.Router, args []string) error { 112 | if len(args) != 1 { 113 | return errors.New("wrong number of arguments") 114 | } 115 | client := &http.Client{} 116 | url, err := r.Get("monitor").URL("id", args[0]) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | resp, err := client.Do(newRequest(http.MethodGet, url.Path, nil)) 122 | if err != nil { 123 | return err 124 | } 125 | 126 | data, err := readResponse(resp) 127 | if err != nil { 128 | return err 129 | } 130 | 131 | var info goma.MonitorInfo 132 | if err := json.Unmarshal(data, &info); err != nil { 133 | return err 134 | } 135 | fmt.Println("Name:", info.Name) 136 | fmt.Printf("Running: %v\n", info.Running) 137 | fmt.Printf("Failing: %v\n", info.Failing) 138 | return nil 139 | } 140 | 141 | func cmdStart(r *mux.Router, args []string) error { 142 | if len(args) != 1 { 143 | return errors.New("wrong number of arguments") 144 | } 145 | client := &http.Client{} 146 | url, err := r.Get("monitor").URL("id", args[0]) 147 | if err != nil { 148 | return err 149 | } 150 | req := newRequest(http.MethodPost, url.Path, strings.NewReader("start")) 151 | req.Header.Set("Content-Type", "text/plain; charset=utf-8") 152 | resp, err := client.Do(req) 153 | if err != nil { 154 | return err 155 | } 156 | _, err = readResponse(resp) 157 | if err != nil { 158 | return err 159 | } 160 | fmt.Println("Started.") 161 | return nil 162 | } 163 | 164 | func cmdStop(r *mux.Router, args []string) error { 165 | if len(args) != 1 { 166 | return errors.New("wrong number of arguments") 167 | } 168 | client := &http.Client{} 169 | url, err := r.Get("monitor").URL("id", args[0]) 170 | if err != nil { 171 | return err 172 | } 173 | req := newRequest(http.MethodPost, url.Path, strings.NewReader("stop")) 174 | req.Header.Set("Content-Type", "text/plain; charset=utf-8") 175 | resp, err := client.Do(req) 176 | if err != nil { 177 | return err 178 | } 179 | _, err = readResponse(resp) 180 | if err != nil { 181 | return err 182 | } 183 | fmt.Println("Stopped.") 184 | return nil 185 | } 186 | 187 | func cmdUnregister(r *mux.Router, args []string) error { 188 | if len(args) != 1 { 189 | return errors.New("wrong number of arguments") 190 | } 191 | client := &http.Client{} 192 | url, err := r.Get("monitor").URL("id", args[0]) 193 | if err != nil { 194 | return err 195 | } 196 | resp, err := client.Do(newRequest(http.MethodDelete, url.Path, nil)) 197 | if err != nil { 198 | return err 199 | } 200 | _, err = readResponse(resp) 201 | if err != nil { 202 | return err 203 | } 204 | fmt.Println("Unregistered.") 205 | return nil 206 | } 207 | 208 | func cmdVerbosity(r *mux.Router, args []string) error { 209 | client := &http.Client{} 210 | url, err := r.Get("verbosity").URL() 211 | if err != nil { 212 | return err 213 | } 214 | if len(args) == 0 { 215 | req := newRequest(http.MethodGet, url.Path, nil) 216 | resp, err := client.Do(req) 217 | if err != nil { 218 | return err 219 | } 220 | data, err := readResponse(resp) 221 | if err != nil { 222 | return err 223 | } 224 | 225 | fmt.Println(string(data)) 226 | return nil 227 | } 228 | 229 | req := newRequest(http.MethodPut, url.Path, strings.NewReader(args[0])) 230 | req.Header.Set("Content-Type", "text/plain; charset=utf-8") 231 | resp, err := client.Do(req) 232 | if err != nil { 233 | return err 234 | } 235 | _, err = readResponse(resp) 236 | if err == nil { 237 | fmt.Println("success.") 238 | } 239 | return err 240 | } 241 | 242 | func runCommand(cmd string, args []string) error { 243 | router := goma.NewRouter() 244 | 245 | commands := map[string]func(r *mux.Router, args []string) error{ 246 | "list": cmdList, 247 | "register": cmdRegister, 248 | "show": cmdShow, 249 | "start": cmdStart, 250 | "stop": cmdStop, 251 | "unregister": cmdUnregister, 252 | "verbosity": cmdVerbosity, 253 | } 254 | if f, ok := commands[cmd]; ok { 255 | return f(router, args) 256 | } 257 | return fmt.Errorf("no such command: %s", cmd) 258 | } 259 | -------------------------------------------------------------------------------- /cmd/goma/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/BurntSushi/toml" 9 | "github.com/cybozu-go/goma" 10 | "github.com/cybozu-go/goma/monitor" 11 | ) 12 | 13 | func loadTOML(f string) ([]*goma.MonitorDefinition, error) { 14 | s := &struct { 15 | Monitors []*goma.MonitorDefinition `toml:"monitor"` 16 | }{nil} 17 | fn := func() (toml.MetaData, error) { 18 | return toml.DecodeFile(f, s) 19 | } 20 | if f == "-" { 21 | fn = func() (toml.MetaData, error) { 22 | return toml.DecodeReader(os.Stdin, s) 23 | } 24 | } 25 | md, err := fn() 26 | if err != nil { 27 | return nil, err 28 | } 29 | if len(md.Undecoded()) > 0 { 30 | return nil, fmt.Errorf("undecoded keys: %v", md.Undecoded()) 31 | } 32 | return s.Monitors, nil 33 | } 34 | 35 | func loadFile(f string) error { 36 | defs, err := loadTOML(f) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | monitors := make([]*monitor.Monitor, 0, len(defs)) 42 | for _, md := range defs { 43 | m, err := goma.CreateMonitor(md) 44 | if err != nil { 45 | return err 46 | } 47 | monitors = append(monitors, m) 48 | } 49 | 50 | for _, m := range monitors { 51 | // ignoring errors is safe at this point. 52 | monitor.Register(m) 53 | m.Start() 54 | } 55 | return nil 56 | } 57 | 58 | func loadConfigs(dir string) error { 59 | files, err := filepath.Glob(filepath.Join(dir, "*.toml")) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | for _, f := range files { 65 | if err := loadFile(f); err != nil { 66 | return err 67 | } 68 | } 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /cmd/goma/config_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | const content = ` 9 | [[monitor]] 10 | [monitor.filter] 11 | window = 2 12 | ` 13 | 14 | func writeToTempFile(content string) (string, error) { 15 | tmpFile, err := os.CreateTemp("", "") 16 | if err != nil { 17 | return "", err 18 | } 19 | defer tmpFile.Close() 20 | _, err = tmpFile.WriteString(content) 21 | if err != nil { 22 | return "", err 23 | } 24 | return tmpFile.Name(), nil 25 | } 26 | 27 | func TestLoadTOML(t *testing.T) { 28 | tmpFileName, err := writeToTempFile(content) 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | defer os.Remove(tmpFileName) 33 | 34 | monitor, err := loadTOML(tmpFileName) 35 | if err != nil { 36 | t.Fatal(err) 37 | } 38 | 39 | v := monitor[0].Filter["window"] 40 | _, ok := v.(int64) 41 | if !ok { 42 | t.Fatalf("monitor[0].filter.window is not int64: %T", v) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /cmd/goma/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/cybozu-go/goma" 10 | _ "github.com/cybozu-go/goma/actions/all" 11 | _ "github.com/cybozu-go/goma/filters/all" 12 | "github.com/cybozu-go/goma/monitor" 13 | _ "github.com/cybozu-go/goma/probes/all" 14 | "github.com/cybozu-go/log" 15 | "github.com/cybozu-go/well" 16 | ) 17 | 18 | const ( 19 | defaultConfDir = "/usr/local/etc/goma" 20 | defaultListenAddr = "localhost:3838" 21 | ) 22 | 23 | var ( 24 | confDir = flag.String("d", defaultConfDir, "directory for monitor configs") 25 | listenAddr = flag.String("s", defaultListenAddr, "HTTP server address") 26 | ) 27 | 28 | func usage() { 29 | fmt.Fprint(os.Stderr, `Usage: goma [options] COMMAND [arg...] 30 | 31 | If COMMAND is "serve", goma runs in server mode. 32 | For other commands, goma works as a client for goma server. 33 | 34 | Options: 35 | `) 36 | flag.PrintDefaults() 37 | fmt.Fprint(os.Stderr, ` 38 | Commands: 39 | serve Start agent server. 40 | list List registered monitors. 41 | register FILE Register monitors defined in FILE. 42 | If FILE is "-", goma reads from stdin. 43 | show ID Show the status of a monitor for ID. 44 | start ID Start a monitor. 45 | stop ID Stop a monitor. 46 | unregister ID Stop and unregister a monitor. 47 | verbosity [LEVEL] Query or change logging threshold. 48 | `) 49 | } 50 | 51 | func main() { 52 | flag.Usage = usage 53 | flag.Parse() 54 | well.LogConfig{}.Apply() 55 | 56 | args := flag.Args() 57 | 58 | if len(args) == 0 { 59 | usage() 60 | return 61 | } 62 | 63 | command := args[0] 64 | 65 | if command != "serve" { 66 | err := runCommand(command, args[1:]) 67 | if err != nil { 68 | fmt.Fprintln(os.Stderr, strings.TrimSpace(err.Error())) 69 | os.Exit(1) 70 | } 71 | return 72 | } 73 | 74 | if err := loadConfigs(*confDir); err != nil { 75 | log.ErrorExit(err) 76 | } 77 | 78 | goma.Serve(*listenAddr) 79 | err := well.Wait() 80 | if err != nil && !well.IsSignaled(err) { 81 | log.ErrorExit(err) 82 | } 83 | 84 | // stop all monitors gracefully. 85 | for _, m := range monitor.ListMonitors() { 86 | m.Stop() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /create.go: -------------------------------------------------------------------------------- 1 | package goma 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/cybozu-go/goma/actions" 9 | "github.com/cybozu-go/goma/filters" 10 | "github.com/cybozu-go/goma/monitor" 11 | "github.com/cybozu-go/goma/probes" 12 | ) 13 | 14 | const ( 15 | typeKey = "type" 16 | defaultInterval = 60 * time.Second 17 | defaultTimeout = 59 * time.Second 18 | ) 19 | 20 | // Errors for goma. 21 | var ( 22 | ErrBadName = errors.New("bad monitor name") 23 | ErrNoType = errors.New("no type") 24 | ErrInvalidType = errors.New("invalid type") 25 | ErrInvalidRange = errors.New("invalid min/max range") 26 | ErrNoKey = errors.New("no key") 27 | ) 28 | 29 | // MonitorDefinition is a struct to load monitor definitions. 30 | // TOML and JSON can be used. 31 | type MonitorDefinition struct { 32 | Name string `toml:"name" json:"name"` 33 | Probe map[string]interface{} `toml:"probe" json:"probe"` 34 | Filter map[string]interface{} `toml:"filter" json:"filter,omitempty"` 35 | Actions []map[string]interface{} `toml:"actions" json:"actions"` 36 | Interval int `toml:"interval" json:"interval,omitempty"` 37 | Timeout int `toml:"timeout" json:"timeout,omitempty"` 38 | Min float64 `toml:"min" json:"min,omitempty"` 39 | Max float64 `toml:"max" json:"max,omitempty"` 40 | } 41 | 42 | func getType(m map[string]interface{}) (t string, err error) { 43 | v, ok := m[typeKey] 44 | if !ok { 45 | err = ErrNoType 46 | return 47 | } 48 | s, ok := v.(string) 49 | if !ok { 50 | err = ErrInvalidType 51 | return 52 | } 53 | t = s 54 | return 55 | } 56 | 57 | func getParams(m map[string]interface{}) map[string]interface{} { 58 | nm := make(map[string]interface{}) 59 | for k, v := range m { 60 | if k == typeKey { 61 | continue 62 | } 63 | nm[k] = v 64 | } 65 | return nm 66 | } 67 | 68 | // CreateMonitor creates a monitor from MonitorDefinition. 69 | func CreateMonitor(d *MonitorDefinition) (*monitor.Monitor, error) { 70 | if len(d.Name) == 0 { 71 | return nil, ErrBadName 72 | } 73 | 74 | t, err := getType(d.Probe) 75 | if err != nil { 76 | return nil, err 77 | } 78 | probe, err := probes.Construct(t, getParams(d.Probe)) 79 | if err != nil { 80 | return nil, fmt.Errorf("%s: %v in probe", d.Name, err) 81 | } 82 | 83 | var filter filters.Filter 84 | if d.Filter != nil { 85 | t, err = getType(d.Filter) 86 | if err != nil { 87 | return nil, err 88 | } 89 | f, err := filters.Construct(t, getParams(d.Filter)) 90 | if err != nil { 91 | return nil, fmt.Errorf("%s: %v in filter", d.Name, err) 92 | } 93 | filter = f 94 | } 95 | 96 | var actors []actions.Actor 97 | for _, ad := range d.Actions { 98 | t, err = getType(ad) 99 | if err != nil { 100 | return nil, err 101 | } 102 | a, err := actions.Construct(t, getParams(ad)) 103 | if err != nil { 104 | return nil, fmt.Errorf("%s: %v in action %s", d.Name, err, t) 105 | } 106 | actors = append(actors, a) 107 | } 108 | 109 | interval := time.Duration(d.Interval) * time.Second 110 | if interval == 0 { 111 | interval = defaultInterval 112 | } 113 | 114 | timeout := time.Duration(d.Timeout) * time.Second 115 | if timeout == 0 { 116 | timeout = defaultTimeout 117 | } 118 | 119 | if d.Min > d.Max { 120 | return nil, ErrInvalidRange 121 | } 122 | 123 | return monitor.NewMonitor(d.Name, probe, filter, actors, 124 | interval, timeout, d.Min, d.Max), nil 125 | } 126 | -------------------------------------------------------------------------------- /create_test.go: -------------------------------------------------------------------------------- 1 | package goma 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/BurntSushi/toml" 8 | ) 9 | 10 | const ( 11 | testFile = "sample.toml" 12 | ) 13 | 14 | func testMonitor1(t *testing.T, m *MonitorDefinition) { 15 | if m.Name != "monitor1" { 16 | t.Error(`m.Name != "monitor1"`) 17 | } 18 | if m.Interval != 10 { 19 | t.Error(`m.Interval != 10`) 20 | } 21 | if m.Timeout != 1 { 22 | t.Error(`m.Timeout != 1`) 23 | } 24 | if !FloatEquals(m.Min, 0) { 25 | t.Error(`!FloatEquals(m.Min, 0)`) 26 | } 27 | if !FloatEquals(m.Max, 0.3) { 28 | t.Error(`!FloatEquals(m.Max, 0.3)`) 29 | } 30 | if pt, err := getType(m.Probe); err != nil { 31 | t.Error(err) 32 | } else if pt != "exec" { 33 | t.Error(`pt != "exec"`) 34 | } 35 | if pcmd, err := GetString("command", m.Probe); err != nil { 36 | t.Error(err) 37 | } else if pcmd != "/some/probe/cmd" { 38 | t.Error(`pcmd != "/some/probe/cmd"`) 39 | } 40 | } 41 | 42 | func TestSample(t *testing.T) { 43 | t.Parallel() 44 | 45 | d := &struct { 46 | Monitors []*MonitorDefinition `toml:"monitor"` 47 | }{} 48 | _, err := toml.DecodeFile(testFile, d) 49 | if err != nil { 50 | t.Fatal(err) 51 | } 52 | 53 | if len(d.Monitors) != 2 { 54 | t.Fatal("len(d.Monitors) != 2, ", len(d.Monitors)) 55 | } 56 | 57 | m1 := d.Monitors[0] 58 | testMonitor1(t, m1) 59 | data, err := json.Marshal(m1) 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | var jm1 MonitorDefinition 64 | err = json.Unmarshal(data, &jm1) 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | testMonitor1(t, &jm1) 69 | } 70 | -------------------------------------------------------------------------------- /filters/all/all.go: -------------------------------------------------------------------------------- 1 | // Package all import all filters to be compiled-in. 2 | package all 3 | 4 | import ( 5 | // import all filters 6 | _ "github.com/cybozu-go/goma/filters/average" 7 | ) 8 | -------------------------------------------------------------------------------- /filters/average/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package average implements moving-average filter type. 3 | 4 | The constructor takes these parameters: 5 | 6 | Name Type Default Description 7 | init float64 0 Initial value. 8 | window int 10 Window size. 9 | */ 10 | package average 11 | -------------------------------------------------------------------------------- /filters/average/filter.go: -------------------------------------------------------------------------------- 1 | package average 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/cybozu-go/goma/filters" 7 | ) 8 | 9 | const ( 10 | defaultWindowSize = 10 11 | ) 12 | 13 | type filter struct { 14 | init float64 15 | values []float64 16 | index int 17 | } 18 | 19 | func (f *filter) Init() { 20 | for i := 0; i < len(f.values); i++ { 21 | f.values[i] = f.init 22 | } 23 | } 24 | 25 | func (f *filter) Put(v float64) (avg float64) { 26 | f.values[f.index] = v 27 | f.index++ 28 | if f.index == len(f.values) { 29 | f.index = 0 30 | } 31 | 32 | for _, t := range f.values { 33 | avg += t 34 | } 35 | avg /= float64(len(f.values)) 36 | return 37 | } 38 | 39 | func (f *filter) String() string { 40 | return fmt.Sprintf("filter:average(window=%d, init=%g)", 41 | len(f.values), f.init) 42 | } 43 | 44 | func construct(params map[string]interface{}) (filters.Filter, error) { 45 | var init float64 46 | if v, ok := params["init"]; ok { 47 | init, ok = v.(float64) 48 | if !ok { 49 | return nil, fmt.Errorf("init is not a float: %v", v) 50 | } 51 | } 52 | 53 | var window int64 = defaultWindowSize 54 | if v, ok := params["window"]; ok { 55 | window, ok = v.(int64) 56 | if !ok { 57 | return nil, fmt.Errorf("window is not an integer: %v", v) 58 | } 59 | if window < 1 { 60 | return nil, fmt.Errorf("too small window size: %d", window) 61 | } 62 | } 63 | 64 | f := &filter{ 65 | init: init, 66 | values: make([]float64, window), 67 | } 68 | f.Init() 69 | return f, nil 70 | } 71 | 72 | func init() { 73 | filters.Register("average", construct) 74 | } 75 | -------------------------------------------------------------------------------- /filters/average/filter_test.go: -------------------------------------------------------------------------------- 1 | package average 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/cybozu-go/goma" 7 | ) 8 | 9 | func TestDefault(t *testing.T) { 10 | f, err := construct(nil) 11 | if err != nil { 12 | t.Fatal(err) 13 | } 14 | if f.Put(0) != 0 { 15 | t.Error("non-zero average") 16 | } 17 | 18 | f.Put(1) 19 | f.Put(1) 20 | v := f.Put(1) 21 | if !goma.FloatEquals(v, 0.3) { 22 | t.Error(`!goma.FloatEquals(v, 0.3)`) 23 | } 24 | } 25 | 26 | func TestWindow(t *testing.T) { 27 | _, err := construct(map[string]interface{}{ 28 | "window": false, 29 | }) 30 | if err == nil { 31 | t.Error(`window must be int`) 32 | } 33 | 34 | f, err := construct(map[string]interface{}{ 35 | "window": int64(20), 36 | }) 37 | if err != nil { 38 | t.Fatal(err) 39 | } 40 | f.Put(1) 41 | f.Put(1) 42 | v := f.Put(1) 43 | if !goma.FloatEquals(v, 0.15) { 44 | t.Error(`!goma.FloatEquals(v, 0.15)`) 45 | } 46 | } 47 | 48 | func TestInit(t *testing.T) { 49 | _, err := construct(map[string]interface{}{ 50 | "init": 100, 51 | }) 52 | if err == nil { 53 | t.Error(`init must be float64`) 54 | } 55 | 56 | f, err := construct(map[string]interface{}{ 57 | "init": 1.0, 58 | }) 59 | if err != nil { 60 | t.Fatal(err) 61 | } 62 | f.Put(0) 63 | f.Put(0) 64 | v := f.Put(0) 65 | if !goma.FloatEquals(v, 0.7) { 66 | t.Error(`!goma.FloatEquals(v, 0.7)`) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /filters/filter.go: -------------------------------------------------------------------------------- 1 | // Package filters provides API to implement goma filters. 2 | package filters 3 | 4 | import ( 5 | "errors" 6 | "sync" 7 | ) 8 | 9 | // Filter is the interface for filters. 10 | type Filter interface { 11 | // Init is called when goma starts monitoring. 12 | Init() 13 | 14 | // Put receives a return value from a probe, and returns a filtered value. 15 | Put(f float64) float64 16 | 17 | // String returns a descriptive string for this filter. 18 | String() string 19 | } 20 | 21 | // Constructor is a function to create a filter. 22 | // 23 | // params are configuration options for the probe. 24 | type Constructor func(params map[string]interface{}) (Filter, error) 25 | 26 | // Errors for filters. 27 | var ( 28 | ErrNotFound = errors.New("filter not found") 29 | ) 30 | 31 | var ( 32 | registryLock = new(sync.Mutex) 33 | registry = make(map[string]Constructor) 34 | ) 35 | 36 | // Register registers a constructor of a kind of filters. 37 | func Register(name string, ctor Constructor) { 38 | registryLock.Lock() 39 | defer registryLock.Unlock() 40 | 41 | if _, ok := registry[name]; ok { 42 | panic("duplicate filter entry: " + name) 43 | } 44 | 45 | registry[name] = ctor 46 | } 47 | 48 | // Construct constructs a named filter. 49 | // This function is used internally in goma. 50 | func Construct(name string, params map[string]interface{}) (Filter, error) { 51 | registryLock.Lock() 52 | ctor, ok := registry[name] 53 | registryLock.Unlock() 54 | 55 | if !ok { 56 | return nil, ErrNotFound 57 | } 58 | 59 | return ctor(params) 60 | } 61 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cybozu-go/goma 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/BurntSushi/toml v0.3.1 7 | github.com/cybozu-go/log v1.5.0 8 | github.com/cybozu-go/well v1.8.1 9 | github.com/go-sql-driver/mysql v1.4.1 10 | github.com/gorilla/mux v1.6.2 11 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df 12 | ) 13 | 14 | require ( 15 | github.com/cybozu-go/netutil v1.2.0 // indirect 16 | github.com/fsnotify/fsnotify v1.4.7 // indirect 17 | github.com/gorilla/context v1.1.2 // indirect 18 | github.com/hashicorp/hcl v1.0.0 // indirect 19 | github.com/magiconair/properties v1.8.0 // indirect 20 | github.com/mitchellh/mapstructure v1.0.0 // indirect 21 | github.com/pelletier/go-toml v1.2.0 // indirect 22 | github.com/pkg/errors v0.8.0 // indirect 23 | github.com/spf13/afero v1.1.2 // indirect 24 | github.com/spf13/cast v1.2.0 // indirect 25 | github.com/spf13/jwalterweatherman v1.0.0 // indirect 26 | github.com/spf13/pflag v1.0.3 // indirect 27 | github.com/spf13/viper v1.2.1 // indirect 28 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect 29 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect 30 | golang.org/x/text v0.3.8 // indirect 31 | google.golang.org/appengine v1.6.8 // indirect 32 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 33 | gopkg.in/yaml.v2 v2.2.1 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= 2 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 3 | github.com/cybozu-go/log v1.5.0 h1:cjLr+pNga4NL5sj5vnnG00xKmKXSWx0grQQ4LnV1Ris= 4 | github.com/cybozu-go/log v1.5.0/go.mod h1:zpfovuCgUx+a/ErvQrThoT+/z1RVQoLDOf95wkBeRiw= 5 | github.com/cybozu-go/netutil v1.2.0 h1:UBO0+hB43zd5mIXRfD195eBMHvgWlHP2mYuQ2F5Yxtg= 6 | github.com/cybozu-go/netutil v1.2.0/go.mod h1:Wx92iF1dPrtuSzLUMEidtrKTFiDWpLcsYvbQ1lHSmxY= 7 | github.com/cybozu-go/well v1.8.1 h1:YlEPreiDBI+KxE5rcAkkaB5j/Iyow6nIVmUpq3u5DYQ= 8 | github.com/cybozu-go/well v1.8.1/go.mod h1:9PK1AltjltFwZBtTWVXnCJ0fIeZMxGovYfLmCcZxQog= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 12 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 13 | github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= 14 | github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 15 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 16 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 17 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 18 | github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= 19 | github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= 20 | github.com/gorilla/mux v1.6.2 h1:Pgr17XVTNXAk3q/r4CpKzC5xBM/qW1uVLV+IhRZpIIk= 21 | github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= 22 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 23 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 24 | github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= 25 | github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 26 | github.com/mitchellh/mapstructure v1.0.0 h1:vVpGvMXJPqSDh2VYHF7gsfQj8Ncx+Xw5Y1KHeTRY+7I= 27 | github.com/mitchellh/mapstructure v1.0.0/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 28 | github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc= 29 | github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= 30 | github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= 31 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 32 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 33 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 34 | github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI= 35 | github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= 36 | github.com/spf13/cast v1.2.0 h1:HHl1DSRbEQN2i8tJmtS6ViPyHx35+p51amrdsiTCrkg= 37 | github.com/spf13/cast v1.2.0/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg= 38 | github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk= 39 | github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= 40 | github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 41 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= 42 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= 43 | github.com/spf13/viper v1.2.1 h1:bIcUwXqLseLF3BDAZduuNfekWG87ibtFxi59Bq+oI9M= 44 | github.com/spf13/viper v1.2.1/go.mod h1:P4AexN0a+C9tGAnUFNwDMYYZv3pjFuvmeiMyKRaNVlI= 45 | github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= 46 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 47 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 48 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 49 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 50 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 51 | golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 52 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 53 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 54 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0= 55 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 56 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 57 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 58 | golang.org/x/sys v0.0.0-20180906133057-8cf3aee42992/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 59 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 60 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 61 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 62 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 63 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= 64 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 66 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 67 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 68 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 69 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 70 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= 71 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= 72 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 73 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 74 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 75 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 76 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 77 | google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 78 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 79 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 80 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 81 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= 82 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= 83 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 84 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 85 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= 86 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= 87 | gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= 88 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 89 | -------------------------------------------------------------------------------- /goma.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cybozu-go/goma/e3cd85b3d4e9fdfde4dd100d196fa69456c8b8eb/goma.png -------------------------------------------------------------------------------- /handle_list.go: -------------------------------------------------------------------------------- 1 | package goma 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/cybozu-go/goma/monitor" 8 | ) 9 | 10 | // List represents JSON response for list command. 11 | type List []*MonitorInfo 12 | 13 | func handleList(w http.ResponseWriter, r *http.Request) { 14 | l := make(List, 0) 15 | for _, m := range monitor.ListMonitors() { 16 | l = append(l, &MonitorInfo{ 17 | ID: m.ID(), 18 | Name: m.Name(), 19 | Running: m.Running(), 20 | Failing: m.Failing(), 21 | }) 22 | } 23 | 24 | data, err := json.Marshal(l) 25 | if err != nil { 26 | http.Error(w, err.Error(), http.StatusInternalServerError) 27 | return 28 | } 29 | 30 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 31 | w.Write(data) 32 | } 33 | -------------------------------------------------------------------------------- /handle_monitor.go: -------------------------------------------------------------------------------- 1 | package goma 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/cybozu-go/goma/monitor" 11 | "github.com/gorilla/mux" 12 | ) 13 | 14 | // MonitorInfo represents status of a monitor. 15 | // This is used by show and list commands. 16 | type MonitorInfo struct { 17 | ID int `json:"id,string"` 18 | Name string `json:"name"` 19 | Running bool `json:"running"` 20 | Failing bool `json:"failing"` 21 | } 22 | 23 | func handleMonitor(w http.ResponseWriter, r *http.Request) { 24 | // guaranteed no error by mux. 25 | id, _ := strconv.Atoi(mux.Vars(r)["id"]) 26 | 27 | m := monitor.FindMonitor(id) 28 | if m == nil { 29 | http.NotFound(w, r) 30 | return 31 | } 32 | 33 | if r.Method == http.MethodGet { 34 | mi := &MonitorInfo{ 35 | ID: m.ID(), 36 | Name: m.Name(), 37 | Running: m.Running(), 38 | Failing: m.Failing(), 39 | } 40 | data, err := json.Marshal(mi) 41 | if err != nil { 42 | http.Error(w, err.Error(), http.StatusInternalServerError) 43 | return 44 | } 45 | w.Header().Set("Content-Type", "application/json; charset=utf-8") 46 | w.Write(data) 47 | return 48 | } 49 | 50 | if r.Method == http.MethodDelete { 51 | m.Stop() 52 | monitor.Unregister(m) 53 | return 54 | } 55 | 56 | if r.Method != http.MethodPost { 57 | http.Error(w, "invalid method", http.StatusBadRequest) 58 | return 59 | } 60 | 61 | data, err := io.ReadAll(r.Body) 62 | if err != nil { 63 | http.Error(w, err.Error(), http.StatusInternalServerError) 64 | return 65 | } 66 | 67 | switch strings.TrimSpace(string(data)) { 68 | case "start": 69 | if err := m.Start(); err != nil { 70 | http.Error(w, err.Error(), http.StatusInternalServerError) 71 | } 72 | case "stop": 73 | m.Stop() 74 | default: 75 | http.Error(w, "unknown action", http.StatusBadRequest) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /handle_register.go: -------------------------------------------------------------------------------- 1 | package goma 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "mime" 7 | "net/http" 8 | 9 | "github.com/cybozu-go/goma/monitor" 10 | "github.com/cybozu-go/log" 11 | ) 12 | 13 | func handleRegister(w http.ResponseWriter, r *http.Request) { 14 | mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) 15 | if err != nil { 16 | http.Error(w, err.Error(), http.StatusBadRequest) 17 | return 18 | } 19 | if mt != "application/json" { 20 | http.Error(w, "bad content type", http.StatusBadRequest) 21 | return 22 | } 23 | 24 | d := json.NewDecoder(r.Body) 25 | var md MonitorDefinition 26 | if err := d.Decode(&md); err != nil { 27 | http.Error(w, err.Error(), http.StatusBadRequest) 28 | return 29 | } 30 | 31 | m, err := CreateMonitor(&md) 32 | if err != nil { 33 | http.Error(w, err.Error(), http.StatusBadRequest) 34 | return 35 | } 36 | 37 | // ignoring error is safe here. 38 | monitor.Register(m) 39 | log.Info("new monitor", map[string]interface{}{ 40 | "monitor_id": m.ID(), 41 | "name": m.Name(), 42 | }) 43 | m.Start() 44 | 45 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 46 | w.Write([]byte(fmt.Sprintf("%d", m.ID()))) 47 | } 48 | -------------------------------------------------------------------------------- /handle_verbosity.go: -------------------------------------------------------------------------------- 1 | package goma 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/cybozu-go/log" 9 | ) 10 | 11 | func handleVerbosity(w http.ResponseWriter, r *http.Request) { 12 | if r.Method == http.MethodGet { 13 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 14 | w.Write([]byte(log.LevelName(log.DefaultLogger().Threshold()))) 15 | return 16 | } 17 | 18 | if r.Method != http.MethodPut && r.Method != http.MethodPost { 19 | http.Error(w, "Bad method", http.StatusBadRequest) 20 | return 21 | } 22 | 23 | // for PUT or POST, set new verbosity. 24 | data, err := io.ReadAll(r.Body) 25 | if err != nil { 26 | log.Error("handleSetVerbosity", map[string]interface{}{ 27 | "error": err.Error(), 28 | }) 29 | http.Error(w, err.Error(), http.StatusInternalServerError) 30 | return 31 | } 32 | level := strings.TrimSpace(string(data)) 33 | err = log.DefaultLogger().SetThresholdByName(level) 34 | if err != nil { 35 | http.Error(w, err.Error(), http.StatusBadRequest) 36 | return 37 | } 38 | log.Info("new verbosity", map[string]interface{}{ 39 | "level": level, 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package goma 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/gorilla/mux" 7 | ) 8 | 9 | func handleVersion(w http.ResponseWriter, r *http.Request) { 10 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 11 | w.Write([]byte(Version)) 12 | } 13 | 14 | // NewRouter creates gorilla/mux *Router for REST API. 15 | func NewRouter() *mux.Router { 16 | r := mux.NewRouter() 17 | r.Path("/list"). 18 | Name("list"). 19 | Methods(http.MethodGet). 20 | HandlerFunc(handleList) 21 | 22 | r.Path("/register"). 23 | Name("register"). 24 | Methods(http.MethodPost). 25 | HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 | handleRegister(w, r) 27 | }) 28 | 29 | r.Path("/monitor/{id:[0-9]+}"). 30 | Name("monitor"). 31 | HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 32 | handleMonitor(w, r) 33 | }) 34 | 35 | r.Path("/verbosity"). 36 | Name("verbosity"). 37 | HandlerFunc(handleVerbosity) 38 | 39 | r.Path("/version"). 40 | Name("version"). 41 | Methods(http.MethodGet). 42 | HandlerFunc(handleVersion) 43 | 44 | return r 45 | } 46 | -------------------------------------------------------------------------------- /monitor/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package monitor implements monitoring logics. 3 | */ 4 | package monitor 5 | -------------------------------------------------------------------------------- /monitor/error.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import "errors" 4 | 5 | // Errors for monitors. 6 | var ( 7 | ErrRegistered = errors.New("monitor has already been registered") 8 | ErrNotRegistered = errors.New("monitor has not been registered") 9 | ErrStarted = errors.New("monitor has already been started") 10 | ) 11 | -------------------------------------------------------------------------------- /monitor/monitor.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "time" 8 | 9 | "github.com/cybozu-go/goma/actions" 10 | "github.com/cybozu-go/goma/filters" 11 | "github.com/cybozu-go/goma/probes" 12 | "github.com/cybozu-go/log" 13 | "github.com/cybozu-go/well" 14 | ) 15 | 16 | // Monitor is a unit of monitoring. 17 | // 18 | // It consists of a (configured) probe, zero or one filter, and one or 19 | // more actions. goma will invoke Prover.Probe periodically at given 20 | // interval. 21 | type Monitor struct { 22 | id int 23 | name string 24 | probe probes.Prober 25 | filter filters.Filter 26 | actors []actions.Actor 27 | interval time.Duration 28 | timeout time.Duration 29 | min float64 30 | max float64 31 | failedAt *time.Time 32 | 33 | // goroutine management 34 | lock sync.Mutex 35 | env *well.Environment 36 | } 37 | 38 | // NewMonitor creates and initializes a monitor. 39 | // 40 | // name can be any descriptive string for the monitor. 41 | // p and a should not be nil. f may be nil. 42 | // interval is the interval between probes. 43 | // timeout is the maximum duration for a probe to run. 44 | // min and max defines the range for normal probe results. 45 | func NewMonitor( 46 | name string, 47 | p probes.Prober, 48 | f filters.Filter, 49 | a []actions.Actor, 50 | interval, timeout time.Duration, 51 | min, max float64) *Monitor { 52 | return &Monitor{ 53 | id: uninitializedID, 54 | name: name, 55 | probe: p, 56 | filter: f, 57 | actors: a, 58 | interval: interval, 59 | timeout: timeout, 60 | min: min, 61 | max: max, 62 | } 63 | } 64 | 65 | // Start starts monitoring. 66 | // If already started, this returns a non-nil error. 67 | func (m *Monitor) Start() error { 68 | m.lock.Lock() 69 | defer m.lock.Unlock() 70 | 71 | if m.env != nil { 72 | return ErrStarted 73 | } 74 | 75 | m.env = well.NewEnvironment(context.Background()) 76 | m.env.Go(m.run) 77 | 78 | log.Info("monitor started", map[string]interface{}{ 79 | "monitor": m.name, 80 | }) 81 | 82 | return nil 83 | } 84 | 85 | // Stop stops monitoring. 86 | func (m *Monitor) Stop() { 87 | m.lock.Lock() 88 | defer m.lock.Unlock() 89 | 90 | if m.env == nil { 91 | return 92 | } 93 | 94 | log.Debug("monitor is stopping", map[string]interface{}{ 95 | "monitor": m.name, 96 | }) 97 | 98 | m.env.Cancel(nil) 99 | m.env.Wait() 100 | m.env = nil 101 | 102 | m.failedAt = nil 103 | 104 | log.Info("monitor stopped", map[string]interface{}{ 105 | "monitor": m.name, 106 | }) 107 | } 108 | 109 | func (m *Monitor) die() { 110 | m.lock.Lock() 111 | defer m.lock.Unlock() 112 | 113 | m.env = nil 114 | } 115 | 116 | func callProbe(ctx context.Context, p probes.Prober, timeout time.Duration) float64 { 117 | ctx, cancel := context.WithTimeout(ctx, timeout) 118 | defer cancel() 119 | return p.Probe(ctx) 120 | } 121 | 122 | func (m *Monitor) run(ctx context.Context) error { 123 | if m.filter != nil { 124 | m.filter.Init() 125 | } 126 | for _, a := range m.actors { 127 | err := a.Init(m.name) 128 | if err != nil { 129 | log.Error("failed to init action", map[string]interface{}{ 130 | "monitor": m.name, 131 | "action": a.String(), 132 | }) 133 | m.die() 134 | return err 135 | } 136 | } 137 | 138 | for { 139 | // create a timer before starting probe. 140 | // This way, we can keep consistent interval between probes. 141 | t := time.After(m.interval) 142 | 143 | v := callProbe(ctx, m.probe, m.timeout) 144 | 145 | // check cancel 146 | select { 147 | case <-ctx.Done(): 148 | return nil 149 | default: 150 | // not canceled 151 | } 152 | 153 | if m.filter != nil { 154 | v = m.filter.Put(v) 155 | } 156 | 157 | if (v < m.min) || (m.max < v) { 158 | if m.failedAt == nil { 159 | now := time.Now() 160 | m.failedAt = &now 161 | for _, a := range m.actors { 162 | if err := a.Fail(m.name, v); err != nil { 163 | log.Error("failed to call Actor.Fail", map[string]interface{}{ 164 | "monitor": m.name, 165 | "action": a.String(), 166 | }) 167 | } 168 | } 169 | log.Warn("monitor failure", map[string]interface{}{ 170 | "monitor": m.name, 171 | "value": fmt.Sprint(v), 172 | }) 173 | } 174 | } else { 175 | if m.failedAt != nil { 176 | d := time.Since(*m.failedAt) 177 | for _, a := range m.actors { 178 | if err := a.Recover(m.name, d); err != nil { 179 | log.Error("failed to call Actor.Recover", map[string]interface{}{ 180 | "monitor": m.name, 181 | "action": a.String(), 182 | }) 183 | } 184 | } 185 | m.failedAt = nil 186 | log.Warn("monitor recovery", map[string]interface{}{ 187 | "monitor": m.name, 188 | "duration": int(d.Seconds()), 189 | }) 190 | } 191 | } 192 | 193 | select { 194 | case <-ctx.Done(): 195 | return nil 196 | case <-t: 197 | // interval timer expires 198 | } 199 | } 200 | } 201 | 202 | // ID returns the monitor ID. 203 | // 204 | // ID is valid only after registration. 205 | func (m *Monitor) ID() int { 206 | return m.id 207 | } 208 | 209 | // Name returns the name of the monitor. 210 | func (m *Monitor) Name() string { 211 | return m.name 212 | } 213 | 214 | // String is the same as Name. 215 | func (m *Monitor) String() string { 216 | return m.name 217 | } 218 | 219 | // Failing returns true if the monitor is detecting a failure. 220 | func (m *Monitor) Failing() bool { 221 | return m.failedAt != nil 222 | } 223 | 224 | // Running returns true if the monitor is running. 225 | func (m *Monitor) Running() bool { 226 | m.lock.Lock() 227 | defer m.lock.Unlock() 228 | 229 | return m.env != nil 230 | } 231 | -------------------------------------------------------------------------------- /monitor/registry.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import "sync" 4 | 5 | const ( 6 | uninitializedID = -1 7 | ) 8 | 9 | var ( 10 | registryLock = new(sync.Mutex) 11 | registry = make(map[int]*Monitor) 12 | registryIndex int 13 | ) 14 | 15 | // Register registers a monitor. 16 | func Register(m *Monitor) error { 17 | if m.id != uninitializedID { 18 | return ErrRegistered 19 | } 20 | 21 | registryLock.Lock() 22 | defer registryLock.Unlock() 23 | 24 | m.id = registryIndex 25 | registry[registryIndex] = m 26 | registryIndex++ 27 | return nil 28 | } 29 | 30 | // FindMonitor looks up a monitor in the registry. 31 | // If not found, nil is returned. 32 | func FindMonitor(id int) *Monitor { 33 | registryLock.Lock() 34 | defer registryLock.Unlock() 35 | 36 | return registry[id] 37 | } 38 | 39 | // Unregister removes a monitor from the registry. 40 | // The monitor should have stopped. 41 | func Unregister(m *Monitor) error { 42 | if m.id == uninitializedID { 43 | return ErrNotRegistered 44 | } 45 | 46 | registryLock.Lock() 47 | defer registryLock.Unlock() 48 | 49 | delete(registry, m.id) 50 | m.id = uninitializedID 51 | return nil 52 | } 53 | 54 | // ListMonitors returns a list of monitors ordered by ID (ascending). 55 | func ListMonitors() []*Monitor { 56 | registryLock.Lock() 57 | defer registryLock.Unlock() 58 | 59 | l := make([]*Monitor, 0, len(registry)) 60 | 61 | for i := 0; i < registryIndex; i++ { 62 | if m, ok := registry[i]; ok { 63 | l = append(l, m) 64 | } 65 | } 66 | 67 | return l 68 | } 69 | -------------------------------------------------------------------------------- /probes/all/all.go: -------------------------------------------------------------------------------- 1 | // Package all import all probes to be compiled-in. 2 | package all 3 | 4 | import ( 5 | // import all probes 6 | _ "github.com/cybozu-go/goma/probes/exec" 7 | _ "github.com/cybozu-go/goma/probes/http" 8 | _ "github.com/cybozu-go/goma/probes/mysql" 9 | ) 10 | -------------------------------------------------------------------------------- /probes/exec/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package exec implements "exec" probe type that runs an arbitrary command. 3 | 4 | The value of the probe will be 0 if command exits successfully, 5 | or 1.0 if command timed out or does not exit normally. 6 | 7 | If parse is true, the command output (stdout) will be interpreted 8 | as a floating point number, and will be used as the probe value. 9 | 10 | The constructor takes these parameters: 11 | 12 | Name Type Default Description 13 | command string The command to run. 14 | args []string nil Command arguments. 15 | parse bool false See the above description. 16 | errval float64 0 When parse is true and command failed, 17 | this value is returned as the probe value. 18 | env []string nil Environment variables. See os.Environ. 19 | */ 20 | package exec 21 | -------------------------------------------------------------------------------- /probes/exec/probe.go: -------------------------------------------------------------------------------- 1 | package exec 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/exec" 7 | "sort" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/cybozu-go/goma" 12 | "github.com/cybozu-go/goma/probes" 13 | "github.com/cybozu-go/log" 14 | ) 15 | 16 | type probe struct { 17 | command string 18 | args []string 19 | parse bool 20 | errval float64 21 | env []string 22 | } 23 | 24 | func (p *probe) Probe(ctx context.Context) float64 { 25 | cmd := exec.CommandContext(ctx, p.command, p.args...) 26 | if p.env != nil { 27 | cmd.Env = p.env 28 | } 29 | 30 | data, err := cmd.Output() 31 | if err != nil { 32 | log.Error("probe:exec error", map[string]interface{}{ 33 | "command": p.command, 34 | "args": p.args, 35 | "error": err.Error(), 36 | }) 37 | if p.parse { 38 | return p.errval 39 | } 40 | return 1.0 41 | } 42 | 43 | if p.parse { 44 | f, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64) 45 | if err != nil { 46 | return p.errval 47 | } 48 | return f 49 | } 50 | 51 | return 0 52 | } 53 | 54 | func (p *probe) String() string { 55 | return "probe:exec:" + p.command 56 | } 57 | 58 | func mergeEnv(env, bgenv []string) (merged []string) { 59 | m := make(map[string]string) 60 | for _, e := range bgenv { 61 | m[strings.SplitN(e, "=", 2)[0]] = e 62 | } 63 | for _, e := range env { 64 | m[strings.SplitN(e, "=", 2)[0]] = e 65 | } 66 | for _, v := range m { 67 | merged = append(merged, v) 68 | } 69 | sort.Strings(merged) 70 | return 71 | } 72 | 73 | func construct(params map[string]interface{}) (probes.Prober, error) { 74 | command, err := goma.GetString("command", params) 75 | if err != nil { 76 | return nil, err 77 | } 78 | args, err := goma.GetStringList("args", params) 79 | if err != nil && err != goma.ErrNoKey { 80 | return nil, err 81 | } 82 | parse, err := goma.GetBool("parse", params) 83 | if err != nil && err != goma.ErrNoKey { 84 | return nil, err 85 | } 86 | errval, err := goma.GetFloat("errval", params) 87 | if err != nil && err != goma.ErrNoKey { 88 | return nil, err 89 | } 90 | env, err := goma.GetStringList("env", params) 91 | if err != nil && err != goma.ErrNoKey { 92 | return nil, err 93 | } 94 | if env != nil { 95 | env = mergeEnv(env, os.Environ()) 96 | } 97 | 98 | return &probe{ 99 | command: command, 100 | args: args, 101 | parse: parse, 102 | errval: errval, 103 | env: env, 104 | }, nil 105 | } 106 | 107 | func init() { 108 | probes.Register("exec", construct) 109 | } 110 | -------------------------------------------------------------------------------- /probes/exec/probe_test.go: -------------------------------------------------------------------------------- 1 | package exec 2 | 3 | import "testing" 4 | 5 | func TestMergeEnv(t *testing.T) { 6 | t.Parallel() 7 | 8 | env1 := []string{"A=123", "B=456"} 9 | env2 := []string{"A=789", "C=11111"} 10 | merged := mergeEnv(env1, env2) 11 | 12 | if len(merged) != 3 { 13 | t.Fatal(`len(merged) != 3`) 14 | } 15 | if merged[0] != "A=123" { 16 | t.Error(`merged[0] != "A=123"`) 17 | } 18 | if merged[1] != "B=456" { 19 | t.Error(`merged[1] != "B=456"`) 20 | } 21 | if merged[2] != "C=11111" { 22 | t.Error(`merged[2] != "C=11111"`) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /probes/exec/probe_unix_test.go: -------------------------------------------------------------------------------- 1 | //go:build !nacl && !plan9 && !windows 2 | // +build !nacl,!plan9,!windows 3 | 4 | package exec 5 | 6 | import ( 7 | "context" 8 | "testing" 9 | "time" 10 | 11 | "github.com/cybozu-go/goma" 12 | ) 13 | 14 | func TestConstructBasic(t *testing.T) { 15 | t.Parallel() 16 | if _, err := construct(nil); err != goma.ErrNoKey { 17 | t.Error(`err != goma.ErrNoKey`) 18 | } 19 | 20 | if _, err := construct(map[string]interface{}{ 21 | "command": true, 22 | }); err != goma.ErrInvalidType { 23 | t.Error(`err != goma.ErrInvalidType`) 24 | } 25 | if p, err := construct(map[string]interface{}{ 26 | "command": "true", 27 | }); err != nil { 28 | t.Error(err) 29 | } else { 30 | if f := p.Probe(context.Background()); f != 0 { 31 | t.Error(`p.Probe(context.Background()) should return 0`) 32 | } 33 | } 34 | } 35 | 36 | func TestConstructArgs(t *testing.T) { 37 | t.Parallel() 38 | if _, err := construct(map[string]interface{}{ 39 | "command": "echo", 40 | "args": false, 41 | }); err != goma.ErrInvalidType { 42 | t.Error(`args=false should cause error`) 43 | } 44 | 45 | if p, err := construct(map[string]interface{}{ 46 | "command": "echo", 47 | "args": []interface{}{"123.45"}, 48 | "parse": true, 49 | }); err != nil { 50 | t.Error(err) 51 | } else { 52 | f := p.Probe(context.Background()) 53 | if !goma.FloatEquals(f, 123.45) { 54 | t.Error(`!goma.FloatEquals(f, 123.45)`) 55 | } 56 | } 57 | } 58 | 59 | func TestProbeFalse(t *testing.T) { 60 | t.Parallel() 61 | 62 | p, err := construct(map[string]interface{}{ 63 | "command": "false", 64 | }) 65 | if err != nil { 66 | t.Fatal(err) 67 | } 68 | 69 | f := p.Probe(context.Background()) 70 | if !goma.FloatEquals(f, 1.0) { 71 | t.Error(`!goma.FloatEquals(f, 1.0)`) 72 | } 73 | } 74 | 75 | func TestProbeParse(t *testing.T) { 76 | t.Parallel() 77 | 78 | p, err := construct(map[string]interface{}{ 79 | "command": "false", 80 | "parse": true, 81 | "errval": 3.0, 82 | }) 83 | if err != nil { 84 | t.Fatal(err) 85 | } 86 | 87 | f := p.Probe(context.Background()) 88 | if !goma.FloatEquals(f, 3.0) { 89 | t.Error(`!goma.FloatEquals(f, 3.0)`) 90 | } 91 | } 92 | 93 | func TestProbeEnv(t *testing.T) { 94 | t.Parallel() 95 | 96 | p, err := construct(map[string]interface{}{ 97 | "command": "sh", 98 | "args": []interface{}{"-c", `echo "$GOMA_VALUE"`}, 99 | "parse": true, 100 | "env": []interface{}{"GOMA_VALUE=123.45"}, 101 | }) 102 | if err != nil { 103 | t.Fatal(err) 104 | } 105 | 106 | f := p.Probe(context.Background()) 107 | if !goma.FloatEquals(f, 123.45) { 108 | t.Error(`!goma.FloatEquals(f, 123.45)`) 109 | } 110 | } 111 | 112 | func TestProbeTimeout(t *testing.T) { 113 | t.Parallel() 114 | 115 | p, err := construct(map[string]interface{}{ 116 | "command": "sleep", 117 | "args": []interface{}{"10"}, 118 | }) 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | 123 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) 124 | defer cancel() 125 | f := p.Probe(ctx) 126 | if !goma.FloatEquals(f, 1.0) { 127 | t.Error(`!goma.FloatEquals(f, 1.0)`) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /probes/http/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package http implements "http" probe type that test HTTP(S) servers. 3 | 4 | The value of the probe will be 0 if HTTP server responds with status 5 | between 200 and 299, or 1.0 for other status values, connection errors, 6 | or timeouts. 7 | 8 | If parse is true, the response body will be interpreted 9 | as a floating point number, and will be used as the probe value. 10 | 11 | Basic authentication can be used by embedding user:password in url. 12 | 13 | The constructor takes these parameters: 14 | 15 | Name Type Default Description 16 | url string URL to test HTTP server. 17 | method string GET Method to use. 18 | agent string goma/0.1 User-Agent string. 19 | proxy string URL for proxy server. Optional. 20 | header map[string]string HTTP headers. Optional. 21 | parse bool false See the above description. 22 | errval float64 0 When parse is true and command failed, 23 | this value is returned as the probe value. 24 | */ 25 | package http 26 | -------------------------------------------------------------------------------- /probes/http/probe.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net" 7 | "net/http" 8 | "net/url" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/cybozu-go/goma" 14 | "github.com/cybozu-go/goma/probes" 15 | "github.com/cybozu-go/log" 16 | ) 17 | 18 | type probe struct { 19 | client *http.Client 20 | url *url.URL 21 | method string 22 | header map[string]string 23 | parse bool 24 | errval float64 25 | } 26 | 27 | func (p *probe) Probe(ctx context.Context) float64 { 28 | header := make(http.Header) 29 | for k, v := range p.header { 30 | header.Set(k, v) 31 | } 32 | 33 | req := &http.Request{ 34 | Method: p.method, 35 | URL: p.url, 36 | Proto: "HTTP/1.1", 37 | ProtoMajor: 1, 38 | ProtoMinor: 1, 39 | Header: header, 40 | Host: p.url.Host, 41 | } 42 | 43 | resp, err := p.client.Do(req.WithContext(ctx)) 44 | if err != nil { 45 | log.Error("probe:http error", map[string]interface{}{ 46 | "url": p.url.String(), 47 | "error": err.Error(), 48 | }) 49 | if p.parse { 50 | return p.errval 51 | } 52 | return 1.0 53 | } 54 | defer resp.Body.Close() 55 | 56 | data, err := io.ReadAll(resp.Body) 57 | if err != nil { 58 | if p.parse { 59 | return p.errval 60 | } 61 | return 1.0 62 | } 63 | 64 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 65 | if p.parse { 66 | return p.errval 67 | } 68 | return 1.0 69 | } 70 | 71 | if p.parse { 72 | f, err := strconv.ParseFloat(strings.TrimSpace(string(data)), 64) 73 | if err != nil { 74 | log.Error("probe:http parsing failure", map[string]interface{}{ 75 | "url": p.url.String(), 76 | "error": err.Error(), 77 | }) 78 | return p.errval 79 | } 80 | return f 81 | } 82 | return 0 83 | } 84 | 85 | func (p *probe) String() string { 86 | return "probe:http:" + p.url.String() 87 | } 88 | 89 | func construct(params map[string]interface{}) (probes.Prober, error) { 90 | urlStr, err := goma.GetString("url", params) 91 | if err != nil { 92 | return nil, err 93 | } 94 | u, err := url.Parse(urlStr) 95 | if err != nil { 96 | return nil, err 97 | } 98 | method := "GET" 99 | m, err := goma.GetString("method", params) 100 | if err == nil { 101 | method = m 102 | } else if err != goma.ErrNoKey { 103 | return nil, err 104 | } 105 | header, err := goma.GetStringMap("header", params) 106 | switch err { 107 | case nil: 108 | case goma.ErrNoKey: 109 | header = make(map[string]string) 110 | default: 111 | return nil, err 112 | } 113 | 114 | switch agent, err := goma.GetString("agent", params); err { 115 | case nil: 116 | header["User-Agent"] = agent 117 | case goma.ErrNoKey: 118 | header["User-Agent"] = "goma/" + goma.Version 119 | default: 120 | return nil, err 121 | } 122 | 123 | proxy := http.ProxyFromEnvironment 124 | if proxyURL, err := goma.GetString("proxy", params); err == nil { 125 | u2, err := url.Parse(proxyURL) 126 | if err != nil { 127 | return nil, err 128 | } 129 | proxy = http.ProxyURL(u2) 130 | } else if err != goma.ErrNoKey { 131 | return nil, err 132 | } 133 | 134 | parse, err := goma.GetBool("parse", params) 135 | if err != nil && err != goma.ErrNoKey { 136 | return nil, err 137 | } 138 | errval, err := goma.GetFloat("errval", params) 139 | if err != nil && err != goma.ErrNoKey { 140 | return nil, err 141 | } 142 | 143 | transport := &http.Transport{ 144 | Proxy: proxy, 145 | Dial: (&net.Dialer{ 146 | Timeout: 30 * time.Second, 147 | KeepAlive: 30 * time.Second, 148 | }).Dial, 149 | TLSHandshakeTimeout: 10 * time.Second, 150 | MaxIdleConnsPerHost: 1, 151 | ExpectContinueTimeout: 500 * time.Millisecond, 152 | } 153 | client := &http.Client{ 154 | Transport: transport, 155 | } 156 | 157 | return &probe{ 158 | client: client, 159 | url: u, 160 | method: method, 161 | header: header, 162 | parse: parse, 163 | errval: errval, 164 | }, nil 165 | } 166 | 167 | func init() { 168 | probes.Register("http", construct) 169 | } 170 | -------------------------------------------------------------------------------- /probes/http/probe_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net" 9 | "net/http" 10 | "os" 11 | "strconv" 12 | "strings" 13 | "testing" 14 | "time" 15 | 16 | "github.com/cybozu-go/goma" 17 | ) 18 | 19 | const ( 20 | testAddress = "localhost:13838" 21 | testUserAgent = "testUserAgent" 22 | testHeaderName = "X-Goma-Test" 23 | testHeaderValue = "gomagoma" 24 | ) 25 | 26 | func serve(l net.Listener) { 27 | router := http.NewServeMux() 28 | router.HandleFunc("/echo/", func(w http.ResponseWriter, r *http.Request) { 29 | w.Header().Set("Content-Type", "text/plain; charset=utf-8") 30 | t := strings.Split(r.URL.Path, "/") 31 | w.Write([]byte(t[len(t)-1])) 32 | }) 33 | router.HandleFunc("/200", func(w http.ResponseWriter, r *http.Request) {}) 34 | router.HandleFunc("/500", func(w http.ResponseWriter, r *http.Request) { 35 | http.Error(w, "500", http.StatusInternalServerError) 36 | }) 37 | router.HandleFunc("/postonly", func(w http.ResponseWriter, r *http.Request) { 38 | if r.Method != http.MethodPost { 39 | http.Error(w, "Bad method", http.StatusBadRequest) 40 | } 41 | }) 42 | router.HandleFunc("/header", func(w http.ResponseWriter, r *http.Request) { 43 | if r.Header.Get(testHeaderName) != testHeaderValue { 44 | http.Error(w, "Bad header", http.StatusBadRequest) 45 | } 46 | }) 47 | router.HandleFunc("/ua", func(w http.ResponseWriter, r *http.Request) { 48 | if r.Header.Get("User-Agent") != testUserAgent { 49 | http.Error(w, "Bad User Agent", http.StatusBadRequest) 50 | } 51 | }) 52 | router.HandleFunc("/sleep/", func(w http.ResponseWriter, r *http.Request) { 53 | t := strings.Split(r.URL.Path, "/") 54 | i, err := strconv.Atoi(t[len(t)-1]) 55 | if err != nil { 56 | http.Error(w, err.Error(), http.StatusBadRequest) 57 | return 58 | } 59 | time.Sleep(time.Duration(i) * time.Second) 60 | }) 61 | 62 | s := &http.Server{ 63 | Handler: router, 64 | } 65 | s.Serve(l) 66 | } 67 | 68 | func TestMain(m *testing.M) { 69 | flag.Parse() 70 | 71 | l, err := net.Listen("tcp", testAddress) 72 | if err != nil { 73 | log.Fatal(err) 74 | } 75 | go serve(l) 76 | os.Exit(m.Run()) 77 | } 78 | 79 | func getURL(elem ...string) string { 80 | return fmt.Sprintf("http://%s/%s", testAddress, strings.Join(elem, "/")) 81 | } 82 | 83 | func TestConstruct(t *testing.T) { 84 | t.Parallel() 85 | 86 | if _, err := construct(nil); err != goma.ErrNoKey { 87 | t.Error(`err != goma.ErrNoKey`) 88 | } 89 | 90 | p, err := construct(map[string]interface{}{ 91 | "url": getURL("200"), 92 | }) 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | f := p.Probe(context.Background()) 97 | if f != 0 { 98 | t.Error(`f != 0`) 99 | } 100 | // repeat 101 | f = p.Probe(context.Background()) 102 | if f != 0 { 103 | t.Error(`f != 0`) 104 | } 105 | 106 | p, err = construct(map[string]interface{}{ 107 | "url": getURL("500"), 108 | }) 109 | if err != nil { 110 | t.Fatal(err) 111 | } 112 | f = p.Probe(context.Background()) 113 | if !goma.FloatEquals(f, 1.0) { 114 | t.Error(`!goma.FloatEquals(f, 1.0)`) 115 | } 116 | } 117 | 118 | func TestHeader(t *testing.T) { 119 | t.Parallel() 120 | 121 | p, err := construct(map[string]interface{}{ 122 | "url": getURL("header"), 123 | "header": map[string]interface{}{ 124 | testHeaderName: testHeaderValue, 125 | }, 126 | }) 127 | if err != nil { 128 | t.Fatal(err) 129 | } 130 | 131 | f := p.Probe(context.Background()) 132 | if f != 0 { 133 | t.Error(`f != 0`) 134 | } 135 | 136 | p, err = construct(map[string]interface{}{ 137 | "url": getURL("header"), 138 | }) 139 | if err != nil { 140 | t.Fatal(err) 141 | } 142 | 143 | f = p.Probe(context.Background()) 144 | if !goma.FloatEquals(f, 1.0) { 145 | t.Error(`!goma.FloatEquals(f, 1.0)`) 146 | } 147 | } 148 | 149 | func TestUserAgent(t *testing.T) { 150 | t.Parallel() 151 | 152 | p, err := construct(map[string]interface{}{ 153 | "url": getURL("ua"), 154 | "agent": testUserAgent, 155 | "header": map[string]interface{}{ 156 | testHeaderName: testHeaderValue, 157 | }, 158 | }) 159 | if err != nil { 160 | t.Fatal(err) 161 | } 162 | 163 | f := p.Probe(context.Background()) 164 | if f != 0 { 165 | t.Error(`f != 0`) 166 | } 167 | 168 | p, err = construct(map[string]interface{}{ 169 | "url": getURL("ua"), 170 | "agent": testUserAgent, 171 | }) 172 | if err != nil { 173 | t.Fatal(err) 174 | } 175 | 176 | f = p.Probe(context.Background()) 177 | if f != 0 { 178 | t.Error(`f != 0`) 179 | } 180 | } 181 | 182 | func TestMethod(t *testing.T) { 183 | t.Parallel() 184 | 185 | p, err := construct(map[string]interface{}{ 186 | "url": getURL("postonly"), 187 | "method": "POST", 188 | }) 189 | if err != nil { 190 | t.Fatal(err) 191 | } 192 | 193 | f := p.Probe(context.Background()) 194 | if f != 0 { 195 | t.Error(`f != 0`) 196 | } 197 | 198 | p, err = construct(map[string]interface{}{ 199 | "url": getURL("postonly"), 200 | }) 201 | if err != nil { 202 | t.Fatal(err) 203 | } 204 | 205 | f = p.Probe(context.Background()) 206 | if !goma.FloatEquals(f, 1.0) { 207 | t.Error(`!goma.FloatEquals(f, 1.0)`) 208 | } 209 | } 210 | 211 | func TestProxy(t *testing.T) { 212 | t.Parallel() 213 | 214 | proxyURL := os.Getenv("GOMA_PROXY") 215 | if len(proxyURL) == 0 { 216 | t.Skip() 217 | } 218 | 219 | p, err := construct(map[string]interface{}{ 220 | "url": "http://example.org/", 221 | "proxy": proxyURL, 222 | }) 223 | if err != nil { 224 | t.Fatal(err) 225 | } 226 | 227 | f := p.Probe(context.Background()) 228 | if f != 0 { 229 | t.Error(`f != 0`) 230 | } 231 | } 232 | 233 | func TestParse(t *testing.T) { 234 | t.Parallel() 235 | 236 | p, err := construct(map[string]interface{}{ 237 | "url": getURL("echo", "123.45"), 238 | "parse": true, 239 | }) 240 | if err != nil { 241 | t.Fatal(err) 242 | } 243 | 244 | f := p.Probe(context.Background()) 245 | if !goma.FloatEquals(f, 123.45) { 246 | t.Error(`!goma.FloatEquals(f, 123.45)`) 247 | } 248 | 249 | p, err = construct(map[string]interface{}{ 250 | "url": getURL("500"), 251 | "parse": true, 252 | "errval": 100.0, 253 | }) 254 | if err != nil { 255 | t.Fatal(err) 256 | } 257 | 258 | f = p.Probe(context.Background()) 259 | if !goma.FloatEquals(f, 100.0) { 260 | t.Error(`!goma.FloatEquals(f, 100.0)`) 261 | } 262 | } 263 | 264 | func TestTimeout(t *testing.T) { 265 | t.Parallel() 266 | 267 | p, err := construct(map[string]interface{}{ 268 | "url": getURL("sleep", "10"), 269 | }) 270 | if err != nil { 271 | t.Fatal(err) 272 | } 273 | 274 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) 275 | defer cancel() 276 | f := p.Probe(ctx) 277 | if !goma.FloatEquals(f, 1.0) { 278 | t.Error(`!goma.FloatEquals(f, 1.0)`) 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /probes/mysql/doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package mysql implements "mysql" probe type that test MySQL servers. 3 | 4 | The underlying driver is https://github.com/go-sql-driver/mysql . 5 | 6 | The value returned from a SELECT query will be the value of the probe. 7 | The SELECT statement should return a floating point value. 8 | 9 | The constructor takes these parameters: 10 | 11 | Name Type Default Description 12 | dsn string DSN for MySQL server. Required. 13 | query string SELECT statement. Required. 14 | errval float64 0 Return value upon an error. 15 | 16 | This probe utilizes max_execution_time system variable if available 17 | (for MySQL 5.7.8+). If not, the probe will kill the running thread 18 | when the deadline expires. 19 | */ 20 | package mysql 21 | -------------------------------------------------------------------------------- /probes/mysql/probe.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/cybozu-go/goma" 10 | "github.com/cybozu-go/goma/probes" 11 | "github.com/cybozu-go/log" 12 | // for driver 13 | _ "github.com/go-sql-driver/mysql" 14 | ) 15 | 16 | // Obtain connection ID by "SELECT connection_id()", 17 | // kill it by "KILL ID" to interrupt the query execution. 18 | 19 | type probe struct { 20 | dsn string 21 | db *sql.DB 22 | query string 23 | errval float64 24 | haveMaxExecutionTime bool 25 | } 26 | 27 | func (p *probe) Probe(ctx context.Context) float64 { 28 | var err error 29 | var connID int64 30 | if p.haveMaxExecutionTime { 31 | var d time.Duration 32 | deadline, ok := ctx.Deadline() 33 | if ok { 34 | d = deadline.Sub(time.Now()) 35 | } 36 | _, err = p.db.Exec("SET max_execution_time = ?", d.Nanoseconds()/1000000) 37 | if err != nil { 38 | log.Error("probe:mysql SET max_execution_time", map[string]interface{}{ 39 | "dsn": p.dsn, 40 | "error": err.Error(), 41 | }) 42 | return p.errval 43 | } 44 | goto QUERY 45 | } 46 | err = p.db.QueryRow("SELECT connection_id()").Scan(&connID) 47 | if err != nil { 48 | log.Error("probe:mysql SELECT connection_id()", map[string]interface{}{ 49 | "dsn": p.dsn, 50 | "error": err.Error(), 51 | }) 52 | return p.errval 53 | } 54 | 55 | QUERY: 56 | done := make(chan float64, 1) 57 | go func() { 58 | var v float64 59 | err = p.db.QueryRow(p.query).Scan(&v) 60 | if err != nil { 61 | done <- p.errval 62 | log.Error("probe:mysql db.QueryRow", map[string]interface{}{ 63 | "dsn": p.dsn, 64 | "error": err.Error(), 65 | }) 66 | return 67 | } 68 | done <- v 69 | }() 70 | 71 | select { 72 | case <-ctx.Done(): 73 | if !p.haveMaxExecutionTime { 74 | // kill thread 75 | p.db.Exec("KILL ?", connID) 76 | } 77 | return p.errval 78 | case v := <-done: 79 | return v 80 | } 81 | } 82 | 83 | func (p *probe) String() string { 84 | return fmt.Sprintf("mysql:%s:%s", p.dsn, p.query) 85 | } 86 | 87 | func construct(params map[string]interface{}) (probes.Prober, error) { 88 | dsn, err := goma.GetString("dsn", params) 89 | if err != nil { 90 | return nil, err 91 | } 92 | query, err := goma.GetString("query", params) 93 | if err != nil { 94 | return nil, err 95 | } 96 | errval, err := goma.GetFloat("errval", params) 97 | if err != nil && err != goma.ErrNoKey { 98 | return nil, err 99 | } 100 | 101 | db, err := sql.Open("mysql", dsn) 102 | if err != nil { 103 | return nil, err 104 | } 105 | err = db.Ping() 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | // max_execution_time is available for MySQL 5.7.8+ 111 | _, err = db.Exec("SET max_execution_time = 10000000") 112 | haveMaxExecutionTime := err == nil 113 | if haveMaxExecutionTime { 114 | db.Exec("SET max_execution_time = 0") 115 | } 116 | 117 | return &probe{ 118 | dsn: dsn, 119 | db: db, 120 | query: query, 121 | errval: errval, 122 | haveMaxExecutionTime: haveMaxExecutionTime, 123 | }, nil 124 | } 125 | 126 | func init() { 127 | probes.Register("mysql", construct) 128 | } 129 | -------------------------------------------------------------------------------- /probes/mysql/probe_test.go: -------------------------------------------------------------------------------- 1 | package mysql 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/cybozu-go/goma" 10 | ) 11 | 12 | var ( 13 | dsn = os.Getenv("MYSQL_DSN") 14 | ) 15 | 16 | func TestConstruct(t *testing.T) { 17 | if len(dsn) == 0 { 18 | t.Skip("No MYSQL_DSN env") 19 | } 20 | t.Parallel() 21 | 22 | _, err := construct(nil) 23 | if err != goma.ErrNoKey { 24 | t.Error(`err != goma.ErrNoKey`) 25 | } 26 | 27 | _, err = construct(map[string]interface{}{ 28 | "dsn": dsn, 29 | }) 30 | if err != goma.ErrNoKey { 31 | t.Error(`err != goma.ErrNoKey`) 32 | } 33 | 34 | p, err := construct(map[string]interface{}{ 35 | "dsn": dsn, 36 | "query": "SELECT 1", 37 | }) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | 42 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 43 | defer cancel() 44 | v := p.Probe(ctx) 45 | if !goma.FloatEquals(v, 1.0) { 46 | t.Error(!goma.FloatEquals(v, 1.0)) 47 | } 48 | } 49 | 50 | func TestError(t *testing.T) { 51 | if len(dsn) == 0 { 52 | t.Skip("No MYSQL_DSN env") 53 | } 54 | t.Parallel() 55 | 56 | p, err := construct(map[string]interface{}{ 57 | "dsn": dsn, 58 | "query": "SELECT hogenotfound()", 59 | }) 60 | if err != nil { 61 | t.Fatal(err) 62 | } 63 | 64 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 65 | defer cancel() 66 | v := p.Probe(ctx) 67 | if v != 0 { 68 | t.Error(`v != 0`) 69 | } 70 | } 71 | 72 | func TestErrval(t *testing.T) { 73 | if len(dsn) == 0 { 74 | t.Skip("No MYSQL_DSN env") 75 | } 76 | t.Parallel() 77 | 78 | p, err := construct(map[string]interface{}{ 79 | "dsn": dsn, 80 | "query": "SELECT hogenotfound()", 81 | "errval": 123, 82 | }) 83 | if err != nil { 84 | t.Fatal(err) 85 | } 86 | 87 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 88 | defer cancel() 89 | v := p.Probe(ctx) 90 | if !goma.FloatEquals(v, 123) { 91 | t.Error(`!goma.FloatEquals(v, 123)`) 92 | } 93 | } 94 | 95 | func TestFloat(t *testing.T) { 96 | if len(dsn) == 0 { 97 | t.Skip("No MYSQL_DSN env") 98 | } 99 | t.Parallel() 100 | 101 | p, err := construct(map[string]interface{}{ 102 | "dsn": dsn, 103 | "query": "SELECT 123.45", 104 | }) 105 | if err != nil { 106 | t.Fatal(err) 107 | } 108 | 109 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 110 | defer cancel() 111 | v := p.Probe(ctx) 112 | if !goma.FloatEquals(v, 123.45) { 113 | t.Error(!goma.FloatEquals(v, 123.45)) 114 | } 115 | } 116 | 117 | func TestTimeout(t *testing.T) { 118 | if len(dsn) == 0 { 119 | t.Skip("No MYSQL_DSN env") 120 | } 121 | t.Parallel() 122 | 123 | p, err := construct(map[string]interface{}{ 124 | "dsn": dsn, 125 | "query": "SELECT 100 FROM (SELECT SLEEP(10)) AS sub", 126 | "errval": 123.45, 127 | }) 128 | if err != nil { 129 | t.Fatal(err) 130 | } 131 | 132 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) 133 | defer cancel() 134 | v := p.Probe(ctx) 135 | if !goma.FloatEquals(v, 123.45) { 136 | t.Error(!goma.FloatEquals(v, 123.45)) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /probes/probe.go: -------------------------------------------------------------------------------- 1 | // Package probes provides API to implement goma probes. 2 | package probes 3 | 4 | import ( 5 | "context" 6 | "errors" 7 | "sync" 8 | ) 9 | 10 | // Prober is the interface for probes. 11 | type Prober interface { 12 | // Probe implements a probing method. 13 | // 14 | // The returned float64 value will be interpreted by the monitor 15 | // who run the probe. Errors occurring within the probe should 16 | // produce a float64 value indicating the error. 17 | // 18 | // ctx.Deadline() is always set. 19 | // Probe must return immediately when the ctx.Done() is closed. 20 | // The return value will not be used in such cases. 21 | Probe(ctx context.Context) float64 22 | 23 | // String returns a descriptive string for this probe. 24 | String() string 25 | } 26 | 27 | // Constructor is a function to create a probe. 28 | // 29 | // params are configuration options for the probe. 30 | type Constructor func(params map[string]interface{}) (Prober, error) 31 | 32 | // Errors for probes. 33 | var ( 34 | ErrNotFound = errors.New("probe not found") 35 | ) 36 | 37 | var ( 38 | registryLock = new(sync.Mutex) 39 | registry = make(map[string]Constructor) 40 | ) 41 | 42 | // Register registers a constructor of a kind of probes. 43 | func Register(name string, ctor Constructor) { 44 | registryLock.Lock() 45 | defer registryLock.Unlock() 46 | 47 | if _, ok := registry[name]; ok { 48 | panic("duplicate probe entry: " + name) 49 | } 50 | 51 | registry[name] = ctor 52 | } 53 | 54 | // Construct constructs a named probe. 55 | // This function is used internally in goma. 56 | func Construct(name string, params map[string]interface{}) (Prober, error) { 57 | registryLock.Lock() 58 | ctor, ok := registry[name] 59 | registryLock.Unlock() 60 | 61 | if !ok { 62 | return nil, ErrNotFound 63 | } 64 | 65 | return ctor(params) 66 | } 67 | -------------------------------------------------------------------------------- /sample.toml: -------------------------------------------------------------------------------- 1 | # Sample configuration for goma monitors. 2 | 3 | [[monitor]] 4 | name = "monitor1" # any descriptive string. 5 | interval = 10 # seconds between probes. 6 | timeout = 1 # seconds for timeout of a probe. 7 | min = 0.0 # minimum of the normal probe output. 8 | max = 0.3 # maximum of the normal probe output. 9 | 10 | [monitor.probe] 11 | type = "exec" # probe type. required. 12 | command = "/some/probe/cmd" # probe options. 13 | 14 | [monitor.filter] # filter is optional. 15 | type = "average" # filter type. 16 | 17 | [[monitor.actions]] # list of actions. 18 | type = "exec" # action type. 19 | command = "/action/cmd" # action options. 20 | args = ["arg1", "argw2"] # ditto. 21 | env = ["HOGE=123", "FUGA=456"] # ditto. 22 | 23 | 24 | [[monitor]] 25 | name = "monitor2" 26 | interval = 60 27 | timeout = 5 28 | 29 | [monitor.probe] 30 | type = "http" 31 | url = "https://www.kintone.com/" 32 | 33 | [[monitor.actions]] 34 | type = "mail" 35 | from = "no-reply@example.org" 36 | fail_to = ["admin@example.org"] 37 | 38 | [[monitor.actions]] 39 | type = "http" 40 | url_fail = "http://example.org/fail" 41 | method = "post" 42 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package goma 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/cybozu-go/well" 8 | ) 9 | 10 | const ( 11 | defaultReadTimeout = 10 * time.Second 12 | defaultWriteTimeout = 10 * time.Second 13 | 14 | // Version may be used for REST API version checks in future. 15 | Version = "1.0" 16 | 17 | // VersionHeader is the HTTP request header for Version. 18 | VersionHeader = "X-Goma-Version" 19 | ) 20 | 21 | // Serve runs REST API server until the global environment is canceled. 22 | func Serve(addr string) error { 23 | s := &well.HTTPServer{ 24 | Server: &http.Server{ 25 | Addr: addr, 26 | Handler: NewRouter(), 27 | ReadTimeout: defaultReadTimeout, 28 | WriteTimeout: defaultWriteTimeout, 29 | }, 30 | } 31 | return s.ListenAndServe() 32 | } 33 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | // Utility functions for plugins. 2 | 3 | package goma 4 | 5 | const ( 6 | // EPSILON is permitted error for float comparison. 7 | EPSILON = 0.00000001 8 | ) 9 | 10 | // FloatEquals compares two floats allowing error within EPSILON. 11 | func FloatEquals(a, b float64) bool { 12 | if a == b { 13 | return true 14 | } 15 | return (a-b) < EPSILON && (b-a) < EPSILON 16 | } 17 | 18 | // GetBool extracts a boolean from TOML decoded map. 19 | // If m[key] does not exist or is not a bool, non-nil error is returned. 20 | func GetBool(key string, m map[string]interface{}) (bool, error) { 21 | v, ok := m[key] 22 | if !ok { 23 | return false, ErrNoKey 24 | } 25 | b, ok := v.(bool) 26 | if !ok { 27 | return false, ErrInvalidType 28 | } 29 | return b, nil 30 | } 31 | 32 | // GetInt extracts an integer from TOML decoded map. 33 | // If m[key] does not exist or is not an integer, non-nil error is returned. 34 | func GetInt(key string, m map[string]interface{}) (int, error) { 35 | v, ok := m[key] 36 | if !ok { 37 | return 0, ErrNoKey 38 | } 39 | i, ok := v.(int) 40 | if !ok { 41 | return 0, ErrInvalidType 42 | } 43 | return i, nil 44 | } 45 | 46 | // GetFloat extracts a float from TOML decoded map. 47 | // If m[key] does not exist or is not a float/int, non-nil error is returned. 48 | func GetFloat(key string, m map[string]interface{}) (float64, error) { 49 | v, ok := m[key] 50 | if !ok { 51 | return 0, ErrNoKey 52 | } 53 | switch v := v.(type) { 54 | case float64: 55 | return v, nil 56 | case int: 57 | return float64(v), nil 58 | default: 59 | return 0, ErrInvalidType 60 | } 61 | } 62 | 63 | // GetString extracts a string from TOML decoded map. 64 | // If m[key] does not exist or is not a string, non-nil error is returned. 65 | func GetString(key string, m map[string]interface{}) (string, error) { 66 | v, ok := m[key] 67 | if !ok { 68 | return "", ErrNoKey 69 | } 70 | s, ok := v.(string) 71 | if !ok { 72 | return "", ErrInvalidType 73 | } 74 | return s, nil 75 | } 76 | 77 | // GetStringList constructs a string list from TOML decoded map. 78 | // If m[key] does not exist or is not a string list, non-nil error is returned. 79 | func GetStringList(key string, m map[string]interface{}) ([]string, error) { 80 | v, ok := m[key] 81 | if !ok { 82 | return nil, ErrNoKey 83 | } 84 | 85 | if sl, ok := v.([]string); ok { 86 | return sl, nil 87 | } 88 | 89 | l, ok := v.([]interface{}) 90 | if !ok { 91 | return nil, ErrInvalidType 92 | } 93 | ret := make([]string, 0, len(l)) 94 | for _, t := range l { 95 | s, ok := t.(string) 96 | if !ok { 97 | return nil, ErrInvalidType 98 | } 99 | ret = append(ret, s) 100 | } 101 | return ret, nil 102 | } 103 | 104 | // GetStringMap constructs a map[string]string from TOML decoded map. 105 | // If m[key] does not exist or is not a string map, non-nil error is returned. 106 | func GetStringMap(key string, m map[string]interface{}) (map[string]string, error) { 107 | v, ok := m[key] 108 | if !ok { 109 | return nil, ErrNoKey 110 | } 111 | 112 | if sm, ok := v.(map[string]string); ok { 113 | return sm, nil 114 | } 115 | 116 | m2, ok := v.(map[string]interface{}) 117 | if !ok { 118 | return nil, ErrInvalidType 119 | } 120 | ret := make(map[string]string) 121 | for k, v2 := range m2 { 122 | s, ok := v2.(string) 123 | if !ok { 124 | return nil, ErrInvalidType 125 | } 126 | ret[k] = s 127 | } 128 | return ret, nil 129 | } 130 | --------------------------------------------------------------------------------