diff --git a/aagent/machine/machine.go b/aagent/machine/machine.go index 389345038..a489b68ff 100644 --- a/aagent/machine/machine.go +++ b/aagent/machine/machine.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019-2022, R.I. Pienaar and the Choria Project contributors +// Copyright (c) 2019-2024, R.I. Pienaar and the Choria Project contributors // // SPDX-License-Identifier: Apache-2.0 @@ -688,7 +688,7 @@ func (m *Machine) KnownStates() []string { defer m.Unlock() lister := func() []string { - states := []string{} + var states []string for k := range m.knownStates { states = append(states, k) diff --git a/aagent/watchers/expressionwatcher/expression.go b/aagent/watchers/expressionwatcher/expression.go new file mode 100644 index 000000000..f6f734169 --- /dev/null +++ b/aagent/watchers/expressionwatcher/expression.go @@ -0,0 +1,233 @@ +// Copyright (c) 2024, R.I. Pienaar and the Choria Project contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package expression + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/choria-io/go-choria/aagent/model" + "github.com/choria-io/go-choria/aagent/util" + "github.com/choria-io/go-choria/aagent/watchers/event" + "github.com/choria-io/go-choria/aagent/watchers/watcher" + iu "github.com/choria-io/go-choria/internal/util" +) + +type State int + +const ( + SuccessWhen State = iota + FailWhen + NoMatch + Skipped + Error + + wtype = "expression" + version = "v1" +) + +var stateNames = map[State]string{ + SuccessWhen: "success_when", + FailWhen: "failed_when", + NoMatch: "no_match", + Skipped: "skipped", + Error: "error", +} + +type properties struct { + FailWhen string `mapstructure:"fail_when"` + SuccessWhen string `mapstructure:"success_when"` +} + +type Watcher struct { + *watcher.Watcher + properties *properties + + interval time.Duration + name string + machine model.Machine + + previous State + terminate chan struct{} + mu *sync.Mutex +} + +func New(machine model.Machine, name string, states []string, failEvent string, successEvent string, interval string, ai time.Duration, properties map[string]any) (any, error) { + var err error + + ew := &Watcher{ + name: name, + machine: machine, + interval: 10 * time.Second, + terminate: make(chan struct{}), + previous: Skipped, + mu: &sync.Mutex{}, + } + + ew.Watcher, err = watcher.NewWatcher(name, wtype, ai, states, machine, failEvent, successEvent) + if err != nil { + return nil, err + } + + err = ew.setProperties(properties) + if err != nil { + return nil, fmt.Errorf("could not set properties: %s", err) + } + + if interval != "" { + ew.interval, err = iu.ParseDuration(interval) + if err != nil { + return nil, fmt.Errorf("invalid interval: %v", err) + } + + if ew.interval < 500*time.Millisecond { + return nil, fmt.Errorf("interval %v is too small", ew.interval) + } + } + + return ew, nil +} + +func (w *Watcher) Run(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + w.Infof("Expression watcher starting") + + tick := time.NewTicker(w.interval) + + for { + select { + case <-tick.C: + w.Debugf("Performing watch due to ticker") + w.performWatch() + + case <-w.StateChangeC(): + w.Debugf("Performing watch due to state change") + w.performWatch() + + case <-w.terminate: + w.Infof("Handling terminate notification") + return + + case <-ctx.Done(): + w.Infof("Stopping on context interrupt") + return + } + } +} +func (w *Watcher) performWatch() { + err := w.handleCheck(w.watch()) + if err != nil { + w.Errorf("could not handle watcher event: %s", err) + } +} + +func (w *Watcher) handleCheck(state State, err error) error { + w.mu.Lock() + previous := w.previous + w.previous = state + w.mu.Unlock() + + // shouldn't happen but just a safety here + if err != nil { + state = Error + } + + switch state { + case SuccessWhen: + w.NotifyWatcherState(w.CurrentState()) + + if previous != SuccessWhen { + return w.SuccessTransition() + } + + case FailWhen: + w.NotifyWatcherState(w.CurrentState()) + + if previous != FailWhen { + return w.FailureTransition() + } + + case Error: + if err != nil { + w.Errorf("Evaluating expressions failed: %v", err) + } + + w.NotifyWatcherState(w.CurrentState()) + } + + return nil +} + +func (w *Watcher) watch() (state State, err error) { + if !w.ShouldWatch() { + return Skipped, nil + } + + if w.properties.SuccessWhen != "" { + res, err := w.evaluateExpression(w.properties.SuccessWhen) + if err != nil { + return Error, err + } + + if res { + return SuccessWhen, nil + } + } + + if w.properties.FailWhen != "" { + res, err := w.evaluateExpression(w.properties.FailWhen) + if err != nil { + return Error, err + } + + if res { + return FailWhen, nil + } + } + + return NoMatch, nil +} + +func (w *Watcher) CurrentState() any { + w.mu.Lock() + defer w.mu.Unlock() + + return &StateNotification{ + Event: event.New(w.name, wtype, version, w.machine), + PreviousOutcome: stateNames[w.previous], + } +} + +func (w *Watcher) Delete() { + close(w.terminate) +} + +func (w *Watcher) setProperties(props map[string]any) error { + if w.properties == nil { + w.properties = &properties{} + } + + err := util.ParseMapStructure(props, w.properties) + if err != nil { + return err + } + + return w.validate() +} + +func (w *Watcher) validate() error { + if w.interval < time.Second { + return fmt.Errorf("interval should be more than 1 second: %v", w.interval) + } + + if w.properties.FailWhen == "" && w.properties.SuccessWhen == "" { + return fmt.Errorf("success_when or fail_when is required") + } + + return nil +} diff --git a/aagent/watchers/expressionwatcher/expression_test.go b/aagent/watchers/expressionwatcher/expression_test.go new file mode 100644 index 000000000..99d91fa01 --- /dev/null +++ b/aagent/watchers/expressionwatcher/expression_test.go @@ -0,0 +1,189 @@ +// Copyright (c) 2024, R.I. Pienaar and the Choria Project contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package expression + +import ( + "fmt" + "testing" + "time" + + "github.com/choria-io/go-choria/aagent/model" + "github.com/choria-io/go-choria/aagent/watchers/event" + "github.com/golang/mock/gomock" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMachine(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "AAgent/Watchers/ExpressionsWatcher") +} + +var _ = Describe("AAgent/Watchers/ExpressionsWatcher", func() { + var ( + w *Watcher + machine *model.MockMachine + mockctl *gomock.Controller + td string + err error + ) + + BeforeEach(func() { + td = GinkgoT().TempDir() + + mockctl = gomock.NewController(GinkgoT()) + + machine = model.NewMockMachine(mockctl) + machine.EXPECT().Directory().Return(td).AnyTimes() + machine.EXPECT().SignerKey().Return("").AnyTimes() + + var wi any + wi, err = New(machine, "ginkgo_machine", nil, "FAIL_EVENT", "SUCCESS_EVENT", "1m", time.Hour, map[string]any{ + "success_when": "true", + }) + Expect(err).ToNot(HaveOccurred()) + w = wi.(*Watcher) + }) + + AfterEach(func() { + mockctl.Finish() + }) + + Describe("handleCheck", func() { + var now time.Time + + BeforeEach(func() { + now = time.Now() + machine.EXPECT().Identity().Return("ginkgo.example.net").AnyTimes() + machine.EXPECT().InstanceID().Return("123").AnyTimes() + machine.EXPECT().Version().Return("1.0.0").AnyTimes() + machine.EXPECT().TimeStampSeconds().Return(now.Unix()).AnyTimes() + machine.EXPECT().Name().Return("ginkgo").AnyTimes() + }) + + It("Should handle SuccessWhen", func() { + w.previous = Skipped + + machine.EXPECT().Infof(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + machine.EXPECT().Infof(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + machine.EXPECT().NotifyWatcherState("ginkgo_machine", gomock.Eq(&StateNotification{ + Event: event.New(w.name, wtype, version, w.machine), + PreviousOutcome: stateNames[SuccessWhen], + })).Times(2) + + // noce only since second time would be a flip-flop + machine.EXPECT().Transition("SUCCESS_EVENT").Times(1) + + err := w.handleCheck(SuccessWhen, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(w.previous).To(Equal(SuccessWhen)) + + err = w.handleCheck(SuccessWhen, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(w.previous).To(Equal(SuccessWhen)) + }) + + It("Should handle FailWhen", func() { + w.previous = Skipped + + machine.EXPECT().Infof(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + machine.EXPECT().Infof(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + machine.EXPECT().NotifyWatcherState("ginkgo_machine", gomock.Eq(&StateNotification{ + Event: event.New(w.name, wtype, version, w.machine), + PreviousOutcome: stateNames[FailWhen], + })).Times(2) + + // noce only since second time would be a flip-flop + machine.EXPECT().Transition("FAIL_EVENT").Times(1) + + err := w.handleCheck(FailWhen, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(w.previous).To(Equal(FailWhen)) + + err = w.handleCheck(FailWhen, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(w.previous).To(Equal(FailWhen)) + }) + + It("Should handle Error", func() { + machine.EXPECT().Errorf("ginkgo_machine", gomock.Any(), gomock.Any()).Times(1) + machine.EXPECT().NotifyWatcherState("ginkgo_machine", gomock.Eq(&StateNotification{ + Event: event.New(w.name, wtype, version, w.machine), + PreviousOutcome: stateNames[Error], + })).Times(1) + + err := w.handleCheck(Error, fmt.Errorf("simulated")) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("watch", func() { + BeforeEach(func() { + machine.EXPECT().Data().Return(map[string]any{"test": 1}).AnyTimes() + machine.EXPECT().Facts().Return([]byte(`{"fqdn":"ginkgo.example.net"}`)).AnyTimes() + machine.EXPECT().Identity().Return("ginkgo.example.net").AnyTimes() + + w.properties.FailWhen = "" + w.properties.SuccessWhen = "" + }) + + It("Should handle success_when expressions", func() { + machine.EXPECT().Debugf(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + w.properties.SuccessWhen = "data.test == 1" + state, err := w.watch() + Expect(err).ToNot(HaveOccurred()) + Expect(state).To(Equal(SuccessWhen)) + + w.properties.SuccessWhen = "data.test == 2" + state, err = w.watch() + Expect(err).ToNot(HaveOccurred()) + Expect(state).To(Equal(NoMatch)) + + w.properties.SuccessWhen = "1" + state, err = w.watch() + Expect(err).To(MatchError("expected bool, but got int")) + Expect(state).To(Equal(Error)) + }) + + It("Should handle fail_when expressions", func() { + machine.EXPECT().Debugf(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() + w.properties.FailWhen = "data.test == 1" + state, err := w.watch() + Expect(err).ToNot(HaveOccurred()) + Expect(state).To(Equal(FailWhen)) + + w.properties.FailWhen = "data.test == 2" + state, err = w.watch() + Expect(err).ToNot(HaveOccurred()) + Expect(state).To(Equal(NoMatch)) + + w.properties.FailWhen = "1" + state, err = w.watch() + Expect(err).To(MatchError("expected bool, but got int")) + Expect(state).To(Equal(Error)) + }) + }) + + Describe("setProperties", func() { + It("Should validate the interval", func() { + w.interval = time.Millisecond + Expect(w.setProperties(nil)).To(MatchError("interval should be more than 1 second: 1ms")) + }) + + It("Should require one expressions", func() { + w.properties.FailWhen = "" + w.properties.SuccessWhen = "" + + Expect(w.setProperties(nil)).To(MatchError("success_when or fail_when is required")) + + w.properties.FailWhen = "true" + Expect(w.setProperties(nil)).ToNot(HaveOccurred()) + + w.properties.FailWhen = "" + w.properties.SuccessWhen = "true" + Expect(w.setProperties(nil)).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/aagent/watchers/expressionwatcher/matcher.go b/aagent/watchers/expressionwatcher/matcher.go new file mode 100644 index 000000000..1527ef4b0 --- /dev/null +++ b/aagent/watchers/expressionwatcher/matcher.go @@ -0,0 +1,43 @@ +// Copyright (c) 2024, R.I. Pienaar and the Choria Project contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package expression + +import ( + "fmt" + + "github.com/expr-lang/expr" +) + +func (w *Watcher) evaluateExpression(e string) (bool, error) { + if e == "" { + return false, fmt.Errorf("invalid expression") + } + + env := map[string]any{ + "data": w.machine.Data(), + "facts": w.machine.Facts(), + "identity": w.machine.Identity(), + } + + execEnv := expr.Env(env) + prog, err := expr.Compile(e, execEnv, expr.AsBool()) + if err != nil { + return false, err + } + + res, err := expr.Run(prog, env) + if err != nil { + return false, err + } + + b, ok := res.(bool) + if !ok { + return false, fmt.Errorf("match was non boolean") + } + + w.Debugf("Evaluated expression %q returned: %v", e, b) + + return b, nil +} diff --git a/aagent/watchers/expressionwatcher/plugin.go b/aagent/watchers/expressionwatcher/plugin.go new file mode 100644 index 000000000..08a3ed824 --- /dev/null +++ b/aagent/watchers/expressionwatcher/plugin.go @@ -0,0 +1,13 @@ +// Copyright (c) 2024, R.I. Pienaar and the Choria Project contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package expression + +import ( + "github.com/choria-io/go-choria/aagent/watchers/plugin" +) + +func ChoriaPlugin() *plugin.WatcherPlugin { + return plugin.NewWatcherPlugin(wtype, version, func() any { return &StateNotification{} }, New) +} diff --git a/aagent/watchers/expressionwatcher/state_notification.go b/aagent/watchers/expressionwatcher/state_notification.go new file mode 100644 index 000000000..1382a7b80 --- /dev/null +++ b/aagent/watchers/expressionwatcher/state_notification.go @@ -0,0 +1,36 @@ +// Copyright (c) 2024, R.I. Pienaar and the Choria Project contributors +// +// SPDX-License-Identifier: Apache-2.0 + +package expression + +import ( + "encoding/json" + "fmt" + + "github.com/choria-io/go-choria/aagent/watchers/event" + cloudevents "github.com/cloudevents/sdk-go/v2" +) + +// StateNotification describes the current state of the watcher +// described by io.choria.machine.watcher.exec.v1.state +type StateNotification struct { + event.Event + + PreviousOutcome string `json:"previous_outcome"` +} + +// CloudEvent creates a CloudEvent from the state notification +func (s *StateNotification) CloudEvent() cloudevents.Event { + return s.Event.CloudEvent(s) +} + +// JSON creates a JSON representation of the notification +func (s *StateNotification) JSON() ([]byte, error) { + return json.Marshal(s.CloudEvent()) +} + +// String is a string representation of the notification suitable for printing +func (s *StateNotification) String() string { + return fmt.Sprintf("%s %s#%s previous: %s", s.Identity, s.Machine, s.Name, s.PreviousOutcome) +} diff --git a/cmd/machine_run.go b/cmd/machine_run.go index da28b1003..283f1f5f3 100644 --- a/cmd/machine_run.go +++ b/cmd/machine_run.go @@ -84,7 +84,7 @@ func (r *mRunCommand) Run(wg *sync.WaitGroup) (err error) { } if r.dataFile != "" { - dat := make(map[string]string) + dat := make(map[string]any) df, err := os.ReadFile(r.dataFile) if err != nil { return err diff --git a/go.mod b/go.mod index 8500add9c..168d1d1f2 100644 --- a/go.mod +++ b/go.mod @@ -71,7 +71,7 @@ require ( github.com/choria-io/goform v0.0.3 // indirect github.com/creack/pty v1.1.18 // indirect github.com/dgryski/trifles v0.0.0-20220729183022-231ecf6ed548 // indirect - github.com/emicklei/dot v1.6.1 // indirect + github.com/emicklei/dot v1.6.2 // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/tcell/v2 v2.7.4 // indirect github.com/go-ini/ini v1.67.0 // indirect @@ -80,7 +80,7 @@ require ( github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/google/flatbuffers v23.3.3+incompatible // indirect - github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd // indirect + github.com/google/pprof v0.0.0-20240415144954-be81aee2d733 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/goss-org/GOnetstat v0.0.0-20230101144325-22be0bd9e64d // indirect @@ -118,7 +118,7 @@ require ( github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.39.0 // indirect - github.com/shopspring/decimal v1.3.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/cast v1.6.0 // indirect github.com/tadglines/go-pkgs v0.0.0-20210623144937-b983b20f54f9 // indirect github.com/tchap/go-patricia/v2 v2.3.1 // indirect diff --git a/go.sum b/go.sum index 6a72828df..329d223c1 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,8 @@ github.com/dgryski/trifles v0.0.0-20220729183022-231ecf6ed548 h1:acdRTG6Vp8kMaN3 github.com/dgryski/trifles v0.0.0-20220729183022-231ecf6ed548/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/emicklei/dot v1.6.1 h1:ujpDlBkkwgWUY+qPId5IwapRW/xEoligRSYjioR6DFI= -github.com/emicklei/dot v1.6.1/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/emicklei/dot v1.6.2 h1:08GN+DD79cy/tzN6uLCT84+2Wk9u+wvqP+Hkx/dIR8A= +github.com/emicklei/dot v1.6.2/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= github.com/expr-lang/expr v1.16.4 h1:1Mq5RHw5T5jxXMUvyb+eT546mJREm1yFyNHkybYQ81c= github.com/expr-lang/expr v1.16.4/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= @@ -142,8 +142,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/pprof v0.0.0-20240415144954-be81aee2d733 h1:nHRIUuWr4qaFmeHwGBzW5QtiLr3Zy5EXjnRpFG9RarE= +github.com/google/pprof v0.0.0-20240415144954-be81aee2d733/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -292,8 +292,8 @@ github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPO github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= -github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= -github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= diff --git a/packager/plugins.yaml b/packager/plugins.yaml index b635699a1..ad6ec0ac6 100644 --- a/packager/plugins.yaml +++ b/packager/plugins.yaml @@ -21,6 +21,7 @@ kv_watcher: github.com/choria-io/go-choria/aagent/watchers/kvwatcher gossip_watcher: github.com/choria-io/go-choria/aagent/watchers/gossipwatcher archive_watcher: github.com/choria-io/go-choria/aagent/watchers/archivewatcher plugins_watcher: github.com/choria-io/go-choria/aagent/watchers/pluginswatcher +expression_watcher: github.com/choria-io/go-choria/aagent/watchers/expressionwatcher # Data Plugins machine_data: github.com/choria-io/go-choria/aagent/data/machinedata