Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,10 @@ Monitors the readings of another sensor and, when a numeric reading crosses a co
- `on_resolve` fires once when the reading clears the threshold.
- Actions are best-effort: a failure is logged and never affects monitoring. Each named resource is automatically added as a dependency.

**References and captures.** String values in a `command` may embed `${...}` references, resolved just before the command is sent:
**References and captures.** String values in a `command` may embed `{{...}}` references, resolved just before the command is sent:

- Rule/reading context: `${value}` (the current reading), `${key}`, `${threshold}`, `${operator}`.
- Captured responses: set `"capture": "<name>"` on an action to store its `DoCommand` response, then reference its fields from later actions (including `on_resolve`) as `${<name>.<field>}`.
- Rule/reading context: `{{value}}` (the current reading), `{{key}}`, `{{threshold}}`, `{{operator}}`.
- Captured responses: set `"capture": "<name>"` on an action to store its `DoCommand` response, then reference its fields from later actions (including `on_resolve`) as `{{<name>.<field>}}`.

A value that is exactly one reference keeps the referenced value's type; a reference embedded in a larger string is substituted as text. If a reference can't be resolved (e.g. nothing was captured), that action is skipped. A capture is overwritten on each cooldown re-fire (so resolve references the most recent response) and cleared once the rule resolves.

Expand Down Expand Up @@ -137,14 +137,14 @@ A low-bean alert that posts to Slack on trigger and adds a ✅ reaction to that
"on_trigger": [
{
"resource": "slack-notifier",
"command": { "command": "send", "text": "☕ Regular beans are low (${value}), please refill." },
"command": { "command": "send", "text": "☕ Regular beans are low ({{value}}), please refill." },
"capture": "msg"
}
],
"on_resolve": [
{
"resource": "slack-notifier",
"command": { "command": "react", "name": "white_check_mark", "ts": "${msg.ts}", "channel": "${msg.channel}" }
"command": { "command": "react", "name": "white_check_mark", "ts": "{{msg.ts}}", "channel": "{{msg.channel}}" }
}
]
},
Expand Down
21 changes: 11 additions & 10 deletions resources/sensormonitor/sensormonitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@ const defaultPollInterval = 10 * time.Second
type Action struct {
// Resource is the name of the resource to call.
Resource string `json:"resource"`
// Command is the DoCommand payload. String values may contain ${...}
// Command is the DoCommand payload. String values may contain {{...}}
// references that are resolved before the command is sent — see resolveValue.
Command map[string]interface{} `json:"command"`
// Capture, when set, stores this action's response under this name so later
// actions can reference its fields as ${name.field}.
// actions can reference its fields as {{name.field}}.
Capture string `json:"capture,omitempty"`
}

Expand Down Expand Up @@ -485,14 +485,14 @@ func toFloat64(v interface{}) (float64, bool) {
}
}

// refExact matches a string that is exactly one reference, e.g. "${msg.ts}".
// refExact matches a string that is exactly one reference, e.g. "{{msg.ts}}".
// refAny matches every reference embedded anywhere in a string.
var (
refExact = regexp.MustCompile(`^\$\{([^}]+)\}$`)
refAny = regexp.MustCompile(`\$\{([^}]+)\}`)
refExact = regexp.MustCompile(`^\{\{([^{}]+)\}\}$`)
refAny = regexp.MustCompile(`\{\{([^{}]+)\}\}`)
)

// resolveValue walks a command value and substitutes ${name} / ${name.path}
// resolveValue walks a command value and substitutes {{name}} / {{name.path}}
// references against ctx. A value that is exactly one reference keeps the
// referenced value's type (so a captured map or number passes through intact); a
// reference embedded in a larger string is substituted textually. An unresolved
Expand All @@ -501,18 +501,19 @@ func resolveValue(v interface{}, ctx map[string]interface{}) (interface{}, error
switch x := v.(type) {
case string:
if mm := refExact.FindStringSubmatch(x); mm != nil {
val, ok := lookupPath(ctx, mm[1])
path := strings.TrimSpace(mm[1])
val, ok := lookupPath(ctx, path)
if !ok {
return nil, fmt.Errorf("unresolved reference ${%s}", mm[1])
return nil, fmt.Errorf("unresolved reference {{%s}}", path)
}
return val, nil
}
var refErr error
out := refAny.ReplaceAllStringFunc(x, func(match string) string {
path := refAny.FindStringSubmatch(match)[1]
path := strings.TrimSpace(refAny.FindStringSubmatch(match)[1])
val, ok := lookupPath(ctx, path)
if !ok {
refErr = fmt.Errorf("unresolved reference ${%s}", path)
refErr = fmt.Errorf("unresolved reference {{%s}}", path)
return match
}
return stringify(val)
Expand Down
18 changes: 9 additions & 9 deletions resources/sensormonitor/sensormonitor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ func TestResolveValue(t *testing.T) {
}

t.Run("exact reference keeps type", func(t *testing.T) {
got, err := resolveValue("${value}", ctx)
got, err := resolveValue("{{value}}", ctx)
if err != nil {
t.Fatal(err)
}
Expand All @@ -258,7 +258,7 @@ func TestResolveValue(t *testing.T) {
})

t.Run("exact reference into capture", func(t *testing.T) {
got, err := resolveValue("${msg.ts}", ctx)
got, err := resolveValue("{{msg.ts}}", ctx)
if err != nil {
t.Fatal(err)
}
Expand All @@ -268,7 +268,7 @@ func TestResolveValue(t *testing.T) {
})

t.Run("embedded references are stringified", func(t *testing.T) {
got, err := resolveValue("temp ${value} over ${threshold}", ctx)
got, err := resolveValue("temp {{value}} over {{threshold}}", ctx)
if err != nil {
t.Fatal(err)
}
Expand All @@ -279,8 +279,8 @@ func TestResolveValue(t *testing.T) {

t.Run("recurses into maps and slices", func(t *testing.T) {
got, err := resolveValue(map[string]interface{}{
"a": "${value}",
"b": []interface{}{"${key}"},
"a": "{{value}}",
"b": []interface{}{"{{key}}"},
}, ctx)
if err != nil {
t.Fatal(err)
Expand All @@ -295,7 +295,7 @@ func TestResolveValue(t *testing.T) {
})

t.Run("unresolved reference errors", func(t *testing.T) {
if _, err := resolveValue("${nope.field}", ctx); err == nil {
if _, err := resolveValue("{{nope.field}}", ctx); err == nil {
t.Fatal("expected error for unresolved reference")
}
})
Expand Down Expand Up @@ -387,14 +387,14 @@ func TestCaptureCarriesAcrossTriggerAndResolve(t *testing.T) {
Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15,
OnTrigger: []Action{{
Resource: "notifier",
Command: map[string]interface{}{"command": "send", "text": "low (${value})"},
Command: map[string]interface{}{"command": "send", "text": "low ({{value}})"},
Capture: "msg",
}},
OnResolve: []Action{{
Resource: "notifier",
Command: map[string]interface{}{
"command": "react", "name": "white_check_mark",
"ts": "${msg.ts}", "channel": "${msg.channel}",
"ts": "{{msg.ts}}", "channel": "{{msg.channel}}",
},
}},
}},
Expand Down Expand Up @@ -428,7 +428,7 @@ func TestResolveActionSkippedWhenCaptureMissing(t *testing.T) {
Sensor: "src",
Rules: []Rule{{Key: "usage", Operator: ">=", Threshold: 15,
OnTrigger: []Action{{Resource: "notifier", Command: map[string]interface{}{"command": "send"}}},
OnResolve: []Action{{Resource: "notifier", Command: map[string]interface{}{"command": "react", "ts": "${msg.ts}"}}},
OnResolve: []Action{{Resource: "notifier", Command: map[string]interface{}{"command": "react", "ts": "{{msg.ts}}"}}},
}},
}, src, map[string]*fakeTarget{"notifier": notifier})

Expand Down