├── .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 | [][releases]
5 | [][godoc]
6 | [](https://circleci.com/gh/cybozu-go/goma)
7 | [](https://goreportcard.com/report/github.com/cybozu-go/goma)
8 |
9 | Goma is:
10 |
11 | * Japanese name of sesame seeds,  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 |
--------------------------------------------------------------------------------