From 17cd1f4abb222c448d977a23c5d60c854e8dd746 Mon Sep 17 00:00:00 2001 From: Yuly Date: Tue, 4 Oct 2022 14:55:34 -0700 Subject: [PATCH 01/12] Support Redis cluster mode --- .run/Run SQS Client.run.xml | 2 +- README.md | 221 ++++++++++++++++++++++++------------ build.settings | 2 +- clients/sqs_client.go | 2 +- cmd/main.go | 8 +- docker/entrypoint.sh | 2 +- pubsub/listener.go | 7 +- storage/redis_store.go | 40 +++---- 8 files changed, 179 insertions(+), 105 deletions(-) diff --git a/.run/Run SQS Client.run.xml b/.run/Run SQS Client.run.xml index f13da06..869c1a7 100644 --- a/.run/Run SQS Client.run.xml +++ b/.run/Run SQS Client.run.xml @@ -2,7 +2,7 @@ - + diff --git a/README.md b/README.md index d6a69ad..b5663ad 100644 --- a/README.md +++ b/README.md @@ -56,72 +56,12 @@ See [Sending Events](#sending-events) below for details on how to encode an SQS The HTTP server exposes a REST API that allows to create (`POST`) and retrieve (`GET`) both `configurations` and `statemachines`, encoding their contents using JSON. -### State Machines - -To create a `statemachine` simply requires indicating its configuration and an (optional) ID: - -``` -POST /api/v1/statemachines - -{ - "configuration_version": "devices:v3" -} -``` - -if the optional `id` is omitted, one will be generated and returned in the `Location` header (as well as in the body of the response): - -``` -Location: /api/v1/statemachines/6b5af0e8-9033-47e2-97db-337476f1402a - -{ - "id": "6b5af0e8-9033-47e2-97db-337476f1402a", - "statemachine": { - "config_id": "devices:v3", - "state": "started" - } -} -``` - -To obtain the current state of the FSM, simply use a `GET`: - -``` -GET /api/v1/statemachines/6b5af0e8-9033-47e2-97db-337476f1402a - -200 OK - -{ - "id": "6b5af0e8-9033-47e2-97db-337476f1402a", - "statemachine": { - "config_id": "devices:v3", - "state": "backorderd", - "history": [ - { - "event_id": "258", - "timestamp": { - "seconds": 1661733324, - "nanos": 461000000 - }, - "transition": { - "from": "started", - "to": "backorderd", - "event": "backorder" - }, - "originator": "SimpleSender" - } - ] - } -} -``` - -which shows that an event `backorder` was sent at `Sun Aug 28 2022 17:35:24 PDT` (the `timestamp` in seconds from epoch) transitioning our device order to a `backordered` state. - -See [`sqs_client`](clients/sqs_client.go) for a fully worked out example as to how to send an SQS event. - - ### Configurations Before creating an FSM, you need to define the associated configuration (trying to create an FSM with a `configuration_version` that does not match an existing `configuration` will result in a `404 NOT FOUND` error). +> `TODO`: we may eventually choose to return a more descriptive error code (e.g., either a `403 FORBIDDEN` or a `406 METHOD NOT ALLOWED` in future) + To create a new configuration use: ``` @@ -184,7 +124,7 @@ Location: /api/v1/configurations/test.orders:v3 Configurations are deemed to be immutable, so no `PUT` is offered, and also trying to re-create a configuration with the same `{name, version}` tuple will result in a `409 CONFLICT` error. -Similarly to FSMs, configurations can be retrieved using the `GET` and endpoint returned: +Configurations can be retrieved using the `GET` and endpoint returned: ``` GET /api/v1/configurations/test.orders:v3 @@ -204,6 +144,102 @@ GET /api/v1/configurations/test.orders:v3 } ``` +### State Machines + +To create a `statemachine` simply requires indicating its [configuration](#configurations) and an (optional) ID: + +``` +POST /api/v1/statemachines + +{ + "configuration_version": "devices:v3", + "id": "6b5af0e8-9033-47e2-97db-337476f1402a" +} +``` + +if the optional `id` is omitted, one will be generated and returned in the `Location` header (as well as in the body of the response): + +``` +Location: /api/v1/statemachines/devices/6b5af0e8-9033-47e2-97db-337476f1402a + +{ + "id": "6b5af0e8-9033-47e2-97db-337476f1402a", + "statemachine": { + "config_id": "devices:v3", + "state": "started" + } +} +``` + +> **Note** +> The "type" of FSM (in other words, its configuration - but **not** the version) +> is included in the FSM's URI. + +To obtain the current state of the FSM, simply use a `GET`: + +``` +GET /api/v1/statemachines/devices/6b5af0e8-9033-47e2-97db-337476f1402a + +200 OK + +{ + "id": "6b5af0e8-9033-47e2-97db-337476f1402a", + "statemachine": { + "config_id": "devices:v3", + "state": "backorderd", + "history": [ + { + "event_id": "258", + "timestamp": { + "seconds": 1661733324, + "nanos": 461000000 + }, + "transition": { + "from": "started", + "to": "backorderd", + "event": "backorder" + }, + "originator": "SimpleSender" + } + ] + } +} +``` + +which shows that an event `backorder` was sent at `Sun Aug 28 2022 17:35:24 PDT` (the `timestamp` in seconds from epoch) transitioning our device order to a `backordered` state. + +Again, the "type" of FSM **must** be specified in the URL (`/devices`). + +See [`grpc_client`](clients/grpc_client.go) for a fully worked out example as to how to send events to an FSM. + +### Event Outcomes + +After [sending an event](#sending-events), the outcome of the event can be retrieved using the `event_id` (either specified, or auto-generated by the server): + +``` +GET /api/v1/events/outcome/orders/f8b6a19b-12c9-40b1-aa35-240cd829b014 + +{ + "status_code": "Ok", + "message": "event [accept] transitioned FSM [25] to state [pending]", + "destination": "orders#6b5af0e8-9033-47e2-97db-337476f1402a" +} +``` + +if there was an error, it would be reported too, with the relevant message, if available: + +``` +GET /api/v1/events/outcome/test.orders/4018047a-50c1-45ea-b87e-e79b195568db + +{ + "status_code": "TransitionNotAllowed", + "message": "event [self-destroy] could not be processed: unexpected event transition", + "destination": "test.orders#6b5af0e8-9033-47e2-97db-337476f1402a" +} +``` + +Note that, as for FSMs, we need to qualify the `event_id` with the `configuration.name` in the URL (in this example `test.orders`). + ## Sending Events > Note that **it is not possible to send events via the REST API**: this is **by design** and not just a "missing feature"; please do not submit requests to add a `POST /api/v1/events` API: it's not going to happen. @@ -238,7 +274,8 @@ msg := &protos.EventRequest{ // This is the unique ID for the entity you are sending the event to; MUST // match the `id` of an existing `statemachine` (see the REST API). - Dest: "6b5af0e8-9033-47e2-97db-337476f1402a", + // NOTE -- the ID is prefixed by the configuration name. + Dest: "devices#6b5af0e8-9033-47e2-97db-337476f1402a", } _, err = queue.SendMessage(&sqs.SendMessageInput{ @@ -248,11 +285,43 @@ _, err = queue.SendMessage(&sqs.SendMessageInput{ }) ``` -This will cause a `backorder` event to be sent to our FSM whose `id` matches the UUID in `Dest`; if there are errors (eg, the FSM does not exist, or the event is not allowed for the machine's configuration and current state) errors may be optionally sent to the SQS queue configured via the `-errors` option (see [Running the Server](#running-the-server)): see the [`pubsub` code](pubsub/sqs_pub.go) code for details as to how we encode the error message as an SQS message. +This will cause a `backorder` event to be sent to our FSM whose `id` matches the UUID in `Dest`; if there are errors (eg, the FSM does not exist, or the event is not allowed for the machine's configuration and current state) errors may be optionally sent to the SQS queue configured via the `-notifications` option (see [Running the Server](#running-the-server)): see the [`pubsub` code](pubsub/sqs_pub.go) code for details as to how we encode the error message as an SQS message. See [`EventRequest` in `statemachine-proto`](https://github.com/massenz/statemachine-proto/blob/main/api/statemachine.proto#L86) for details on the event being sent. -#### SQS Error notifications +To try this out, you can use the [`SQS Client`](clients/sqs_client.go) example: + +``` +└─( http POST :7399/api/v1/statemachines configuration_version=test.orders:v3 id=26 +HTTP/1.1 201 Created +Location: /api/v1/statemachines/test.orders/26 + +{ + "id": "26", + "statemachine": { + "config_id": "test.orders:v3", + "state": "start" + } +} + +└─( SQS_Client -endpoint http://localhost:4566 -q events \ + -dest test.orders#26 -evt ship + +Publishing Event `ship` for FSM `test.orders#26` to SQS Topic: [events] +Sent event [724ea354-4739-4782-8785-6ce55b86a25d] to queue events + +└─( http :7399/api/v1/events/outcome/test.orders/724ea354-4739-4782-8785-6ce55b86a25d +HTTP/1.1 200 OK + +{ + "destination": "test.orders#26", + "message": "event [ship] transitioned FSM [25] to state [shipped]", + "status_code": "Ok" +} +``` + + +#### SQS Notifications Event processing outcomes are returned in [`EventResponse` protocol buffers](https://github.com/massenz/statemachine-proto/blob/main/api/statemachine.proto#L112), which are then serialized inside the `body` of the SQS message; to retrieve the actual Go struct, you can use code such as this (see [test code](pubsub/sqs_pub_test.go#L148) for actual working code): @@ -269,14 +338,14 @@ receivedEvt.EventId --> is the ID of the Event that failed if receivedEvt.Outcome.Code == protos.EventOutcome_InternalError { // whatever... } -return fmt.Errorf("cannot process event to statemachine [%s]: %s, +return fmt.Errorf("cannot process event to statemachine [%s]: %s, receivedEvt.Outcome.Dest, receivedEvt.Outcome.Details) ``` The possible error codes are (see the `.proto` definition for the up-to-date values): -```protobuf +```proto enum StatusCode { Ok = 0; GenericError = 1; @@ -297,7 +366,7 @@ Please refer to [gRPC documentation](https://grpc.io/docs/), the [example gRPC c The TL;DR version of all the above is that code like this: -```golang +```go response, err := client.ProcessEvent(context.Background(), &api.EventRequest{ Event: &api.Event{ @@ -331,7 +400,7 @@ To install the CLI run this: > **Beware** Gingko now is at `v2` and will install that one by default if you follow the instruction on the site: use instead the command above and run `go mod tidy` before running the tests/builds to download packages
> (see [this issue](https://github.com/onsi/ginkgo/issues/945) for more details) - + **Protocol Buffers definitions**
They are kept in the [statemachine-proto](https://github.com/massenz/statemachine-proto) repository; nothing specific is needed to use them; however, if you want to review the messages and services definitions, you can see them there. @@ -363,17 +432,15 @@ To create the necessary SQS Queues in AWS, please see the `aws` CLI command in ` The `sm-server` accepts a number of configuration options (some of them are **required**): ``` -└─( build/bin/sm-server -help +└─( build/bin/sm-server -help Usage of build/bin/sm-server: -debug Verbose logs; better to avoid on Production services -endpoint-url string HTTP URL for AWS SQS to connect to; usually best left undefined, unless required for local testing purposes (LocalStack uses http://localhost:4566) - -errors string - The name of the Dead-Letter Queue (DLQ) in SQS to post errors to; if not specified, the DLQ will not be used -events string - If defined, it will attempt to connect to the given SQS Queue (ignores any value that is passed via the -kafka flag) + If defined, it will attempt to connect to the given SQS Queue to receive events from the Pub/Sub system -grpc-port int The port for the gRPC server (default 7398) -http-port int @@ -382,8 +449,12 @@ Usage of build/bin/sm-server: If set, it only listens to incoming requests from the local host -max-retries int 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 -redis string URI for the Redis cluster (host:port) + -cluster + Enables connecting to a Redis deployment in cluster mode -timeout duration Timeout for Redis (as a Duration string, e.g. 1s, 20ms, etc.) (default 200ms) -trace @@ -454,3 +525,5 @@ 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%). + +We prefer you submit a PR directly from cloning this repository and creating a feature branch, rather than from a fork. diff --git a/build.settings b/build.settings index 06527af..4b619b4 100644 --- a/build.settings +++ b/build.settings @@ -1,4 +1,4 @@ # Build configuration -version = 0.6.0 +version = 0.6.1 diff --git a/clients/sqs_client.go b/clients/sqs_client.go index 81f4fab..e509da9 100644 --- a/clients/sqs_client.go +++ b/clients/sqs_client.go @@ -106,5 +106,5 @@ func main() { if err != nil { panic(err) } - fmt.Println("Sent event to queue", *q) + fmt.Printf("Sent event [%s] to queue %s\n", msg.Event.EventId, *q) } diff --git a/cmd/main.go b/cmd/main.go index 57b89f3..c13f7de 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -76,7 +76,11 @@ func main() { 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", "", "URI for the Redis cluster (host:port)") + 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)") @@ -108,7 +112,7 @@ func main() { } else { logger.Info("Connecting to Redis server at %s", *redisUrl) logger.Info("with timeout: %s, max-retries: %d", *timeout, *maxRetries) - store = storage.NewRedisStore(*redisUrl, 1, *timeout, *maxRetries) + store = storage.NewRedisStore(*redisUrl, *cluster, 1, *timeout, *maxRetries) } server.SetStore(store) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 9451769..a2b3f37 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -25,7 +25,7 @@ then endpoint="--endpoint-url ${AWS_ENDPOINT}" fi -cmd="./sm-server -http-port ${SERVER_PORT} ${endpoint:-} ${DEBUG} \ +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} \ $@" diff --git a/pubsub/listener.go b/pubsub/listener.go index 32d0665..03e8540 100644 --- a/pubsub/listener.go +++ b/pubsub/listener.go @@ -90,10 +90,13 @@ func (listener *EventsListener) ListenForMessages() { 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, @@ -101,8 +104,8 @@ func (listener *EventsListener) ListenForMessages() { request.GetEvent().GetTransition().GetEvent(), err))) continue } - listener.logger.Info("Event `%s` transitioned FSM [%s] to state `%s` - updating store", - request.Event.Transition.Event, smId, fsm.State) + 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, diff --git a/storage/redis_store.go b/storage/redis_store.go index 6754972..5689781 100644 --- a/storage/redis_store.go +++ b/storage/redis_store.go @@ -44,7 +44,7 @@ const ( type RedisStore struct { logger *slf4go.Log - client *redis.Client + client redis.Cmdable Timeout time.Duration MaxRetries int } @@ -141,42 +141,36 @@ func (csm *RedisStore) GetTimeout() time.Duration { } func NewRedisStoreWithDefaults(address string) StoreManager { - return NewRedisStore(address, DefaultRedisDb, DefaultTimeout, DefaultMaxRetries) + return NewRedisStore(address, false, DefaultRedisDb, DefaultTimeout, DefaultMaxRetries) } -func NewRedisStore(address string, db int, timeout time.Duration, maxRetries int) StoreManager { - +func NewRedisStore(address string, isCluster bool, db int, timeout time.Duration, maxRetries int) StoreManager { logger := slf4go.NewLog(fmt.Sprintf("redis://%s/%d", address, db)) + var tlsConfig *tls.Config + var client redis.Cmdable + if os.Getenv("REDIS_TLS") != "" { logger.Info("Using TLS for Redis connection") tlsConfig = &tls.Config{MinVersion: tls.VersionTLS12} } - return &RedisStore{ - logger: logger, - client: redis.NewClient(&redis.Options{ + + if isCluster { + client = redis.NewClusterClient(&redis.ClusterOptions{ + TLSConfig: tlsConfig, + Addrs: strings.Split(address, ","), + }) + } else { + client = redis.NewClient(&redis.Options{ TLSConfig: tlsConfig, Addr: address, DB: db, // 0 means default DB - }), - Timeout: timeout, - MaxRetries: maxRetries, + }) } -} - -// FIXME: the "constructor" functions are very similar, the creation pattern will need to be -// refactored to avoid code duplication. -func NewRedisStoreWithCreds(address string, db int, timeout time.Duration, maxRetries int, - username string, password string) StoreManager { return &RedisStore{ - logger: slf4go.NewLog(fmt.Sprintf("redis:%s", address)), - client: redis.NewClient(&redis.Options{ - Addr: address, - Username: username, - Password: password, - DB: db, - }), + logger: slf4go.NewLog(fmt.Sprintf("redis://%s/%d", address, db)), + client: client, Timeout: timeout, MaxRetries: maxRetries, } From ec87bf75bdba387f7f4bcbd468c428d112bdc21b Mon Sep 17 00:00:00 2001 From: Marco Massenzio Date: Tue, 4 Oct 2022 23:37:51 -0700 Subject: [PATCH 02/12] Reformatted code & Updated copyright --- .run/Run All Tests.run.xml | 13 +- .run/Run SQS Client.run.xml | 11 +- .run/Run Server.run.xml | 11 +- .run/Run gRPC Client.run.xml | 11 +- Makefile | 4 + README.md | 37 +- api/fsm.go | 221 +++++------ api/statemachine_suite_test.go | 13 +- api/statemachine_test.go | 389 +++++++++--------- clients/grpc_client.go | 13 +- clients/orders.go | 13 +- clients/sqs_client.go | 13 +- cmd/main.go | 319 ++++++++------- docker/entrypoint.sh | 13 +- get-tag | 4 + grpc/grpc_server.go | 261 ++++++------ grpc/grpc_server_test.go | 499 +++++++++++------------ grpc/grpc_suite_test.go | 9 + pubsub/listener.go | 217 +++++----- pubsub/listener_test.go | 351 ++++++++--------- pubsub/pubsub_suite_test.go | 173 ++++---- pubsub/sqs_pub.go | 107 +++-- pubsub/sqs_pub_test.go | 291 +++++++------- pubsub/sqs_sub.go | 257 ++++++------ pubsub/sqs_sub_test.go | 125 +++--- pubsub/types.go | 67 ++-- server/configuration_handlers.go | 137 +++---- server/configuration_handlers_test.go | 287 +++++++------- server/event_handlers.go | 99 +++-- server/event_handlers_test.go | 327 ++++++++-------- server/health_handler.go | 85 ++-- server/http_server.go | 111 +++--- server/http_server_test.go | 13 +- server/server_suite_test.go | 13 +- server/statemachine_handlers.go | 163 ++++---- server/statemachine_handlers_test.go | 545 +++++++++++++------------- server/types.go | 13 +- storage/keys.go | 30 +- storage/memory_store.go | 157 ++++---- storage/memory_store_test.go | 177 ++++----- storage/redis_store.go | 13 +- storage/redis_store_test.go | 395 +++++++++---------- storage/storage_suite_test.go | 23 +- storage/types.go | 81 ++-- 44 files changed, 2942 insertions(+), 3169 deletions(-) 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..1c7d01e 100644 --- a/.run/Run SQS Client.run.xml +++ b/.run/Run SQS Client.run.xml @@ -1,3 +1,12 @@ + + @@ -9,4 +18,4 @@ - \ No newline at end of file + 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..2a5b0a9 100644 --- a/Makefile +++ b/Makefile @@ -57,3 +57,7 @@ clean: @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: + @go fmt $(pkgs) ./cmd ./clients diff --git a/README.md b/README.md index b5663ad..f95da1a 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 @@ -524,6 +526,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/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..4445468 100644 --- a/cmd/main.go +++ b/cmd/main.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) */ @@ -23,164 +14,164 @@ 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 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()) } // 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, sub, listener}, log.TRACE) + serverLogLevel = log.TRACE + } } // startGrpcServer will start a new gRPC server, bound to @@ -188,19 +179,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/entrypoint.sh b/docker/entrypoint.sh index a2b3f37..ebc6f0c 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -2,17 +2,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/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..55bb63d 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,118 @@ 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" + timeout = 5 * time.Second + eventsQueue = "test-events" + notificationsQueue = "test-notifications" ) 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} { + 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} { + 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..e095b1e 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,12 @@ 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" ) // NewSqsPublisher will create a new `Publisher` to send error notifications received on the @@ -33,57 +24,57 @@ 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, - } + client := getSqsClient(awsUrl) + if client == nil { + return nil + } + return &SqsPublisher{ + logger: log.NewLog("SQS-Pub"), + client: client, + errors: errorsChannel, + } } // 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") + 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") } diff --git a/pubsub/sqs_pub_test.go b/pubsub/sqs_pub_test.go index c011cfd..fcc63e9 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,152 @@ 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()) + 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)) + // 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) + 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") - } - }) - }) + 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") + } + }) + }) }) 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..96980a2 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,19 +32,19 @@ 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, @@ -61,18 +52,18 @@ type ListenerOptions struct { // // Error events are polled from the `errors` channel, and published to the SQS queue. type SqsPublisher struct { - logger *log.Log - client *sqs.SQS - errors <-chan protos.EventResponse + logger *log.Log + client *sqs.SQS + errors <-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 } From 021893f87900df44bd3e698bd471aa5e55d194ca Mon Sep 17 00:00:00 2001 From: Marco Massenzio <1153951+massenz@users.noreply.github.com> Date: Fri, 7 Oct 2022 22:07:37 -0700 Subject: [PATCH 03/12] Added the 'help' target (#53) --- Makefile | 73 +++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 27 deletions(-) diff --git a/Makefile b/Makefile index 2a5b0a9..e33bc87 100644 --- a/Makefile +++ b/Makefile @@ -20,44 +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: +.PHONY: queues +queues: ## Creates the SQS Queues in LocalStack @for queue in events notifications; 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)) - -.PHONY: build test container cov clean fmt -fmt: - @go fmt $(pkgs) ./cmd ./clients From bd9f72100c3ffcf876205ce6debea89bdac3325c Mon Sep 17 00:00:00 2001 From: Marco Massenzio <1153951+massenz@users.noreply.github.com> Date: Wed, 12 Oct 2022 11:56:27 -0700 Subject: [PATCH 04/12] Added `name` to Test job Apparently, missing the `name` property prevents using this check in the "protected branches" rule. --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9626e1a..0ff6fa8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,6 +13,7 @@ on: jobs: Test: + name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 From 48de0edb8dc059f441148607c5f8f67f9a67ca9c Mon Sep 17 00:00:00 2001 From: Marco Massenzio <1153951+massenz@users.noreply.github.com> Date: Wed, 12 Oct 2022 12:02:26 -0700 Subject: [PATCH 05/12] Update build.yml --- .github/workflows/build.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ff6fa8..534a762 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,7 @@ # Copyright (c) 2022 AlertAvert.com. All rights reserved. # Author: Marco Massenzio (marco@alertavert.com) # -name: Build & Test +name: Test on: push: @@ -13,7 +13,6 @@ on: jobs: Test: - name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 From d6f2d208beb7caf2c02924e0ca012be3cc1825b1 Mon Sep 17 00:00:00 2001 From: Marco Massenzio <1153951+massenz@users.noreply.github.com> Date: Wed, 12 Oct 2022 12:15:46 -0700 Subject: [PATCH 06/12] Update build.yml --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 534a762..0ff6fa8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,7 @@ # Copyright (c) 2022 AlertAvert.com. All rights reserved. # Author: Marco Massenzio (marco@alertavert.com) # -name: Test +name: Build & Test on: push: @@ -13,6 +13,7 @@ on: jobs: Test: + name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 From 4b20788ae66750f2d6a05eabe323eba69e0e02d1 Mon Sep 17 00:00:00 2001 From: Marco Massenzio <1153951+massenz@users.noreply.github.com> Date: Wed, 12 Oct 2022 12:16:42 -0700 Subject: [PATCH 07/12] Update codeql-analysis.yml --- .github/workflows/codeql-analysis.yml | 5 ----- 1 file changed, 5 deletions(-) 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: From 488ab06107f8dc00e3bcd72d0e0027d2e2071600 Mon Sep 17 00:00:00 2001 From: Marco Massenzio <1153951+massenz@users.noreply.github.com> Date: Wed, 12 Oct 2022 12:25:20 -0700 Subject: [PATCH 08/12] Update build.yml --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ff6fa8..9626e1a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,6 @@ on: jobs: Test: - name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 From 740452820beaf5dd4aa3703641833aed55cf9868 Mon Sep 17 00:00:00 2001 From: Zach C <110567319+z-cran@users.noreply.github.com> Date: Wed, 12 Oct 2022 13:10:31 -0700 Subject: [PATCH 09/12] [#49] Add optional separate channel for non-error outcomes (#54) --- Makefile | 2 +- README.md | 11 +++- cmd/main.go | 20 ++++-- docker/entrypoint.sh | 6 +- pubsub/pubsub_suite_test.go | 9 ++- pubsub/sqs_pub.go | 40 ++++++++---- pubsub/sqs_pub_test.go | 120 ++++++++++++++++++++++++++++++------ pubsub/types.go | 10 ++- 8 files changed, 172 insertions(+), 46 deletions(-) diff --git a/Makefile b/Makefile index e33bc87..1d8a9ac 100644 --- a/Makefile +++ b/Makefile @@ -76,7 +76,7 @@ services: ## Starts the Redis and LocalStack containers .PHONY: queues queues: ## Creates the SQS Queues in LocalStack - @for queue in events notifications; do \ + @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 diff --git a/README.md b/README.md index f95da1a..ba5b9d6 100644 --- a/README.md +++ b/README.md @@ -453,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 @@ -515,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): diff --git a/cmd/main.go b/cmd/main.go index 4445468..d6661d9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -71,7 +71,7 @@ func main() { "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") + "If set, allows 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)") @@ -80,6 +80,12 @@ func main() { 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 acksTopic = flag.String("acks", "", + "(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") + var notifyErrorsOnly = flag.Bool("notify-errors-only", false, + "If set, only errors will be sent to notification topics") 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") @@ -113,6 +119,9 @@ func main() { 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 { @@ -120,14 +129,17 @@ func main() { } if *notificationsTopic != "" { - logger.Info("Configuring DLQ Topic: %s", *notificationsTopic) + logger.Info("Configuring Topic: %s", notificationsTopic) + if *acksTopic != "" { + logger.Info("Configuring 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) + go pub.Publish(*notificationsTopic, *acksTopic, *notifyErrorsOnly) } listener = pubsub.NewEventsListener(&pubsub.ListenerOptions{ EventsChannel: eventsCh, @@ -169,7 +181,7 @@ func setLogLevel(debug bool, trace bool) { logger.Warn("trace logging Enabled") logger.Level = log.TRACE server.EnableTracing() - SetLogLevel([]log.Loggable{store, sub, listener}, log.TRACE) + SetLogLevel([]log.Loggable{store, pub, sub, listener}, log.TRACE) serverLogLevel = log.TRACE } } diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index ebc6f0c..829d81d 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -16,9 +16,11 @@ then endpoint="--endpoint-url ${AWS_ENDPOINT}" fi -cmd="./sm-server -http-port ${SERVER_PORT} ${endpoint:-} ${CLUSTER} ${DEBUG} \ + +cmd="./sm-server -http-port ${SERVER_PORT} ${endpoint:-} \ +${CLUSTER:-} ${DEBUG:-} ${NOTIFY_ERRORS_ONLY:-} \ -redis ${REDIS}:${REDIS_PORT} -timeout ${TIMEOUT:-25ms} -max-retries ${RETRIES:-3} \ --events ${EVENTS_Q} -notifications ${ERRORS_Q} \ +-events ${EVENTS_Q} -notifications ${ERRORS_Q} ${ACKS:-} \ $@" echo $cmd diff --git a/pubsub/pubsub_suite_test.go b/pubsub/pubsub_suite_test.go index 55bb63d..d000eb8 100644 --- a/pubsub/pubsub_suite_test.go +++ b/pubsub/pubsub_suite_test.go @@ -29,9 +29,12 @@ import ( ) const ( - timeout = 5 * time.Second 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) { @@ -56,7 +59,7 @@ var ( var _ = BeforeSuite(func() { testLog.Level = log.NONE Expect(os.Setenv("AWS_REGION", region)).ToNot(HaveOccurred()) - for _, topic := range []string{eventsQueue, notificationsQueue} { + for _, topic := range []string{eventsQueue, notificationsQueue, acksQueue} { topic = fmt.Sprintf("%s-%d", topic, GinkgoParallelProcess()) _, err := testSqsClient.GetQueueUrl(&sqs.GetQueueUrlInput{ @@ -74,7 +77,7 @@ var _ = BeforeSuite(func() { }) var _ = AfterSuite(func() { - for _, topic := range []string{eventsQueue, notificationsQueue} { + for _, topic := range []string{eventsQueue, notificationsQueue, acksQueue} { topic = getQueueName(topic) out, err := testSqsClient.GetQueueUrl(&sqs.GetQueueUrlInput{ diff --git a/pubsub/sqs_pub.go b/pubsub/sqs_pub.go index e095b1e..f443d49 100644 --- a/pubsub/sqs_pub.go +++ b/pubsub/sqs_pub.go @@ -23,15 +23,15 @@ 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 { +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, - errors: errorsChannel, + logger: log.NewLog("SQS-Pub"), + client: client, + notifications: channel, } } @@ -56,13 +56,31 @@ func GetQueueUrl(client *sqs.SQS, topic string) string { 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) +// 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) { + s.logger.Info("SQS Publisher started for topics: %s %s", errorsTopic, acksTopic) + s.logger.Info("SQS Publisher notifyErrorsOnly: %s", notifyErrorsOnly) + + errorsQueueUrl := GetQueueUrl(s.client, errorsTopic) + var acksQueueUrl string + if acksTopic != "" { + acksQueueUrl = GetQueueUrl(s.client, acksTopic) + } + delay := int64(0) + 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, diff --git a/pubsub/sqs_pub_test.go b/pubsub/sqs_pub_test.go index fcc63e9..24493d3 100644 --- a/pubsub/sqs_pub_test.go +++ b/pubsub/sqs_pub_test.go @@ -37,6 +37,8 @@ var _ = Describe("SQS Publisher", func() { 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{ @@ -50,12 +52,12 @@ var _ = Describe("SQS Publisher", func() { done := make(chan interface{}) go func() { defer close(done) - go testPublisher.Publish(getQueueName(notificationsQueue)) + go testPublisher.Publish(getQueueName(notificationsQueue), "", false) }() notificationsCh <- notification res := getSqsMessage(getQueueName(notificationsQueue)) - Expect(res).ToNot(BeNil()) - Expect(res.Body).ToNot(BeNil()) + Eventually(res).ShouldNot(BeNil()) + Eventually(res.Body).ShouldNot(BeNil()) // Emulate SQS Client behavior body := *res.Body @@ -81,19 +83,69 @@ var _ = Describe("SQS Publisher", func() { done := make(chan interface{}) go func() { defer close(done) - go testPublisher.Publish(getQueueName(notificationsQueue)) + go testPublisher.Publish(getQueueName(notificationsQueue), "", false) }() notificationsCh <- notification m := getSqsMessage(getQueueName(notificationsQueue)) var response protos.EventResponse - Expect(proto.UnmarshalText(*m.Body, &response)).ShouldNot(HaveOccurred()) - Expect(&response).To(Respect(¬ification)) + 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(100 * time.Millisecond): + 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") } }) @@ -101,7 +153,7 @@ var _ = Describe("SQS Publisher", func() { done := make(chan interface{}) go func() { defer close(done) - testPublisher.Publish(getQueueName(notificationsQueue)) + go testPublisher.Publish(getQueueName(notificationsQueue), "", false) }() close(notificationsCh) select { @@ -112,13 +164,13 @@ var _ = Describe("SQS Publisher", func() { } }) It("will survive an empty Message", func() { - go testPublisher.Publish(getQueueName(notificationsQueue)) + 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)) + go testPublisher.Publish(getQueueName(notificationsQueue), "", false) for i := range [10]int{} { evt := api.NewEvent("do-something") evt.EventId = fmt.Sprintf("event-%d", i) @@ -134,19 +186,21 @@ var _ = Describe("SQS Publisher", func() { 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. + // 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)) - Expect(res).ToNot(BeNil()) - Expect(res.Body).ToNot(BeNil()) + Eventually(res).ShouldNot(BeNil()) + Eventually(res.Body).ShouldNot(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-")) + 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) @@ -157,5 +211,35 @@ var _ = Describe("SQS Publisher", func() { 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/types.go b/pubsub/types.go index 96980a2..da69ecb 100644 --- a/pubsub/types.go +++ b/pubsub/types.go @@ -48,13 +48,11 @@ type ListenerOptions struct { } // 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. From 6c512a6f89ae672e4bf5b58212f9de539389be21 Mon Sep 17 00:00:00 2001 From: Marco Massenzio Date: Wed, 12 Oct 2022 18:37:02 -0700 Subject: [PATCH 10/12] Fix for Dockerfile --- build.settings | 2 +- docker/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/docker/Dockerfile b/docker/Dockerfile index 74d10dd..2a23930 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -14,7 +14,7 @@ ENV AWS_REGION=us-west-2 AWS_PROFILE=sm-bot # Sensible defaults for the server # See entrypoint.sh -ENV SERVER_PORT=7399 DEBUG="" \ +ENV SERVER_PORT=7399 DEBUG="" CLUSTER="" \ EVENTS_Q="events" ERRORS_Q="notifications" \ REDIS=redis REDIS_PORT=6379 From 879d2a3d6c9be26ce8f29152618041b3fcd2b2d4 Mon Sep 17 00:00:00 2001 From: Zach C <110567319+z-cran@users.noreply.github.com> Date: Wed, 12 Oct 2022 20:10:10 -0700 Subject: [PATCH 11/12] fixes logs showing pointers (#55) --- cmd/main.go | 4 ++-- pubsub/sqs_pub.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index d6661d9..a3c8670 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -129,9 +129,9 @@ func main() { } if *notificationsTopic != "" { - logger.Info("Configuring Topic: %s", notificationsTopic) + logger.Info("Configuring Topic: %s", *notificationsTopic) if *acksTopic != "" { - logger.Info("Configuring Topic: %s", acksTopic) + logger.Info("Configuring Topic: %s", *acksTopic) } notificationsCh = make(chan protos.EventResponse) defer close(notificationsCh) diff --git a/pubsub/sqs_pub.go b/pubsub/sqs_pub.go index f443d49..671313e 100644 --- a/pubsub/sqs_pub.go +++ b/pubsub/sqs_pub.go @@ -16,6 +16,7 @@ import ( "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 @@ -61,15 +62,14 @@ func GetQueueUrl(client *sqs.SQS, topic string) string { // 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) { - s.logger.Info("SQS Publisher started for topics: %s %s", errorsTopic, acksTopic) - s.logger.Info("SQS Publisher notifyErrorsOnly: %s", notifyErrorsOnly) - 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 { From 0b6712301394a3469ed528a1b1c585e90588b156 Mon Sep 17 00:00:00 2001 From: Marco Massenzio <1153951+massenz@users.noreply.github.com> Date: Wed, 12 Oct 2022 22:15:01 -0700 Subject: [PATCH 12/12] Refactored startup flags and Dockerfile accordingly (#56) --- .run/Run SQS Client.run.xml | 13 +------ cmd/main.go | 73 ++++++++++++++++++------------------- docker/Dockerfile | 13 ++++--- docker/entrypoint.sh | 43 +++++++++++++++++++--- 4 files changed, 82 insertions(+), 60 deletions(-) diff --git a/.run/Run SQS Client.run.xml b/.run/Run SQS Client.run.xml index 1c7d01e..6cf872a 100644 --- a/.run/Run SQS Client.run.xml +++ b/.run/Run SQS Client.run.xml @@ -1,21 +1,12 @@ - - - + - + \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index a3c8670..362d092 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -7,10 +7,6 @@ * 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 ( @@ -58,48 +54,46 @@ var ( func main() { 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 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 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 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, - "If set, allows 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 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", "", - "The name of the notification topic in SQS to publish events' outcomes to; if not "+ + "(optional) The name of the topic to publish events' outcomes to; if not "+ "specified, no outcomes will be published") - var acksTopic = flag.String("acks", "", - "(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") var notifyErrorsOnly = flag.Bool("notify-errors-only", false, - "If set, only errors will be sent to notification topics") - 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") + "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) + logger.Info("starting State Machine Server - Rel. %s", server.Release) if *localOnly { - logger.Info("Listening on local interface only") + logger.Info("listening on local interface only") host = "localhost" } else { - logger.Warn("Listening on all interfaces") + logger.Warn("listening on all interfaces") } addr := fmt.Sprintf("%s:%d", host, *port) @@ -107,7 +101,7 @@ func main() { 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("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) } @@ -122,16 +116,19 @@ func main() { if *acksTopic != "" && *notifyErrorsOnly { logger.Fatal(fmt.Errorf("cannot set an acks topic while disabling errors notifications")) } - logger.Info("Connecting to SQS Topic: %s", *eventsTopic) + 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 Topic: %s", *notificationsTopic) + logger.Info("notifications topic: %s", *notificationsTopic) + if *notifyErrorsOnly { + logger.Info("only errors will be published to the notifications topic") + } if *acksTopic != "" { - logger.Info("Configuring Topic: %s", *acksTopic) + logger.Info("acks topic: %s", *acksTopic) } notificationsCh = make(chan protos.EventResponse) defer close(notificationsCh) @@ -153,15 +150,15 @@ func main() { // This should not be invoked until we have initialized all the services. setLogLevel(*debug, *trace) - logger.Info("Starting Events Listener") + logger.Info("starting events listener") go listener.ListenForMessages() - logger.Info("gRPC Server running at tcp://:%d", *grpcPort) + 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) + logger.Info("HTTP server (REST API) running at %s://%s", scheme, addr) srv := server.NewHTTPServer(addr, serverLogLevel) logger.Fatal(srv.ListenAndServe()) } diff --git a/docker/Dockerfile b/docker/Dockerfile index 2a23930..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="" CLUSTER="" \ - 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 829d81d..07895a2 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -10,17 +10,48 @@ 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:-} ${NOTIFY_ERRORS_ONLY:-} \ --redis ${REDIS}:${REDIS_PORT} -timeout ${TIMEOUT:-25ms} -max-retries ${RETRIES:-3} \ --events ${EVENTS_Q} -notifications ${ERRORS_Q} ${ACKS:-} \ +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