diff --git a/README.md b/README.md index 90c3719..a98afee 100644 --- a/README.md +++ b/README.md @@ -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": ""` on an action to store its `DoCommand` response, then reference its fields from later actions (including `on_resolve`) as `${.}`. +- Rule/reading context: `{{value}}` (the current reading), `{{key}}`, `{{threshold}}`, `{{operator}}`. +- Captured responses: set `"capture": ""` on an action to store its `DoCommand` response, then reference its fields from later actions (including `on_resolve`) as `{{.}}`. 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. @@ -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}}" } } ] }, diff --git a/resources/sensormonitor/sensormonitor.go b/resources/sensormonitor/sensormonitor.go index fe49f31..b04deee 100644 --- a/resources/sensormonitor/sensormonitor.go +++ b/resources/sensormonitor/sensormonitor.go @@ -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"` } @@ -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 @@ -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) diff --git a/resources/sensormonitor/sensormonitor_test.go b/resources/sensormonitor/sensormonitor_test.go index ff6b36b..71e0dc4 100644 --- a/resources/sensormonitor/sensormonitor_test.go +++ b/resources/sensormonitor/sensormonitor_test.go @@ -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) } @@ -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) } @@ -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) } @@ -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) @@ -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") } }) @@ -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}}", }, }}, }}, @@ -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})