diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3789171..c0870a2 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -12,13 +12,8 @@ name: "CodeQL" on: - push: - branches: [ "main" ] pull_request: - # The branches below must be a subset of the branches above branches: [ "main" ] - schedule: - - cron: '28 10 * * 2' jobs: analyze: diff --git a/.run/Run All Tests.run.xml b/.run/Run All Tests.run.xml index 5d273f6..6cf7d44 100644 --- a/.run/Run All Tests.run.xml +++ b/.run/Run All Tests.run.xml @@ -1,17 +1,8 @@ diff --git a/.run/Run SQS Client.run.xml b/.run/Run SQS Client.run.xml index 869c1a7..6cf872a 100644 --- a/.run/Run SQS Client.run.xml +++ b/.run/Run SQS Client.run.xml @@ -2,7 +2,7 @@ - + diff --git a/.run/Run Server.run.xml b/.run/Run Server.run.xml index 27d2204..10c3807 100644 --- a/.run/Run Server.run.xml +++ b/.run/Run Server.run.xml @@ -1,3 +1,12 @@ + + @@ -13,4 +22,4 @@ - \ No newline at end of file + diff --git a/.run/Run gRPC Client.run.xml b/.run/Run gRPC Client.run.xml index 75a2636..09aad26 100644 --- a/.run/Run gRPC Client.run.xml +++ b/.run/Run gRPC Client.run.xml @@ -1,3 +1,12 @@ + + @@ -8,4 +17,4 @@ - \ No newline at end of file + diff --git a/Makefile b/Makefile index b4cff4b..1d8a9ac 100644 --- a/Makefile +++ b/Makefile @@ -20,40 +20,63 @@ all_go := $(shell for d in $(pkgs); do find $$d -name "*.go"; done) test_srcs := $(shell for d in $(pkgs); do find $$d -name "*_test.go"; done) srcs := $(filter-out $(test_srcs),$(all_go)) -# Builds the server -# +##@ General + +# The help target prints out all targets with their descriptions organized +# beneath their categories. The categories are represented by '##@' and the +# target descriptions by '##'. The awk commands is responsible for reading the +# entire set of makefiles included in this invocation, looking for lines of the +# file as xyz: ## something, and then pretty-format the target and help. Then, +# if there's a line with ##@ something, that gets pretty-printed as a category. +# More info on the usage of ANSI control characters for terminal formatting: +# https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters +# More info on the awk command: +# http://linuxcommand.org/lc3_adv_awk.php + +.PHONY: help +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +.PHONY: clean +clean: ## Cleans up the binary, container image and other data + @rm -f $(out) + @docker-compose -f $(compose) down + @docker rmi $(shell docker images -q --filter=reference=$(image)) + +.PHONY: build test container cov clean fmt +fmt: ## Formats the Go source code using 'go fmt' + @go fmt $(pkgs) ./cmd ./clients + +##@ Development $(out): cmd/main.go $(srcs) go build -ldflags "-X $(module)/server.Release=$(tag)" -o $(out) cmd/main.go @chmod +x $(out) -build: $(out) +build: $(out) ## Builds the server + +test: $(srcs) $(test_srcs) services queues ## Runs all tests, starting services first, if required + ginkgo $(pkgs) + +cov: $(srcs) $(test_srcs) ## Runs the Test Coverage target and opens a browser window with the coverage report + @go test -coverprofile=/tmp/cov.out $(pkgs) + @go tool cover -html=/tmp/cov.out +##@ Container Management # Convenience targets to run locally containers and # setup the test environments. # -# TODO: will be replaced once we adopt TestContainers -# (see Issue # 26) -services: +.PHONY: container +container: $(out) ## Builds the container image + docker build -f $(dockerfile) -t $(image):$(tag) . + +# TODO: will be replaced once we adopt TestContainers (#26) +.PHONY: services +services: ## Starts the Redis and LocalStack containers @docker-compose -f $(compose) up -d -queues: - @for queue in events notifications; do \ +.PHONY: queues +queues: ## Creates the SQS Queues in LocalStack + @for queue in events notifications acks; do \ aws --no-cli-pager --endpoint-url=http://localhost:4566 \ --region us-west-2 \ sqs create-queue --queue-name $$queue; done >/dev/null - -test: $(srcs) $(test_srcs) services queues - ginkgo $(pkgs) - -container: $(out) - docker build -f $(dockerfile) -t $(image):$(tag) . - -# Runs test coverage and displays the results in browser -cov: $(srcs) $(test_srcs) - @go test -coverprofile=/tmp/cov.out $(pkgs) - @go tool cover -html=/tmp/cov.out - -clean: - @rm -f $(out) - @docker-compose -f $(compose) down - @docker rmi $(shell docker images -q --filter=reference=$(image)) diff --git a/README.md b/README.md index b5663ad..ba5b9d6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ A basic implementation of a Finite State Machine in Go **The code is copyright (c) 2022 AlertAvert.com. All rights reserved**
The code is released under the Apache 2.0 License, see `LICENSE` for details. +Fixes and additions are always welcome and warmly appreciated, please see the [Contributing](#contributing) section for some guidance. + # Overview ## Design @@ -451,10 +453,14 @@ Usage of build/bin/sm-server: Max number of attempts for a recoverable error to be retried against the Redis cluster (default 3) -notifications string The name of the notification topic in SQS to publish events' outcomes to; if not specified, no outcomes will be published + -acks string + (Requires `notifications`) The name of the acks topic in SQS to publish events' outcomes to; if specified, Ok outcomes will be published to the acks topic and other (error) outcomes to the notification topic -redis string URI for the Redis cluster (host:port) -cluster - Enables connecting to a Redis deployment in cluster mode + If set, allows connecting to a Redis instance with cluster-mode enabled + -notify-error-only + If set, only errors will be sent to notification topics -timeout duration Timeout for Redis (as a Duration string, e.g. 1s, 20ms, etc.) (default 200ms) -trace @@ -513,6 +519,11 @@ ENV ERRORS_Q=notifications ENV REDIS=redis ENV REDIS_PORT=6379 ENV DEBUG="" + +# Optional settings for the server +ENV ACKS="-acks acks" +ENV CLUSTER="-cluster" +ENV NOTIFY_ERRORS_ONLY="-notify-errors-only" ``` Additionally, a valid `credentials` file will need to be mounted (using the `-v` flag) in the container if connecting to AWS (instead of LocalStack): @@ -524,6 +535,39 @@ where the `[profile]` matches the value in `AWS_PROFILE`. # Contributing -Please follow the Go Style enshrined in `go fmt` before submitting PRs, refer to actual [Issues](#), and provide sufficient testing (ideally, ensuring your code coverage is better than 80%). +Please follow the Go Style enshrined in `go fmt` before submitting PRs, refer to actual [Issues](https://github.com/massenz/go-statemachine/issues), and provide sufficient testing (ideally, ensuring code coverage is better than 70%). We prefer you submit a PR directly from cloning this repository and creating a feature branch, rather than from a fork. + +If you are relatively new to this topic, there are a few issues labeled as [`Good First Issue`](https://github.com/massenz/go-statemachine/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22), those are a good starting point. + +Please, always **squash and rebase** on `main`, we try to keep a relatively clean and linear commit history, and each PR should just be **one** commit with a descriptive title (see below). + +## Detailed Requirements to submit a PR + +1. make sure the Issue you are trying to address is referenced appropriately in the PR Title: + +``` +[#34] Implements gRPC API to update/retrieve Event outcomes +``` + +2. prior to submitting the PR, make sure that the code is properly formatted and tested: + +``` +make fmt && make test +``` + +3. if this is a breaking change, or a significant change to the API or behavior, please update the [README](README.md) accordingly. + +## Guidelines to be A Good Contributor + +Provide enough detail of your changes in the PR comments and make it easy for reviewers: + +* if your code contains several lines of "commented out dead code" make sure that you clearly explain why this is so with a `TODO` and an explanation of why are you leaving dead code around (remember, we are using `git` here, there is no such thing "in case we forget" - `git` **never** forgets) +* try and be consistent with the rest of the code base and, specifically, the code around the changes you are implementing +* be consistent with the `import` format and sequence: if in doubt, again, look at the existing code and be **consistent** +* make sure the new code is **covered by unit tests**, use `make cov` to check coverage % and view lines covered in the browser +* try and adopt "The Boyscout Rule": leave the campsite cleaner than you found it -- in other words, adding tests, fixing typos, fixing **minor** issues is always **greatly** appreciated +* conversely, try and keep the PR focused on one topic/issue/change, and one only: we'd rather review 2 PRs, than try and disentangle the two unrelated issues you're trying to address + +If in doubt, look at the existing code, or feel free to ask in the PR's comments - we don't bite :-) diff --git a/api/fsm.go b/api/fsm.go index d85b4ad..19bd24e 100644 --- a/api/fsm.go +++ b/api/fsm.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,104 +10,104 @@ package api import ( - "fmt" - "github.com/golang/protobuf/proto" - "github.com/google/uuid" - log "github.com/massenz/slf4go/logging" - "google.golang.org/protobuf/types/known/timestamppb" - "strings" - - protos "github.com/massenz/statemachine-proto/golang/api" + "fmt" + "github.com/golang/protobuf/proto" + "github.com/google/uuid" + log "github.com/massenz/slf4go/logging" + "google.golang.org/protobuf/types/known/timestamppb" + "strings" + + protos "github.com/massenz/statemachine-proto/golang/api" ) const ( - ConfigurationVersionSeparator = ":" + ConfigurationVersionSeparator = ":" ) var ( - MalformedConfigurationError = fmt.Errorf("this configuration cannot be parsed") - MissingNameConfigurationError = fmt.Errorf( - "configuration must always specify a name (and optionally a version)") - MissingStatesConfigurationError = fmt.Errorf( - "configuration must always specify at least one state") - MissingDestinationError = fmt.Errorf( - "event must always have a `Destination` statemachine") - MissingEventNameError = fmt.Errorf( - "events must always specify the event type") - MismatchStartingStateConfigurationError = fmt.Errorf( - "the StartingState must be one of the possible FSM states") - EmptyStartingStateConfigurationError = fmt.Errorf("the StartingState must be non-empty") - UnexpectedTransitionError = fmt.Errorf("unexpected event transition") - UnexpectedError = fmt.Errorf("the request was malformed") - UnreachableStateConfigurationError = "state %s is not used in any of the transitions" - - // Logger is made accessible so that its `Level` can be changed - // or can be sent to a `NullLog` during testing. - Logger = log.NewLog("fsm") + MalformedConfigurationError = fmt.Errorf("this configuration cannot be parsed") + MissingNameConfigurationError = fmt.Errorf( + "configuration must always specify a name (and optionally a version)") + MissingStatesConfigurationError = fmt.Errorf( + "configuration must always specify at least one state") + MissingDestinationError = fmt.Errorf( + "event must always have a `Destination` statemachine") + MissingEventNameError = fmt.Errorf( + "events must always specify the event type") + MismatchStartingStateConfigurationError = fmt.Errorf( + "the StartingState must be one of the possible FSM states") + EmptyStartingStateConfigurationError = fmt.Errorf("the StartingState must be non-empty") + UnexpectedTransitionError = fmt.Errorf("unexpected event transition") + UnexpectedError = fmt.Errorf("the request was malformed") + UnreachableStateConfigurationError = "state %s is not used in any of the transitions" + + // Logger is made accessible so that its `Level` can be changed + // or can be sent to a `NullLog` during testing. + Logger = log.NewLog("fsm") ) // ConfiguredStateMachine is the internal representation of an FSM, which // carries within itself the configuration for ease of use. type ConfiguredStateMachine struct { - Config *protos.Configuration - FSM *protos.FiniteStateMachine + Config *protos.Configuration + FSM *protos.FiniteStateMachine } func NewStateMachine(configuration *protos.Configuration) (*ConfiguredStateMachine, error) { - if configuration.Name == "" || configuration.Version == "" { - Logger.Error("Missing configuration name") - return nil, MalformedConfigurationError - } - return &ConfiguredStateMachine{ - FSM: &protos.FiniteStateMachine{ - ConfigId: strings.Join([]string{configuration.Name, configuration.Version}, ConfigurationVersionSeparator), - State: configuration.StartingState, - }, - Config: configuration, - }, nil + if configuration.Name == "" || configuration.Version == "" { + Logger.Error("Missing configuration name") + return nil, MalformedConfigurationError + } + return &ConfiguredStateMachine{ + FSM: &protos.FiniteStateMachine{ + ConfigId: strings.Join([]string{configuration.Name, configuration.Version}, ConfigurationVersionSeparator), + State: configuration.StartingState, + }, + Config: configuration, + }, nil } // SendEvent registers the event with the FSM and effects the transition, if valid. // It also creates a new Event, and stores in the provided cache. func (x *ConfiguredStateMachine) SendEvent(evt *protos.Event) error { - // We need to clone the Event, as we will be mutating it, - // and storing the pointer in the FSM's `History`: - // we cannot be sure what the caller is going to do with it. - newEvent := proto.Clone(evt).(*protos.Event) - for _, t := range x.Config.Transitions { - if t.From == x.FSM.State && t.Event == newEvent.Transition.Event { - newEvent.Transition.From = x.FSM.State - newEvent.Transition.To = t.To - x.FSM.State = t.To - x.FSM.History = append(x.FSM.History, newEvent) - return nil - } - } - return UnexpectedTransitionError + // We need to clone the Event, as we will be mutating it, + // and storing the pointer in the FSM's `History`: + // we cannot be sure what the caller is going to do with it. + newEvent := proto.Clone(evt).(*protos.Event) + for _, t := range x.Config.Transitions { + if t.From == x.FSM.State && t.Event == newEvent.Transition.Event { + newEvent.Transition.From = x.FSM.State + newEvent.Transition.To = t.To + x.FSM.State = t.To + x.FSM.History = append(x.FSM.History, newEvent) + return nil + } + } + return UnexpectedTransitionError } func (x *ConfiguredStateMachine) Reset() { - x.FSM.State = x.Config.StartingState - x.FSM.History = nil + x.FSM.State = x.Config.StartingState + x.FSM.History = nil } func GetVersionId(c *protos.Configuration) string { - return c.Name + ConfigurationVersionSeparator + c.Version + return c.Name + ConfigurationVersionSeparator + c.Version } // HasState will check whether a given state is either origin or destination for the Transition func HasState(transition *protos.Transition, state string) bool { - return state == transition.GetFrom() || state == transition.GetTo() + return state == transition.GetFrom() || state == transition.GetTo() } // CfgHasState checks that `state` is one of the Configuration's `States` func CfgHasState(configuration *protos.Configuration, state string) bool { - for _, s := range configuration.States { - if s == state { - return true - } - } - return false + for _, s := range configuration.States { + if s == state { + return true + } + } + return false } // CheckValid checks that the Configuration is valid and that the current FSM `state` is one of @@ -124,8 +115,8 @@ func CfgHasState(configuration *protos.Configuration, state string) bool { // // We also check that the reported FSM ConfigId, matches the Configuration's name, version. func (x *ConfiguredStateMachine) CheckValid() bool { - return CheckValid(x.Config) == nil && CfgHasState(x.Config, x.FSM.State) && - x.FSM.ConfigId == GetVersionId(x.Config) + return CheckValid(x.Config) == nil && CfgHasState(x.Config, x.FSM.State) && + x.FSM.ConfigId == GetVersionId(x.Config) } // CheckValid will validate that there is at least one state, @@ -135,49 +126,49 @@ func (x *ConfiguredStateMachine) CheckValid() bool { // Finally, it will check that the name is valid, // and that the generated `ConfigId` is a valid URI segment. func CheckValid(c *protos.Configuration) error { - if c.Name == "" { - return MissingNameConfigurationError - } - if len(c.States) == 0 { - return MissingStatesConfigurationError - } - if c.StartingState == "" { - return EmptyStartingStateConfigurationError - } - if !CfgHasState(c, c.StartingState) { - return MismatchStartingStateConfigurationError - } - // TODO: we should actually build the full graph and check it's fully connected. - for _, s := range c.States { - found := false - for _, t := range c.Transitions { - if HasState(t, s) { - found = true - break - } - } - if !found { - return fmt.Errorf(UnreachableStateConfigurationError, s) - } - } - return nil + if c.Name == "" { + return MissingNameConfigurationError + } + if len(c.States) == 0 { + return MissingStatesConfigurationError + } + if c.StartingState == "" { + return EmptyStartingStateConfigurationError + } + if !CfgHasState(c, c.StartingState) { + return MismatchStartingStateConfigurationError + } + // TODO: we should actually build the full graph and check it's fully connected. + for _, s := range c.States { + found := false + for _, t := range c.Transitions { + if HasState(t, s) { + found = true + break + } + } + if !found { + return fmt.Errorf(UnreachableStateConfigurationError, s) + } + } + return nil } // NewEvent creates a new Event, with the given `eventName` transition. func NewEvent(eventName string) *protos.Event { - var event = protos.Event{ - Transition: &protos.Transition{Event: eventName}, - } - UpdateEvent(&event) - return &event + var event = protos.Event{ + Transition: &protos.Transition{Event: eventName}, + } + UpdateEvent(&event) + return &event } // UpdateEvent adds the ID and timestamp to the event, if not already set. func UpdateEvent(event *protos.Event) { - if event.EventId == "" { - event.EventId = uuid.NewString() - } - if event.Timestamp == nil { - event.Timestamp = timestamppb.Now() - } + if event.EventId == "" { + event.EventId = uuid.NewString() + } + if event.Timestamp == nil { + event.Timestamp = timestamppb.Now() + } } diff --git a/api/statemachine_suite_test.go b/api/statemachine_suite_test.go index a88f2ca..c7c736f 100644 --- a/api/statemachine_suite_test.go +++ b/api/statemachine_suite_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ diff --git a/api/statemachine_test.go b/api/statemachine_test.go index ab005d3..4f4157c 100644 --- a/api/statemachine_test.go +++ b/api/statemachine_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,57 +10,57 @@ package api_test import ( - "github.com/golang/protobuf/jsonpb" - log "github.com/massenz/slf4go/logging" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - "io/ioutil" - - . "github.com/massenz/go-statemachine/api" - protos "github.com/massenz/statemachine-proto/golang/api" + "github.com/golang/protobuf/jsonpb" + log "github.com/massenz/slf4go/logging" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "io/ioutil" + + . "github.com/massenz/go-statemachine/api" + protos "github.com/massenz/statemachine-proto/golang/api" ) var _ = Describe("FSM Protocol Buffers", func() { - BeforeEach(func() { - Logger = log.NewLog("statemachine-test") - Logger.Level = log.NONE - }) - Context("if well defined", func() { - It("can be initialized", func() { - var spaceship = protos.Configuration{ - StartingState: "earth", - States: []string{"earth", "orbit", "mars"}, - Transitions: []*protos.Transition{ - {From: "earth", To: "orbit", Event: "launch"}, - {From: "orbit", To: "mars", Event: "land"}, - }, - } - Expect(spaceship.StartingState).To(Equal("earth")) - Expect(len(spaceship.States)).To(Equal(3)) - Expect(spaceship.Transitions).ToNot(BeEmpty()) - Expect(spaceship.Transitions[0]).ToNot(BeNil()) - }) - It("can be created and used", func() { - fsm := &protos.FiniteStateMachine{} - fsm.State = "mars" - Expect(fsm).ShouldNot(BeNil()) - Expect(fsm.State).Should(Equal("mars")) - Expect(fsm.History).Should(BeEmpty()) - }) - }) - - Context("when defined via JSON", func() { - var ( - t = protos.Transition{} - evt = protos.Event{} - gccConfig = protos.Configuration{} - transition, simpleEvent, compiler string - ) - - BeforeEach(func() { - transition = `{"from": "source", "to": "binary", "event": "build"}` - simpleEvent = `{"transition": {"event": "build"}}` - compiler = `{ + BeforeEach(func() { + Logger = log.NewLog("statemachine-test") + Logger.Level = log.NONE + }) + Context("if well defined", func() { + It("can be initialized", func() { + var spaceship = protos.Configuration{ + StartingState: "earth", + States: []string{"earth", "orbit", "mars"}, + Transitions: []*protos.Transition{ + {From: "earth", To: "orbit", Event: "launch"}, + {From: "orbit", To: "mars", Event: "land"}, + }, + } + Expect(spaceship.StartingState).To(Equal("earth")) + Expect(len(spaceship.States)).To(Equal(3)) + Expect(spaceship.Transitions).ToNot(BeEmpty()) + Expect(spaceship.Transitions[0]).ToNot(BeNil()) + }) + It("can be created and used", func() { + fsm := &protos.FiniteStateMachine{} + fsm.State = "mars" + Expect(fsm).ShouldNot(BeNil()) + Expect(fsm.State).Should(Equal("mars")) + Expect(fsm.History).Should(BeEmpty()) + }) + }) + + Context("when defined via JSON", func() { + var ( + t = protos.Transition{} + evt = protos.Event{} + gccConfig = protos.Configuration{} + transition, simpleEvent, compiler string + ) + + BeforeEach(func() { + transition = `{"from": "source", "to": "binary", "event": "build"}` + simpleEvent = `{"transition": {"event": "build"}}` + compiler = `{ "name": "compiler", "version": "v1", "states": ["source", "tested", "binary"], @@ -79,144 +70,144 @@ var _ = Describe("FSM Protocol Buffers", func() { ], "starting_state": "source" }` - }) - - It("can be parsed without errors", func() { - - Expect(jsonpb.UnmarshalString(transition, &t)).ShouldNot(HaveOccurred()) - Expect(t.From).To(Equal("source")) - Expect(t.To).To(Equal("binary")) - Expect(t.Event).To(Equal("build")) - }) - It("events only need the name of the event to pars", func() { - Expect(jsonpb.UnmarshalString(simpleEvent, &evt)).ShouldNot(HaveOccurred()) - Expect(evt.Transition.Event).To(Equal("build")) - - }) - It("can define complex configurations", func() { - Expect(jsonpb.UnmarshalString(compiler, &gccConfig)).ShouldNot(HaveOccurred()) - Expect(len(gccConfig.States)).To(Equal(3)) - Expect(len(gccConfig.Transitions)).To(Equal(2)) - Expect(gccConfig.Version).To(Equal("v1")) - }) - It("can be used to create FSMs", func() { - Expect(jsonpb.UnmarshalString(compiler, &gccConfig)).ShouldNot(HaveOccurred()) - fsm, err := NewStateMachine(&gccConfig) - Expect(err).ShouldNot(HaveOccurred()) - Expect(fsm.FSM.State).To(Equal("source")) - Expect(fsm.FSM.ConfigId).To(Equal("compiler:v1")) - - }) - }) - - Describe("Finite State Machines", func() { - Context("with a configuration", func() { - var spaceship protos.Configuration - - BeforeEach(func() { - spaceship = protos.Configuration{ - StartingState: "earth", - States: []string{"earth", "orbit", "mars"}, - Transitions: []*protos.Transition{ - {From: "earth", To: "orbit", Event: "launch"}, - {From: "orbit", To: "mars", Event: "land"}, - }, - } - }) - - It("without name will fail", func() { - spaceship.Version = "v0.1" - _, err := NewStateMachine(&spaceship) - Expect(err).Should(HaveOccurred()) - }) - It("will fail with a missing configuration version", func() { - spaceship.Name = "mars_orbiter" - _, err := NewStateMachine(&spaceship) - Expect(err).To(HaveOccurred()) - }) - It("will carry the configuration embedded", func() { - spaceship.Name = "mars_orbiter" - spaceship.Version = "v1.0.1" - s, err := NewStateMachine(&spaceship) - Expect(err).ToNot(HaveOccurred()) - Expect(s).ToNot(BeNil()) - Expect(s.Config).ToNot(BeNil()) - Expect(s.Config.String()).To(Equal(spaceship.String())) - Expect(s.FSM.ConfigId).To(Equal(GetVersionId(&spaceship))) - }) - }) - - Context("with a valid configuration", func() { - defer GinkgoRecover() - var spaceship protos.Configuration - - BeforeEach(func() { - spaceship = protos.Configuration{ - Name: "mars_rover", - Version: "v2.0", - StartingState: "earth", - States: []string{"earth", "orbit", "mars"}, - Transitions: []*protos.Transition{ - {From: "earth", To: "orbit", Event: "launch"}, - {From: "orbit", To: "mars", Event: "land"}, - }, - } - }) - It("can transition across states ", func() { - lander, err := NewStateMachine(&spaceship) - Expect(err).ToNot(HaveOccurred()) - Expect(lander.FSM.State).To(Equal("earth")) - Expect(lander.SendEvent(NewEvent("launch"))).ShouldNot(HaveOccurred()) - Expect(lander.FSM.State).To(Equal("orbit")) - Expect(lander.SendEvent(NewEvent("land"))).ShouldNot(HaveOccurred()) - Expect(lander.FSM.State).To(Equal("mars")) - }) - It("should fail for an unsupported transition", func() { - lander, _ := NewStateMachine(&spaceship) - Expect(lander.SendEvent(NewEvent("navigate"))).Should(HaveOccurred()) - }) - It("can be reset", func() { - lander, _ := NewStateMachine(&spaceship) - Expect(lander.SendEvent(NewEvent("launch"))).ShouldNot(HaveOccurred()) - Expect(lander.SendEvent(NewEvent("land"))).ShouldNot(HaveOccurred()) - Expect(lander.FSM.State).To(Equal("mars")) - - // Never mind, Elon, let's go home... - lander.Reset() - Expect(lander.FSM.State).To(Equal("earth")) - Expect(lander.FSM.History).To(BeNil()) - }) - }) - - Context("can be configured via complex JSON", func() { - defer GinkgoRecover() - var ( - orders protos.Configuration - configJson []byte - ) - BeforeEach(func() { - var err error - configJson, err = ioutil.ReadFile("../data/orders.json") - Expect(err).ToNot(HaveOccurred()) - Expect(jsonpb.UnmarshalString(string(configJson), &orders)).ToNot(HaveOccurred()) - }) - It("JSON can be unmarshalled", func() { - Expect(orders.Name).To(Equal("test.orders")) - Expect(orders.Version).To(Equal("v1")) - - }) - It("can be created and events received", func() { - fsm, err := NewStateMachine(&orders) - Expect(err).ToNot(HaveOccurred()) - Expect(fsm.FSM).ToNot(BeNil()) - Expect(fsm.FSM.State).To(Equal("start")) - - Expect(fsm.SendEvent(NewEvent("accepted"))).ToNot(HaveOccurred()) - Expect(fsm.SendEvent(NewEvent("shipped"))).ToNot(HaveOccurred()) - - Expect(fsm.FSM.State).To(Equal("shipping")) - Expect(len(fsm.FSM.History)).To(Equal(2)) - }) - }) - }) + }) + + It("can be parsed without errors", func() { + + Expect(jsonpb.UnmarshalString(transition, &t)).ShouldNot(HaveOccurred()) + Expect(t.From).To(Equal("source")) + Expect(t.To).To(Equal("binary")) + Expect(t.Event).To(Equal("build")) + }) + It("events only need the name of the event to pars", func() { + Expect(jsonpb.UnmarshalString(simpleEvent, &evt)).ShouldNot(HaveOccurred()) + Expect(evt.Transition.Event).To(Equal("build")) + + }) + It("can define complex configurations", func() { + Expect(jsonpb.UnmarshalString(compiler, &gccConfig)).ShouldNot(HaveOccurred()) + Expect(len(gccConfig.States)).To(Equal(3)) + Expect(len(gccConfig.Transitions)).To(Equal(2)) + Expect(gccConfig.Version).To(Equal("v1")) + }) + It("can be used to create FSMs", func() { + Expect(jsonpb.UnmarshalString(compiler, &gccConfig)).ShouldNot(HaveOccurred()) + fsm, err := NewStateMachine(&gccConfig) + Expect(err).ShouldNot(HaveOccurred()) + Expect(fsm.FSM.State).To(Equal("source")) + Expect(fsm.FSM.ConfigId).To(Equal("compiler:v1")) + + }) + }) + + Describe("Finite State Machines", func() { + Context("with a configuration", func() { + var spaceship protos.Configuration + + BeforeEach(func() { + spaceship = protos.Configuration{ + StartingState: "earth", + States: []string{"earth", "orbit", "mars"}, + Transitions: []*protos.Transition{ + {From: "earth", To: "orbit", Event: "launch"}, + {From: "orbit", To: "mars", Event: "land"}, + }, + } + }) + + It("without name will fail", func() { + spaceship.Version = "v0.1" + _, err := NewStateMachine(&spaceship) + Expect(err).Should(HaveOccurred()) + }) + It("will fail with a missing configuration version", func() { + spaceship.Name = "mars_orbiter" + _, err := NewStateMachine(&spaceship) + Expect(err).To(HaveOccurred()) + }) + It("will carry the configuration embedded", func() { + spaceship.Name = "mars_orbiter" + spaceship.Version = "v1.0.1" + s, err := NewStateMachine(&spaceship) + Expect(err).ToNot(HaveOccurred()) + Expect(s).ToNot(BeNil()) + Expect(s.Config).ToNot(BeNil()) + Expect(s.Config.String()).To(Equal(spaceship.String())) + Expect(s.FSM.ConfigId).To(Equal(GetVersionId(&spaceship))) + }) + }) + + Context("with a valid configuration", func() { + defer GinkgoRecover() + var spaceship protos.Configuration + + BeforeEach(func() { + spaceship = protos.Configuration{ + Name: "mars_rover", + Version: "v2.0", + StartingState: "earth", + States: []string{"earth", "orbit", "mars"}, + Transitions: []*protos.Transition{ + {From: "earth", To: "orbit", Event: "launch"}, + {From: "orbit", To: "mars", Event: "land"}, + }, + } + }) + It("can transition across states ", func() { + lander, err := NewStateMachine(&spaceship) + Expect(err).ToNot(HaveOccurred()) + Expect(lander.FSM.State).To(Equal("earth")) + Expect(lander.SendEvent(NewEvent("launch"))).ShouldNot(HaveOccurred()) + Expect(lander.FSM.State).To(Equal("orbit")) + Expect(lander.SendEvent(NewEvent("land"))).ShouldNot(HaveOccurred()) + Expect(lander.FSM.State).To(Equal("mars")) + }) + It("should fail for an unsupported transition", func() { + lander, _ := NewStateMachine(&spaceship) + Expect(lander.SendEvent(NewEvent("navigate"))).Should(HaveOccurred()) + }) + It("can be reset", func() { + lander, _ := NewStateMachine(&spaceship) + Expect(lander.SendEvent(NewEvent("launch"))).ShouldNot(HaveOccurred()) + Expect(lander.SendEvent(NewEvent("land"))).ShouldNot(HaveOccurred()) + Expect(lander.FSM.State).To(Equal("mars")) + + // Never mind, Elon, let's go home... + lander.Reset() + Expect(lander.FSM.State).To(Equal("earth")) + Expect(lander.FSM.History).To(BeNil()) + }) + }) + + Context("can be configured via complex JSON", func() { + defer GinkgoRecover() + var ( + orders protos.Configuration + configJson []byte + ) + BeforeEach(func() { + var err error + configJson, err = ioutil.ReadFile("../data/orders.json") + Expect(err).ToNot(HaveOccurred()) + Expect(jsonpb.UnmarshalString(string(configJson), &orders)).ToNot(HaveOccurred()) + }) + It("JSON can be unmarshalled", func() { + Expect(orders.Name).To(Equal("test.orders")) + Expect(orders.Version).To(Equal("v1")) + + }) + It("can be created and events received", func() { + fsm, err := NewStateMachine(&orders) + Expect(err).ToNot(HaveOccurred()) + Expect(fsm.FSM).ToNot(BeNil()) + Expect(fsm.FSM.State).To(Equal("start")) + + Expect(fsm.SendEvent(NewEvent("accepted"))).ToNot(HaveOccurred()) + Expect(fsm.SendEvent(NewEvent("shipped"))).ToNot(HaveOccurred()) + + Expect(fsm.FSM.State).To(Equal("shipping")) + Expect(len(fsm.FSM.History)).To(Equal(2)) + }) + }) + }) }) diff --git a/build.settings b/build.settings index 4b619b4..22c9436 100644 --- a/build.settings +++ b/build.settings @@ -1,4 +1,4 @@ # Build configuration -version = 0.6.1 +version = 0.6.2 diff --git a/clients/grpc_client.go b/clients/grpc_client.go index c445458..6cf620a 100644 --- a/clients/grpc_client.go +++ b/clients/grpc_client.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ diff --git a/clients/orders.go b/clients/orders.go index a7ea8e9..fbea106 100644 --- a/clients/orders.go +++ b/clients/orders.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ diff --git a/clients/sqs_client.go b/clients/sqs_client.go index e509da9..209968a 100644 --- a/clients/sqs_client.go +++ b/clients/sqs_client.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ diff --git a/cmd/main.go b/cmd/main.go index c13f7de..362d092 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,186 +1,186 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ -// CLI to process Kubernetes Specs with a JSON configuration. -// -// Created by M. Massenzio, 2021-02-20 - package main import ( - "flag" - "fmt" - "github.com/massenz/go-statemachine/grpc" - "github.com/massenz/go-statemachine/pubsub" - "github.com/massenz/go-statemachine/server" - "github.com/massenz/go-statemachine/storage" - log "github.com/massenz/slf4go/logging" - protos "github.com/massenz/statemachine-proto/golang/api" - "net" - "sync" + "flag" + "fmt" + "github.com/massenz/go-statemachine/grpc" + "github.com/massenz/go-statemachine/pubsub" + "github.com/massenz/go-statemachine/server" + "github.com/massenz/go-statemachine/storage" + log "github.com/massenz/slf4go/logging" + protos "github.com/massenz/statemachine-proto/golang/api" + "net" + "sync" ) func SetLogLevel(services []log.Loggable, level log.LogLevel) { - for _, s := range services { - if s != nil { - s.SetLogLevel(level) - } - } + for _, s := range services { + if s != nil { + s.SetLogLevel(level) + } + } } var ( - logger = log.NewLog("sm-server") - serverLogLevel log.LogLevel = log.INFO + logger = log.NewLog("sm-server") + serverLogLevel log.LogLevel = log.INFO - host = "0.0.0.0" + host = "0.0.0.0" - store storage.StoreManager + store storage.StoreManager - sub *pubsub.SqsSubscriber - pub *pubsub.SqsPublisher = nil - listener *pubsub.EventsListener + sub *pubsub.SqsSubscriber + pub *pubsub.SqsPublisher = nil + listener *pubsub.EventsListener - // TODO: for now blocking channels; we will need to confirm - // whether we can support a fully concurrent system with a - // buffered channel - notificationsCh chan protos.EventResponse = nil - eventsCh = make(chan protos.EventRequest) + // TODO: for now blocking channels; we will need to confirm + // whether we can support a fully concurrent system with a + // buffered channel + notificationsCh chan protos.EventResponse = nil + eventsCh = make(chan protos.EventRequest) - wg sync.WaitGroup + wg sync.WaitGroup ) func main() { - defer close(eventsCh) - - var debug = flag.Bool("debug", false, - "Verbose logs; better to avoid on Production services") - var trace = flag.Bool("trace", false, - "Enables trace logs for every API request and Pub/Sub event; it may impact performance, "+ - "do not use in production or on heavily loaded systems ("+ - "will override the -debug option)") - var localOnly = flag.Bool("local", false, - "If set, it only listens to incoming requests from the local host") - var port = flag.Int("http-port", 7399, "HTTP Server port for the REST API") - var redisUrl = flag.String("redis", "", "For single node redis instances: URI "+ - "for the Redis instance (host:port). For redis clusters: a comma-separated list of redis nodes. "+ - "If using an ElastiCache Redis cluster with cluster mode enabled, you can supply the configuration endpoint.") - var cluster = flag.Bool("cluster", false, - "Needs to be set if connecting to a Redis instance with cluster mode enabled") - var awsEndpoint = flag.String("endpoint-url", "", - "HTTP URL for AWS SQS to connect to; usually best left undefined, "+ - "unless required for local testing purposes (LocalStack uses http://localhost:4566)") - var eventsTopic = flag.String("events", "", "If defined, it will attempt to connect "+ - "to the given SQS Queue to receive events from the Pub/Sub system") - var notificationsTopic = flag.String("notifications", "", - "The name of the notification topic in SQS to publish events' outcomes to; if not "+ - "specified, no outcomes will be published") - var grpcPort = flag.Int("grpc-port", 7398, "The port for the gRPC server") - var maxRetries = flag.Int("max-retries", storage.DefaultMaxRetries, - "Max number of attempts for a recoverable error to be retried against the Redis cluster") - var timeout = flag.Duration("timeout", storage.DefaultTimeout, - "Timeout for Redis (as a Duration string, e.g. 1s, 20ms, etc.)") - flag.Parse() - - logger.Info("Starting State Machine Server - Rel. %s", server.Release) - - if *localOnly { - logger.Info("Listening on local interface only") - host = "localhost" - } else { - logger.Warn("Listening on all interfaces") - } - addr := fmt.Sprintf("%s:%d", host, *port) - - if *redisUrl == "" { - logger.Warn("in-memory storage configured, all data will NOT survive a server restart") - store = storage.NewInMemoryStore() - } else { - logger.Info("Connecting to Redis server at %s", *redisUrl) - logger.Info("with timeout: %s, max-retries: %d", *timeout, *maxRetries) - store = storage.NewRedisStore(*redisUrl, *cluster, 1, *timeout, *maxRetries) - } - server.SetStore(store) - - // TODO: we should allow to start the server using solely the gRPC interface, - // without SQS to receive events. - if *eventsTopic == "" { - logger.Fatal(fmt.Errorf("no event topic configured, state machines will not " + - "be able to receive events")) - } - logger.Info("Connecting to SQS Topic: %s", *eventsTopic) - sub = pubsub.NewSqsSubscriber(eventsCh, awsEndpoint) - if sub == nil { - panic("Cannot create a valid SQS Subscriber") - } - - if *notificationsTopic != "" { - logger.Info("Configuring DLQ Topic: %s", *notificationsTopic) - notificationsCh = make(chan protos.EventResponse) - defer close(notificationsCh) - pub = pubsub.NewSqsPublisher(notificationsCh, awsEndpoint) - if pub == nil { - panic("Cannot create a valid SQS Publisher") - } - go pub.Publish(*notificationsTopic) - } - listener = pubsub.NewEventsListener(&pubsub.ListenerOptions{ - EventsChannel: eventsCh, - NotificationsChannel: notificationsCh, - StatemachinesStore: store, - // TODO: workers pool not implemented yet. - ListenersPoolSize: 0, - }) - go sub.Subscribe(*eventsTopic, nil) - - // This should not be invoked until we have initialized all the services. - setLogLevel(*debug, *trace) - - logger.Info("Starting Events Listener") - go listener.ListenForMessages() - - logger.Info("gRPC Server running at tcp://:%d", *grpcPort) - go startGrpcServer(*grpcPort, eventsCh) - - // TODO: configure & start server using TLS, if configured to do so. - scheme := "http" - logger.Info("HTTP Server (REST API) running at %s://%s", scheme, addr) - srv := server.NewHTTPServer(addr, serverLogLevel) - logger.Fatal(srv.ListenAndServe()) + defer close(eventsCh) + + var acksTopic = flag.String("acks", "", + "(Requires `-notifications`) The name of the topic in SQS to publish Ok outcomes to; "+ + "unless the -notify-errors-only flag is set") + var awsEndpoint = flag.String("endpoint-url", "", + "HTTP URL for AWS SQS to connect to; usually best left undefined, "+ + "unless required for local testing purposes (LocalStack uses http://localhost:4566)") + var cluster = flag.Bool("cluster", false, + "If set, connects to Redis with cluster-mode enabled") + var debug = flag.Bool("debug", false, + "Verbose logs; better to avoid on Production services") + var eventsTopic = flag.String("events", "", "Topi name to receive events from") + var grpcPort = flag.Int("grpc-port", 7398, "The port for the gRPC server") + var localOnly = flag.Bool("local", false, + "If set, it only listens to incoming requests from the local host") + var maxRetries = flag.Int("max-retries", storage.DefaultMaxRetries, + "Max number of attempts for a recoverable error to be retried against the Redis cluster") + var notificationsTopic = flag.String("notifications", "", + "(optional) The name of the topic to publish events' outcomes to; if not "+ + "specified, no outcomes will be published") + var notifyErrorsOnly = flag.Bool("notify-errors-only", false, + "If set, only errors will be sent to notification topic (cannot be used with -acks)") + var port = flag.Int("http-port", 7399, "HTTP Server port for the REST API") + var redisUrl = flag.String("redis", "", "For single node redis instances: URI "+ + "for the Redis instance (host:port). For redis clusters: a comma-separated list of redis nodes. "+ + "If using an ElastiCache Redis cluster with cluster mode enabled, "+ + "this can also be the configuration endpoint.") + var timeout = flag.Duration("timeout", storage.DefaultTimeout, + "Timeout for Redis (as a Duration string, e.g. 1s, 20ms, etc.)") + var trace = flag.Bool("trace", false, + "Extremely verbose logs for every API request and Pub/Sub event; it may impact"+ + " performance, do not use in production or on heavily loaded systems (will override the -debug option)") + flag.Parse() + + logger.Info("starting State Machine Server - Rel. %s", server.Release) + + if *localOnly { + logger.Info("listening on local interface only") + host = "localhost" + } else { + logger.Warn("listening on all interfaces") + } + addr := fmt.Sprintf("%s:%d", host, *port) + + if *redisUrl == "" { + logger.Warn("in-memory storage configured, all data will NOT survive a server restart") + store = storage.NewInMemoryStore() + } else { + logger.Info("connecting to Redis server at %s", *redisUrl) + logger.Info("with timeout: %s, max-retries: %d", *timeout, *maxRetries) + store = storage.NewRedisStore(*redisUrl, *cluster, 1, *timeout, *maxRetries) + } + server.SetStore(store) + + // TODO: we should allow to start the server using solely the gRPC interface, + // without SQS to receive events. + if *eventsTopic == "" { + logger.Fatal(fmt.Errorf("no event topic configured, state machines will not " + + "be able to receive events")) + } + if *acksTopic != "" && *notifyErrorsOnly { + logger.Fatal(fmt.Errorf("cannot set an acks topic while disabling errors notifications")) + } + logger.Info("connecting to SQS Topic: %s", *eventsTopic) + sub = pubsub.NewSqsSubscriber(eventsCh, awsEndpoint) + if sub == nil { + panic("Cannot create a valid SQS Subscriber") + } + + if *notificationsTopic != "" { + logger.Info("notifications topic: %s", *notificationsTopic) + if *notifyErrorsOnly { + logger.Info("only errors will be published to the notifications topic") + } + if *acksTopic != "" { + logger.Info("acks topic: %s", *acksTopic) + } + notificationsCh = make(chan protos.EventResponse) + defer close(notificationsCh) + pub = pubsub.NewSqsPublisher(notificationsCh, awsEndpoint) + if pub == nil { + panic("Cannot create a valid SQS Publisher") + } + go pub.Publish(*notificationsTopic, *acksTopic, *notifyErrorsOnly) + } + listener = pubsub.NewEventsListener(&pubsub.ListenerOptions{ + EventsChannel: eventsCh, + NotificationsChannel: notificationsCh, + StatemachinesStore: store, + // TODO: workers pool not implemented yet. + ListenersPoolSize: 0, + }) + go sub.Subscribe(*eventsTopic, nil) + + // This should not be invoked until we have initialized all the services. + setLogLevel(*debug, *trace) + + logger.Info("starting events listener") + go listener.ListenForMessages() + + logger.Info("gRPC server running at tcp://:%d", *grpcPort) + go startGrpcServer(*grpcPort, eventsCh) + + // TODO: configure & start server using TLS, if configured to do so. + scheme := "http" + logger.Info("HTTP server (REST API) running at %s://%s", scheme, addr) + srv := server.NewHTTPServer(addr, serverLogLevel) + logger.Fatal(srv.ListenAndServe()) } // setLogLevel sets the logging level for all the services' loggers, depending on // whether the -debug or -trace flag is enabled (if neither, we log at INFO level). // If both are set, then -trace takes priority. func setLogLevel(debug bool, trace bool) { - if debug { - logger.Info("verbose logging enabled") - logger.Level = log.DEBUG - SetLogLevel([]log.Loggable{store, pub, sub, listener}, log.DEBUG) - serverLogLevel = log.DEBUG - } - - if trace { - logger.Warn("trace logging Enabled") - logger.Level = log.TRACE - server.EnableTracing() - SetLogLevel([]log.Loggable{store, sub, listener}, log.TRACE) - serverLogLevel = log.TRACE - } + if debug { + logger.Info("verbose logging enabled") + logger.Level = log.DEBUG + SetLogLevel([]log.Loggable{store, pub, sub, listener}, log.DEBUG) + serverLogLevel = log.DEBUG + } + + if trace { + logger.Warn("trace logging Enabled") + logger.Level = log.TRACE + server.EnableTracing() + SetLogLevel([]log.Loggable{store, pub, sub, listener}, log.TRACE) + serverLogLevel = log.TRACE + } } // startGrpcServer will start a new gRPC server, bound to @@ -188,19 +188,19 @@ func setLogLevel(debug bool, trace bool) { // `EventRequest` to the receiving channel. // This MUST be run as a go-routine, which never returns func startGrpcServer(port int, events chan<- protos.EventRequest) { - defer wg.Done() - l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) - if err != nil { - panic(err) - } - // TODO: should we add a `done` channel? - grpcServer, err := grpc.NewGrpcServer(&grpc.Config{ - EventsChannel: events, - Logger: logger, - Store: store, - }) - err = grpcServer.Serve(l) - if err != nil { - panic(err) - } + defer wg.Done() + l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + panic(err) + } + // TODO: should we add a `done` channel? + grpcServer, err := grpc.NewGrpcServer(&grpc.Config{ + EventsChannel: events, + Logger: logger, + Store: store, + }) + err = grpcServer.Serve(l) + if err != nil { + panic(err) + } } diff --git a/docker/Dockerfile b/docker/Dockerfile index 74d10dd..ca3e320 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -12,11 +12,14 @@ RUN groupadd -r sm-bot && useradd -r -g sm-bot sm-bot # Fake AWS configuration to connect to LocalStack docker. ENV AWS_REGION=us-west-2 AWS_PROFILE=sm-bot -# Sensible defaults for the server -# See entrypoint.sh -ENV SERVER_PORT=7399 DEBUG="" \ - EVENTS_Q="events" ERRORS_Q="notifications" \ - REDIS=redis REDIS_PORT=6379 +# Sensible defaults for the server, for reference +# we list all the environment variables used by the +# entrypoint script. +# +ENV GRPC_PORT=7398 SERVER_PORT=7399 DEBUG="" \ + EVENTS_Q="events" NOTIFICATIONS_Q="notifications" \ + ACKS_Q="" ERRORS_ONLY=1 \ + CLUSTER="" REDIS=redis REDIS_PORT=6379 WORKDIR /app RUN chown sm-bot:sm-bot /app diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index a2b3f37..07895a2 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -2,32 +2,56 @@ # # Copyright (c) 2022 AlertAvert.com. All rights reserved. # -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. +# Licensed under the Apache License, Version 2.0 +# http://www.apache.org/licenses/LICENSE-2.0 # # Author: Marco Massenzio (marco@alertavert.com) # set -eu -unset endpoint +declare endpoint="" +declare acks="" +declare errors_only="" +declare notifications="" +declare retries="" +declare timeout="" + if [[ -n ${AWS_ENDPOINT:-} ]] then - endpoint="--endpoint-url ${AWS_ENDPOINT}" + endpoint="-endpoint-url ${AWS_ENDPOINT}" +fi + +if [[ -n ${ERRORS_ONLY:-} ]] +then + errors_only="-notify-errors-only" +fi + +if [[ -n ${ACKS_Q:-} ]] +then + acks="-acks ${ACKS_Q}" +fi + +if [[ -n ${NOTIFICATIONS_Q:-} ]] +then + notifications="-notifications ${NOTIFICATIONS_Q}" +fi + +if [[ -n ${TIMEOUT:-} ]] +then + timeout="-timeout ${TIMEOUT}" +fi + +if [[ -n ${RETRIES:-} ]] +then + retries="-max-retries ${RETRIES}" fi -cmd="./sm-server -http-port ${SERVER_PORT} ${endpoint:-} ${CLUSTER} ${DEBUG} \ --redis ${REDIS}:${REDIS_PORT} -timeout ${TIMEOUT:-25ms} -max-retries ${RETRIES:-3} \ --events ${EVENTS_Q} -notifications ${ERRORS_Q} \ +cmd="./sm-server -http-port ${SERVER_PORT} -grpc-port ${GRPC_PORT} \ +-events ${EVENTS_Q} -redis ${REDIS}:${REDIS_PORT} \ +${CLUSTER:-} ${DEBUG:-} ${endpoint} \ +${errors_only} ${timeout} ${retries} \ +${acks} ${notifications} \ $@" echo $cmd diff --git a/get-tag b/get-tag index 629669e..3268146 100755 --- a/get-tag +++ b/get-tag @@ -1,6 +1,10 @@ #!/usr/bin/env bash # # Copyright (c) 2022 AlertAvert.com. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 +# http://www.apache.org/licenses/LICENSE-2.0 +# # Author: Marco Massenzio (marco@alertavert.com) # set -eu diff --git a/grpc/grpc_server.go b/grpc/grpc_server.go index 2d7392a..f742714 100644 --- a/grpc/grpc_server.go +++ b/grpc/grpc_server.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,163 +10,163 @@ package grpc import ( - "context" - "fmt" - "github.com/google/uuid" - "github.com/massenz/slf4go/logging" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "strings" - "time" - - "github.com/massenz/go-statemachine/api" - "github.com/massenz/go-statemachine/storage" - protos "github.com/massenz/statemachine-proto/golang/api" + "context" + "fmt" + "github.com/google/uuid" + "github.com/massenz/slf4go/logging" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "strings" + "time" + + "github.com/massenz/go-statemachine/api" + "github.com/massenz/go-statemachine/storage" + protos "github.com/massenz/statemachine-proto/golang/api" ) type Config struct { - EventsChannel chan<- protos.EventRequest - Store storage.StoreManager - Logger *logging.Log - Timeout time.Duration + EventsChannel chan<- protos.EventRequest + Store storage.StoreManager + Logger *logging.Log + Timeout time.Duration } var _ protos.StatemachineServiceServer = (*grpcSubscriber)(nil) const ( - DefaultTimeout = 200 * time.Millisecond + DefaultTimeout = 200 * time.Millisecond ) type grpcSubscriber struct { - protos.UnimplementedStatemachineServiceServer - *Config + protos.UnimplementedStatemachineServiceServer + *Config } func (s *grpcSubscriber) ProcessEvent(ctx context.Context, request *protos.EventRequest) (*protos. EventResponse, error) { - if request.Dest == "" { - return nil, status.Error(codes.FailedPrecondition, api.MissingDestinationError.Error()) - } - if request.GetEvent() == nil || request.Event.GetTransition() == nil || - request.Event.Transition.GetEvent() == "" { - return nil, status.Error(codes.FailedPrecondition, api.MissingEventNameError.Error()) - } - // If missing, add ID and timestamp. - api.UpdateEvent(request.Event) - - var timeout = s.Timeout - deadline, hasDeadline := ctx.Deadline() - if hasDeadline { - timeout = deadline.Sub(time.Now()) - } - s.Logger.Trace("Sending Event to channel: %v", request.Event) - select { - case s.EventsChannel <- *request: - return &protos.EventResponse{ - EventId: request.Event.EventId, - }, nil - case <-time.After(timeout): - s.Logger.Error("Timeout exceeded when trying to post event to internal channel") - return nil, status.Error(codes.DeadlineExceeded, "cannot post event") - } + if request.Dest == "" { + return nil, status.Error(codes.FailedPrecondition, api.MissingDestinationError.Error()) + } + if request.GetEvent() == nil || request.Event.GetTransition() == nil || + request.Event.Transition.GetEvent() == "" { + return nil, status.Error(codes.FailedPrecondition, api.MissingEventNameError.Error()) + } + // If missing, add ID and timestamp. + api.UpdateEvent(request.Event) + + var timeout = s.Timeout + deadline, hasDeadline := ctx.Deadline() + if hasDeadline { + timeout = deadline.Sub(time.Now()) + } + s.Logger.Trace("Sending Event to channel: %v", request.Event) + select { + case s.EventsChannel <- *request: + return &protos.EventResponse{ + EventId: request.Event.EventId, + }, nil + case <-time.After(timeout): + s.Logger.Error("Timeout exceeded when trying to post event to internal channel") + return nil, status.Error(codes.DeadlineExceeded, "cannot post event") + } } func (s *grpcSubscriber) PutConfiguration(ctx context.Context, cfg *protos.Configuration) (*protos.PutResponse, error) { - // FIXME: use Context to set a timeout, etc. - if err := api.CheckValid(cfg); err != nil { - s.Logger.Error("invalid configuration: %v", err) - return nil, status.Errorf(codes.InvalidArgument, "invalid configuration: %v", err) - } - if err := s.Store.PutConfig(cfg); err != nil { - s.Logger.Error("could not store configuration: %v", err) - return nil, status.Error(codes.Internal, err.Error()) - } - s.Logger.Trace("configuration stored: %s", api.GetVersionId(cfg)) - return &protos.PutResponse{ - Id: api.GetVersionId(cfg), - Config: cfg, - }, nil + // FIXME: use Context to set a timeout, etc. + if err := api.CheckValid(cfg); err != nil { + s.Logger.Error("invalid configuration: %v", err) + return nil, status.Errorf(codes.InvalidArgument, "invalid configuration: %v", err) + } + if err := s.Store.PutConfig(cfg); err != nil { + s.Logger.Error("could not store configuration: %v", err) + return nil, status.Error(codes.Internal, err.Error()) + } + s.Logger.Trace("configuration stored: %s", api.GetVersionId(cfg)) + return &protos.PutResponse{ + Id: api.GetVersionId(cfg), + Config: cfg, + }, nil } func (s *grpcSubscriber) GetConfiguration(ctx context.Context, request *protos.GetRequest) ( - *protos.Configuration, error) { - s.Logger.Trace("retrieving Configuration %s", request.GetId()) - cfg, found := s.Store.GetConfig(request.GetId()) - if !found { - return nil, status.Errorf(codes.NotFound, "configuration %s not found", request.GetId()) - } - return cfg, nil + *protos.Configuration, error) { + s.Logger.Trace("retrieving Configuration %s", request.GetId()) + cfg, found := s.Store.GetConfig(request.GetId()) + if !found { + return nil, status.Errorf(codes.NotFound, "configuration %s not found", request.GetId()) + } + return cfg, nil } func (s *grpcSubscriber) PutFiniteStateMachine(ctx context.Context, - fsm *protos.FiniteStateMachine) (*protos.PutResponse, error) { - // First check that the configuration for the FSM is valid - cfg, ok := s.Store.GetConfig(fsm.ConfigId) - if !ok { - return nil, status.Error(codes.FailedPrecondition, storage.ConfigNotFoundError.Error()) - } - // FIXME: we need to allow clients to specify the ID of the FSM to create - id := uuid.NewString() - // If the State of the FSM is not specified, - // we set it to the initial state of the configuration. - if fsm.State == "" { - fsm.State = cfg.StartingState - } - s.Logger.Trace("storing FSM [%s] configured with %s", id, fsm.ConfigId) - if err := s.Store.PutStateMachine(id, fsm); err != nil { - s.Logger.Error("could not store FSM [%v]: %v", fsm, err) - return nil, status.Error(codes.Internal, err.Error()) - } - return &protos.PutResponse{Id: id, Fsm: fsm}, nil + fsm *protos.FiniteStateMachine) (*protos.PutResponse, error) { + // First check that the configuration for the FSM is valid + cfg, ok := s.Store.GetConfig(fsm.ConfigId) + if !ok { + return nil, status.Error(codes.FailedPrecondition, storage.ConfigNotFoundError.Error()) + } + // FIXME: we need to allow clients to specify the ID of the FSM to create + id := uuid.NewString() + // If the State of the FSM is not specified, + // we set it to the initial state of the configuration. + if fsm.State == "" { + fsm.State = cfg.StartingState + } + s.Logger.Trace("storing FSM [%s] configured with %s", id, fsm.ConfigId) + if err := s.Store.PutStateMachine(id, fsm); err != nil { + s.Logger.Error("could not store FSM [%v]: %v", fsm, err) + return nil, status.Error(codes.Internal, err.Error()) + } + return &protos.PutResponse{Id: id, Fsm: fsm}, nil } func (s *grpcSubscriber) GetFiniteStateMachine(ctx context.Context, request *protos.GetRequest) ( - *protos.FiniteStateMachine, error) { - // TODO: use Context to set a timeout, and then pass it on to the Store. - // This may require a pretty large refactoring of the store interface. - s.Logger.Debug("looking up FSM %s", request.GetId()) - // The ID in the request contains the FSM ID, - // prefixed by the Config Name (which defines the "type" of FSM) - splitId := strings.Split(request.GetId(), storage.KeyPrefixIDSeparator) - if len(splitId) != 2 { - return nil, status.Errorf(codes.InvalidArgument, "invalid FSM ID: %s", request.GetId()) - } - fsm, ok := s.Store.GetStateMachine(splitId[1], splitId[0]) - if !ok { - return nil, status.Error(codes.NotFound, storage.FSMNotFoundError.Error()) - } - return fsm, nil + *protos.FiniteStateMachine, error) { + // TODO: use Context to set a timeout, and then pass it on to the Store. + // This may require a pretty large refactoring of the store interface. + s.Logger.Debug("looking up FSM %s", request.GetId()) + // The ID in the request contains the FSM ID, + // prefixed by the Config Name (which defines the "type" of FSM) + splitId := strings.Split(request.GetId(), storage.KeyPrefixIDSeparator) + if len(splitId) != 2 { + return nil, status.Errorf(codes.InvalidArgument, "invalid FSM ID: %s", request.GetId()) + } + fsm, ok := s.Store.GetStateMachine(splitId[1], splitId[0]) + if !ok { + return nil, status.Error(codes.NotFound, storage.FSMNotFoundError.Error()) + } + return fsm, nil } func (s *grpcSubscriber) GetEventOutcome(ctx context.Context, request *protos.GetRequest) ( - *protos.EventResponse, error) { - - s.Logger.Debug("looking up EventOutcome %s", request.GetId()) - dest := strings.Split(request.GetId(), "#") - if len(dest) != 2 { - return nil, status.Error(codes.InvalidArgument, - fmt.Sprintf("invalid destination [%s] expected: #", request.GetId())) - } - smType, evtId := dest[0], dest[1] - outcome, ok := s.Store.GetOutcomeForEvent(evtId, smType) - if !ok { - return nil, status.Error(codes.NotFound, fmt.Sprintf("outcome for event %s not found", evtId)) - } - return &protos.EventResponse{ - EventId: evtId, - Outcome: outcome, - }, nil + *protos.EventResponse, error) { + + s.Logger.Debug("looking up EventOutcome %s", request.GetId()) + dest := strings.Split(request.GetId(), "#") + if len(dest) != 2 { + return nil, status.Error(codes.InvalidArgument, + fmt.Sprintf("invalid destination [%s] expected: #", request.GetId())) + } + smType, evtId := dest[0], dest[1] + outcome, ok := s.Store.GetOutcomeForEvent(evtId, smType) + if !ok { + return nil, status.Error(codes.NotFound, fmt.Sprintf("outcome for event %s not found", evtId)) + } + return &protos.EventResponse{ + EventId: evtId, + Outcome: outcome, + }, nil } // NewGrpcServer creates a new gRPC server to handle incoming events and other API calls. // The `Config` can be used to configure the backing store, a timeout and the logger. func NewGrpcServer(config *Config) (*grpc.Server, error) { - // Unless explicitly configured, we use for the server the same timeout as for the Redis store - if config.Timeout == 0 { - config.Timeout = DefaultTimeout - } - gsrv := grpc.NewServer() - protos.RegisterStatemachineServiceServer(gsrv, &grpcSubscriber{Config: config}) - return gsrv, nil + // Unless explicitly configured, we use for the server the same timeout as for the Redis store + if config.Timeout == 0 { + config.Timeout = DefaultTimeout + } + gsrv := grpc.NewServer() + protos.RegisterStatemachineServiceServer(gsrv, &grpcSubscriber{Config: config}) + return gsrv, nil } diff --git a/grpc/grpc_server_test.go b/grpc/grpc_server_test.go index 48de43c..90deee2 100644 --- a/grpc/grpc_server_test.go +++ b/grpc/grpc_server_test.go @@ -1,266 +1,275 @@ +/* + * Copyright (c) 2022 AlertAvert.com. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Author: Marco Massenzio (marco@alertavert.com) + */ + package grpc_test import ( - . "github.com/JiaYongfei/respect/gomega" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - "google.golang.org/grpc/codes" - "strings" + . "github.com/JiaYongfei/respect/gomega" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "google.golang.org/grpc/codes" + "strings" - "context" - "fmt" - "github.com/massenz/slf4go/logging" - g "google.golang.org/grpc" - "google.golang.org/grpc/status" - "net" - "time" + "context" + "fmt" + "github.com/massenz/slf4go/logging" + g "google.golang.org/grpc" + "google.golang.org/grpc/status" + "net" + "time" - . "github.com/massenz/go-statemachine/api" - "github.com/massenz/go-statemachine/grpc" - "github.com/massenz/go-statemachine/storage" - "github.com/massenz/statemachine-proto/golang/api" + . "github.com/massenz/go-statemachine/api" + "github.com/massenz/go-statemachine/grpc" + "github.com/massenz/go-statemachine/storage" + "github.com/massenz/statemachine-proto/golang/api" ) var _ = Describe("GrpcServer", func() { - Context("when processing events", func() { - var testCh chan api.EventRequest - var listener net.Listener - var client api.StatemachineServiceClient - var done func() + Context("when processing events", func() { + var testCh chan api.EventRequest + var listener net.Listener + var client api.StatemachineServiceClient + var done func() - BeforeEach(func() { - var err error - testCh = make(chan api.EventRequest, 5) - listener, err = net.Listen("tcp", ":0") - Ω(err).ShouldNot(HaveOccurred()) + BeforeEach(func() { + var err error + testCh = make(chan api.EventRequest, 5) + listener, err = net.Listen("tcp", ":0") + Ω(err).ShouldNot(HaveOccurred()) - cc, err := g.Dial(listener.Addr().String(), g.WithInsecure()) - Ω(err).ShouldNot(HaveOccurred()) + cc, err := g.Dial(listener.Addr().String(), g.WithInsecure()) + Ω(err).ShouldNot(HaveOccurred()) - client = api.NewStatemachineServiceClient(cc) - l := logging.NewLog("grpc-server-test") - l.Level = logging.NONE - server, err := grpc.NewGrpcServer(&grpc.Config{ - EventsChannel: testCh, - Logger: l, - }) - Ω(err).ToNot(HaveOccurred()) - Ω(server).ToNot(BeNil()) + client = api.NewStatemachineServiceClient(cc) + l := logging.NewLog("grpc-server-test") + l.Level = logging.NONE + server, err := grpc.NewGrpcServer(&grpc.Config{ + EventsChannel: testCh, + Logger: l, + }) + Ω(err).ToNot(HaveOccurred()) + Ω(server).ToNot(BeNil()) - go func() { - Ω(server.Serve(listener)).Should(Succeed()) - }() - done = func() { - server.Stop() - } - }) - It("should succeed for well-formed events", func() { - response, err := client.ProcessEvent(context.Background(), &api.EventRequest{ - Event: &api.Event{ - EventId: "1", - Transition: &api.Transition{ - Event: "test-vt", - }, - Originator: "test", - }, - Dest: "2", - }) - Ω(err).ToNot(HaveOccurred()) - Ω(response).ToNot(BeNil()) - Ω(response.EventId).To(Equal("1")) - done() - select { - case evt := <-testCh: - Ω(evt.Event.EventId).To(Equal("1")) - Ω(evt.Event.Transition.Event).To(Equal("test-vt")) - Ω(evt.Event.Originator).To(Equal("test")) - Ω(evt.Dest).To(Equal("2")) - case <-time.After(10 * time.Millisecond): - Fail("Timed out") + go func() { + Ω(server.Serve(listener)).Should(Succeed()) + }() + done = func() { + server.Stop() + } + }) + It("should succeed for well-formed events", func() { + response, err := client.ProcessEvent(context.Background(), &api.EventRequest{ + Event: &api.Event{ + EventId: "1", + Transition: &api.Transition{ + Event: "test-vt", + }, + Originator: "test", + }, + Dest: "2", + }) + Ω(err).ToNot(HaveOccurred()) + Ω(response).ToNot(BeNil()) + Ω(response.EventId).To(Equal("1")) + done() + select { + case evt := <-testCh: + Ω(evt.Event.EventId).To(Equal("1")) + Ω(evt.Event.Transition.Event).To(Equal("test-vt")) + Ω(evt.Event.Originator).To(Equal("test")) + Ω(evt.Dest).To(Equal("2")) + case <-time.After(10 * time.Millisecond): + Fail("Timed out") - } - }) - It("should create an ID for events without", func() { - response, err := client.ProcessEvent(context.Background(), &api.EventRequest{ - Event: &api.Event{ - Transition: &api.Transition{ - Event: "test-vt", - }, - Originator: "test", - }, - Dest: "123456", - }) - Ω(err).ToNot(HaveOccurred()) - Ω(response.EventId).ToNot(BeNil()) - generatedId := response.EventId - done() - select { - case evt := <-testCh: - Ω(evt.Event.EventId).Should(Equal(generatedId)) - Ω(evt.Event.Transition.Event).To(Equal("test-vt")) - case <-time.After(10 * time.Millisecond): - Fail("Timed out") - } - }) - It("should fail for missing destination", func() { - _, err := client.ProcessEvent(context.Background(), &api.EventRequest{ - Event: &api.Event{ - Transition: &api.Transition{ - Event: "test-vt", - }, - Originator: "test", - }, - }) - assertStatusCode(codes.FailedPrecondition, err) - done() - select { - case evt := <-testCh: - Fail(fmt.Sprintf("Unexpected event: %s", evt.String())) - case <-time.After(10 * time.Millisecond): - Succeed() - } - }) - It("should fail for missing event", func() { - _, err := client.ProcessEvent(context.Background(), &api.EventRequest{ - Event: &api.Event{ - Transition: &api.Transition{ - Event: "", - }, - Originator: "test", - }, - Dest: "9876", - }) - assertStatusCode(codes.FailedPrecondition, err) - done() - select { - case evt := <-testCh: - Fail(fmt.Sprintf("UnΩed event: %s", evt.String())) - case <-time.After(10 * time.Millisecond): - Succeed() - } - }) - }) + } + }) + It("should create an ID for events without", func() { + response, err := client.ProcessEvent(context.Background(), &api.EventRequest{ + Event: &api.Event{ + Transition: &api.Transition{ + Event: "test-vt", + }, + Originator: "test", + }, + Dest: "123456", + }) + Ω(err).ToNot(HaveOccurred()) + Ω(response.EventId).ToNot(BeNil()) + generatedId := response.EventId + done() + select { + case evt := <-testCh: + Ω(evt.Event.EventId).Should(Equal(generatedId)) + Ω(evt.Event.Transition.Event).To(Equal("test-vt")) + case <-time.After(10 * time.Millisecond): + Fail("Timed out") + } + }) + It("should fail for missing destination", func() { + _, err := client.ProcessEvent(context.Background(), &api.EventRequest{ + Event: &api.Event{ + Transition: &api.Transition{ + Event: "test-vt", + }, + Originator: "test", + }, + }) + assertStatusCode(codes.FailedPrecondition, err) + done() + select { + case evt := <-testCh: + Fail(fmt.Sprintf("Unexpected event: %s", evt.String())) + case <-time.After(10 * time.Millisecond): + Succeed() + } + }) + It("should fail for missing event", func() { + _, err := client.ProcessEvent(context.Background(), &api.EventRequest{ + Event: &api.Event{ + Transition: &api.Transition{ + Event: "", + }, + Originator: "test", + }, + Dest: "9876", + }) + assertStatusCode(codes.FailedPrecondition, err) + done() + select { + case evt := <-testCh: + Fail(fmt.Sprintf("UnΩed event: %s", evt.String())) + case <-time.After(10 * time.Millisecond): + Succeed() + } + }) + }) - Context("when retrieving data from the store", func() { - var ( - listener net.Listener - client api.StatemachineServiceClient - cfg *api.Configuration - fsm *api.FiniteStateMachine - done func() - store = storage.NewInMemoryStore() - ) - store.SetLogLevel(logging.NONE) + Context("when retrieving data from the store", func() { + var ( + listener net.Listener + client api.StatemachineServiceClient + cfg *api.Configuration + fsm *api.FiniteStateMachine + done func() + store = storage.NewInMemoryStore() + ) + store.SetLogLevel(logging.NONE) - // Server setup - BeforeEach(func() { - listener, _ = net.Listen("tcp", ":0") - cc, _ := g.Dial(listener.Addr().String(), g.WithInsecure()) - client = api.NewStatemachineServiceClient(cc) - // Use this to log errors when diagnosing test failures; then set to NONE once done. - l := logging.NewLog("grpc-server-test") - l.Level = logging.NONE - server, _ := grpc.NewGrpcServer(&grpc.Config{ - Store: store, - Logger: l, - }) + // Server setup + BeforeEach(func() { + listener, _ = net.Listen("tcp", ":0") + cc, _ := g.Dial(listener.Addr().String(), g.WithInsecure()) + client = api.NewStatemachineServiceClient(cc) + // Use this to log errors when diagnosing test failures; then set to NONE once done. + l := logging.NewLog("grpc-server-test") + l.Level = logging.NONE + server, _ := grpc.NewGrpcServer(&grpc.Config{ + Store: store, + Logger: l, + }) - go func() { - Ω(server.Serve(listener)).Should(Succeed()) - }() - done = func() { - server.Stop() - } - }) - // Server shutdown - AfterEach(func() { - done() - }) - // Store setup - BeforeEach(func() { - cfg = &api.Configuration{ - Name: "test-conf", - Version: "v1", - States: []string{"start", "stop"}, - Transitions: []*api.Transition{ - {From: "start", To: "stop", Event: "shutdown"}, - }, - StartingState: "start", - } - fsm = &api.FiniteStateMachine{ConfigId: GetVersionId(cfg)} - }) - It("should store valid configurations", func() { - _, ok := store.GetConfig(GetVersionId(cfg)) - Ω(ok).To(BeFalse()) - response, err := client.PutConfiguration(context.Background(), cfg) - Ω(err).ToNot(HaveOccurred()) - Ω(response).ToNot(BeNil()) - Ω(response.Id).To(Equal(GetVersionId(cfg))) - found, ok := store.GetConfig(response.Id) - Ω(ok).Should(BeTrue()) - Ω(found).Should(Respect(cfg)) - }) - It("should fail for invalid configuration", func() { - invalid := &api.Configuration{ - Name: "invalid", - Version: "v1", - States: []string{}, - Transitions: nil, - StartingState: "", - } - _, err := client.PutConfiguration(context.Background(), invalid) - assertStatusCode(codes.InvalidArgument, err) - }) - It("should retrieve a valid configuration", func() { - Ω(store.PutConfig(cfg)).To(Succeed()) - response, err := client.GetConfiguration(context.Background(), - &api.GetRequest{Id: GetVersionId(cfg)}) - Ω(err).ToNot(HaveOccurred()) - Ω(response).ToNot(BeNil()) - Ω(response).Should(Respect(cfg)) - }) - It("should return an empty configuration for an invalid ID", func() { - _, err := client.GetConfiguration(context.Background(), &api.GetRequest{Id: "fake"}) - assertStatusCode(codes.NotFound, err) - }) - It("should store a valid FSM", func() { - Ω(store.PutConfig(cfg)).To(Succeed()) - resp, err := client.PutFiniteStateMachine(context.Background(), fsm) - Ω(err).ToNot(HaveOccurred()) - Ω(resp).ToNot(BeNil()) - Ω(resp.Id).ToNot(BeNil()) - Ω(resp.Fsm).Should(Respect(fsm)) - }) - It("should fail with an invalid Config ID", func() { - invalid := &api.FiniteStateMachine{ConfigId: "fake"} - _, err := client.PutFiniteStateMachine(context.Background(), invalid) - assertStatusCode(codes.FailedPrecondition, err) - }) - It("can retrieve a stored FSM", func() { - id := "123456" - Ω(store.PutConfig(cfg)) - Ω(store.PutStateMachine(id, fsm)).Should(Succeed()) - Ω(client.GetFiniteStateMachine(context.Background(), - &api.GetRequest{ - Id: strings.Join([]string{cfg.Name, id}, storage.KeyPrefixIDSeparator), - })).Should(Respect(fsm)) - }) - It("will return an Invalid error for an invalid ID", func() { - _, err := client.GetFiniteStateMachine(context.Background(), &api.GetRequest{Id: "fake"}) - assertStatusCode(codes.InvalidArgument, err) - }) - It("will return a NotFound error for a missing ID", func() { - _, err := client.GetFiniteStateMachine(context.Background(), - &api.GetRequest{Id: "cfg#fake"}) - assertStatusCode(codes.NotFound, err) - }) - }) + go func() { + Ω(server.Serve(listener)).Should(Succeed()) + }() + done = func() { + server.Stop() + } + }) + // Server shutdown + AfterEach(func() { + done() + }) + // Store setup + BeforeEach(func() { + cfg = &api.Configuration{ + Name: "test-conf", + Version: "v1", + States: []string{"start", "stop"}, + Transitions: []*api.Transition{ + {From: "start", To: "stop", Event: "shutdown"}, + }, + StartingState: "start", + } + fsm = &api.FiniteStateMachine{ConfigId: GetVersionId(cfg)} + }) + It("should store valid configurations", func() { + _, ok := store.GetConfig(GetVersionId(cfg)) + Ω(ok).To(BeFalse()) + response, err := client.PutConfiguration(context.Background(), cfg) + Ω(err).ToNot(HaveOccurred()) + Ω(response).ToNot(BeNil()) + Ω(response.Id).To(Equal(GetVersionId(cfg))) + found, ok := store.GetConfig(response.Id) + Ω(ok).Should(BeTrue()) + Ω(found).Should(Respect(cfg)) + }) + It("should fail for invalid configuration", func() { + invalid := &api.Configuration{ + Name: "invalid", + Version: "v1", + States: []string{}, + Transitions: nil, + StartingState: "", + } + _, err := client.PutConfiguration(context.Background(), invalid) + assertStatusCode(codes.InvalidArgument, err) + }) + It("should retrieve a valid configuration", func() { + Ω(store.PutConfig(cfg)).To(Succeed()) + response, err := client.GetConfiguration(context.Background(), + &api.GetRequest{Id: GetVersionId(cfg)}) + Ω(err).ToNot(HaveOccurred()) + Ω(response).ToNot(BeNil()) + Ω(response).Should(Respect(cfg)) + }) + It("should return an empty configuration for an invalid ID", func() { + _, err := client.GetConfiguration(context.Background(), &api.GetRequest{Id: "fake"}) + assertStatusCode(codes.NotFound, err) + }) + It("should store a valid FSM", func() { + Ω(store.PutConfig(cfg)).To(Succeed()) + resp, err := client.PutFiniteStateMachine(context.Background(), fsm) + Ω(err).ToNot(HaveOccurred()) + Ω(resp).ToNot(BeNil()) + Ω(resp.Id).ToNot(BeNil()) + Ω(resp.Fsm).Should(Respect(fsm)) + }) + It("should fail with an invalid Config ID", func() { + invalid := &api.FiniteStateMachine{ConfigId: "fake"} + _, err := client.PutFiniteStateMachine(context.Background(), invalid) + assertStatusCode(codes.FailedPrecondition, err) + }) + It("can retrieve a stored FSM", func() { + id := "123456" + Ω(store.PutConfig(cfg)) + Ω(store.PutStateMachine(id, fsm)).Should(Succeed()) + Ω(client.GetFiniteStateMachine(context.Background(), + &api.GetRequest{ + Id: strings.Join([]string{cfg.Name, id}, storage.KeyPrefixIDSeparator), + })).Should(Respect(fsm)) + }) + It("will return an Invalid error for an invalid ID", func() { + _, err := client.GetFiniteStateMachine(context.Background(), &api.GetRequest{Id: "fake"}) + assertStatusCode(codes.InvalidArgument, err) + }) + It("will return a NotFound error for a missing ID", func() { + _, err := client.GetFiniteStateMachine(context.Background(), + &api.GetRequest{Id: "cfg#fake"}) + assertStatusCode(codes.NotFound, err) + }) + }) }) func assertStatusCode(code codes.Code, err error) { - Ω(err).To(HaveOccurred()) - s, ok := status.FromError(err) - Ω(ok).To(BeTrue()) - Ω(s.Code()).To(Equal(code)) + Ω(err).To(HaveOccurred()) + s, ok := status.FromError(err) + Ω(ok).To(BeTrue()) + Ω(s.Code()).To(Equal(code)) } diff --git a/grpc/grpc_suite_test.go b/grpc/grpc_suite_test.go index bf60df7..b1531ca 100644 --- a/grpc/grpc_suite_test.go +++ b/grpc/grpc_suite_test.go @@ -1,3 +1,12 @@ +/* + * Copyright (c) 2022 AlertAvert.com. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Author: Marco Massenzio (marco@alertavert.com) + */ + package grpc_test import ( diff --git a/pubsub/listener.go b/pubsub/listener.go index 03e8540..19e7657 100644 --- a/pubsub/listener.go +++ b/pubsub/listener.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,125 +10,125 @@ package pubsub import ( - "fmt" - . "github.com/massenz/go-statemachine/api" - "github.com/massenz/go-statemachine/storage" - log "github.com/massenz/slf4go/logging" - protos "github.com/massenz/statemachine-proto/golang/api" - "strings" + "fmt" + . "github.com/massenz/go-statemachine/api" + "github.com/massenz/go-statemachine/storage" + log "github.com/massenz/slf4go/logging" + protos "github.com/massenz/statemachine-proto/golang/api" + "strings" ) func NewEventsListener(options *ListenerOptions) *EventsListener { - return &EventsListener{ - logger: log.NewLog("Listener"), - events: options.EventsChannel, - store: options.StatemachinesStore, - notifications: options.NotificationsChannel, - } + return &EventsListener{ + logger: log.NewLog("Listener"), + events: options.EventsChannel, + store: options.StatemachinesStore, + notifications: options.NotificationsChannel, + } } // SetLogLevel to implement the log.Loggable interface func (listener *EventsListener) SetLogLevel(level log.LogLevel) { - listener.logger.Level = level + listener.logger.Level = level } func (listener *EventsListener) PostNotificationAndReportOutcome(eventResponse *protos.EventResponse) { - if eventResponse.Outcome.Code != protos.EventOutcome_Ok { - listener.logger.Error("[Event ID: %s]: %s", eventResponse.EventId, eventResponse.GetOutcome().Details) - } - if listener.notifications != nil { - listener.logger.Debug("Posting notification: %v", eventResponse.GetEventId()) - listener.notifications <- *eventResponse - } - listener.logger.Debug("Reporting outcome: %v", eventResponse.GetEventId()) - listener.reportOutcome(eventResponse) + if eventResponse.Outcome.Code != protos.EventOutcome_Ok { + listener.logger.Error("[Event ID: %s]: %s", eventResponse.EventId, eventResponse.GetOutcome().Details) + } + if listener.notifications != nil { + listener.logger.Debug("Posting notification: %v", eventResponse.GetEventId()) + listener.notifications <- *eventResponse + } + listener.logger.Debug("Reporting outcome: %v", eventResponse.GetEventId()) + listener.reportOutcome(eventResponse) } func (listener *EventsListener) ListenForMessages() { - listener.logger.Info("Events message listener started") - for request := range listener.events { - listener.logger.Debug("Received request %s", request.Event.String()) - if request.Dest == "" { - listener.PostNotificationAndReportOutcome(makeResponse(&request, - protos.EventOutcome_MissingDestination, - fmt.Sprintf("no destination specified"))) - continue - } - // TODO: this is an API change and needs to be documented - // Destination comprises both the type (configuration name) and ID of the statemachine, - // separated by the # character: # (e.g., `order#1234`) - dest := strings.Split(request.Dest, "#") - if len(dest) != 2 { - listener.PostNotificationAndReportOutcome(makeResponse(&request, - protos.EventOutcome_MissingDestination, - fmt.Sprintf("invalid destination [%s] expected #", - request.Dest))) - continue - } - smType, smId := dest[0], dest[1] - fsm, ok := listener.store.GetStateMachine(smId, smType) - if !ok { - listener.PostNotificationAndReportOutcome(makeResponse(&request, - protos.EventOutcome_FsmNotFound, - fmt.Sprintf("statemachine [%s] could not be found", request.Dest))) - continue - } - // TODO: cache the configuration locally: they are immutable anyway. - cfg, ok := listener.store.GetConfig(fsm.ConfigId) - if !ok { - listener.PostNotificationAndReportOutcome(makeResponse(&request, - protos.EventOutcome_ConfigurationNotFound, - fmt.Sprintf("configuration [%s] could not be found", fsm.ConfigId))) - continue - } - previousState := fsm.State - cfgFsm := ConfiguredStateMachine{ - Config: cfg, - FSM: fsm, - } - listener.logger.Debug("Preparing to send event `%s` for FSM [%s] (current state: %s)", - request.Event.Transition.Event, smId, previousState) - if err := cfgFsm.SendEvent(request.Event); err != nil { - listener.PostNotificationAndReportOutcome(makeResponse(&request, - protos.EventOutcome_TransitionNotAllowed, - fmt.Sprintf("event [%s] could not be processed: %v", - request.GetEvent().GetTransition().GetEvent(), err))) - continue - } - listener.logger.Debug("Event `%s` transitioned FSM [%s] to state `%s` from state `%s` - updating store", - request.Event.Transition.Event, smId, fsm.State, previousState) - err := listener.store.PutStateMachine(smId, fsm) - if err != nil { - listener.PostNotificationAndReportOutcome(makeResponse(&request, - protos.EventOutcome_InternalError, - fmt.Sprintf("could not update statemachine [%s] in store: %v", - request.Dest, err))) - continue - } - // All good, we want to report success too. - listener.PostNotificationAndReportOutcome(makeResponse(&request, - protos.EventOutcome_Ok, - fmt.Sprintf("event [%s] transitioned FSM [%s] to state [%s]", - request.Event.Transition.Event, smId, fsm.State))) - } + listener.logger.Info("Events message listener started") + for request := range listener.events { + listener.logger.Debug("Received request %s", request.Event.String()) + if request.Dest == "" { + listener.PostNotificationAndReportOutcome(makeResponse(&request, + protos.EventOutcome_MissingDestination, + fmt.Sprintf("no destination specified"))) + continue + } + // TODO: this is an API change and needs to be documented + // Destination comprises both the type (configuration name) and ID of the statemachine, + // separated by the # character: # (e.g., `order#1234`) + dest := strings.Split(request.Dest, "#") + if len(dest) != 2 { + listener.PostNotificationAndReportOutcome(makeResponse(&request, + protos.EventOutcome_MissingDestination, + fmt.Sprintf("invalid destination [%s] expected #", + request.Dest))) + continue + } + smType, smId := dest[0], dest[1] + fsm, ok := listener.store.GetStateMachine(smId, smType) + if !ok { + listener.PostNotificationAndReportOutcome(makeResponse(&request, + protos.EventOutcome_FsmNotFound, + fmt.Sprintf("statemachine [%s] could not be found", request.Dest))) + continue + } + // TODO: cache the configuration locally: they are immutable anyway. + cfg, ok := listener.store.GetConfig(fsm.ConfigId) + if !ok { + listener.PostNotificationAndReportOutcome(makeResponse(&request, + protos.EventOutcome_ConfigurationNotFound, + fmt.Sprintf("configuration [%s] could not be found", fsm.ConfigId))) + continue + } + previousState := fsm.State + cfgFsm := ConfiguredStateMachine{ + Config: cfg, + FSM: fsm, + } + listener.logger.Debug("Preparing to send event `%s` for FSM [%s] (current state: %s)", + request.Event.Transition.Event, smId, previousState) + if err := cfgFsm.SendEvent(request.Event); err != nil { + listener.PostNotificationAndReportOutcome(makeResponse(&request, + protos.EventOutcome_TransitionNotAllowed, + fmt.Sprintf("event [%s] could not be processed: %v", + request.GetEvent().GetTransition().GetEvent(), err))) + continue + } + listener.logger.Debug("Event `%s` transitioned FSM [%s] to state `%s` from state `%s` - updating store", + request.Event.Transition.Event, smId, fsm.State, previousState) + err := listener.store.PutStateMachine(smId, fsm) + if err != nil { + listener.PostNotificationAndReportOutcome(makeResponse(&request, + protos.EventOutcome_InternalError, + fmt.Sprintf("could not update statemachine [%s] in store: %v", + request.Dest, err))) + continue + } + // All good, we want to report success too. + listener.PostNotificationAndReportOutcome(makeResponse(&request, + protos.EventOutcome_Ok, + fmt.Sprintf("event [%s] transitioned FSM [%s] to state [%s]", + request.Event.Transition.Event, smId, fsm.State))) + } } func (listener *EventsListener) reportOutcome(response *protos.EventResponse) { - smType := strings.Split(response.Outcome.Dest, "#")[0] - if err := listener.store.AddEventOutcome(response.EventId, smType, response.Outcome, - storage.NeverExpire); err != nil { - listener.logger.Error("could not add outcome to store: %v", err) - } + smType := strings.Split(response.Outcome.Dest, "#")[0] + if err := listener.store.AddEventOutcome(response.EventId, smType, response.Outcome, + storage.NeverExpire); err != nil { + listener.logger.Error("could not add outcome to store: %v", err) + } } func makeResponse(request *protos.EventRequest, code protos.EventOutcome_StatusCode, - details string) *protos.EventResponse { - return &protos.EventResponse{ - EventId: request.GetEvent().GetEventId(), - Outcome: &protos.EventOutcome{ - Code: code, - Dest: request.Dest, - Details: details, - }, - } + details string) *protos.EventResponse { + return &protos.EventResponse{ + EventId: request.GetEvent().GetEventId(), + Outcome: &protos.EventOutcome{ + Code: code, + Dest: request.Dest, + Details: details, + }, + } } diff --git a/pubsub/listener_test.go b/pubsub/listener_test.go index ea412e2..ac06da9 100644 --- a/pubsub/listener_test.go +++ b/pubsub/listener_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,183 +10,183 @@ package pubsub_test import ( - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" - "github.com/massenz/slf4go/logging" - "time" + "github.com/massenz/slf4go/logging" + "time" - "github.com/massenz/go-statemachine/pubsub" - "github.com/massenz/go-statemachine/storage" - protos "github.com/massenz/statemachine-proto/golang/api" + "github.com/massenz/go-statemachine/pubsub" + "github.com/massenz/go-statemachine/storage" + protos "github.com/massenz/statemachine-proto/golang/api" ) var _ = Describe("A Listener", func() { - Context("when store-backed", func() { - var ( - testListener *pubsub.EventsListener - eventsCh chan protos.EventRequest - notificationsCh chan protos.EventResponse - store storage.StoreManager - ) - BeforeEach(func() { - eventsCh = make(chan protos.EventRequest) - notificationsCh = make(chan protos.EventResponse) - store = storage.NewInMemoryStore() - store.SetLogLevel(logging.NONE) - testListener = pubsub.NewEventsListener(&pubsub.ListenerOptions{ - EventsChannel: eventsCh, - NotificationsChannel: notificationsCh, - StatemachinesStore: store, - ListenersPoolSize: 0, - }) - // Set to DEBUG when diagnosing test failures - testListener.SetLogLevel(logging.NONE) - }) - It("can post error notifications", func() { - defer close(notificationsCh) - msg := protos.Event{ - EventId: "feed-beef", - Originator: "me", - Transition: &protos.Transition{ - Event: "test-me", - }, - Details: "more details", - } - detail := "some error" - notification := &protos.EventResponse{ - EventId: msg.GetEventId(), - Outcome: &protos.EventOutcome{ - Code: protos.EventOutcome_MissingDestination, - Details: detail, - }, - } - go testListener.PostNotificationAndReportOutcome(notification) - select { - case n := <-notificationsCh: - Ω(n.EventId).To(Equal(msg.GetEventId())) - Ω(n.Outcome).ToNot(BeNil()) - Ω(n.Outcome.Dest).To(BeEmpty()) - Ω(n.Outcome.Details).To(Equal(detail)) - Ω(n.Outcome.Code).To(Equal(protos.EventOutcome_MissingDestination)) + Context("when store-backed", func() { + var ( + testListener *pubsub.EventsListener + eventsCh chan protos.EventRequest + notificationsCh chan protos.EventResponse + store storage.StoreManager + ) + BeforeEach(func() { + eventsCh = make(chan protos.EventRequest) + notificationsCh = make(chan protos.EventResponse) + store = storage.NewInMemoryStore() + store.SetLogLevel(logging.NONE) + testListener = pubsub.NewEventsListener(&pubsub.ListenerOptions{ + EventsChannel: eventsCh, + NotificationsChannel: notificationsCh, + StatemachinesStore: store, + ListenersPoolSize: 0, + }) + // Set to DEBUG when diagnosing test failures + testListener.SetLogLevel(logging.NONE) + }) + It("can post error notifications", func() { + defer close(notificationsCh) + msg := protos.Event{ + EventId: "feed-beef", + Originator: "me", + Transition: &protos.Transition{ + Event: "test-me", + }, + Details: "more details", + } + detail := "some error" + notification := &protos.EventResponse{ + EventId: msg.GetEventId(), + Outcome: &protos.EventOutcome{ + Code: protos.EventOutcome_MissingDestination, + Details: detail, + }, + } + go testListener.PostNotificationAndReportOutcome(notification) + select { + case n := <-notificationsCh: + Ω(n.EventId).To(Equal(msg.GetEventId())) + Ω(n.Outcome).ToNot(BeNil()) + Ω(n.Outcome.Dest).To(BeEmpty()) + Ω(n.Outcome.Details).To(Equal(detail)) + Ω(n.Outcome.Code).To(Equal(protos.EventOutcome_MissingDestination)) - case <-time.After(timeout): - Fail("timed out waiting for notification") - } - }) - It("can receive events", func() { - event := protos.Event{ - EventId: "feed-beef", - Originator: "me", - Transition: &protos.Transition{ - Event: "move", - }, - Details: "more details", - } - request := protos.EventRequest{ - Event: &event, - Dest: "test#12345-faa44", - } - Ω(store.PutConfig(&protos.Configuration{ - Name: "test", - Version: "v1", - States: []string{"start", "end"}, - Transitions: []*protos.Transition{{From: "start", To: "end", Event: "move"}}, - StartingState: "start", - })).ToNot(HaveOccurred()) - Ω(store.PutStateMachine("12345-faa44", &protos.FiniteStateMachine{ - ConfigId: "test:v1", - State: "start", - History: nil, - })).ToNot(HaveOccurred()) + case <-time.After(timeout): + Fail("timed out waiting for notification") + } + }) + It("can receive events", func() { + event := protos.Event{ + EventId: "feed-beef", + Originator: "me", + Transition: &protos.Transition{ + Event: "move", + }, + Details: "more details", + } + request := protos.EventRequest{ + Event: &event, + Dest: "test#12345-faa44", + } + Ω(store.PutConfig(&protos.Configuration{ + Name: "test", + Version: "v1", + States: []string{"start", "end"}, + Transitions: []*protos.Transition{{From: "start", To: "end", Event: "move"}}, + StartingState: "start", + })).ToNot(HaveOccurred()) + Ω(store.PutStateMachine("12345-faa44", &protos.FiniteStateMachine{ + ConfigId: "test:v1", + State: "start", + History: nil, + })).ToNot(HaveOccurred()) - go func() { - testListener.ListenForMessages() - }() - eventsCh <- request - close(eventsCh) + go func() { + testListener.ListenForMessages() + }() + eventsCh <- request + close(eventsCh) - select { - case notification := <-notificationsCh: - // First we want to test that the outcome was successful - Ω(notification.EventId).To(Equal(event.GetEventId())) - Ω(notification.Outcome).ToNot(BeNil()) - Ω(notification.Outcome.Dest).To(Equal(request.GetDest())) - Ω(notification.Outcome.Details).To(ContainSubstring("transitioned")) - Ω(notification.Outcome.Code).To(Equal(protos.EventOutcome_Ok)) + select { + case notification := <-notificationsCh: + // First we want to test that the outcome was successful + Ω(notification.EventId).To(Equal(event.GetEventId())) + Ω(notification.Outcome).ToNot(BeNil()) + Ω(notification.Outcome.Dest).To(Equal(request.GetDest())) + Ω(notification.Outcome.Details).To(ContainSubstring("transitioned")) + Ω(notification.Outcome.Code).To(Equal(protos.EventOutcome_Ok)) - // Now we want to test that the state machine was updated - fsm, ok := store.GetStateMachine("12345-faa44", "test") - Ω(ok).ToNot(BeFalse()) - Ω(fsm.State).To(Equal("end")) - Ω(len(fsm.History)).To(Equal(1)) - Ω(fsm.History[0].Details).To(Equal("more details")) - Ω(fsm.History[0].Transition.Event).To(Equal("move")) - case <-time.After(timeout): - Fail("the listener did not exit when the events channel was closed") - } - }) - It("sends notifications for missing state-machine", func() { - event := protos.Event{ - EventId: "feed-beef", - Originator: "me", - Transition: &protos.Transition{ - Event: "move", - }, - Details: "more details", - } - request := protos.EventRequest{ - Event: &event, - Dest: "test#fake-fsm", - } - go func() { - testListener.ListenForMessages() - }() - eventsCh <- request - close(eventsCh) - select { - case n := <-notificationsCh: - Ω(n.EventId).To(Equal(request.Event.EventId)) - Ω(n.Outcome).ToNot(BeNil()) - Ω(n.Outcome.Dest).To(Equal(request.Dest)) - Ω(n.Outcome.Code).To(Equal(protos.EventOutcome_FsmNotFound)) - case <-time.After(timeout): - Fail("the listener did not exit when the events channel was closed") - } - }) - It("sends notifications for missing destinations", func() { - request := protos.EventRequest{ - Event: &protos.Event{ - EventId: "feed-beef", - }, - Dest: "", - } - go func() { testListener.ListenForMessages() }() - eventsCh <- request - close(eventsCh) + // Now we want to test that the state machine was updated + fsm, ok := store.GetStateMachine("12345-faa44", "test") + Ω(ok).ToNot(BeFalse()) + Ω(fsm.State).To(Equal("end")) + Ω(len(fsm.History)).To(Equal(1)) + Ω(fsm.History[0].Details).To(Equal("more details")) + Ω(fsm.History[0].Transition.Event).To(Equal("move")) + case <-time.After(timeout): + Fail("the listener did not exit when the events channel was closed") + } + }) + It("sends notifications for missing state-machine", func() { + event := protos.Event{ + EventId: "feed-beef", + Originator: "me", + Transition: &protos.Transition{ + Event: "move", + }, + Details: "more details", + } + request := protos.EventRequest{ + Event: &event, + Dest: "test#fake-fsm", + } + go func() { + testListener.ListenForMessages() + }() + eventsCh <- request + close(eventsCh) + select { + case n := <-notificationsCh: + Ω(n.EventId).To(Equal(request.Event.EventId)) + Ω(n.Outcome).ToNot(BeNil()) + Ω(n.Outcome.Dest).To(Equal(request.Dest)) + Ω(n.Outcome.Code).To(Equal(protos.EventOutcome_FsmNotFound)) + case <-time.After(timeout): + Fail("the listener did not exit when the events channel was closed") + } + }) + It("sends notifications for missing destinations", func() { + request := protos.EventRequest{ + Event: &protos.Event{ + EventId: "feed-beef", + }, + Dest: "", + } + go func() { testListener.ListenForMessages() }() + eventsCh <- request + close(eventsCh) - select { - case n := <-notificationsCh: - Ω(n.EventId).To(Equal(request.Event.EventId)) - Ω(n.Outcome).ToNot(BeNil()) - Ω(n.Outcome.Code).To(Equal(protos.EventOutcome_MissingDestination)) - case <-time.After(timeout): - Fail("no error notification received") - } - }) - It("should exit when the channel is closed", func() { - done := make(chan interface{}) - go func() { - defer close(done) - testListener.ListenForMessages() - }() - close(eventsCh) - select { - case <-done: - Succeed() - case <-time.After(timeout): - Fail("the listener did not exit when the events channel was closed") - } - }) - }) + select { + case n := <-notificationsCh: + Ω(n.EventId).To(Equal(request.Event.EventId)) + Ω(n.Outcome).ToNot(BeNil()) + Ω(n.Outcome.Code).To(Equal(protos.EventOutcome_MissingDestination)) + case <-time.After(timeout): + Fail("no error notification received") + } + }) + It("should exit when the channel is closed", func() { + done := make(chan interface{}) + go func() { + defer close(done) + testListener.ListenForMessages() + }() + close(eventsCh) + select { + case <-done: + Succeed() + case <-time.After(timeout): + Fail("the listener did not exit when the events channel was closed") + } + }) + }) }) diff --git a/pubsub/pubsub_suite_test.go b/pubsub/pubsub_suite_test.go index 46beaad..d000eb8 100644 --- a/pubsub/pubsub_suite_test.go +++ b/pubsub/pubsub_suite_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,118 +10,121 @@ package pubsub_test import ( - "fmt" - "github.com/golang/protobuf/proto" - "github.com/massenz/go-statemachine/pubsub" - "github.com/massenz/statemachine-proto/golang/api" - "os" - "testing" - "time" + "fmt" + "github.com/golang/protobuf/proto" + "github.com/massenz/go-statemachine/pubsub" + "github.com/massenz/statemachine-proto/golang/api" + "os" + "testing" + "time" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/sqs" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sqs" - log "github.com/massenz/slf4go/logging" + log "github.com/massenz/slf4go/logging" ) const ( - timeout = 5 * time.Second - eventsQueue = "test-events" - notificationsQueue = "test-notifications" + eventsQueue = "test-events" + notificationsQueue = "test-notifications" + acksQueue = "test-acks" + // Including these for clarity and configurability; they are set to default values + timeout = 1 * time.Second // Default timeout for Eventually is 1s + pollingInterval = 10 * time.Millisecond // Default polling interval for Eventually is 10ms ) func TestPubSub(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Pub/Sub Suite") + RegisterFailHandler(Fail) + RunSpecs(t, "Pub/Sub Suite") } // Although these are constants, we cannot take the pointers unless we declare them vars. var ( - sqsUrl = "http://localhost:4566" - region = "us-west-2" - testSqsClient = sqs.New(session.Must(session.NewSessionWithOptions(session.Options{ - SharedConfigState: session.SharedConfigEnable, - Config: aws.Config{ - Endpoint: &sqsUrl, - Region: ®ion, - }, - }))) - testLog = log.NewLog("PUBSUB") + sqsUrl = "http://localhost:4566" + region = "us-west-2" + testSqsClient = sqs.New(session.Must(session.NewSessionWithOptions(session.Options{ + SharedConfigState: session.SharedConfigEnable, + Config: aws.Config{ + Endpoint: &sqsUrl, + Region: ®ion, + }, + }))) + testLog = log.NewLog("PUBSUB") ) var _ = BeforeSuite(func() { - testLog.Level = log.NONE - Expect(os.Setenv("AWS_REGION", region)).ToNot(HaveOccurred()) - for _, topic := range []string{eventsQueue, notificationsQueue} { - topic = fmt.Sprintf("%s-%d", topic, GinkgoParallelProcess()) + testLog.Level = log.NONE + Expect(os.Setenv("AWS_REGION", region)).ToNot(HaveOccurred()) + for _, topic := range []string{eventsQueue, notificationsQueue, acksQueue} { + topic = fmt.Sprintf("%s-%d", topic, GinkgoParallelProcess()) - _, err := testSqsClient.GetQueueUrl(&sqs.GetQueueUrlInput{ - QueueName: &topic, - }) - if err != nil { - // the queue does not exist and ought to be created - testLog.Info("Creating SQS Queue %s", topic) - _, err = testSqsClient.CreateQueue(&sqs.CreateQueueInput{ - QueueName: &topic, - }) - Expect(err).NotTo(HaveOccurred()) - } - } + _, err := testSqsClient.GetQueueUrl(&sqs.GetQueueUrlInput{ + QueueName: &topic, + }) + if err != nil { + // the queue does not exist and ought to be created + testLog.Info("Creating SQS Queue %s", topic) + _, err = testSqsClient.CreateQueue(&sqs.CreateQueueInput{ + QueueName: &topic, + }) + Expect(err).NotTo(HaveOccurred()) + } + } }) var _ = AfterSuite(func() { - for _, topic := range []string{eventsQueue, notificationsQueue} { - topic = getQueueName(topic) + for _, topic := range []string{eventsQueue, notificationsQueue, acksQueue} { + topic = getQueueName(topic) - out, err := testSqsClient.GetQueueUrl(&sqs.GetQueueUrlInput{ - QueueName: &topic, - }) - Expect(err).NotTo(HaveOccurred()) - if out != nil { - testLog.Info("Deleting SQS Queue %s", topic) - _, err = testSqsClient.DeleteQueue(&sqs.DeleteQueueInput{QueueUrl: out.QueueUrl}) - Expect(err).NotTo(HaveOccurred()) - } - } + out, err := testSqsClient.GetQueueUrl(&sqs.GetQueueUrlInput{ + QueueName: &topic, + }) + Expect(err).NotTo(HaveOccurred()) + if out != nil { + testLog.Info("Deleting SQS Queue %s", topic) + _, err = testSqsClient.DeleteQueue(&sqs.DeleteQueueInput{QueueUrl: out.QueueUrl}) + Expect(err).NotTo(HaveOccurred()) + } + } }) // getQueueName provides a way to obtain a process-independent name for the SQS queue, // when Ginkgo tests are run in parallel (-p) func getQueueName(topic string) string { - return fmt.Sprintf("%s-%d", topic, GinkgoParallelProcess()) + return fmt.Sprintf("%s-%d", topic, GinkgoParallelProcess()) } func getSqsMessage(queue string) *sqs.Message { - q := pubsub.GetQueueUrl(testSqsClient, queue) - out, err := testSqsClient.ReceiveMessage(&sqs.ReceiveMessageInput{ - QueueUrl: &q, - }) - Expect(err).ToNot(HaveOccurred()) - // It could be that the message is not yet available, so we need to retry - if len(out.Messages) == 0 { - return nil - } - Expect(len(out.Messages)).To(Equal(1)) - _, err = testSqsClient.DeleteMessage(&sqs.DeleteMessageInput{ - QueueUrl: &q, - ReceiptHandle: out.Messages[0].ReceiptHandle, - }) - Expect(err).ToNot(HaveOccurred()) - return out.Messages[0] + q := pubsub.GetQueueUrl(testSqsClient, queue) + out, err := testSqsClient.ReceiveMessage(&sqs.ReceiveMessageInput{ + QueueUrl: &q, + }) + Expect(err).ToNot(HaveOccurred()) + // It could be that the message is not yet available, so we need to retry + if len(out.Messages) == 0 { + return nil + } + Expect(len(out.Messages)).To(Equal(1)) + _, err = testSqsClient.DeleteMessage(&sqs.DeleteMessageInput{ + QueueUrl: &q, + ReceiptHandle: out.Messages[0].ReceiptHandle, + }) + Expect(err).ToNot(HaveOccurred()) + return out.Messages[0] } // postSqsMessage mirrors the decoding of the SQS Message in the Subscriber and will // send it over the `queue`, so that we can test the Publisher can correctly receive it. func postSqsMessage(queue string, msg *api.EventRequest) error { - q := pubsub.GetQueueUrl(testSqsClient, queue) - testLog.Debug("Post Message -- Timestamp: %v", msg.Event.Timestamp) - _, err := testSqsClient.SendMessage(&sqs.SendMessageInput{ - MessageBody: aws.String(proto.MarshalTextString(msg)), - QueueUrl: &q, - }) - return err + q := pubsub.GetQueueUrl(testSqsClient, queue) + testLog.Debug("Post Message -- Timestamp: %v", msg.Event.Timestamp) + _, err := testSqsClient.SendMessage(&sqs.SendMessageInput{ + MessageBody: aws.String(proto.MarshalTextString(msg)), + QueueUrl: &q, + }) + return err } diff --git a/pubsub/sqs_pub.go b/pubsub/sqs_pub.go index dac53f2..671313e 100644 --- a/pubsub/sqs_pub.go +++ b/pubsub/sqs_pub.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,12 +10,13 @@ package pubsub import ( - "fmt" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/service/sqs" - "github.com/golang/protobuf/proto" - log "github.com/massenz/slf4go/logging" - protos "github.com/massenz/statemachine-proto/golang/api" + "fmt" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/sqs" + "github.com/golang/protobuf/proto" + log "github.com/massenz/slf4go/logging" + protos "github.com/massenz/statemachine-proto/golang/api" + "strconv" ) // NewSqsPublisher will create a new `Publisher` to send error notifications received on the @@ -32,58 +24,75 @@ import ( // // The `awsUrl` is the URL of the AWS SQS service, which can be obtained from the AWS Console, // or by the local AWS CLI. -func NewSqsPublisher(errorsChannel <-chan protos.EventResponse, awsUrl *string) *SqsPublisher { - client := getSqsClient(awsUrl) - if client == nil { - return nil - } - return &SqsPublisher{ - logger: log.NewLog("SQS-Pub"), - client: client, - errors: errorsChannel, - } +func NewSqsPublisher(channel <-chan protos.EventResponse, awsUrl *string) *SqsPublisher { + client := getSqsClient(awsUrl) + if client == nil { + return nil + } + return &SqsPublisher{ + logger: log.NewLog("SQS-Pub"), + client: client, + notifications: channel, + } } // SetLogLevel allows the SqsSubscriber to implement the log.Loggable interface func (s *SqsPublisher) SetLogLevel(level log.LogLevel) { - if s == nil { - fmt.Println("WARN: attempting to set log level on nil Publisher") - return - } - s.logger.Level = level + if s == nil { + fmt.Println("WARN: attempting to set log level on nil Publisher") + return + } + s.logger.Level = level } // GetQueueUrl retrieves from AWS SQS the URL for the queue, given the topic name func GetQueueUrl(client *sqs.SQS, topic string) string { - out, err := client.GetQueueUrl(&sqs.GetQueueUrlInput{ - QueueName: &topic, - }) - if err != nil || out.QueueUrl == nil { - // From the Google School: fail fast and noisily from an unrecoverable error - log.RootLog.Fatal(fmt.Errorf("cannot get SQS Queue URL for topic %s: %v", topic, err)) - } - return *out.QueueUrl + out, err := client.GetQueueUrl(&sqs.GetQueueUrlInput{ + QueueName: &topic, + }) + if err != nil || out.QueueUrl == nil { + // From the Google School: fail fast and noisily from an unrecoverable error + log.RootLog.Fatal(fmt.Errorf("cannot get SQS Queue URL for topic %s: %v", topic, err)) + } + return *out.QueueUrl } -// Publish sends an error message to the DLQ `topic` -func (s *SqsPublisher) Publish(topic string) { - queueUrl := GetQueueUrl(s.client, topic) - s.logger = log.NewLog(fmt.Sprintf("SQS-Pub{%s}", topic)) - s.logger.Info("SQS Publisher started for queue: %s", queueUrl) - for eventResponse := range s.errors { - delay := int64(0) - s.logger.Debug("[%s] %s", eventResponse.String(), queueUrl) - msgResult, err := s.client.SendMessage(&sqs.SendMessageInput{ - DelaySeconds: &delay, - // Encodes the Event as a string, using Protobuf implementation. - MessageBody: aws.String(proto.MarshalTextString(&eventResponse)), - QueueUrl: &queueUrl, - }) - if err != nil { - s.logger.Error("Cannot publish eventResponse (%s): %v", eventResponse.String(), err) - continue - } - s.logger.Debug("Notification successfully posted to SQS: %s", *msgResult.MessageId) - } - s.logger.Info("SQS Publisher exiting") +// Publish sends an message to provided topics depending on SQS Publisher settings. +// If an acksTopic is provided, it will send Ok outcomes to that topic and errors to errorsTopic; +// else, all outcomes will be sent to the errorsTopic. If notifyErrorsOnly is true, only error outcomes +// will be sent. +func (s *SqsPublisher) Publish(errorsTopic string, acksTopic string, notifyErrorsOnly bool) { + errorsQueueUrl := GetQueueUrl(s.client, errorsTopic) + var acksQueueUrl string + if acksTopic != "" { + acksQueueUrl = GetQueueUrl(s.client, acksTopic) + } + delay := int64(0) + s.logger.Info("SQS Publisher started for topics: %s %s", errorsQueueUrl, acksQueueUrl) + s.logger.Info("SQS Publisher notifyErrorsOnly: %s", strconv.FormatBool(notifyErrorsOnly)) + for eventResponse := range s.notifications { + isOKOutcome := eventResponse.Outcome != nil && eventResponse.Outcome.Code == protos.EventOutcome_Ok + if isOKOutcome && notifyErrorsOnly { + s.logger.Debug("Skipping notification for Ok outcome [Event ID: %s]", eventResponse.EventId) + continue + } + queueUrl := errorsQueueUrl + if isOKOutcome && acksTopic != "" { + queueUrl = acksQueueUrl + } + + s.logger.Debug("[%s] %s", eventResponse.String(), queueUrl) + msgResult, err := s.client.SendMessage(&sqs.SendMessageInput{ + DelaySeconds: &delay, + // Encodes the Event as a string, using Protobuf implementation. + MessageBody: aws.String(proto.MarshalTextString(&eventResponse)), + QueueUrl: &queueUrl, + }) + if err != nil { + s.logger.Error("Cannot publish eventResponse (%s): %v", eventResponse.String(), err) + continue + } + s.logger.Debug("Notification successfully posted to SQS: %s", *msgResult.MessageId) + } + s.logger.Info("SQS Publisher exiting") } diff --git a/pubsub/sqs_pub_test.go b/pubsub/sqs_pub_test.go index c011cfd..24493d3 100644 --- a/pubsub/sqs_pub_test.go +++ b/pubsub/sqs_pub_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,152 +10,236 @@ package pubsub_test import ( - . "github.com/JiaYongfei/respect/gomega" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + . "github.com/JiaYongfei/respect/gomega" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" - "fmt" - "github.com/golang/protobuf/proto" - "github.com/massenz/slf4go/logging" - "time" + "fmt" + "github.com/golang/protobuf/proto" + "github.com/massenz/slf4go/logging" + "time" - "github.com/massenz/go-statemachine/api" - "github.com/massenz/go-statemachine/pubsub" + "github.com/massenz/go-statemachine/api" + "github.com/massenz/go-statemachine/pubsub" - protos "github.com/massenz/statemachine-proto/golang/api" + protos "github.com/massenz/statemachine-proto/golang/api" ) var _ = Describe("SQS Publisher", func() { - Context("when correctly initialized", func() { - var ( - testPublisher *pubsub.SqsPublisher - notificationsCh chan protos.EventResponse - ) - BeforeEach(func() { - notificationsCh = make(chan protos.EventResponse) - testPublisher = pubsub.NewSqsPublisher(notificationsCh, &sqsUrl) - Expect(testPublisher).ToNot(BeNil()) - // Set to DEBUG when diagnosing test failures - testPublisher.SetLogLevel(logging.NONE) - }) - It("can publish error notifications", func() { - notification := protos.EventResponse{ - EventId: "feed-beef", - Outcome: &protos.EventOutcome{ - Code: protos.EventOutcome_InternalError, - Dest: "me", - Details: "error details", - }, - } - done := make(chan interface{}) - go func() { - defer close(done) - go testPublisher.Publish(getQueueName(notificationsQueue)) - }() - notificationsCh <- notification - res := getSqsMessage(getQueueName(notificationsQueue)) - Expect(res).ToNot(BeNil()) - Expect(res.Body).ToNot(BeNil()) - - // Emulate SQS Client behavior - body := *res.Body - var receivedEvt protos.EventResponse - Expect(proto.UnmarshalText(body, &receivedEvt)).Should(Succeed()) - Expect(receivedEvt).To(Respect(notification)) - - close(notificationsCh) - select { - case <-done: - Succeed() - case <-time.After(timeout): - Fail("timed out waiting for Publisher to exit") - } - }) - It("will publish successful outcomes", func() { - notification := protos.EventResponse{ - EventId: "dead-beef", - Outcome: &protos.EventOutcome{ - Code: protos.EventOutcome_Ok, - }, - } - done := make(chan interface{}) - go func() { - defer close(done) - go testPublisher.Publish(getQueueName(notificationsQueue)) - }() - notificationsCh <- notification - m := getSqsMessage(getQueueName(notificationsQueue)) - var response protos.EventResponse - Expect(proto.UnmarshalText(*m.Body, &response)).ShouldNot(HaveOccurred()) - Expect(&response).To(Respect(¬ification)) - close(notificationsCh) - - select { - case <-done: - Succeed() - case <-time.After(100 * time.Millisecond): - Fail("timed out waiting for Publisher to exit") - } - }) - It("will terminate gracefully when the notifications channel is closed", func() { - done := make(chan interface{}) - go func() { - defer close(done) - testPublisher.Publish(getQueueName(notificationsQueue)) - }() - close(notificationsCh) - select { - case <-done: - Succeed() - case <-time.After(timeout): - Fail("Publisher did not exit within timeout") - } - }) - It("will survive an empty Message", func() { - go testPublisher.Publish(getQueueName(notificationsQueue)) - notificationsCh <- protos.EventResponse{} - close(notificationsCh) - getSqsMessage(getQueueName(notificationsQueue)) - }) - It("will send several messages within a short timeframe", func() { - go testPublisher.Publish(getQueueName(notificationsQueue)) - for i := range [10]int{} { - evt := api.NewEvent("do-something") - evt.EventId = fmt.Sprintf("event-%d", i) - notificationsCh <- protos.EventResponse{ - EventId: evt.EventId, - Outcome: &protos.EventOutcome{ - Code: protos.EventOutcome_InternalError, - Dest: fmt.Sprintf("test-%d", i), - Details: "more details about the error", - }, - } - } - done := make(chan interface{}) - go func() { - // This is necessary as we make assertions in this goroutine, - // and we want to make sure we can see the errors if they fail. - defer GinkgoRecover() - defer close(done) - for i := range [10]int{} { - res := getSqsMessage(getQueueName(notificationsQueue)) - Expect(res).ToNot(BeNil()) - Expect(res.Body).ToNot(BeNil()) - var receivedEvt protos.EventResponse - Expect(proto.UnmarshalText(*res.Body, &receivedEvt)).Should(Succeed()) - Expect(receivedEvt.EventId).To(Equal(fmt.Sprintf("event-%d", i))) - Expect(receivedEvt.Outcome.Code).To(Equal(protos.EventOutcome_InternalError)) - Expect(receivedEvt.Outcome.Details).To(Equal("more details about the error")) - Expect(receivedEvt.Outcome.Dest).To(ContainSubstring("test-")) - } - }() - close(notificationsCh) - select { - case <-done: - Succeed() - case <-time.After(timeout): - Fail("timed out waiting for Publisher to exit") - } - }) - }) + Context("when correctly initialized", func() { + var ( + testPublisher *pubsub.SqsPublisher + notificationsCh chan protos.EventResponse + ) + BeforeEach(func() { + notificationsCh = make(chan protos.EventResponse) + testPublisher = pubsub.NewSqsPublisher(notificationsCh, &sqsUrl) + Expect(testPublisher).ToNot(BeNil()) + // Set to DEBUG when diagnosing test failures + testPublisher.SetLogLevel(logging.NONE) + SetDefaultEventuallyPollingInterval(pollingInterval) + SetDefaultEventuallyTimeout(timeout) + }) + It("can publish error notifications", func() { + notification := protos.EventResponse{ + EventId: "feed-beef", + Outcome: &protos.EventOutcome{ + Code: protos.EventOutcome_InternalError, + Dest: "me", + Details: "error details", + }, + } + done := make(chan interface{}) + go func() { + defer close(done) + go testPublisher.Publish(getQueueName(notificationsQueue), "", false) + }() + notificationsCh <- notification + res := getSqsMessage(getQueueName(notificationsQueue)) + Eventually(res).ShouldNot(BeNil()) + Eventually(res.Body).ShouldNot(BeNil()) + + // Emulate SQS Client behavior + body := *res.Body + var receivedEvt protos.EventResponse + Expect(proto.UnmarshalText(body, &receivedEvt)).Should(Succeed()) + Expect(receivedEvt).To(Respect(notification)) + + close(notificationsCh) + select { + case <-done: + Succeed() + case <-time.After(timeout): + Fail("timed out waiting for Publisher to exit") + } + }) + It("will publish successful outcomes", func() { + notification := protos.EventResponse{ + EventId: "dead-beef", + Outcome: &protos.EventOutcome{ + Code: protos.EventOutcome_Ok, + }, + } + done := make(chan interface{}) + go func() { + defer close(done) + go testPublisher.Publish(getQueueName(notificationsQueue), "", false) + }() + notificationsCh <- notification + m := getSqsMessage(getQueueName(notificationsQueue)) + var response protos.EventResponse + Eventually(func(g Gomega) { + g.Expect(proto.UnmarshalText(*m.Body, &response)).ShouldNot(HaveOccurred()) + g.Expect(&response).To(Respect(¬ification)) + }).Should(Succeed()) + close(notificationsCh) + + select { + case <-done: + Succeed() + case <-time.After(timeout): + Fail("timed out waiting for Publisher to exit") + } + }) + It("will publish OK outcomes to acks queue if configured", func() { + notification := protos.EventResponse{ + EventId: "dead-pork", + Outcome: &protos.EventOutcome{ + Code: protos.EventOutcome_InternalError, + }, + } + ack := protos.EventResponse{ + EventId: "dead-beef", + Outcome: &protos.EventOutcome{ + Code: protos.EventOutcome_Ok, + }, + } + done := make(chan interface{}) + go func() { + defer close(done) + go testPublisher.Publish(getQueueName(notificationsQueue), getQueueName(acksQueue), false) + }() + var response protos.EventResponse + + // Confirm notificationsQueue received the error + notificationsCh <- notification + res := getSqsMessage(getQueueName(notificationsQueue)) + Eventually(func(g Gomega) { + g.Expect(proto.UnmarshalText(*res.Body, &response)).ShouldNot(HaveOccurred()) + g.Expect(&response).To(Respect(¬ification)) + }).Should(Succeed()) + + // Confirm acksQueue received the Ok + notificationsCh <- ack + res = getSqsMessage(getQueueName(acksQueue)) + Eventually(func(g Gomega) { + g.Expect(proto.UnmarshalText(*res.Body, &response)).ShouldNot(HaveOccurred()) + g.Expect(&response).To(Respect(&ack)) + }).Should(Succeed()) + // Confirm notificationsQueue did not receive the Ok + res = getSqsMessage(getQueueName(notificationsQueue)) + Eventually(res).Should(BeNil()) + + close(notificationsCh) + + select { + case <-done: + Succeed() + case <-time.After(timeout): + Fail("timed out waiting for Publisher to exit") + } + }) + It("will terminate gracefully when the notifications channel is closed", func() { + done := make(chan interface{}) + go func() { + defer close(done) + go testPublisher.Publish(getQueueName(notificationsQueue), "", false) + }() + close(notificationsCh) + select { + case <-done: + Succeed() + case <-time.After(timeout): + Fail("Publisher did not exit within timeout") + } + }) + It("will survive an empty Message", func() { + go testPublisher.Publish(getQueueName(notificationsQueue), "", false) + notificationsCh <- protos.EventResponse{} + close(notificationsCh) + getSqsMessage(getQueueName(notificationsQueue)) + }) + It("will send several messages within a short timeframe", func() { + go testPublisher.Publish(getQueueName(notificationsQueue), "", false) + for i := range [10]int{} { + evt := api.NewEvent("do-something") + evt.EventId = fmt.Sprintf("event-%d", i) + notificationsCh <- protos.EventResponse{ + EventId: evt.EventId, + Outcome: &protos.EventOutcome{ + Code: protos.EventOutcome_InternalError, + Dest: fmt.Sprintf("test-%d", i), + Details: "more details about the error", + }, + } + } + done := make(chan interface{}) + go func() { + // This is necessary as we make assertions in this goroutine, + // and we want to make sure we can see the notifications if they fail. + defer GinkgoRecover() + defer close(done) + for i := range [10]int{} { + res := getSqsMessage(getQueueName(notificationsQueue)) + Eventually(res).ShouldNot(BeNil()) + Eventually(res.Body).ShouldNot(BeNil()) + var receivedEvt protos.EventResponse + Eventually(func(g Gomega) { + g.Expect(proto.UnmarshalText(*res.Body, &receivedEvt)).Should(Succeed()) + g.Expect(receivedEvt.EventId).To(Equal(fmt.Sprintf("event-%d", i))) + g.Expect(receivedEvt.Outcome.Code).To(Equal(protos.EventOutcome_InternalError)) + g.Expect(receivedEvt.Outcome.Details).To(Equal("more details about the error")) + g.Expect(receivedEvt.Outcome.Dest).To(ContainSubstring("test-")) + }).Should(Succeed()) + } + }() + close(notificationsCh) + select { + case <-done: + Succeed() + case <-time.After(timeout): + Fail("timed out waiting for Publisher to exit") + } + }) + It("will only notify error outcomes if configured to", func() { + ack := protos.EventResponse{ + EventId: "dead-beef", + Outcome: &protos.EventOutcome{ + Code: protos.EventOutcome_Ok, + }, + } + testPublisher = pubsub.NewSqsPublisher(notificationsCh, &sqsUrl) + done := make(chan interface{}) + go func() { + defer close(done) + go testPublisher.Publish(getQueueName(notificationsQueue), getQueueName(acksQueue), true) + }() + + notificationsCh <- ack + // Confirm both acksQueue and notificationsQueue do not get the Ok message + res := getSqsMessage(getQueueName(notificationsQueue)) + Expect(res).To(BeNil()) + res = getSqsMessage(getQueueName(acksQueue)) + Expect(res).To(BeNil()) + + close(notificationsCh) + + select { + case <-done: + Succeed() + case <-time.After(timeout): + Fail("timed out waiting for Publisher to exit") + } + }) + }) }) diff --git a/pubsub/sqs_sub.go b/pubsub/sqs_sub.go index fa73a2b..d0d29a6 100644 --- a/pubsub/sqs_sub.go +++ b/pubsub/sqs_sub.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,18 +10,18 @@ package pubsub import ( - "fmt" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/sqs" - "github.com/golang/protobuf/proto" - log "github.com/massenz/slf4go/logging" - "os" - "time" + "fmt" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/sqs" + "github.com/golang/protobuf/proto" + log "github.com/massenz/slf4go/logging" + "os" + "time" - "github.com/massenz/go-statemachine/api" + "github.com/massenz/go-statemachine/api" - protos "github.com/massenz/statemachine-proto/golang/api" + protos "github.com/massenz/statemachine-proto/golang/api" ) // TODO: should we need to generalize and abstract the implementation of a Subscriber? @@ -39,136 +30,136 @@ import ( // getSqsClient connects to AWS and obtains an SQS client; passing `nil` as the `awsEndpointUrl` will // connect by default to AWS; use a different (possibly local) URL for a LocalStack test deployment. func getSqsClient(awsEndpointUrl *string) *sqs.SQS { - var sess *session.Session - if awsEndpointUrl == nil { - sess = session.Must(session.NewSessionWithOptions(session.Options{ - SharedConfigState: session.SharedConfigEnable, - })) - } else { - region, found := os.LookupEnv("AWS_REGION") - if !found { - fmt.Printf("No AWS Region configured, cannot connect to SQS provider at %s\n", - *awsEndpointUrl) - return nil - } - sess = session.Must(session.NewSessionWithOptions(session.Options{ - SharedConfigState: session.SharedConfigEnable, - Config: aws.Config{ - Endpoint: awsEndpointUrl, - Region: ®ion, - }, - })) - } - return sqs.New(sess) + var sess *session.Session + if awsEndpointUrl == nil { + sess = session.Must(session.NewSessionWithOptions(session.Options{ + SharedConfigState: session.SharedConfigEnable, + })) + } else { + region, found := os.LookupEnv("AWS_REGION") + if !found { + fmt.Printf("No AWS Region configured, cannot connect to SQS provider at %s\n", + *awsEndpointUrl) + return nil + } + sess = session.Must(session.NewSessionWithOptions(session.Options{ + SharedConfigState: session.SharedConfigEnable, + Config: aws.Config{ + Endpoint: awsEndpointUrl, + Region: ®ion, + }, + })) + } + return sqs.New(sess) } // NewSqsSubscriber will create a new `Subscriber` to listen to // incoming api.Event from a SQS `queue`. func NewSqsSubscriber(eventsChannel chan<- protos.EventRequest, sqsUrl *string) *SqsSubscriber { - client := getSqsClient(sqsUrl) - if client == nil { - return nil - } - return &SqsSubscriber{ - logger: log.NewLog("SQS-Sub"), - client: client, - events: eventsChannel, - Timeout: DefaultVisibilityTimeout, - PollingInterval: DefaultPollingInterval, - } + client := getSqsClient(sqsUrl) + if client == nil { + return nil + } + return &SqsSubscriber{ + logger: log.NewLog("SQS-Sub"), + client: client, + events: eventsChannel, + Timeout: DefaultVisibilityTimeout, + PollingInterval: DefaultPollingInterval, + } } // SetLogLevel allows the SqsSubscriber to implement the log.Loggable interface func (s *SqsSubscriber) SetLogLevel(level log.LogLevel) { - s.logger.Level = level + s.logger.Level = level } // Subscribe runs until signaled on the Done channel and listens for incoming Events func (s *SqsSubscriber) Subscribe(topic string, done <-chan interface{}) { - queueUrl := GetQueueUrl(s.client, topic) - s.logger = log.NewLog(fmt.Sprintf("SQS-Sub{%s}", topic)) - s.logger.Info("SQS Subscriber started for queue: %s", queueUrl) + queueUrl := GetQueueUrl(s.client, topic) + s.logger = log.NewLog(fmt.Sprintf("SQS-Sub{%s}", topic)) + s.logger.Info("SQS Subscriber started for queue: %s", queueUrl) - timeout := int64(s.Timeout.Seconds()) - for { - select { - case <-done: - s.logger.Info("SQS Subscriber terminating") - return - default: - } - start := time.Now() - s.logger.Trace("Polling SQS at %v", start) - msgResult, err := s.client.ReceiveMessage(&sqs.ReceiveMessageInput{ - AttributeNames: []*string{ - aws.String(sqs.MessageSystemAttributeNameSentTimestamp), - }, - MessageAttributeNames: []*string{ - aws.String(sqs.QueueAttributeNameAll), - }, - QueueUrl: &queueUrl, - MaxNumberOfMessages: aws.Int64(10), - VisibilityTimeout: &timeout, - }) - if err == nil { - if len(msgResult.Messages) > 0 { - s.logger.Debug("Got %d messages", len(msgResult.Messages)) - } else { - s.logger.Trace("no messages in queue") - } - for _, msg := range msgResult.Messages { - s.logger.Trace("processing %v", msg.String()) - go s.ProcessMessage(msg, &queueUrl) - } - } else { - s.logger.Error(err.Error()) - } - timeLeft := s.PollingInterval - time.Since(start) - if timeLeft > 0 { - s.logger.Trace("sleeping for %v", timeLeft) - time.Sleep(timeLeft) - } - } + timeout := int64(s.Timeout.Seconds()) + for { + select { + case <-done: + s.logger.Info("SQS Subscriber terminating") + return + default: + } + start := time.Now() + s.logger.Trace("Polling SQS at %v", start) + msgResult, err := s.client.ReceiveMessage(&sqs.ReceiveMessageInput{ + AttributeNames: []*string{ + aws.String(sqs.MessageSystemAttributeNameSentTimestamp), + }, + MessageAttributeNames: []*string{ + aws.String(sqs.QueueAttributeNameAll), + }, + QueueUrl: &queueUrl, + MaxNumberOfMessages: aws.Int64(10), + VisibilityTimeout: &timeout, + }) + if err == nil { + if len(msgResult.Messages) > 0 { + s.logger.Debug("Got %d messages", len(msgResult.Messages)) + } else { + s.logger.Trace("no messages in queue") + } + for _, msg := range msgResult.Messages { + s.logger.Trace("processing %v", msg.String()) + go s.ProcessMessage(msg, &queueUrl) + } + } else { + s.logger.Error(err.Error()) + } + timeLeft := s.PollingInterval - time.Since(start) + if timeLeft > 0 { + s.logger.Trace("sleeping for %v", timeLeft) + time.Sleep(timeLeft) + } + } } func (s *SqsSubscriber) ProcessMessage(msg *sqs.Message, queueUrl *string) { - s.logger.Trace("Processing Message %v", msg.MessageId) + s.logger.Trace("Processing Message %v", msg.MessageId) - // The body of the message (the actual request) is mandatory. - if msg.Body == nil { - s.logger.Error("Message %v has no body", msg.MessageId) - // TODO: publish error to DLQ. - return - } - var request protos.EventRequest - err := proto.UnmarshalText(*msg.Body, &request) - if err != nil { - s.logger.Error("Message %v has invalid body: %s", msg.MessageId, err.Error()) - // TODO: publish error to DLQ. - return - } + // The body of the message (the actual request) is mandatory. + if msg.Body == nil { + s.logger.Error("Message %v has no body", msg.MessageId) + // TODO: publish error to DLQ. + return + } + var request protos.EventRequest + err := proto.UnmarshalText(*msg.Body, &request) + if err != nil { + s.logger.Error("Message %v has invalid body: %s", msg.MessageId, err.Error()) + // TODO: publish error to DLQ. + return + } - destId := request.Dest - if destId == "" { - errDetails := fmt.Sprintf("No Destination ID in %v", request.String()) - s.logger.Error(errDetails) - // TODO: publish error to DLQ. - return - } - // The Event ID and timestamp are optional and, if missing, will be generated here. - api.UpdateEvent(request.Event) - s.events <- request + destId := request.Dest + if destId == "" { + errDetails := fmt.Sprintf("No Destination ID in %v", request.String()) + s.logger.Error(errDetails) + // TODO: publish error to DLQ. + return + } + // The Event ID and timestamp are optional and, if missing, will be generated here. + api.UpdateEvent(request.Event) + s.events <- request - s.logger.Debug("Removing message %v from SQS", *msg.MessageId) - _, err = s.client.DeleteMessage(&sqs.DeleteMessageInput{ - QueueUrl: queueUrl, - ReceiptHandle: msg.ReceiptHandle, - }) - if err != nil { - // FIXME: add retries - errDetails := fmt.Sprintf("Failed to remove message %v from SQS", msg.MessageId) - s.logger.Error("%s: %v", errDetails, err) - // TODO: publish error to DLQ, should also retry removal here. - } - s.logger.Trace("Message %v removed", msg.MessageId) + s.logger.Debug("Removing message %v from SQS", *msg.MessageId) + _, err = s.client.DeleteMessage(&sqs.DeleteMessageInput{ + QueueUrl: queueUrl, + ReceiptHandle: msg.ReceiptHandle, + }) + if err != nil { + // FIXME: add retries + errDetails := fmt.Sprintf("Failed to remove message %v from SQS", msg.MessageId) + s.logger.Error("%s: %v", errDetails, err) + // TODO: publish error to DLQ, should also retry removal here. + } + s.logger.Trace("Message %v removed", msg.MessageId) } diff --git a/pubsub/sqs_sub_test.go b/pubsub/sqs_sub_test.go index 2b23a65..9b766cf 100644 --- a/pubsub/sqs_sub_test.go +++ b/pubsub/sqs_sub_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,68 +10,68 @@ package pubsub_test import ( - "time" + "time" - . "github.com/JiaYongfei/respect/gomega" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + . "github.com/JiaYongfei/respect/gomega" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" - log "github.com/massenz/slf4go/logging" + log "github.com/massenz/slf4go/logging" - "github.com/massenz/go-statemachine/api" - "github.com/massenz/go-statemachine/pubsub" - protos "github.com/massenz/statemachine-proto/golang/api" + "github.com/massenz/go-statemachine/api" + "github.com/massenz/go-statemachine/pubsub" + protos "github.com/massenz/statemachine-proto/golang/api" ) var _ = Describe("SQS Subscriber", func() { - Context("when correctly initialized", func() { - var ( - testSubscriber *pubsub.SqsSubscriber - eventsCh chan protos.EventRequest - ) - BeforeEach(func() { - eventsCh = make(chan protos.EventRequest) - testSubscriber = pubsub.NewSqsSubscriber(eventsCh, &sqsUrl) - Expect(testSubscriber).ToNot(BeNil()) - // Set to DEBUG when diagnosing failing tests - testSubscriber.SetLogLevel(log.NONE) - // Make it exit much sooner in tests - d, _ := time.ParseDuration("200msec") - testSubscriber.PollingInterval = d - }) - It("receives events", func() { - msg := protos.EventRequest{ - Event: api.NewEvent("test-event"), - Dest: "some-fsm", - } - msg.Event.EventId = "feed-beef" - msg.Event.Originator = "test-subscriber" - Expect(postSqsMessage(getQueueName(eventsQueue), &msg)).Should(Succeed()) - done := make(chan interface{}) - doneTesting := make(chan interface{}) - go func() { - defer close(done) - testSubscriber.Subscribe(getQueueName(eventsQueue), doneTesting) - }() + Context("when correctly initialized", func() { + var ( + testSubscriber *pubsub.SqsSubscriber + eventsCh chan protos.EventRequest + ) + BeforeEach(func() { + eventsCh = make(chan protos.EventRequest) + testSubscriber = pubsub.NewSqsSubscriber(eventsCh, &sqsUrl) + Expect(testSubscriber).ToNot(BeNil()) + // Set to DEBUG when diagnosing failing tests + testSubscriber.SetLogLevel(log.NONE) + // Make it exit much sooner in tests + d, _ := time.ParseDuration("200msec") + testSubscriber.PollingInterval = d + }) + It("receives events", func() { + msg := protos.EventRequest{ + Event: api.NewEvent("test-event"), + Dest: "some-fsm", + } + msg.Event.EventId = "feed-beef" + msg.Event.Originator = "test-subscriber" + Expect(postSqsMessage(getQueueName(eventsQueue), &msg)).Should(Succeed()) + done := make(chan interface{}) + doneTesting := make(chan interface{}) + go func() { + defer close(done) + testSubscriber.Subscribe(getQueueName(eventsQueue), doneTesting) + }() - select { - case req := <-eventsCh: - testLog.Debug("Received Event -- Timestamp: %v", req.Event.Timestamp) - // We null the timestamp as we don't want to compare that with Respect - msg.Event.Timestamp = nil - req.Event.Timestamp = nil - Expect(req.Event).To(Respect(msg.Event)) - close(doneTesting) - case <-time.After(timeout): - Fail("timed out waiting to receive a message") - } + select { + case req := <-eventsCh: + testLog.Debug("Received Event -- Timestamp: %v", req.Event.Timestamp) + // We null the timestamp as we don't want to compare that with Respect + msg.Event.Timestamp = nil + req.Event.Timestamp = nil + Expect(req.Event).To(Respect(msg.Event)) + close(doneTesting) + case <-time.After(timeout): + Fail("timed out waiting to receive a message") + } - select { - case <-done: - Succeed() - case <-time.After(timeout): - Fail("timed out waiting for the subscriber to exit") - } - }) - }) + select { + case <-done: + Succeed() + case <-time.After(timeout): + Fail("timed out waiting for the subscriber to exit") + } + }) + }) }) diff --git a/pubsub/types.go b/pubsub/types.go index f983eb0..da69ecb 100644 --- a/pubsub/types.go +++ b/pubsub/types.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,21 +10,21 @@ package pubsub import ( - "github.com/aws/aws-sdk-go/service/sqs" - "github.com/massenz/go-statemachine/storage" - log "github.com/massenz/slf4go/logging" - protos "github.com/massenz/statemachine-proto/golang/api" - "time" + "github.com/aws/aws-sdk-go/service/sqs" + "github.com/massenz/go-statemachine/storage" + log "github.com/massenz/slf4go/logging" + protos "github.com/massenz/statemachine-proto/golang/api" + "time" ) const ( - // DefaultPollingInterval between SQS polling attempts. - DefaultPollingInterval = 5 * time.Second + // DefaultPollingInterval between SQS polling attempts. + DefaultPollingInterval = 5 * time.Second - // DefaultVisibilityTimeout sets how long SQS will wait for the subscriber to remove the - // message from the queue. - // See: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html - DefaultVisibilityTimeout = 5 * time.Second + // DefaultVisibilityTimeout sets how long SQS will wait for the subscriber to remove the + // message from the queue. + // See: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html + DefaultVisibilityTimeout = 5 * time.Second ) // An EventsListener will process `EventRequests` in a separate goroutine. @@ -41,38 +32,36 @@ const ( // The messages are polled from the `events` channel, and if any error is encountered, // error messages are posted on a `notifications` channel for further processing upstream. type EventsListener struct { - logger *log.Log - events <-chan protos.EventRequest - notifications chan<- protos.EventResponse - store storage.StoreManager + logger *log.Log + events <-chan protos.EventRequest + notifications chan<- protos.EventResponse + store storage.StoreManager } // ListenerOptions are used to configure an EventsListener at creation and are used // to decouple the internals of the listener from its exposed configuration. type ListenerOptions struct { - EventsChannel <-chan protos.EventRequest - NotificationsChannel chan<- protos.EventResponse - StatemachinesStore storage.StoreManager - ListenersPoolSize int8 + EventsChannel <-chan protos.EventRequest + NotificationsChannel chan<- protos.EventResponse + StatemachinesStore storage.StoreManager + ListenersPoolSize int8 } // SqsPublisher is a wrapper around the AWS SQS client, -// and is used to publish messages to a DLQ when an error is encountered. -// -// Error events are polled from the `errors` channel, and published to the SQS queue. +// and is used to publish messages to provided queues when outcomes are encountered. type SqsPublisher struct { - logger *log.Log - client *sqs.SQS - errors <-chan protos.EventResponse + logger *log.Log + client *sqs.SQS + notifications <-chan protos.EventResponse } // SqsSubscriber is a wrapper around the AWS SQS client, and is used to subscribe to Events. // The subscriber will poll the queue for new messages, // and will post them on the `events` channel from where an `EventsListener` will process them. type SqsSubscriber struct { - logger *log.Log - client *sqs.SQS - events chan<- protos.EventRequest - Timeout time.Duration - PollingInterval time.Duration + logger *log.Log + client *sqs.SQS + events chan<- protos.EventRequest + Timeout time.Duration + PollingInterval time.Duration } diff --git a/server/configuration_handlers.go b/server/configuration_handlers.go index acdee09..a28423e 100644 --- a/server/configuration_handlers.go +++ b/server/configuration_handlers.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,79 +10,79 @@ package server import ( - "encoding/json" - "fmt" - "github.com/gorilla/mux" - . "github.com/massenz/go-statemachine/api" - "github.com/massenz/statemachine-proto/golang/api" - pj "google.golang.org/protobuf/encoding/protojson" - "net/http" + "encoding/json" + "fmt" + "github.com/gorilla/mux" + . "github.com/massenz/go-statemachine/api" + "github.com/massenz/statemachine-proto/golang/api" + pj "google.golang.org/protobuf/encoding/protojson" + "net/http" ) func CreateConfigurationHandler(w http.ResponseWriter, r *http.Request) { - defer trace(r.RequestURI)() - defaultContent(w) + defer trace(r.RequestURI)() + defaultContent(w) - var config api.Configuration - err := json.NewDecoder(r.Body).Decode(&config) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if config.Version == "" { - config.Version = "v1" - } - logger.Debug("Creating new configuration with Version ID: %s", GetVersionId(&config)) + var config api.Configuration + err := json.NewDecoder(r.Body).Decode(&config) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if config.Version == "" { + config.Version = "v1" + } + logger.Debug("Creating new configuration with Version ID: %s", GetVersionId(&config)) - // TODO: Check this configuration does not already exist. + // TODO: Check this configuration does not already exist. - err = CheckValid(&config) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - logger.Debug("Configuration is valid (starting state: %s)", config.StartingState) + err = CheckValid(&config) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + logger.Debug("Configuration is valid (starting state: %s)", config.StartingState) - err = storeManager.PutConfig(&config) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - logger.Debug("Configuration stored: %s", GetVersionId(&config)) + err = storeManager.PutConfig(&config) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + logger.Debug("Configuration stored: %s", GetVersionId(&config)) - w.Header().Add("Location", ConfigurationsEndpoint+"/"+GetVersionId(&config)) - w.WriteHeader(http.StatusCreated) - err = json.NewEncoder(w).Encode(&config) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - return + w.Header().Add("Location", ConfigurationsEndpoint+"/"+GetVersionId(&config)) + w.WriteHeader(http.StatusCreated) + err = json.NewEncoder(w).Encode(&config) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + return } func GetConfigurationHandler(w http.ResponseWriter, r *http.Request) { - defer trace(r.RequestURI)() - defaultContent(w) + defer trace(r.RequestURI)() + defaultContent(w) - vars := mux.Vars(r) - if vars == nil { - logger.Error("Unexpected missing path parameter in Request URI: %s", - r.RequestURI) - http.Error(w, UnexpectedError.Error(), http.StatusInternalServerError) - return - } + vars := mux.Vars(r) + if vars == nil { + logger.Error("Unexpected missing path parameter in Request URI: %s", + r.RequestURI) + http.Error(w, UnexpectedError.Error(), http.StatusInternalServerError) + return + } - cfgId := vars["cfg_id"] - config, ok := storeManager.GetConfig(cfgId) - if !ok { - http.Error(w, fmt.Sprintf("Configuration [%s] not found", cfgId), http.StatusNotFound) - return - } - resp, err := pj.Marshal(config) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Write(resp) - return + cfgId := vars["cfg_id"] + config, ok := storeManager.GetConfig(cfgId) + if !ok { + http.Error(w, fmt.Sprintf("Configuration [%s] not found", cfgId), http.StatusNotFound) + return + } + resp, err := pj.Marshal(config) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Write(resp) + return } diff --git a/server/configuration_handlers_test.go b/server/configuration_handlers_test.go index a780e8a..f520622 100644 --- a/server/configuration_handlers_test.go +++ b/server/configuration_handlers_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,99 +10,99 @@ package server_test import ( - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" - "bytes" - "encoding/json" - log "github.com/massenz/slf4go/logging" - "io" - "io/ioutil" - "net/http" - "net/http/httptest" - "strings" + "bytes" + "encoding/json" + log "github.com/massenz/slf4go/logging" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" - . "github.com/massenz/go-statemachine/api" - "github.com/massenz/go-statemachine/server" - "github.com/massenz/go-statemachine/storage" - "github.com/massenz/statemachine-proto/golang/api" + . "github.com/massenz/go-statemachine/api" + "github.com/massenz/go-statemachine/server" + "github.com/massenz/go-statemachine/storage" + "github.com/massenz/statemachine-proto/golang/api" ) var _ = Describe("Configuration Handlers", func() { - var ( - req *http.Request - writer *httptest.ResponseRecorder - store storage.StoreManager + var ( + req *http.Request + writer *httptest.ResponseRecorder + store storage.StoreManager - // NOTE: we are using the Router here as we need to correctly also parse - // the URI for path args (just using the handler will not do that) - // The `router` can be safely set for all the test contexts, once and for all. - router = server.NewRouter() - ) - // Disabling verbose logging, as it pollutes test output; - // set it back to DEBUG when tests fail, and you need to - // diagnose the failure. - server.SetLogLevel(log.NONE) + // NOTE: we are using the Router here as we need to correctly also parse + // the URI for path args (just using the handler will not do that) + // The `router` can be safely set for all the test contexts, once and for all. + router = server.NewRouter() + ) + // Disabling verbose logging, as it pollutes test output; + // set it back to DEBUG when tests fail, and you need to + // diagnose the failure. + server.SetLogLevel(log.NONE) - Context("when creating configurations", func() { - BeforeEach(func() { - writer = httptest.NewRecorder() - store = storage.NewInMemoryStore() - store.SetLogLevel(log.NONE) - server.SetStore(store) - }) - Context("with a valid JSON", func() { - BeforeEach(func() { - configJson, err := ioutil.ReadFile("../data/orders.json") - Expect(err).ToNot(HaveOccurred()) - body := bytes.NewReader(configJson) - req = httptest.NewRequest(http.MethodPost, - strings.Join([]string{server.ApiPrefix, server.ConfigurationsEndpoint}, "/"), body) - }) + Context("when creating configurations", func() { + BeforeEach(func() { + writer = httptest.NewRecorder() + store = storage.NewInMemoryStore() + store.SetLogLevel(log.NONE) + server.SetStore(store) + }) + Context("with a valid JSON", func() { + BeforeEach(func() { + configJson, err := ioutil.ReadFile("../data/orders.json") + Expect(err).ToNot(HaveOccurred()) + body := bytes.NewReader(configJson) + req = httptest.NewRequest(http.MethodPost, + strings.Join([]string{server.ApiPrefix, server.ConfigurationsEndpoint}, "/"), body) + }) - It("should succeed", func() { - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusCreated)) - location := writer.Header().Get("Location") - Expect(strings.HasSuffix(location, "/test.orders:v1")).To(BeTrue()) + It("should succeed", func() { + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusCreated)) + location := writer.Header().Get("Location") + Expect(strings.HasSuffix(location, "/test.orders:v1")).To(BeTrue()) - response := api.Configuration{} - Expect(json.Unmarshal(writer.Body.Bytes(), &response)).ToNot(HaveOccurred()) - Expect(response.Name).To(Equal("test.orders")) - Expect(response.States).To(Equal([]string{ - "start", - "pending", - "shipping", - "delivered", - "complete", - "closed", - })) - Expect(response.StartingState).To(Equal("start")) - }) - It("should fill the cache", func() { - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusCreated)) - _, found := store.GetConfig("test.orders:v1") - Expect(found).To(BeTrue()) - }) - }) + response := api.Configuration{} + Expect(json.Unmarshal(writer.Body.Bytes(), &response)).ToNot(HaveOccurred()) + Expect(response.Name).To(Equal("test.orders")) + Expect(response.States).To(Equal([]string{ + "start", + "pending", + "shipping", + "delivered", + "complete", + "closed", + })) + Expect(response.StartingState).To(Equal("start")) + }) + It("should fill the cache", func() { + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusCreated)) + _, found := store.GetConfig("test.orders:v1") + Expect(found).To(BeTrue()) + }) + }) - Context("with an invalid JSON", func() { - var body io.Reader - BeforeEach(func() { - req = httptest.NewRequest(http.MethodPost, - strings.Join([]string{server.ApiPrefix, server.ConfigurationsEndpoint}, "/"), body) - }) - It("without name, states or transitions, will fail", func() { - body = strings.NewReader(`{ + Context("with an invalid JSON", func() { + var body io.Reader + BeforeEach(func() { + req = httptest.NewRequest(http.MethodPost, + strings.Join([]string{server.ApiPrefix, server.ConfigurationsEndpoint}, "/"), body) + }) + It("without name, states or transitions, will fail", func() { + body = strings.NewReader(`{ "version": "v1", "starting_state": "source" }`) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusBadRequest)) - }) - It("without states, will fail", func() { - body = strings.NewReader(`{ + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusBadRequest)) + }) + It("without states, will fail", func() { + body = strings.NewReader(`{ "name": "fake", "version": "v1", "starting_state": "source" @@ -120,62 +111,62 @@ var _ = Describe("Configuration Handlers", func() { {"from": "tested", "to": "binary", "event": "build"} ], }`) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusBadRequest)) - }) - }) - }) - Context("when retrieving configurations", func() { - var spaceship = api.Configuration{ - Name: "spaceship", - Version: "v1", - StartingState: "earth", - States: []string{"earth", "orbit", "mars"}, - Transitions: []*api.Transition{ - {From: "earth", To: "orbit", Event: "launch"}, - {From: "orbit", To: "mars", Event: "land"}, - }, - } - var cfgId string - BeforeEach(func() { - writer = httptest.NewRecorder() - // We need an empty, clean store for each test to avoid cross-polluting it. - store = storage.NewInMemoryStore() - store.SetLogLevel(log.NONE) - server.SetStore(store) + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusBadRequest)) + }) + }) + }) + Context("when retrieving configurations", func() { + var spaceship = api.Configuration{ + Name: "spaceship", + Version: "v1", + StartingState: "earth", + States: []string{"earth", "orbit", "mars"}, + Transitions: []*api.Transition{ + {From: "earth", To: "orbit", Event: "launch"}, + {From: "orbit", To: "mars", Event: "land"}, + }, + } + var cfgId string + BeforeEach(func() { + writer = httptest.NewRecorder() + // We need an empty, clean store for each test to avoid cross-polluting it. + store = storage.NewInMemoryStore() + store.SetLogLevel(log.NONE) + server.SetStore(store) - Expect(store.PutConfig(&spaceship)).ToNot(HaveOccurred()) - cfgId = GetVersionId(&spaceship) - }) - It("with a valid ID should succeed", func() { - endpoint := strings.Join([]string{server.ApiPrefix, server.ConfigurationsEndpoint, - cfgId}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - var result api.Configuration - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusOK)) - Expect(json.NewDecoder(writer.Body).Decode(&result)).ToNot(HaveOccurred()) - Expect(GetVersionId(&result)).To(Equal(cfgId)) - Expect(result.States).To(Equal(spaceship.States)) - Expect(len(result.Transitions)).To(Equal(len(spaceship.Transitions))) - for n, t := range result.Transitions { - Expect(t.From).To(Equal(spaceship.Transitions[n].From)) - Expect(t.To).To(Equal(spaceship.Transitions[n].To)) - Expect(t.Event).To(Equal(spaceship.Transitions[n].Event)) - } - }) - It("with an invalid ID, it will return Not Found", func() { - endpoint := strings.Join([]string{server.ApiPrefix, server.ConfigurationsEndpoint, - "fake:v3"}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusNotFound)) - }) - It("without ID, it will fail with a NOT ALLOWED error", func() { - endpoint := strings.Join([]string{server.ApiPrefix, server.ConfigurationsEndpoint}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusMethodNotAllowed)) - }) - }) + Expect(store.PutConfig(&spaceship)).ToNot(HaveOccurred()) + cfgId = GetVersionId(&spaceship) + }) + It("with a valid ID should succeed", func() { + endpoint := strings.Join([]string{server.ApiPrefix, server.ConfigurationsEndpoint, + cfgId}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + var result api.Configuration + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusOK)) + Expect(json.NewDecoder(writer.Body).Decode(&result)).ToNot(HaveOccurred()) + Expect(GetVersionId(&result)).To(Equal(cfgId)) + Expect(result.States).To(Equal(spaceship.States)) + Expect(len(result.Transitions)).To(Equal(len(spaceship.Transitions))) + for n, t := range result.Transitions { + Expect(t.From).To(Equal(spaceship.Transitions[n].From)) + Expect(t.To).To(Equal(spaceship.Transitions[n].To)) + Expect(t.Event).To(Equal(spaceship.Transitions[n].Event)) + } + }) + It("with an invalid ID, it will return Not Found", func() { + endpoint := strings.Join([]string{server.ApiPrefix, server.ConfigurationsEndpoint, + "fake:v3"}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusNotFound)) + }) + It("without ID, it will fail with a NOT ALLOWED error", func() { + endpoint := strings.Join([]string{server.ApiPrefix, server.ConfigurationsEndpoint}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusMethodNotAllowed)) + }) + }) }) diff --git a/server/event_handlers.go b/server/event_handlers.go index 0838505..a44b1f0 100644 --- a/server/event_handlers.go +++ b/server/event_handlers.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,53 +10,53 @@ package server import ( - "encoding/json" - "fmt" - "github.com/gorilla/mux" - "net/http" + "encoding/json" + "fmt" + "github.com/gorilla/mux" + "net/http" ) func GetEventHandler(w http.ResponseWriter, r *http.Request) { - defer trace(r.RequestURI)() - defaultContent(w) - - // We don't really need to check for the presence of the parameter, - // as the Mux router takes care of all the error handling for us. - vars := mux.Vars(r) - cfgName := vars["cfg_name"] - evtId := vars["evt_id"] - logger.Debug("Looking up Event: %s#%s", cfgName, evtId) - - event, ok := storeManager.GetEvent(evtId, cfgName) - if !ok { - http.Error(w, fmt.Sprintf("Event [%s] not found", evtId), http.StatusNotFound) - return - } - logger.Debug("Found Event: %s", event.String()) - - err := json.NewEncoder(w).Encode(&EventResponse{ID: evtId, Event: event}) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } + defer trace(r.RequestURI)() + defaultContent(w) + + // We don't really need to check for the presence of the parameter, + // as the Mux router takes care of all the error handling for us. + vars := mux.Vars(r) + cfgName := vars["cfg_name"] + evtId := vars["evt_id"] + logger.Debug("Looking up Event: %s#%s", cfgName, evtId) + + event, ok := storeManager.GetEvent(evtId, cfgName) + if !ok { + http.Error(w, fmt.Sprintf("Event [%s] not found", evtId), http.StatusNotFound) + return + } + logger.Debug("Found Event: %s", event.String()) + + err := json.NewEncoder(w).Encode(&EventResponse{ID: evtId, Event: event}) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } func GetOutcomeHandler(w http.ResponseWriter, r *http.Request) { - defer trace(r.RequestURI)() - defaultContent(w) - - vars := mux.Vars(r) - cfgName := vars["cfg_name"] - evtId := vars["evt_id"] - logger.Debug("Looking up Outcome for Event: %s#%s", cfgName, evtId) - - outcome, ok := storeManager.GetOutcomeForEvent(evtId, cfgName) - if !ok { - http.Error(w, fmt.Sprintf("Outcome for Event [%s] not found", evtId), http.StatusNotFound) - return - } - logger.Debug("Found Event Outcome: %s", outcome.String()) - err := json.NewEncoder(w).Encode(MakeOutcomeResponse(outcome)) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } + defer trace(r.RequestURI)() + defaultContent(w) + + vars := mux.Vars(r) + cfgName := vars["cfg_name"] + evtId := vars["evt_id"] + logger.Debug("Looking up Outcome for Event: %s#%s", cfgName, evtId) + + outcome, ok := storeManager.GetOutcomeForEvent(evtId, cfgName) + if !ok { + http.Error(w, fmt.Sprintf("Outcome for Event [%s] not found", evtId), http.StatusNotFound) + return + } + logger.Debug("Found Event Outcome: %s", outcome.String()) + err := json.NewEncoder(w).Encode(MakeOutcomeResponse(outcome)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } diff --git a/server/event_handlers_test.go b/server/event_handlers_test.go index ea0cf18..f0d6c11 100644 --- a/server/event_handlers_test.go +++ b/server/event_handlers_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,164 +10,164 @@ package server_test import ( - . "github.com/JiaYongfei/respect/gomega" - "github.com/google/uuid" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - - "encoding/json" - log "github.com/massenz/slf4go/logging" - "net/http" - "net/http/httptest" - "strings" - - . "github.com/massenz/go-statemachine/api" - "github.com/massenz/go-statemachine/server" - "github.com/massenz/go-statemachine/storage" - - protos "github.com/massenz/statemachine-proto/golang/api" + . "github.com/JiaYongfei/respect/gomega" + "github.com/google/uuid" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + + "encoding/json" + log "github.com/massenz/slf4go/logging" + "net/http" + "net/http/httptest" + "strings" + + . "github.com/massenz/go-statemachine/api" + "github.com/massenz/go-statemachine/server" + "github.com/massenz/go-statemachine/storage" + + protos "github.com/massenz/statemachine-proto/golang/api" ) var _ = Describe("Event Handlers", func() { - var ( - req *http.Request - writer *httptest.ResponseRecorder - store storage.StoreManager - - // NOTE: we are using the Router here as we need to correctly also parse - // the URI for path args (just using the router will not do that) - // The `router` can be safely set for all the test contexts, once and for all. - router = server.NewRouter() - ) - // Disabling verbose logging, as it pollutes test output; - // set it back to DEBUG when tests fail, and you need to - // diagnose the failure. - server.SetLogLevel(log.NONE) - - Context("when retrieving an Event", func() { - var id string - var evt *protos.Event - - BeforeEach(func() { - store = storage.NewInMemoryStore() - store.SetLogLevel(log.NONE) - server.SetStore(store) - - writer = httptest.NewRecorder() - evt = NewEvent("test") - id = evt.EventId - Expect(store.PutEvent(evt, "test-cfg", storage.NeverExpire)).ToNot(HaveOccurred()) - }) - It("can be retrieved with a valid ID", func() { - endpoint := strings.Join([]string{server.ApiPrefix, - server.EventsEndpoint, "test-cfg", id}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusOK)) - - var result server.EventResponse - Expect(json.NewDecoder(writer.Body).Decode(&result)).ToNot(HaveOccurred()) - Expect(result.ID).To(Equal(id)) - Expect(result.Event).To(Respect(evt)) - }) - It("with an invalid ID will return Not Found", func() { - endpoint := strings.Join([]string{server.ApiPrefix, - server.EventsEndpoint, "test-cfg", uuid.NewString()}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusNotFound)) - }) - It("with a missing Config will (eventually) return Not Found", func() { - endpoint := strings.Join([]string{server.ApiPrefix, - server.EventsEndpoint, "", "12345"}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - router.ServeHTTP(writer, req) - // Note: this is done by the router, automatically, removing the redundant slash - Expect(writer.Code).To(Equal(http.StatusMovedPermanently)) - newLoc := strings.Join([]string{server.ApiPrefix, - server.EventsEndpoint, "12345"}, "/") - Expect(writer.Header().Get("Location")).To(Equal(newLoc)) - - req = httptest.NewRequest(http.MethodGet, newLoc, nil) - writer = httptest.NewRecorder() - router.ServeHTTP(writer, req) - // Note: this is done by the router, automatically, removing the redundant slash - Expect(writer.Code).To(Equal(http.StatusNotFound)) - }) - It("with gibberish data will still fail gracefully", func() { - endpoint := strings.Join([]string{server.ApiPrefix, - server.EventsEndpoint, "fake", id}, "/") - - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusNotFound)) - }) - }) - - Context("when retrieving an Event Outcome", func() { - var id string - var outcome *protos.EventOutcome - var cfgName = "test-cfg" - - BeforeEach(func() { - store = storage.NewInMemoryStore() - store.SetLogLevel(log.NONE) - server.SetStore(store) - - writer = httptest.NewRecorder() - id = uuid.NewString() - outcome = &protos.EventOutcome{ - Code: protos.EventOutcome_Ok, - Dest: "fake-sm", - Details: "something happened", - } - Expect(store.AddEventOutcome(id, cfgName, outcome, - storage.NeverExpire)).ToNot(HaveOccurred()) - }) - It("can be retrieved with a valid ID", func() { - endpoint := strings.Join([]string{server.ApiPrefix, - server.EventsEndpoint, server.EventsOutcome, cfgName, id}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusOK)) - - var result server.OutcomeResponse - Expect(json.NewDecoder(writer.Body).Decode(&result)).ToNot(HaveOccurred()) - Expect(result.StatusCode).To(Equal(outcome.Code.String())) - Expect(result.Message).To(Equal(outcome.Details)) - Expect(result.Destination).To(Equal(outcome.Dest)) - }) - It("with an invalid ID will return Not Found", func() { - endpoint := strings.Join([]string{server.ApiPrefix, - server.EventsEndpoint, server.EventsOutcome, cfgName, uuid.NewString()}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusNotFound)) - }) - It("with a missing Config will (eventually) return Not Found", func() { - endpoint := strings.Join([]string{server.ApiPrefix, - server.EventsEndpoint, server.EventsOutcome, "", "12345"}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - router.ServeHTTP(writer, req) - // Note: this is done by the router, automatically, removing the redundant slash - Expect(writer.Code).To(Equal(http.StatusMovedPermanently)) - newLoc := strings.Join([]string{server.ApiPrefix, - server.EventsEndpoint, server.EventsOutcome, "12345"}, "/") - Expect(writer.Header().Get("Location")).To(Equal(newLoc)) - - req = httptest.NewRequest(http.MethodGet, newLoc, nil) - writer = httptest.NewRecorder() - router.ServeHTTP(writer, req) - // Note: this is done by the router, automatically, removing the redundant slash - Expect(writer.Code).To(Equal(http.StatusNotFound)) - }) - It("with gibberish data will still fail gracefully", func() { - endpoint := strings.Join([]string{server.ApiPrefix, - server.EventsEndpoint, server.EventsOutcome, "fake", id}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusNotFound)) - }) - }) + var ( + req *http.Request + writer *httptest.ResponseRecorder + store storage.StoreManager + + // NOTE: we are using the Router here as we need to correctly also parse + // the URI for path args (just using the router will not do that) + // The `router` can be safely set for all the test contexts, once and for all. + router = server.NewRouter() + ) + // Disabling verbose logging, as it pollutes test output; + // set it back to DEBUG when tests fail, and you need to + // diagnose the failure. + server.SetLogLevel(log.NONE) + + Context("when retrieving an Event", func() { + var id string + var evt *protos.Event + + BeforeEach(func() { + store = storage.NewInMemoryStore() + store.SetLogLevel(log.NONE) + server.SetStore(store) + + writer = httptest.NewRecorder() + evt = NewEvent("test") + id = evt.EventId + Expect(store.PutEvent(evt, "test-cfg", storage.NeverExpire)).ToNot(HaveOccurred()) + }) + It("can be retrieved with a valid ID", func() { + endpoint := strings.Join([]string{server.ApiPrefix, + server.EventsEndpoint, "test-cfg", id}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusOK)) + + var result server.EventResponse + Expect(json.NewDecoder(writer.Body).Decode(&result)).ToNot(HaveOccurred()) + Expect(result.ID).To(Equal(id)) + Expect(result.Event).To(Respect(evt)) + }) + It("with an invalid ID will return Not Found", func() { + endpoint := strings.Join([]string{server.ApiPrefix, + server.EventsEndpoint, "test-cfg", uuid.NewString()}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusNotFound)) + }) + It("with a missing Config will (eventually) return Not Found", func() { + endpoint := strings.Join([]string{server.ApiPrefix, + server.EventsEndpoint, "", "12345"}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + router.ServeHTTP(writer, req) + // Note: this is done by the router, automatically, removing the redundant slash + Expect(writer.Code).To(Equal(http.StatusMovedPermanently)) + newLoc := strings.Join([]string{server.ApiPrefix, + server.EventsEndpoint, "12345"}, "/") + Expect(writer.Header().Get("Location")).To(Equal(newLoc)) + + req = httptest.NewRequest(http.MethodGet, newLoc, nil) + writer = httptest.NewRecorder() + router.ServeHTTP(writer, req) + // Note: this is done by the router, automatically, removing the redundant slash + Expect(writer.Code).To(Equal(http.StatusNotFound)) + }) + It("with gibberish data will still fail gracefully", func() { + endpoint := strings.Join([]string{server.ApiPrefix, + server.EventsEndpoint, "fake", id}, "/") + + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Context("when retrieving an Event Outcome", func() { + var id string + var outcome *protos.EventOutcome + var cfgName = "test-cfg" + + BeforeEach(func() { + store = storage.NewInMemoryStore() + store.SetLogLevel(log.NONE) + server.SetStore(store) + + writer = httptest.NewRecorder() + id = uuid.NewString() + outcome = &protos.EventOutcome{ + Code: protos.EventOutcome_Ok, + Dest: "fake-sm", + Details: "something happened", + } + Expect(store.AddEventOutcome(id, cfgName, outcome, + storage.NeverExpire)).ToNot(HaveOccurred()) + }) + It("can be retrieved with a valid ID", func() { + endpoint := strings.Join([]string{server.ApiPrefix, + server.EventsEndpoint, server.EventsOutcome, cfgName, id}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusOK)) + + var result server.OutcomeResponse + Expect(json.NewDecoder(writer.Body).Decode(&result)).ToNot(HaveOccurred()) + Expect(result.StatusCode).To(Equal(outcome.Code.String())) + Expect(result.Message).To(Equal(outcome.Details)) + Expect(result.Destination).To(Equal(outcome.Dest)) + }) + It("with an invalid ID will return Not Found", func() { + endpoint := strings.Join([]string{server.ApiPrefix, + server.EventsEndpoint, server.EventsOutcome, cfgName, uuid.NewString()}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusNotFound)) + }) + It("with a missing Config will (eventually) return Not Found", func() { + endpoint := strings.Join([]string{server.ApiPrefix, + server.EventsEndpoint, server.EventsOutcome, "", "12345"}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + router.ServeHTTP(writer, req) + // Note: this is done by the router, automatically, removing the redundant slash + Expect(writer.Code).To(Equal(http.StatusMovedPermanently)) + newLoc := strings.Join([]string{server.ApiPrefix, + server.EventsEndpoint, server.EventsOutcome, "12345"}, "/") + Expect(writer.Header().Get("Location")).To(Equal(newLoc)) + + req = httptest.NewRequest(http.MethodGet, newLoc, nil) + writer = httptest.NewRecorder() + router.ServeHTTP(writer, req) + // Note: this is done by the router, automatically, removing the redundant slash + Expect(writer.Code).To(Equal(http.StatusNotFound)) + }) + It("with gibberish data will still fail gracefully", func() { + endpoint := strings.Join([]string{server.ApiPrefix, + server.EventsEndpoint, server.EventsOutcome, "fake", id}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusNotFound)) + }) + }) }) diff --git a/server/health_handler.go b/server/health_handler.go index 306cabb..61aa7b2 100644 --- a/server/health_handler.go +++ b/server/health_handler.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,49 +10,49 @@ package server import ( - "encoding/json" - "fmt" - "net/http" + "encoding/json" + "fmt" + "net/http" ) // NOTE: We make the handlers "exportable" so they can be tested, do NOT call directly. type HealthResponse struct { - Status string `json:"status"` - Release string `json:"release"` + Status string `json:"status"` + Release string `json:"release"` } func HealthHandler(w http.ResponseWriter, r *http.Request) { - // Standard preamble for all handlers, sets tracing (if enabled) and default content type. - defer trace(r.RequestURI)() - defaultContent(w) + // Standard preamble for all handlers, sets tracing (if enabled) and default content type. + defer trace(r.RequestURI)() + defaultContent(w) - var response MessageResponse - res := HealthResponse{ - Status: "OK", - Release: Release, - } - var err error - if storeManager == nil { - err = fmt.Errorf("store manager is not initialized") - } else { - err = storeManager.Health() - } - if err != nil { - logger.Error("Health check failed: %s", err) - res.Status = "ERROR" - response = MessageResponse{ - Msg: res, - Error: fmt.Sprintf("error connecting to storage: %s", err), - } - } else { - response = MessageResponse{ - Msg: res, - } - } - err = json.NewEncoder(w).Encode(response) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + var response MessageResponse + res := HealthResponse{ + Status: "OK", + Release: Release, + } + var err error + if storeManager == nil { + err = fmt.Errorf("store manager is not initialized") + } else { + err = storeManager.Health() + } + if err != nil { + logger.Error("Health check failed: %s", err) + res.Status = "ERROR" + response = MessageResponse{ + Msg: res, + Error: fmt.Sprintf("error connecting to storage: %s", err), + } + } else { + response = MessageResponse{ + Msg: res, + } + } + err = json.NewEncoder(w).Encode(response) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } } diff --git a/server/http_server.go b/server/http_server.go index 75bb063..dc9e678 100644 --- a/server/http_server.go +++ b/server/http_server.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,88 +10,88 @@ package server import ( - "github.com/massenz/go-statemachine/storage" - log "github.com/massenz/slf4go/logging" - "net/http" - "strings" - "time" + "github.com/massenz/go-statemachine/storage" + log "github.com/massenz/slf4go/logging" + "net/http" + "strings" + "time" - "github.com/gorilla/mux" + "github.com/gorilla/mux" ) const ( - ApiPrefix = "/api/v1" - HealthEndpoint = "/health" - ConfigurationsEndpoint = "configurations" - StatemachinesEndpoint = "statemachines" - EventsEndpoint = "events" - EventsOutcome = "outcome" + ApiPrefix = "/api/v1" + HealthEndpoint = "/health" + ConfigurationsEndpoint = "configurations" + StatemachinesEndpoint = "statemachines" + EventsEndpoint = "events" + EventsOutcome = "outcome" ) var ( - // Release carries the version of the binary, as set by the build script - // See: https://blog.alexellis.io/inject-build-time-vars-golang/ - Release string + // Release carries the version of the binary, as set by the build script + // See: https://blog.alexellis.io/inject-build-time-vars-golang/ + Release string - shouldTrace bool - logger = log.NewLog("server") - storeManager storage.StoreManager + shouldTrace bool + logger = log.NewLog("server") + storeManager storage.StoreManager ) func trace(endpoint string) func() { - if !shouldTrace { - return func() {} - } - start := time.Now() - logger.Trace("Handling: [%s]\n", endpoint) - return func() { logger.Trace("%s took %s\n", endpoint, time.Since(start)) } + if !shouldTrace { + return func() {} + } + start := time.Now() + logger.Trace("Handling: [%s]\n", endpoint) + return func() { logger.Trace("%s took %s\n", endpoint, time.Since(start)) } } func defaultContent(w http.ResponseWriter) { - w.Header().Add(ContentType, ApplicationJson) + w.Header().Add(ContentType, ApplicationJson) } func EnableTracing() { - shouldTrace = true - logger.Level = log.TRACE + shouldTrace = true + logger.Level = log.TRACE } func SetLogLevel(level log.LogLevel) { - logger.Level = level + logger.Level = level } // NewRouter returns a gorilla/mux Router for the server routes; exposed so // that path params are testable. func NewRouter() *mux.Router { - r := mux.NewRouter() - r.HandleFunc(HealthEndpoint, HealthHandler).Methods("GET") + r := mux.NewRouter() + r.HandleFunc(HealthEndpoint, HealthHandler).Methods("GET") - r.HandleFunc(strings.Join([]string{ApiPrefix, ConfigurationsEndpoint}, "/"), - CreateConfigurationHandler).Methods("POST") - r.HandleFunc(strings.Join([]string{ApiPrefix, ConfigurationsEndpoint, "{cfg_id}"}, "/"), - GetConfigurationHandler).Methods("GET") + r.HandleFunc(strings.Join([]string{ApiPrefix, ConfigurationsEndpoint}, "/"), + CreateConfigurationHandler).Methods("POST") + r.HandleFunc(strings.Join([]string{ApiPrefix, ConfigurationsEndpoint, "{cfg_id}"}, "/"), + GetConfigurationHandler).Methods("GET") - r.HandleFunc(strings.Join([]string{ApiPrefix, StatemachinesEndpoint}, "/"), - CreateStatemachineHandler).Methods("POST") - r.HandleFunc(strings.Join([]string{ApiPrefix, StatemachinesEndpoint, "{cfg_name}", "{sm_id}"}, "/"), - GetStatemachineHandler).Methods("GET") + r.HandleFunc(strings.Join([]string{ApiPrefix, StatemachinesEndpoint}, "/"), + CreateStatemachineHandler).Methods("POST") + r.HandleFunc(strings.Join([]string{ApiPrefix, StatemachinesEndpoint, "{cfg_name}", "{sm_id}"}, "/"), + GetStatemachineHandler).Methods("GET") - r.HandleFunc(strings.Join([]string{ApiPrefix, EventsEndpoint, "{cfg_name}", "{evt_id}"}, "/"), - GetEventHandler).Methods("GET") - r.HandleFunc(strings.Join([]string{ApiPrefix, EventsEndpoint, EventsOutcome, "{cfg_name}", "{evt_id}"}, "/"), - GetOutcomeHandler).Methods("GET") + r.HandleFunc(strings.Join([]string{ApiPrefix, EventsEndpoint, "{cfg_name}", "{evt_id}"}, "/"), + GetEventHandler).Methods("GET") + r.HandleFunc(strings.Join([]string{ApiPrefix, EventsEndpoint, EventsOutcome, "{cfg_name}", "{evt_id}"}, "/"), + GetOutcomeHandler).Methods("GET") - return r + return r } func NewHTTPServer(addr string, logLevel log.LogLevel) *http.Server { - logger.Level = logLevel - return &http.Server{ - Addr: addr, - Handler: NewRouter(), - } + logger.Level = logLevel + return &http.Server{ + Addr: addr, + Handler: NewRouter(), + } } func SetStore(store storage.StoreManager) { - storeManager = store + storeManager = store } diff --git a/server/http_server_test.go b/server/http_server_test.go index 4d90b39..e6d32f4 100644 --- a/server/http_server_test.go +++ b/server/http_server_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ diff --git a/server/server_suite_test.go b/server/server_suite_test.go index fd2150b..ac087fe 100644 --- a/server/server_suite_test.go +++ b/server/server_suite_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ diff --git a/server/statemachine_handlers.go b/server/statemachine_handlers.go index 6a1121c..db2d569 100644 --- a/server/statemachine_handlers.go +++ b/server/statemachine_handlers.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,93 +10,93 @@ package server import ( - "encoding/json" - "github.com/google/uuid" - "github.com/gorilla/mux" - "net/http" - "strings" + "encoding/json" + "github.com/google/uuid" + "github.com/gorilla/mux" + "net/http" + "strings" - . "github.com/massenz/go-statemachine/api" - "github.com/massenz/statemachine-proto/golang/api" + . "github.com/massenz/go-statemachine/api" + "github.com/massenz/statemachine-proto/golang/api" ) func CreateStatemachineHandler(w http.ResponseWriter, r *http.Request) { - defer trace(r.RequestURI)() - defaultContent(w) + defer trace(r.RequestURI)() + defaultContent(w) - var request StateMachineRequest - err := json.NewDecoder(r.Body).Decode(&request) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if request.ConfigurationVersion == "" { - http.Error(w, "Must always specify a fully qualified configuration version", http.StatusBadRequest) - return - } - cfg, ok := storeManager.GetConfig(request.ConfigurationVersion) - if !ok { - http.Error(w, "configuration not found", http.StatusNotFound) - return - } - logger.Debug("Found configuration %s", cfg) - if request.ID == "" { - request.ID = uuid.New().String() - } - logger.Info("Creating a new statemachine: %s (configuration: %s)", - request.ID, request.ConfigurationVersion) + var request StateMachineRequest + err := json.NewDecoder(r.Body).Decode(&request) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if request.ConfigurationVersion == "" { + http.Error(w, "Must always specify a fully qualified configuration version", http.StatusBadRequest) + return + } + cfg, ok := storeManager.GetConfig(request.ConfigurationVersion) + if !ok { + http.Error(w, "configuration not found", http.StatusNotFound) + return + } + logger.Debug("Found configuration %s", cfg) + if request.ID == "" { + request.ID = uuid.New().String() + } + logger.Info("Creating a new statemachine: %s (configuration: %s)", + request.ID, request.ConfigurationVersion) - fsm := &api.FiniteStateMachine{ - ConfigId: GetVersionId(cfg), - State: cfg.StartingState, - History: make([]*api.Event, 0), - } - err = storeManager.PutStateMachine(request.ID, fsm) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } + fsm := &api.FiniteStateMachine{ + ConfigId: GetVersionId(cfg), + State: cfg.StartingState, + History: make([]*api.Event, 0), + } + err = storeManager.PutStateMachine(request.ID, fsm) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } - w.Header().Add("Location", strings.Join([]string{ApiPrefix, StatemachinesEndpoint, cfg.Name, - request.ID}, "/")) - w.WriteHeader(http.StatusCreated) - err = json.NewEncoder(w).Encode(&StateMachineResponse{ - ID: request.ID, - StateMachine: fsm, - }) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } + w.Header().Add("Location", strings.Join([]string{ApiPrefix, StatemachinesEndpoint, cfg.Name, + request.ID}, "/")) + w.WriteHeader(http.StatusCreated) + err = json.NewEncoder(w).Encode(&StateMachineResponse{ + ID: request.ID, + StateMachine: fsm, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } func GetStatemachineHandler(w http.ResponseWriter, r *http.Request) { - defer trace(r.RequestURI)() - defaultContent(w) + defer trace(r.RequestURI)() + defaultContent(w) - vars := mux.Vars(r) - if vars == nil { - logger.Error("Unexpected missing path parameter smId in Request URI: %s", - r.RequestURI) - http.Error(w, UnexpectedError.Error(), http.StatusMethodNotAllowed) - return - } + vars := mux.Vars(r) + if vars == nil { + logger.Error("Unexpected missing path parameter smId in Request URI: %s", + r.RequestURI) + http.Error(w, UnexpectedError.Error(), http.StatusMethodNotAllowed) + return + } - cfgName := vars["cfg_name"] - smId := vars["sm_id"] - logger.Debug("Looking up FSM %s#%s", cfgName, smId) + cfgName := vars["cfg_name"] + smId := vars["sm_id"] + logger.Debug("Looking up FSM %s#%s", cfgName, smId) - stateMachine, ok := storeManager.GetStateMachine(smId, cfgName) - if !ok { - http.Error(w, "State Machine not found", http.StatusNotFound) - return - } - logger.Debug("Found FSM: %s", stateMachine.String()) + stateMachine, ok := storeManager.GetStateMachine(smId, cfgName) + if !ok { + http.Error(w, "State Machine not found", http.StatusNotFound) + return + } + logger.Debug("Found FSM: %s", stateMachine.String()) - err := json.NewEncoder(w).Encode(&StateMachineResponse{ - ID: smId, - StateMachine: stateMachine, - }) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } + err := json.NewEncoder(w).Encode(&StateMachineResponse{ + ID: smId, + StateMachine: stateMachine, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } } diff --git a/server/statemachine_handlers_test.go b/server/statemachine_handlers_test.go index f932c8f..717e93f 100644 --- a/server/statemachine_handlers_test.go +++ b/server/statemachine_handlers_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,302 +10,302 @@ package server_test import ( - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" - "bytes" - "encoding/json" - log "github.com/massenz/slf4go/logging" - "google.golang.org/protobuf/types/known/timestamppb" - "io" - "net/http" - "net/http/httptest" - "strings" + "bytes" + "encoding/json" + log "github.com/massenz/slf4go/logging" + "google.golang.org/protobuf/types/known/timestamppb" + "io" + "net/http" + "net/http/httptest" + "strings" - . "github.com/massenz/go-statemachine/api" - "github.com/massenz/go-statemachine/server" - "github.com/massenz/go-statemachine/storage" + . "github.com/massenz/go-statemachine/api" + "github.com/massenz/go-statemachine/server" + "github.com/massenz/go-statemachine/storage" - "github.com/massenz/statemachine-proto/golang/api" + "github.com/massenz/statemachine-proto/golang/api" ) func ReaderFromRequest(request *server.StateMachineRequest) io.Reader { - jsonBytes, err := json.Marshal(request) - Expect(err).ToNot(HaveOccurred()) - return bytes.NewBuffer(jsonBytes) + jsonBytes, err := json.Marshal(request) + Expect(err).ToNot(HaveOccurred()) + return bytes.NewBuffer(jsonBytes) } var _ = Describe("Handlers", func() { - var ( - req *http.Request - writer *httptest.ResponseRecorder - store storage.StoreManager + var ( + req *http.Request + writer *httptest.ResponseRecorder + store storage.StoreManager - // NOTE: we are using the Router here as we need to correctly also parse - // the URI for path args (just using the router will not do that) - // The `router` can be safely set for all the test contexts, once and for all. - router = server.NewRouter() - ) - // Disabling verbose logging, as it pollutes test output; - // set it back to DEBUG when tests fail, and you need to - // diagnose the failure. - server.SetLogLevel(log.WARN) + // NOTE: we are using the Router here as we need to correctly also parse + // the URI for path args (just using the router will not do that) + // The `router` can be safely set for all the test contexts, once and for all. + router = server.NewRouter() + ) + // Disabling verbose logging, as it pollutes test output; + // set it back to DEBUG when tests fail, and you need to + // diagnose the failure. + server.SetLogLevel(log.WARN) - Context("when creating state machines", func() { - BeforeEach(func() { - writer = httptest.NewRecorder() - store = storage.NewInMemoryStore() - server.SetStore(store) - }) - Context("with a valid request", func() { - BeforeEach(func() { - request := &server.StateMachineRequest{ - ID: "test-machine", - ConfigurationVersion: "test-config:v1", - } - config := &api.Configuration{ - Name: "test-config", - Version: "v1", - States: nil, - Transitions: nil, - StartingState: "start", - } - Expect(store.PutConfig(config)).ToNot(HaveOccurred()) - req = httptest.NewRequest(http.MethodPost, - strings.Join([]string{server.ApiPrefix, server.StatemachinesEndpoint}, "/"), - ReaderFromRequest(request)) - }) + Context("when creating state machines", func() { + BeforeEach(func() { + writer = httptest.NewRecorder() + store = storage.NewInMemoryStore() + server.SetStore(store) + }) + Context("with a valid request", func() { + BeforeEach(func() { + request := &server.StateMachineRequest{ + ID: "test-machine", + ConfigurationVersion: "test-config:v1", + } + config := &api.Configuration{ + Name: "test-config", + Version: "v1", + States: nil, + Transitions: nil, + StartingState: "start", + } + Expect(store.PutConfig(config)).ToNot(HaveOccurred()) + req = httptest.NewRequest(http.MethodPost, + strings.Join([]string{server.ApiPrefix, server.StatemachinesEndpoint}, "/"), + ReaderFromRequest(request)) + }) - It("should succeed", func() { - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusCreated)) - Expect(writer.Header().Get("Location")).To(Equal( - strings.Join([]string{server.ApiPrefix, server.StatemachinesEndpoint, - "test-config", "test-machine"}, "/"))) - response := server.StateMachineResponse{} - Expect(json.Unmarshal(writer.Body.Bytes(), &response)).ToNot(HaveOccurred()) + It("should succeed", func() { + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusCreated)) + Expect(writer.Header().Get("Location")).To(Equal( + strings.Join([]string{server.ApiPrefix, server.StatemachinesEndpoint, + "test-config", "test-machine"}, "/"))) + response := server.StateMachineResponse{} + Expect(json.Unmarshal(writer.Body.Bytes(), &response)).ToNot(HaveOccurred()) - Expect(response.ID).To(Equal("test-machine")) - Expect(response.StateMachine.ConfigId).To(Equal("test-config:v1")) - Expect(response.StateMachine.State).To(Equal("start")) - }) + Expect(response.ID).To(Equal("test-machine")) + Expect(response.StateMachine.ConfigId).To(Equal("test-config:v1")) + Expect(response.StateMachine.State).To(Equal("start")) + }) - It("should fill the cache", func() { - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusCreated)) - _, found := store.GetStateMachine("test-machine", "test-config") - Expect(found).To(BeTrue()) - }) + It("should fill the cache", func() { + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusCreated)) + _, found := store.GetStateMachine("test-machine", "test-config") + Expect(found).To(BeTrue()) + }) - It("should store the correct data", func() { - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusCreated)) - fsm, found := store.GetStateMachine("test-machine", "test-config") - Expect(found).To(BeTrue()) - Expect(fsm).ToNot(BeNil()) - Expect(fsm.ConfigId).To(Equal("test-config:v1")) - Expect(fsm.State).To(Equal("start")) - }) - }) - Context("without specifying an ID", func() { - BeforeEach(func() { - request := &server.StateMachineRequest{ - ConfigurationVersion: "test-config:v1", - } - config := &api.Configuration{ - Name: "test-config", - Version: "v1", - States: nil, - Transitions: nil, - StartingState: "start", - } - Expect(store.PutConfig(config)).ToNot(HaveOccurred()) - req = httptest.NewRequest(http.MethodPost, - strings.Join([]string{server.ApiPrefix, server.StatemachinesEndpoint}, "/"), - ReaderFromRequest(request)) - }) + It("should store the correct data", func() { + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusCreated)) + fsm, found := store.GetStateMachine("test-machine", "test-config") + Expect(found).To(BeTrue()) + Expect(fsm).ToNot(BeNil()) + Expect(fsm.ConfigId).To(Equal("test-config:v1")) + Expect(fsm.State).To(Equal("start")) + }) + }) + Context("without specifying an ID", func() { + BeforeEach(func() { + request := &server.StateMachineRequest{ + ConfigurationVersion: "test-config:v1", + } + config := &api.Configuration{ + Name: "test-config", + Version: "v1", + States: nil, + Transitions: nil, + StartingState: "start", + } + Expect(store.PutConfig(config)).ToNot(HaveOccurred()) + req = httptest.NewRequest(http.MethodPost, + strings.Join([]string{server.ApiPrefix, server.StatemachinesEndpoint}, "/"), + ReaderFromRequest(request)) + }) - It("should succeed with a newly assigned ID", func() { - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusCreated)) - location := writer.Header().Get("Location") - Expect(location).ToNot(BeEmpty()) - response := server.StateMachineResponse{} - Expect(json.Unmarshal(writer.Body.Bytes(), &response)).ToNot(HaveOccurred()) + It("should succeed with a newly assigned ID", func() { + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusCreated)) + location := writer.Header().Get("Location") + Expect(location).ToNot(BeEmpty()) + response := server.StateMachineResponse{} + Expect(json.Unmarshal(writer.Body.Bytes(), &response)).ToNot(HaveOccurred()) - Expect(response.ID).ToNot(BeEmpty()) + Expect(response.ID).ToNot(BeEmpty()) - Expect(strings.HasSuffix(location, response.ID)).To(BeTrue()) - _, found := store.GetStateMachine(response.ID, "test-config") - Expect(found).To(BeTrue()) - }) + Expect(strings.HasSuffix(location, response.ID)).To(BeTrue()) + _, found := store.GetStateMachine(response.ID, "test-config") + Expect(found).To(BeTrue()) + }) - }) - Context("with a non-existent configuration", func() { - BeforeEach(func() { - request := &server.StateMachineRequest{ - ConfigurationVersion: "test-config:v2", - ID: "1234", - } - req = httptest.NewRequest(http.MethodPost, - strings.Join([]string{server.ApiPrefix, server.StatemachinesEndpoint}, "/"), - ReaderFromRequest(request)) - }) + }) + Context("with a non-existent configuration", func() { + BeforeEach(func() { + request := &server.StateMachineRequest{ + ConfigurationVersion: "test-config:v2", + ID: "1234", + } + req = httptest.NewRequest(http.MethodPost, + strings.Join([]string{server.ApiPrefix, server.StatemachinesEndpoint}, "/"), + ReaderFromRequest(request)) + }) - It("should fail", func() { - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusNotFound)) - location := writer.Header().Get("Location") - Expect(location).To(BeEmpty()) - response := server.StateMachineResponse{} - Expect(json.Unmarshal(writer.Body.Bytes(), &response)).To(HaveOccurred()) - _, found := store.GetConfig("1234") - Expect(found).To(BeFalse()) - }) - }) - }) + It("should fail", func() { + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusNotFound)) + location := writer.Header().Get("Location") + Expect(location).To(BeEmpty()) + response := server.StateMachineResponse{} + Expect(json.Unmarshal(writer.Body.Bytes(), &response)).To(HaveOccurred()) + _, found := store.GetConfig("1234") + Expect(found).To(BeFalse()) + }) + }) + }) - Context("when retrieving a state machine", func() { - var id string - var fsm api.FiniteStateMachine + Context("when retrieving a state machine", func() { + var id string + var fsm api.FiniteStateMachine - BeforeEach(func() { - store = storage.NewInMemoryStore() - server.SetStore(store) + BeforeEach(func() { + store = storage.NewInMemoryStore() + server.SetStore(store) - writer = httptest.NewRecorder() - fsm = api.FiniteStateMachine{ - ConfigId: "order.card:v3", - State: "checkout", - History: []*api.Event{ - {Transition: &api.Transition{Event: "order_placed"}, Originator: ""}, - {Transition: &api.Transition{Event: "checked_out"}, Originator: ""}, - }, - } - id = "12345" - Expect(store.PutStateMachine(id, &fsm)).ToNot(HaveOccurred()) - }) + writer = httptest.NewRecorder() + fsm = api.FiniteStateMachine{ + ConfigId: "order.card:v3", + State: "checkout", + History: []*api.Event{ + {Transition: &api.Transition{Event: "order_placed"}, Originator: ""}, + {Transition: &api.Transition{Event: "checked_out"}, Originator: ""}, + }, + } + id = "12345" + Expect(store.PutStateMachine(id, &fsm)).ToNot(HaveOccurred()) + }) - It("can be retrieved with a valid ID", func() { - store.SetLogLevel(log.NONE) - endpoint := strings.Join([]string{server.ApiPrefix, - server.StatemachinesEndpoint, "order.card", id}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusOK)) + It("can be retrieved with a valid ID", func() { + store.SetLogLevel(log.NONE) + endpoint := strings.Join([]string{server.ApiPrefix, + server.StatemachinesEndpoint, "order.card", id}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusOK)) - var result server.StateMachineResponse - Expect(json.NewDecoder(writer.Body).Decode(&result)).ToNot(HaveOccurred()) - Expect(result.ID).To(Equal(id)) - sm := result.StateMachine - Expect(sm.ConfigId).To(Equal(fsm.ConfigId)) - Expect(sm.State).To(Equal(fsm.State)) - Expect(len(sm.History)).To(Equal(len(fsm.History))) - for n, t := range sm.History { - Expect(t.Transition.Event).To(Equal(fsm.History[n].Transition.Event)) - } - }) - It("with an invalid ID will return Not Found", func() { - endpoint := strings.Join([]string{server.ApiPrefix, - server.StatemachinesEndpoint, "foo"}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) + var result server.StateMachineResponse + Expect(json.NewDecoder(writer.Body).Decode(&result)).ToNot(HaveOccurred()) + Expect(result.ID).To(Equal(id)) + sm := result.StateMachine + Expect(sm.ConfigId).To(Equal(fsm.ConfigId)) + Expect(sm.State).To(Equal(fsm.State)) + Expect(len(sm.History)).To(Equal(len(fsm.History))) + for n, t := range sm.History { + Expect(t.Transition.Event).To(Equal(fsm.History[n].Transition.Event)) + } + }) + It("with an invalid ID will return Not Found", func() { + endpoint := strings.Join([]string{server.ApiPrefix, + server.StatemachinesEndpoint, "foo"}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusNotFound)) - }) - It("with a missing ID will return Not Allowed", func() { - req = httptest.NewRequest(http.MethodGet, strings.Join([]string{server.ApiPrefix, - server.StatemachinesEndpoint}, "/"), nil) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusMethodNotAllowed)) - }) - It("with gibberish data will still fail gracefully", func() { - cfg := api.Configuration{} - Expect(store.PutConfig(&cfg)).ToNot(HaveOccurred()) - endpoint := strings.Join([]string{server.ApiPrefix, - server.StatemachinesEndpoint, "6789"}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusNotFound)) - }) - }) + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusNotFound)) + }) + It("with a missing ID will return Not Allowed", func() { + req = httptest.NewRequest(http.MethodGet, strings.Join([]string{server.ApiPrefix, + server.StatemachinesEndpoint}, "/"), nil) + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusMethodNotAllowed)) + }) + It("with gibberish data will still fail gracefully", func() { + cfg := api.Configuration{} + Expect(store.PutConfig(&cfg)).ToNot(HaveOccurred()) + endpoint := strings.Join([]string{server.ApiPrefix, + server.StatemachinesEndpoint, "6789"}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusNotFound)) + }) + }) - Context("when the statemachine has events", func() { - var store storage.StoreManager - var fsmId = "12345" - var config *api.Configuration + Context("when the statemachine has events", func() { + var store storage.StoreManager + var fsmId = "12345" + var config *api.Configuration - BeforeEach(func() { - writer = httptest.NewRecorder() - store = storage.NewInMemoryStore() - server.SetStore(store) - config = &api.Configuration{ - Name: "car", - Version: "v1", - States: []string{"stopped", "running", "slowing"}, - Transitions: []*api.Transition{ - {From: "stopped", To: "running", Event: "start"}, - {From: "running", To: "slowing", Event: "brake"}, - {From: "slowing", To: "running", Event: "accelerate"}, - {From: "slowing", To: "stopped", Event: "stop"}, - }, - StartingState: "stopped", - } - car, _ := NewStateMachine(config) - Expect(store.PutConfig(config)).To(Succeed()) - Expect(store.PutStateMachine(fsmId, car.FSM)).To(Succeed()) - }) + BeforeEach(func() { + writer = httptest.NewRecorder() + store = storage.NewInMemoryStore() + server.SetStore(store) + config = &api.Configuration{ + Name: "car", + Version: "v1", + States: []string{"stopped", "running", "slowing"}, + Transitions: []*api.Transition{ + {From: "stopped", To: "running", Event: "start"}, + {From: "running", To: "slowing", Event: "brake"}, + {From: "slowing", To: "running", Event: "accelerate"}, + {From: "slowing", To: "stopped", Event: "stop"}, + }, + StartingState: "stopped", + } + car, _ := NewStateMachine(config) + Expect(store.PutConfig(config)).To(Succeed()) + Expect(store.PutStateMachine(fsmId, car.FSM)).To(Succeed()) + }) - It("it should show them", func() { - found, _ := store.GetStateMachine(fsmId, "car") - car := ConfiguredStateMachine{ - Config: config, - FSM: found, - } - Expect(car.SendEvent(&api.Event{ - EventId: "1", - Timestamp: timestamppb.Now(), - Transition: &api.Transition{Event: "start"}, - Originator: "test", - Details: "this is a test", - })).To(Succeed()) - Expect(car.SendEvent(&api.Event{ - EventId: "2", - Timestamp: timestamppb.Now(), - Transition: &api.Transition{Event: "brake"}, - Originator: "test", - Details: "a test is this not", - })).To(Succeed()) - Expect(store.PutStateMachine(fsmId, car.FSM)).To(Succeed()) + It("it should show them", func() { + found, _ := store.GetStateMachine(fsmId, "car") + car := ConfiguredStateMachine{ + Config: config, + FSM: found, + } + Expect(car.SendEvent(&api.Event{ + EventId: "1", + Timestamp: timestamppb.Now(), + Transition: &api.Transition{Event: "start"}, + Originator: "test", + Details: "this is a test", + })).To(Succeed()) + Expect(car.SendEvent(&api.Event{ + EventId: "2", + Timestamp: timestamppb.Now(), + Transition: &api.Transition{Event: "brake"}, + Originator: "test", + Details: "a test is this not", + })).To(Succeed()) + Expect(store.PutStateMachine(fsmId, car.FSM)).To(Succeed()) - endpoint := strings.Join([]string{server.ApiPrefix, server.StatemachinesEndpoint, - config.Name, fsmId}, "/") - req = httptest.NewRequest(http.MethodGet, endpoint, nil) - router.ServeHTTP(writer, req) - Expect(writer.Code).To(Equal(http.StatusOK)) + endpoint := strings.Join([]string{server.ApiPrefix, server.StatemachinesEndpoint, + config.Name, fsmId}, "/") + req = httptest.NewRequest(http.MethodGet, endpoint, nil) + router.ServeHTTP(writer, req) + Expect(writer.Code).To(Equal(http.StatusOK)) - var result server.StateMachineResponse - Expect(json.NewDecoder(writer.Body).Decode(&result)).To(Succeed()) + var result server.StateMachineResponse + Expect(json.NewDecoder(writer.Body).Decode(&result)).To(Succeed()) - Expect(result.ID).To(Equal(fsmId)) - Expect(result.StateMachine).ToNot(BeNil()) - fsm := result.StateMachine - Expect(fsm.State).To(Equal("slowing")) - Expect(len(fsm.History)).To(Equal(2)) - var history []*api.Event - history = fsm.History - event := history[0] - Expect(event.EventId).To(Equal("1")) - Expect(event.Originator).To(Equal("test")) - Expect(event.Transition.Event).To(Equal("start")) - Expect(event.Transition.From).To(Equal("stopped")) - Expect(event.Transition.To).To(Equal("running")) - Expect(event.Details).To(Equal("this is a test")) - event = history[1] - Expect(event.EventId).To(Equal("2")) - Expect(event.Transition.Event).To(Equal("brake")) - Expect(event.Transition.To).To(Equal("slowing")) - Expect(event.Details).To(Equal("a test is this not")) - }) - }) + Expect(result.ID).To(Equal(fsmId)) + Expect(result.StateMachine).ToNot(BeNil()) + fsm := result.StateMachine + Expect(fsm.State).To(Equal("slowing")) + Expect(len(fsm.History)).To(Equal(2)) + var history []*api.Event + history = fsm.History + event := history[0] + Expect(event.EventId).To(Equal("1")) + Expect(event.Originator).To(Equal("test")) + Expect(event.Transition.Event).To(Equal("start")) + Expect(event.Transition.From).To(Equal("stopped")) + Expect(event.Transition.To).To(Equal("running")) + Expect(event.Details).To(Equal("this is a test")) + event = history[1] + Expect(event.EventId).To(Equal("2")) + Expect(event.Transition.Event).To(Equal("brake")) + Expect(event.Transition.To).To(Equal("slowing")) + Expect(event.Details).To(Equal("a test is this not")) + }) + }) }) diff --git a/server/types.go b/server/types.go index b8094f6..89690b7 100644 --- a/server/types.go +++ b/server/types.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ diff --git a/storage/keys.go b/storage/keys.go index 50ebd03..76b7e92 100644 --- a/storage/keys.go +++ b/storage/keys.go @@ -10,16 +10,16 @@ package storage import ( - "strings" + "strings" ) const ( - ConfigsPrefix = "configs" - EventsPrefix = "events" - FsmPrefix = "fsm" + ConfigsPrefix = "configs" + EventsPrefix = "events" + FsmPrefix = "fsm" - KeyPrefixComponentsSeparator = ":" - KeyPrefixIDSeparator = "#" + KeyPrefixComponentsSeparator = ":" + KeyPrefixIDSeparator = "#" ) // Here we keep all the key definition for the various Redis collections. @@ -29,29 +29,29 @@ const ( // By convention, the config ID is the `name:version` of the configuration; however, // this is not enforced here, but rather in the implementation of the stores. func NewKeyForConfig(id string) string { - return strings.Join([]string{ConfigsPrefix, id}, KeyPrefixIDSeparator) + return strings.Join([]string{ConfigsPrefix, id}, KeyPrefixIDSeparator) } // NewKeyForMachine fsm:# func NewKeyForMachine(id string, cfgName string) string { - prefix := strings.Join([]string{FsmPrefix, cfgName}, KeyPrefixComponentsSeparator) - return strings.Join([]string{prefix, id}, KeyPrefixIDSeparator) + prefix := strings.Join([]string{FsmPrefix, cfgName}, KeyPrefixComponentsSeparator) + return strings.Join([]string{prefix, id}, KeyPrefixIDSeparator) } // NewKeyForMachinesByState fsm::state# func NewKeyForMachinesByState(cfgName, state string) string { - prefix := strings.Join([]string{FsmPrefix, cfgName, "state"}, KeyPrefixComponentsSeparator) - return strings.Join([]string{prefix, state}, KeyPrefixIDSeparator) + prefix := strings.Join([]string{FsmPrefix, cfgName, "state"}, KeyPrefixComponentsSeparator) + return strings.Join([]string{prefix, state}, KeyPrefixIDSeparator) } // NewKeyForEvent events:# func NewKeyForEvent(id string, cfgName string) string { - prefix := strings.Join([]string{EventsPrefix, cfgName}, KeyPrefixComponentsSeparator) - return strings.Join([]string{prefix, id}, KeyPrefixIDSeparator) + prefix := strings.Join([]string{EventsPrefix, cfgName}, KeyPrefixComponentsSeparator) + return strings.Join([]string{prefix, id}, KeyPrefixIDSeparator) } // NewKeyForOutcome events::outcome# func NewKeyForOutcome(id string, cfgName string) string { - prefix := strings.Join([]string{EventsPrefix, cfgName, "outcome"}, KeyPrefixComponentsSeparator) - return strings.Join([]string{prefix, id}, KeyPrefixIDSeparator) + prefix := strings.Join([]string{EventsPrefix, cfgName, "outcome"}, KeyPrefixComponentsSeparator) + return strings.Join([]string{prefix, id}, KeyPrefixIDSeparator) } diff --git a/storage/memory_store.go b/storage/memory_store.go index 7db48a7..6e876a3 100644 --- a/storage/memory_store.go +++ b/storage/memory_store.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,132 +10,132 @@ package storage import ( - "github.com/golang/protobuf/proto" - "github.com/massenz/go-statemachine/api" - log "github.com/massenz/slf4go/logging" - protos "github.com/massenz/statemachine-proto/golang/api" - "strings" - "sync" - "time" + "github.com/golang/protobuf/proto" + "github.com/massenz/go-statemachine/api" + log "github.com/massenz/slf4go/logging" + protos "github.com/massenz/statemachine-proto/golang/api" + "strings" + "sync" + "time" ) func NewInMemoryStore() StoreManager { - return &InMemoryStore{ - backingStore: make(map[string][]byte), - logger: log.NewLog("memory_store"), - } + return &InMemoryStore{ + backingStore: make(map[string][]byte), + logger: log.NewLog("memory_store"), + } } type InMemoryStore struct { - logger *log.Log - mux sync.RWMutex - backingStore map[string][]byte + logger *log.Log + mux sync.RWMutex + backingStore map[string][]byte } func (csm *InMemoryStore) get(key string, value proto.Message) bool { - csm.mux.RLock() - defer csm.mux.RUnlock() + csm.mux.RLock() + defer csm.mux.RUnlock() - bytes, ok := csm.backingStore[key] - csm.logger.Trace("key %s - Found: %t", key, ok) - if ok { - err := proto.Unmarshal(bytes, value) - if err != nil { - csm.logger.Error(err.Error()) - return false - } - } - return ok + bytes, ok := csm.backingStore[key] + csm.logger.Trace("key %s - Found: %t", key, ok) + if ok { + err := proto.Unmarshal(bytes, value) + if err != nil { + csm.logger.Error(err.Error()) + return false + } + } + return ok } func (csm *InMemoryStore) put(key string, value proto.Message) error { - csm.mux.Lock() - defer csm.mux.Unlock() + csm.mux.Lock() + defer csm.mux.Unlock() - val, err := proto.Marshal(value) - if err == nil { - csm.logger.Trace("Storing key %s [%T]", key, value) - csm.backingStore[key] = val - } - return err + val, err := proto.Marshal(value) + if err == nil { + csm.logger.Trace("Storing key %s [%T]", key, value) + csm.backingStore[key] = val + } + return err } func (csm *InMemoryStore) GetAllInState(cfg string, state string) []*protos.FiniteStateMachine { - // TODO [#33] Ability to query for all machines in a given state - csm.logger.Error("Not implemented") - return nil + // TODO [#33] Ability to query for all machines in a given state + csm.logger.Error("Not implemented") + return nil } func (csm *InMemoryStore) GetEvent(id string, cfg string) (*protos.Event, bool) { - key := NewKeyForEvent(id, cfg) - event := &protos.Event{} - return event, csm.get(key, event) + key := NewKeyForEvent(id, cfg) + event := &protos.Event{} + return event, csm.get(key, event) } func (csm *InMemoryStore) PutEvent(event *protos.Event, cfg string, ttl time.Duration) error { - key := NewKeyForEvent(event.EventId, cfg) - return csm.put(key, event) + key := NewKeyForEvent(event.EventId, cfg) + return csm.put(key, event) } func (csm *InMemoryStore) AddEventOutcome(id string, cfg string, response *protos.EventOutcome, ttl time.Duration) error { - key := NewKeyForOutcome(id, cfg) - return csm.put(key, response) + key := NewKeyForOutcome(id, cfg) + return csm.put(key, response) } func (csm *InMemoryStore) GetOutcomeForEvent(id string, cfg string) (*protos.EventOutcome, bool) { - key := NewKeyForOutcome(id, cfg) - var outcome protos.EventOutcome - return &outcome, csm.get(key, &outcome) + key := NewKeyForOutcome(id, cfg) + var outcome protos.EventOutcome + return &outcome, csm.get(key, &outcome) } func (csm *InMemoryStore) GetConfig(id string) (cfg *protos.Configuration, ok bool) { - key := NewKeyForConfig(id) - csm.logger.Debug("Fetching Configuration [%s]", key) - cfg = &protos.Configuration{} - return cfg, csm.get(key, cfg) + key := NewKeyForConfig(id) + csm.logger.Debug("Fetching Configuration [%s]", key) + cfg = &protos.Configuration{} + return cfg, csm.get(key, cfg) } func (csm *InMemoryStore) PutConfig(cfg *protos.Configuration) error { - key := NewKeyForConfig(api.GetVersionId(cfg)) - csm.logger.Debug("Storing Configuration [%s] with key: %s", api.GetVersionId(cfg), key) - return csm.put(key, cfg) + key := NewKeyForConfig(api.GetVersionId(cfg)) + csm.logger.Debug("Storing Configuration [%s] with key: %s", api.GetVersionId(cfg), key) + return csm.put(key, cfg) } func (csm *InMemoryStore) GetStateMachine(id string, cfg string) (*protos.FiniteStateMachine, bool) { - csm.logger.Debug("Fetching StateMachine [%s#%s]", cfg, id) - key := NewKeyForMachine(id, cfg) - machine := protos.FiniteStateMachine{} - if csm.get(key, &machine) { - csm.logger.Debug("Found StateMachine [%s#%s]: %s", cfg, id, machine.State) - return &machine, true - } - return nil, false + csm.logger.Debug("Fetching StateMachine [%s#%s]", cfg, id) + key := NewKeyForMachine(id, cfg) + machine := protos.FiniteStateMachine{} + if csm.get(key, &machine) { + csm.logger.Debug("Found StateMachine [%s#%s]: %s", cfg, id, machine.State) + return &machine, true + } + return nil, false } func (csm *InMemoryStore) PutStateMachine(id string, machine *protos.FiniteStateMachine) error { - if machine == nil { - return IllegalStoreError - } - key := NewKeyForMachine(id, strings.Split(machine.ConfigId, api.ConfigurationVersionSeparator)[0]) - csm.logger.Debug("Storing StateMachine [%s] with key: %s", id, key) - return csm.put(key, machine) + if machine == nil { + return IllegalStoreError + } + key := NewKeyForMachine(id, strings.Split(machine.ConfigId, api.ConfigurationVersionSeparator)[0]) + csm.logger.Debug("Storing StateMachine [%s] with key: %s", id, key) + return csm.put(key, machine) } func (csm *InMemoryStore) SetLogLevel(level log.LogLevel) { - csm.logger.Level = level + csm.logger.Level = level } // SetTimeout does not really make sense for an in-memory store, so this is a no-op func (csm *InMemoryStore) SetTimeout(_ time.Duration) { - // do nothing + // do nothing } // GetTimeout does not really make sense for an in-memory store, // so this just returns a NeverExpire constant. func (csm *InMemoryStore) GetTimeout() time.Duration { - return NeverExpire + return NeverExpire } func (csm *InMemoryStore) Health() error { - return nil + return nil } diff --git a/storage/memory_store_test.go b/storage/memory_store_test.go index f674f1b..99cc531 100644 --- a/storage/memory_store_test.go +++ b/storage/memory_store_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,92 +10,92 @@ package storage_test import ( - . "github.com/JiaYongfei/respect/gomega" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + . "github.com/JiaYongfei/respect/gomega" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" - "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/protobuf/types/known/timestamppb" - "github.com/massenz/go-statemachine/api" - "github.com/massenz/go-statemachine/storage" - protos "github.com/massenz/statemachine-proto/golang/api" + "github.com/massenz/go-statemachine/api" + "github.com/massenz/go-statemachine/storage" + protos "github.com/massenz/statemachine-proto/golang/api" ) var _ = Describe("InMemory Store", func() { - Context("for local testing", func() { - It("can be created", func() { - var store = storage.NewInMemoryStore() - Expect(store).ToNot(BeNil()) - }) - }) - Context("can be used to save and retrieve a Configuration", func() { - var store = storage.NewInMemoryStore() - var cfg = &protos.Configuration{} - BeforeEach(func() { - cfg.Name = "my_conf" - cfg.Version = "v3" - cfg.StartingState = "start" - Expect(store.PutConfig(cfg)).ToNot(HaveOccurred()) - }) - It("will give back the saved Configuration", func() { - found, ok := store.GetConfig(api.GetVersionId(cfg)) - Expect(ok).To(BeTrue()) - Expect(found).To(Respect(cfg)) - }) - }) - Context("can be used to save and retrieve a StateMachine", func() { - var store = storage.NewInMemoryStore() - var id = "1234" - var machine *protos.FiniteStateMachine + Context("for local testing", func() { + It("can be created", func() { + var store = storage.NewInMemoryStore() + Expect(store).ToNot(BeNil()) + }) + }) + Context("can be used to save and retrieve a Configuration", func() { + var store = storage.NewInMemoryStore() + var cfg = &protos.Configuration{} + BeforeEach(func() { + cfg.Name = "my_conf" + cfg.Version = "v3" + cfg.StartingState = "start" + Expect(store.PutConfig(cfg)).ToNot(HaveOccurred()) + }) + It("will give back the saved Configuration", func() { + found, ok := store.GetConfig(api.GetVersionId(cfg)) + Expect(ok).To(BeTrue()) + Expect(found).To(Respect(cfg)) + }) + }) + Context("can be used to save and retrieve a StateMachine", func() { + var store = storage.NewInMemoryStore() + var id = "1234" + var machine *protos.FiniteStateMachine - BeforeEach(func() { - machine = &protos.FiniteStateMachine{ - ConfigId: "test:v1", - State: "start", - History: nil, - } - Expect(store.PutStateMachine(id, machine)).ToNot(HaveOccurred()) - }) - It("will give it back unchanged", func() { - found, ok := store.GetStateMachine(id, "test") - Expect(ok).To(BeTrue()) - Expect(found).ToNot(BeNil()) - Expect(found.ConfigId).To(Equal(machine.ConfigId)) - Expect(found.History).To(Equal(machine.History)) - Expect(found.State).To(Equal(machine.State)) - }) - It("will return nil for a non-existent id", func() { - found, ok := store.GetStateMachine("fake", "test") - Expect(ok).To(BeFalse()) - Expect(found).To(BeNil()) - }) - It("will return an error for a nil FSM", func() { - machine.ConfigId = "missing" - Expect(store.PutStateMachine(id, nil)).To(HaveOccurred()) - }) - }) - Context("can be used to save and retrieve Events", func() { - var store = storage.NewInMemoryStore() - var id = "1234" - var event = &protos.Event{ - EventId: id, - Timestamp: timestamppb.Now(), - Transition: &protos.Transition{Event: "start"}, - Originator: "test", - Details: "some details", - } - BeforeEach(func() { - Expect(store.PutEvent(event, "test-cfg", storage.NeverExpire)).ToNot(HaveOccurred()) - }) - It("will give it back unchanged", func() { - found, ok := store.GetEvent(id, "test-cfg") - Expect(ok).To(BeTrue()) - Expect(found).ToNot(BeNil()) - Expect(found).To(Respect(event)) - }) - It("will return false for a non-existent id", func() { - _, ok := store.GetEvent("fake", "test-cfg") - Expect(ok).To(BeFalse()) - }) - }) + BeforeEach(func() { + machine = &protos.FiniteStateMachine{ + ConfigId: "test:v1", + State: "start", + History: nil, + } + Expect(store.PutStateMachine(id, machine)).ToNot(HaveOccurred()) + }) + It("will give it back unchanged", func() { + found, ok := store.GetStateMachine(id, "test") + Expect(ok).To(BeTrue()) + Expect(found).ToNot(BeNil()) + Expect(found.ConfigId).To(Equal(machine.ConfigId)) + Expect(found.History).To(Equal(machine.History)) + Expect(found.State).To(Equal(machine.State)) + }) + It("will return nil for a non-existent id", func() { + found, ok := store.GetStateMachine("fake", "test") + Expect(ok).To(BeFalse()) + Expect(found).To(BeNil()) + }) + It("will return an error for a nil FSM", func() { + machine.ConfigId = "missing" + Expect(store.PutStateMachine(id, nil)).To(HaveOccurred()) + }) + }) + Context("can be used to save and retrieve Events", func() { + var store = storage.NewInMemoryStore() + var id = "1234" + var event = &protos.Event{ + EventId: id, + Timestamp: timestamppb.Now(), + Transition: &protos.Transition{Event: "start"}, + Originator: "test", + Details: "some details", + } + BeforeEach(func() { + Expect(store.PutEvent(event, "test-cfg", storage.NeverExpire)).ToNot(HaveOccurred()) + }) + It("will give it back unchanged", func() { + found, ok := store.GetEvent(id, "test-cfg") + Expect(ok).To(BeTrue()) + Expect(found).ToNot(BeNil()) + Expect(found).To(Respect(event)) + }) + It("will return false for a non-existent id", func() { + _, ok := store.GetEvent("fake", "test-cfg") + Expect(ok).To(BeFalse()) + }) + }) }) diff --git a/storage/redis_store.go b/storage/redis_store.go index 5689781..1f2a094 100644 --- a/storage/redis_store.go +++ b/storage/redis_store.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ diff --git a/storage/redis_store_test.go b/storage/redis_store_test.go index d1f7bbd..f5f228d 100644 --- a/storage/redis_store_test.go +++ b/storage/redis_store_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,210 +10,210 @@ package storage_test import ( - "context" - "fmt" - . "github.com/JiaYongfei/respect/gomega" - "github.com/go-redis/redis/v8" - "github.com/golang/protobuf/proto" - "github.com/google/uuid" - slf4go "github.com/massenz/slf4go/logging" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" - "os" + "context" + "fmt" + . "github.com/JiaYongfei/respect/gomega" + "github.com/go-redis/redis/v8" + "github.com/golang/protobuf/proto" + "github.com/google/uuid" + slf4go "github.com/massenz/slf4go/logging" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "os" - "github.com/massenz/go-statemachine/api" - "github.com/massenz/go-statemachine/storage" - protos "github.com/massenz/statemachine-proto/golang/api" + "github.com/massenz/go-statemachine/api" + "github.com/massenz/go-statemachine/storage" + protos "github.com/massenz/statemachine-proto/golang/api" ) var _ = Describe("RedisStore", func() { - var redisPort = os.Getenv("REDIS_PORT") - if redisPort == "" { - redisPort = storage.DefaultRedisPort - } - localAddress := fmt.Sprintf("localhost:%s", redisPort) + var redisPort = os.Getenv("REDIS_PORT") + if redisPort == "" { + redisPort = storage.DefaultRedisPort + } + localAddress := fmt.Sprintf("localhost:%s", redisPort) - Context("when configured locally", func() { - var store storage.StoreManager - var rdb *redis.Client - var cfg *protos.Configuration + Context("when configured locally", func() { + var store storage.StoreManager + var rdb *redis.Client + var cfg *protos.Configuration - BeforeEach(func() { - cfg = &protos.Configuration{ - Name: "my_conf", - Version: "v3", - StartingState: "start", - } - store = storage.NewRedisStoreWithDefaults(localAddress) - Expect(store).ToNot(BeNil()) - // Mute unnecessary logging during tests; re-enable ( - // and set to DEBUG) when diagnosing failures. - store.SetLogLevel(slf4go.NONE) + BeforeEach(func() { + cfg = &protos.Configuration{ + Name: "my_conf", + Version: "v3", + StartingState: "start", + } + store = storage.NewRedisStoreWithDefaults(localAddress) + Expect(store).ToNot(BeNil()) + // Mute unnecessary logging during tests; re-enable ( + // and set to DEBUG) when diagnosing failures. + store.SetLogLevel(slf4go.NONE) - // This is used to go "behind the back" of our StoreManager and mess with it for testing - // purposes. Do NOT do this in your code. - rdb = redis.NewClient(&redis.Options{ - Addr: localAddress, - DB: storage.DefaultRedisDb, - }) - // Cleaning up the DB to prevent "dirty" store to impact test results - rdb.FlushDB(context.Background()) - }) - It("is healthy", func() { - Expect(store.Health()).To(Succeed()) - }) - It("can get a configuration back", func() { - id := api.GetVersionId(cfg) - val, _ := proto.Marshal(cfg) - res, err := rdb.Set(context.Background(), storage.NewKeyForConfig(id), val, - storage.NeverExpire).Result() - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(Equal("OK")) + // This is used to go "behind the back" of our StoreManager and mess with it for testing + // purposes. Do NOT do this in your code. + rdb = redis.NewClient(&redis.Options{ + Addr: localAddress, + DB: storage.DefaultRedisDb, + }) + // Cleaning up the DB to prevent "dirty" store to impact test results + rdb.FlushDB(context.Background()) + }) + It("is healthy", func() { + Expect(store.Health()).To(Succeed()) + }) + It("can get a configuration back", func() { + id := api.GetVersionId(cfg) + val, _ := proto.Marshal(cfg) + res, err := rdb.Set(context.Background(), storage.NewKeyForConfig(id), val, + storage.NeverExpire).Result() + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal("OK")) - data, ok := store.GetConfig(id) - Expect(ok).To(BeTrue()) - Expect(data).ToNot(BeNil()) - Expect(api.GetVersionId(data)).To(Equal(api.GetVersionId(cfg))) - }) - It("will return orderly if the id does not exist", func() { - id := "fake" - data, ok := store.GetConfig(id) - Expect(ok).To(BeFalse()) - Expect(data).To(BeNil()) - }) - It("can save configurations", func() { - var found protos.Configuration - Expect(store.PutConfig(cfg)).ToNot(HaveOccurred()) - val, err := rdb.Get(context.Background(), - storage.NewKeyForConfig(api.GetVersionId(cfg))).Bytes() - Expect(err).ToNot(HaveOccurred()) + data, ok := store.GetConfig(id) + Expect(ok).To(BeTrue()) + Expect(data).ToNot(BeNil()) + Expect(api.GetVersionId(data)).To(Equal(api.GetVersionId(cfg))) + }) + It("will return orderly if the id does not exist", func() { + id := "fake" + data, ok := store.GetConfig(id) + Expect(ok).To(BeFalse()) + Expect(data).To(BeNil()) + }) + It("can save configurations", func() { + var found protos.Configuration + Expect(store.PutConfig(cfg)).ToNot(HaveOccurred()) + val, err := rdb.Get(context.Background(), + storage.NewKeyForConfig(api.GetVersionId(cfg))).Bytes() + Expect(err).ToNot(HaveOccurred()) - Expect(proto.Unmarshal(val, &found)).ToNot(HaveOccurred()) - Expect(&found).To(Respect(cfg)) - }) - It("should not fail for a non-existent FSM", func() { - data, ok := store.GetStateMachine("fake", "bad-config") - Expect(ok).To(BeFalse()) - Expect(data).To(BeNil()) - }) - It("can get an FSM back", func() { - id := uuid.New().String() - fsm := &protos.FiniteStateMachine{ - ConfigId: "cfg_id", - State: "a-state", - History: nil, - } - // Storing the FSM behind the store's back - val, _ := proto.Marshal(fsm) - key := storage.NewKeyForMachine(id, fsm.ConfigId) - res, err := rdb.Set(context.Background(), key, val, storage.NeverExpire).Result() + Expect(proto.Unmarshal(val, &found)).ToNot(HaveOccurred()) + Expect(&found).To(Respect(cfg)) + }) + It("should not fail for a non-existent FSM", func() { + data, ok := store.GetStateMachine("fake", "bad-config") + Expect(ok).To(BeFalse()) + Expect(data).To(BeNil()) + }) + It("can get an FSM back", func() { + id := uuid.New().String() + fsm := &protos.FiniteStateMachine{ + ConfigId: "cfg_id", + State: "a-state", + History: nil, + } + // Storing the FSM behind the store's back + val, _ := proto.Marshal(fsm) + key := storage.NewKeyForMachine(id, fsm.ConfigId) + res, err := rdb.Set(context.Background(), key, val, storage.NeverExpire).Result() - Expect(err).ToNot(HaveOccurred()) - Expect(res).To(Equal("OK")) + Expect(err).ToNot(HaveOccurred()) + Expect(res).To(Equal("OK")) - data, ok := store.GetStateMachine(id, "cfg_id") - Expect(ok).To(BeTrue()) - Expect(data).ToNot(BeNil()) - Expect(data).To(Respect(fsm)) - }) - It("can save an FSM", func() { - id := "99" // uuid.New().String() - var found protos.FiniteStateMachine - fsm := &protos.FiniteStateMachine{ - ConfigId: "orders:v4", - State: "in_transit", - History: []*protos.Event{ - {Transition: &protos.Transition{Event: "confirmed"}, Originator: "bot"}, - {Transition: &protos.Transition{Event: "shipped"}, Originator: "bot"}, - }, - } - Expect(store.PutStateMachine(id, fsm)).ToNot(HaveOccurred()) - val, err := rdb.Get(context.Background(), storage.NewKeyForMachine(id, "orders")).Bytes() - Expect(err).ToNot(HaveOccurred()) + data, ok := store.GetStateMachine(id, "cfg_id") + Expect(ok).To(BeTrue()) + Expect(data).ToNot(BeNil()) + Expect(data).To(Respect(fsm)) + }) + It("can save an FSM", func() { + id := "99" // uuid.New().String() + var found protos.FiniteStateMachine + fsm := &protos.FiniteStateMachine{ + ConfigId: "orders:v4", + State: "in_transit", + History: []*protos.Event{ + {Transition: &protos.Transition{Event: "confirmed"}, Originator: "bot"}, + {Transition: &protos.Transition{Event: "shipped"}, Originator: "bot"}, + }, + } + Expect(store.PutStateMachine(id, fsm)).ToNot(HaveOccurred()) + val, err := rdb.Get(context.Background(), storage.NewKeyForMachine(id, "orders")).Bytes() + Expect(err).ToNot(HaveOccurred()) - Expect(proto.Unmarshal(val, &found)).ToNot(HaveOccurred()) - // NOTE: this fails, even though the Protobufs are actually identical: - // Expect(found).To(Respect(*fsm)) - // it strangely fails on the History field, which is a slice, and actually matches. - Expect(found.ConfigId).To(Equal(fsm.ConfigId)) - Expect(found.State).To(Equal(fsm.State)) - Expect(found.ConfigId).To(Equal(fsm.ConfigId)) - Expect(found.History).To(HaveLen(len(fsm.History))) - Expect(found.History[0]).To(Respect(fsm.History[0])) - Expect(found.History[1]).To(Respect(fsm.History[1])) - }) - It("can get events back", func() { - id := uuid.New().String() - ev := api.NewEvent("confirmed") - key := storage.NewKeyForEvent(id, "orders") - val, _ := proto.Marshal(ev) - _, err := rdb.Set(context.Background(), key, val, storage.NeverExpire).Result() - Expect(err).ToNot(HaveOccurred()) + Expect(proto.Unmarshal(val, &found)).ToNot(HaveOccurred()) + // NOTE: this fails, even though the Protobufs are actually identical: + // Expect(found).To(Respect(*fsm)) + // it strangely fails on the History field, which is a slice, and actually matches. + Expect(found.ConfigId).To(Equal(fsm.ConfigId)) + Expect(found.State).To(Equal(fsm.State)) + Expect(found.ConfigId).To(Equal(fsm.ConfigId)) + Expect(found.History).To(HaveLen(len(fsm.History))) + Expect(found.History[0]).To(Respect(fsm.History[0])) + Expect(found.History[1]).To(Respect(fsm.History[1])) + }) + It("can get events back", func() { + id := uuid.New().String() + ev := api.NewEvent("confirmed") + key := storage.NewKeyForEvent(id, "orders") + val, _ := proto.Marshal(ev) + _, err := rdb.Set(context.Background(), key, val, storage.NeverExpire).Result() + Expect(err).ToNot(HaveOccurred()) - found, ok := store.GetEvent(id, "orders") - Expect(ok).To(BeTrue()) - Expect(found).To(Respect(ev)) - }) - It("can save events", func() { - ev := api.NewEvent("confirmed") - id := ev.EventId - Expect(store.PutEvent(ev, "orders", storage.NeverExpire)).ToNot(HaveOccurred()) - val, err := rdb.Get(context.Background(), storage.NewKeyForEvent(id, "orders")).Bytes() - Expect(err).ToNot(HaveOccurred()) + found, ok := store.GetEvent(id, "orders") + Expect(ok).To(BeTrue()) + Expect(found).To(Respect(ev)) + }) + It("can save events", func() { + ev := api.NewEvent("confirmed") + id := ev.EventId + Expect(store.PutEvent(ev, "orders", storage.NeverExpire)).ToNot(HaveOccurred()) + val, err := rdb.Get(context.Background(), storage.NewKeyForEvent(id, "orders")).Bytes() + Expect(err).ToNot(HaveOccurred()) - var found protos.Event - Expect(proto.Unmarshal(val, &found)).ToNot(HaveOccurred()) - Expect(&found).To(Respect(ev)) - }) - It("will return an error for a non-existent event", func() { - _, ok := store.GetEvent("fake", "orders") - Expect(ok).To(BeFalse()) - }) - It("can save an event Outcome", func() { - id := uuid.New().String() - cfg := "orders" - response := &protos.EventOutcome{ - Code: protos.EventOutcome_Ok, - Dest: "1234-feed-beef", - Details: "this was just a test", - } - Expect(store.AddEventOutcome(id, cfg, response, storage.NeverExpire)).ToNot(HaveOccurred()) + var found protos.Event + Expect(proto.Unmarshal(val, &found)).ToNot(HaveOccurred()) + Expect(&found).To(Respect(ev)) + }) + It("will return an error for a non-existent event", func() { + _, ok := store.GetEvent("fake", "orders") + Expect(ok).To(BeFalse()) + }) + It("can save an event Outcome", func() { + id := uuid.New().String() + cfg := "orders" + response := &protos.EventOutcome{ + Code: protos.EventOutcome_Ok, + Dest: "1234-feed-beef", + Details: "this was just a test", + } + Expect(store.AddEventOutcome(id, cfg, response, storage.NeverExpire)).ToNot(HaveOccurred()) - key := storage.NewKeyForOutcome(id, cfg) - val, err := rdb.Get(context.Background(), key).Bytes() - Expect(err).ToNot(HaveOccurred()) - var found protos.EventOutcome - Expect(proto.Unmarshal(val, &found)).ToNot(HaveOccurred()) - Expect(&found).To(Respect(response)) - }) - It("can get an event Outcome", func() { - id := uuid.New().String() - cfg := "orders" - response := &protos.EventOutcome{ - Code: protos.EventOutcome_Ok, - Dest: "1234-feed-beef", - Details: "this was just a test", - } - key := storage.NewKeyForOutcome(id, cfg) - val, _ := proto.Marshal(response) - _, err := rdb.Set(context.Background(), key, val, storage.NeverExpire).Result() - Expect(err).ToNot(HaveOccurred()) - found, ok := store.GetOutcomeForEvent(id, cfg) - Expect(ok).To(BeTrue()) - Expect(found).To(Respect(response)) - }) - It("should gracefully handle a nil Configuration", func() { - Expect(store.PutConfig(nil)).To(HaveOccurred()) - }) - It("should gracefully handle a nil Statemachine", func() { - Expect(store.PutStateMachine("fake", nil)).To(HaveOccurred()) - }) - It("should gracefully handle a nil Event", func() { - Expect(store.PutEvent(nil, "orders", storage.NeverExpire)).To(HaveOccurred()) - }) - It("should gracefully handle a nil Outcome", func() { - Expect(store.AddEventOutcome("fake", "test", nil, - storage.NeverExpire)).To(HaveOccurred()) - }) - }) + key := storage.NewKeyForOutcome(id, cfg) + val, err := rdb.Get(context.Background(), key).Bytes() + Expect(err).ToNot(HaveOccurred()) + var found protos.EventOutcome + Expect(proto.Unmarshal(val, &found)).ToNot(HaveOccurred()) + Expect(&found).To(Respect(response)) + }) + It("can get an event Outcome", func() { + id := uuid.New().String() + cfg := "orders" + response := &protos.EventOutcome{ + Code: protos.EventOutcome_Ok, + Dest: "1234-feed-beef", + Details: "this was just a test", + } + key := storage.NewKeyForOutcome(id, cfg) + val, _ := proto.Marshal(response) + _, err := rdb.Set(context.Background(), key, val, storage.NeverExpire).Result() + Expect(err).ToNot(HaveOccurred()) + found, ok := store.GetOutcomeForEvent(id, cfg) + Expect(ok).To(BeTrue()) + Expect(found).To(Respect(response)) + }) + It("should gracefully handle a nil Configuration", func() { + Expect(store.PutConfig(nil)).To(HaveOccurred()) + }) + It("should gracefully handle a nil Statemachine", func() { + Expect(store.PutStateMachine("fake", nil)).To(HaveOccurred()) + }) + It("should gracefully handle a nil Event", func() { + Expect(store.PutEvent(nil, "orders", storage.NeverExpire)).To(HaveOccurred()) + }) + It("should gracefully handle a nil Outcome", func() { + Expect(store.AddEventOutcome("fake", "test", nil, + storage.NeverExpire)).To(HaveOccurred()) + }) + }) }) diff --git a/storage/storage_suite_test.go b/storage/storage_suite_test.go index db27cf0..a56442f 100644 --- a/storage/storage_suite_test.go +++ b/storage/storage_suite_test.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,13 +10,13 @@ package storage_test import ( - "testing" + "testing" - . "github.com/onsi/ginkgo" - . "github.com/onsi/gomega" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" ) func TestStorage(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Storage Suite") + RegisterFailHandler(Fail) + RunSpecs(t, "Storage Suite") } diff --git a/storage/types.go b/storage/types.go index 0f77d9d..41ff6b5 100644 --- a/storage/types.go +++ b/storage/types.go @@ -1,17 +1,8 @@ /* * Copyright (c) 2022 AlertAvert.com. All rights reserved. * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Licensed under the Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0 * * Author: Marco Massenzio (marco@alertavert.com) */ @@ -19,53 +10,53 @@ package storage import ( - "fmt" - log "github.com/massenz/slf4go/logging" - protos "github.com/massenz/statemachine-proto/golang/api" - "time" + "fmt" + log "github.com/massenz/slf4go/logging" + protos "github.com/massenz/statemachine-proto/golang/api" + "time" ) var ( - IllegalStoreError = fmt.Errorf("error storing invalid data") - ConfigNotFoundError = fmt.Errorf("configuration not found") - FSMNotFoundError = fmt.Errorf("statemachine not found") - NotImplementedError = fmt.Errorf("this functionality has not been implemented yet") + IllegalStoreError = fmt.Errorf("error storing invalid data") + ConfigNotFoundError = fmt.Errorf("configuration not found") + FSMNotFoundError = fmt.Errorf("statemachine not found") + NotImplementedError = fmt.Errorf("this functionality has not been implemented yet") ) type ConfigurationStorageManager interface { - GetConfig(versionId string) (*protos.Configuration, bool) - PutConfig(cfg *protos.Configuration) error + GetConfig(versionId string) (*protos.Configuration, bool) + PutConfig(cfg *protos.Configuration) error } type FiniteStateMachineStorageManager interface { - GetStateMachine(id string, cfg string) (*protos.FiniteStateMachine, bool) - PutStateMachine(id string, fsm *protos.FiniteStateMachine) error - GetAllInState(cfg string, state string) []*protos.FiniteStateMachine + GetStateMachine(id string, cfg string) (*protos.FiniteStateMachine, bool) + PutStateMachine(id string, fsm *protos.FiniteStateMachine) error + GetAllInState(cfg string, state string) []*protos.FiniteStateMachine } type EventStorageManager interface { - GetEvent(id string, cfg string) (*protos.Event, bool) - PutEvent(event *protos.Event, cfg string, ttl time.Duration) error - - // AddEventOutcome adds the outcome of an event to the storage, given the `eventId` and the - // "type" (`Configuration.Name`) of the FSM that received the event. - // - // Optionally, it will remove the outcome after a given `ttl` (time-to-live); use - // `NeverExpire` to keep the outcome forever. - AddEventOutcome(eventId string, cfgName string, response *protos.EventOutcome, - ttl time.Duration) error - - // GetOutcomeForEvent returns the outcome of an event, given the `eventId` and the "type" of the - // FSM that received the event. - GetOutcomeForEvent(eventId string, cfgName string) (*protos.EventOutcome, bool) + GetEvent(id string, cfg string) (*protos.Event, bool) + PutEvent(event *protos.Event, cfg string, ttl time.Duration) error + + // AddEventOutcome adds the outcome of an event to the storage, given the `eventId` and the + // "type" (`Configuration.Name`) of the FSM that received the event. + // + // Optionally, it will remove the outcome after a given `ttl` (time-to-live); use + // `NeverExpire` to keep the outcome forever. + AddEventOutcome(eventId string, cfgName string, response *protos.EventOutcome, + ttl time.Duration) error + + // GetOutcomeForEvent returns the outcome of an event, given the `eventId` and the "type" of the + // FSM that received the event. + GetOutcomeForEvent(eventId string, cfgName string) (*protos.EventOutcome, bool) } type StoreManager interface { - log.Loggable - ConfigurationStorageManager - FiniteStateMachineStorageManager - EventStorageManager - SetTimeout(duration time.Duration) - GetTimeout() time.Duration - Health() error + log.Loggable + ConfigurationStorageManager + FiniteStateMachineStorageManager + EventStorageManager + SetTimeout(duration time.Duration) + GetTimeout() time.Duration + Health() error }