├── .gitignore ├── .gitlab-ci.yml ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── bin ├── app ├── release_name └── version ├── dev.config ├── erlang.mk ├── log └── .gitignore ├── rel ├── sys.config └── vm.args ├── relx.config ├── snapshots └── .gitignore ├── src ├── tansu.erl ├── tansu_api.erl ├── tansu_api_info_resource.erl ├── tansu_api_keys_resource.erl ├── tansu_api_locks_resource.erl ├── tansu_api_proxy_resource.erl ├── tansu_api_server_resource.erl ├── tansu_app.erl ├── tansu_cluster_membership.erl ├── tansu_config.erl ├── tansu_consensus.erl ├── tansu_consensus_candidate.erl ├── tansu_consensus_follower.erl ├── tansu_consensus_leader.erl ├── tansu_kv_expiry.erl ├── tansu_kv_snapshot.erl ├── tansu_log.erl ├── tansu_log.hrl ├── tansu_mnesia.erl ├── tansu_oapi_resource.erl ├── tansu_ps.erl ├── tansu_rpc.erl ├── tansu_sm.erl ├── tansu_sm_mnesia_expiry.erl ├── tansu_sm_mnesia_kv.erl ├── tansu_sm_mnesia_tx.erl ├── tansu_stream.erl ├── tansu_sup.erl ├── tansu_tcp_advertiser.erl ├── tansu_timeout.erl └── tansu_uuid.erl └── test ├── .gitignore ├── common.erl ├── docker_client.erl ├── kvc.erl ├── property_test └── kv_simple.erl └── tansu_SUITE.erl /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.plt 3 | erl_crash.dump 4 | deps 5 | .eunit 6 | .DS_Store 7 | .erlang.mk.* 8 | ebin 9 | _rel 10 | relx 11 | logs 12 | .erlang.mk 13 | db 14 | xrefr 15 | *.d 16 | elvis 17 | elvis.config 18 | \#*\# 19 | *.log 20 | .fuse* -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: shortishly/docker-erlang 2 | 3 | stages: 4 | - build 5 | 6 | master: 7 | stage: build 8 | script: 9 | - make 10 | - docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD 11 | - docker tag $(bin/release_name):$(bin/version) $DOCKER_USERNAME/$(bin/app):$(bin/version) 12 | - docker tag $(bin/release_name):$(bin/version) $DOCKER_USERNAME/$(bin/app):latest 13 | - docker push $DOCKER_USERNAME/$(bin/app):$(bin/version) 14 | - docker push $DOCKER_USERNAME/$(bin/app):latest 15 | - docker rmi $DOCKER_USERNAME/$(bin/app):$(bin/version) 16 | - docker rmi $DOCKER_USERNAME/$(bin/app):latest 17 | - docker rmi $(bin/release_name):$(bin/version) 18 | only: 19 | - master 20 | 21 | app: 22 | stage: build 23 | script: 24 | - make deps app 25 | only: 26 | - develop 27 | 28 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: erlang 2 | 3 | otp_release: 4 | - 18.2 5 | 6 | script: 7 | - make deps app 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM scratch 2 | 3 | ARG REL_NAME 4 | ARG REL_VSN=1 5 | ARG ERTS_VSN 6 | 7 | ENV BINDIR /erts-${ERTS_VSN}/bin 8 | ENV BOOT /releases/${REL_VSN}/${REL_NAME} 9 | ENV CONFIG /releases/${REL_VSN}/sys.config 10 | ENV ARGS_FILE /releases/${REL_VSN}/vm.args 11 | 12 | ENV TZ=GMT 13 | 14 | ENTRYPOINT exec ${BINDIR}/erlexec \ 15 | -boot_var /lib \ 16 | -boot ${BOOT} \ 17 | -noinput \ 18 | -config ${CONFIG} \ 19 | -args_file ${ARGS_FILE} 20 | 21 | EXPOSE 22 80 22 | VOLUME /db /snapshosts 23 | 24 | ADD _rel/${REL_NAME}/ / 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #-*- mode: makefile-gmake -*- 2 | # Copyright (c) 2016 Peter Morgan 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | # 16 | PROJECT = tansu 17 | PROJECT_DESCRIPTION = Tansu distributed key/value and lock store 18 | PROJECT_VERSION = 0.28.0 19 | 20 | DEPS = \ 21 | cors \ 22 | cowboy \ 23 | crown \ 24 | envy \ 25 | gproc \ 26 | gun \ 27 | jsx \ 28 | mdns \ 29 | recon \ 30 | rfc4122 \ 31 | shelly 32 | 33 | LOCAL_DEPS = \ 34 | crypto \ 35 | inets \ 36 | mnesia \ 37 | sasl 38 | 39 | dep_cors = git https://github.com/shortishly/cors.git master 40 | dep_cowboy = git https://github.com/ninenines/cowboy.git 2.0.0-pre.3 41 | dep_crown = git https://github.com/shortishly/crown.git master 42 | dep_envy = git https://github.com/shortishly/envy.git master 43 | dep_mdns = git https://github.com/shortishly/mdns.git master 44 | dep_rfc4122 = git https://github.com/shortishly/rfc4122.git master 45 | dep_shelly = git https://github.com/shortishly/shelly.git master 46 | 47 | SHELL_OPTS = \ 48 | -boot start_sasl \ 49 | -config dev.config \ 50 | -mnesia dir db \ 51 | -s $(PROJECT) \ 52 | -s rb \ 53 | -s sync 54 | 55 | SHELL_DEPS = \ 56 | sync 57 | 58 | TEST_DEPS = \ 59 | triq 60 | 61 | include erlang.mk 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tansu 2 | 3 | [![Build Status](https://travis-ci.org/shortishly/tansu.svg)](https://travis-ci.org/shortishly/tansu) 4 | 5 | 6 | Tansu is a distributed key value store designed to maintain 7 | configuration and other data that must be highly available. It uses 8 | the [Raft Consensus algorithm](https://raft.github.io) for leadership 9 | election and distribution of state amongst its members. Node discovery 10 | is via [mDNS](https://github.com/shortishly/mdns) and will 11 | automatically form a mesh of nodes sharing the same environment. 12 | 13 | ## Features 14 | 15 | ### Key Value Store 16 | 17 | Tansu has a REST interface to set, get or delete the value represented 18 | by a key. It also provides a HTTP 19 | [Server Sent Event Stream](https://en.wikipedia.org/wiki/Server-sent_events) 20 | of changes to the store. 21 | 22 | ### Check And Set 23 | 24 | Tansu provides REST interface for simple Check And Set (CAS) 25 | operations. 26 | 27 | ### Locks 28 | 29 | Tansu provides test and set operations that can be used to operate 30 | locks through a simple REST based HTTP 31 | [Server Sent Event Stream](https://en.wikipedia.org/wiki/Server-sent_events) 32 | interface. 33 | 34 | 35 | ## Quick Start 36 | 37 | To start a 5 node Tansu cluster using Docker: 38 | 39 | ```shell 40 | for i in {1..5}; do 41 | docker run \ 42 | --name tansu-$(printf %03d $i) \ 43 | -d shortishly/tansu; 44 | done 45 | ``` 46 | 47 | The following examples use a random Tansu node: 48 | 49 | ```shell 50 | RANDOM_IP=$(docker inspect --format={{.NetworkSettings.IPAddress}} tansu-$(printf %03d $[1 + $[RANDOM % 5]])) 51 | ``` 52 | 53 | You can use the same `${RANDOM_IP}` for each example, or pick a new 54 | one each time. Tansu will automatically proxy any requests that must 55 | be handled to the `leader` if necessary (typically, locks, CAS and 56 | writes are handled by the leader) or can be handled by a `follower` 57 | (reads are handled directly by followers). 58 | 59 | 60 | ### Key Value Store 61 | 62 | Stream changes to any key below "hello": 63 | 64 | ```shell 65 | curl -i -s "http://${RANDOM_IP}/api/keys/hello?stream=true&children=true" 66 | ``` 67 | 68 | Note that you can create streams for keys that do not currently exist 69 | in the store. Once a value has been assigned to the key the stream 70 | will issue change notifications. You can also listen for changes to 71 | any key contained under the sub hierarchy by adding `children=true` to 72 | the query. 73 | 74 | #### Set 75 | 76 | In another shell assign the value "world" to the key "hello": 77 | 78 | ```shell 79 | curl -X PUT -i -s http://${RANDOM_IP}/api/keys/hello -d value=world 80 | ``` 81 | 82 | The stream will now contain a `create` notification: 83 | 84 | ```json 85 | id: 1 86 | event: create 87 | data: {"category":"user","key":"/hello","metadata":{"tansu":{"content_type":"text/plain","created":1,"parent":"/","updated":1}},"value":"world"} 88 | ``` 89 | 90 | Or a key that is below "hello": 91 | 92 | ```shell 93 | curl -X PUT -i -s http://${RANDOM_IP}/api/keys/hello/joe -d value=mike 94 | ``` 95 | 96 | The stream will now contain a `create` notification: 97 | 98 | ```shell 99 | id: 2 100 | event: create 101 | data: {"category":"user","key":"/hello/joe","metadata":{"tansu":{"content_type":"text/plain","created":2,"parent":"/hello","updated":2}},"value":"mike"} 102 | ``` 103 | 104 | Or with a content type: 105 | 106 | ```shell 107 | curl -X PUT -H "Content-Type: application/json" -i http://${RANDOM_IP}/api/keys/hello --data-binary '{"stuff": true}' 108 | ``` 109 | 110 | With an update in the stream: 111 | 112 | ```json 113 | id: 3 114 | event: set 115 | data: {"category":"user","key":"/hello","metadata":{"tansu":{"content_type":"application/json","created":1,"parent":"/","updated":3}},"previous":"world","value":{"stuff":true}} 116 | ``` 117 | 118 | #### Get 119 | 120 | The current value of "hello": 121 | 122 | ```shell 123 | curl -i -s http://${RANDOM_IP}/api/keys/hello 124 | ``` 125 | 126 | ```json 127 | {"stuff": true} 128 | ``` 129 | 130 | #### Delete 131 | 132 | Ask a random member of the cluster to delete the key "hello": 133 | 134 | ```shell 135 | curl -i -X DELETE http://${RANDOM_IP}/api/keys/hello 136 | ``` 137 | 138 | The stream now contains a `delete` notification: 139 | 140 | ```json 141 | id: 5 142 | event: delete 143 | data: {"category":"user","key":"/hello","metadata":{"tansu":{"content_type":"application/json","created":1,"parent":"/","updated":5}},"value":{"stuff":true}} 144 | ``` 145 | 146 | #### TTL 147 | 148 | A value can also be given a time to live by also supplying a TTL header: 149 | 150 | 151 | ```shell 152 | curl -X PUT -H "Content-Type: application/json" -H "ttl: 10" -i http://${RANDOM_IP}/api/keys/hello --data-binary '{"ephemeral": true}' 153 | ``` 154 | 155 | The event stream will contain details of the `create` together with a TTL 156 | attribute: 157 | 158 | ```json 159 | id: 6 160 | event: create 161 | data: {"category":"user","key":"/hello","metadata":{"tansu":{"content_type":"application/json","created":6,"parent":"/","ttl":10,"updated":6}},"value":{"ephemeral":true}} 162 | ``` 163 | 164 | Ten seconds later when the key is removed: 165 | 166 | ```json 167 | id: 7 168 | event: delete 169 | data: {"category":"user","key":"/hello","metadata":{"tansu":{"content_type":"application/json","created":6,"parent":"/","ttl":0,"updated":7}},"value":{"ephemeral":true}} 170 | ``` 171 | 172 | ### Test and Set 173 | 174 | Set the value of `/hello` to be `jack` only if that key does not already exist: 175 | 176 | ```shell 177 | curl -X PUT -i http://${RANDOM_IP}/api/keys/hello?prevExist=false -d value=jack 178 | ``` 179 | 180 | The stream identifies test and set changes with `type=cas`: 181 | 182 | ```json 183 | id: 8 184 | event: set 185 | data: {"category":"user","key":"/hello","metadata":{"tansu":{"content_type":"text/plain","created":8,"parent":"/","type":"cas","updated":8}},"value":"jack"} 186 | ``` 187 | 188 | Set the value of `/hello` to be `quentin` only if its current value is `jack`: 189 | 190 | ```shell 191 | curl -X PUT -i http://${RANDOM_IP}/api/keys/hello?prevValue=jack -d value=quentin 192 | ``` 193 | 194 | ```json 195 | id: 9 196 | event: set 197 | data: {"category":"user","key":"/hello","metadata":{"tansu":{"content_type":"text/plain","created":8,"parent":"/","type":"cas","updated":9}},"previous":"jack","value":"quentin"} 198 | ``` 199 | 200 | Delete the value of `/hello` only if its current value is `quentin`: 201 | 202 | ```shell 203 | curl -X DELETE -i http://${RANDOM_IP}/api/keys/hello?prevValue=quentin 204 | ``` 205 | 206 | ```json 207 | id: 10 208 | event: delete 209 | data: {"category":"user","key":"/hello","metadata":{"tansu":{"content_type":"text/plain","created":8,"parent":"/","updated":10}},"value":"quentin"} 210 | ``` 211 | 212 | 213 | ### Locks 214 | 215 | Locks are obtained by issuing a HTTP GET on `/api/locks/` followed by 216 | the name of the lock. The response is a Server Sent Event stream that 217 | will indicate the status the lock to the caller. The lock holder will 218 | retain the lock until the connection is dropped (either by the client 219 | or sever). The free lock is then automatically granted to a waiting 220 | connection. 221 | 222 | In several different shells simultaneously request a lock on "abc": 223 | 224 | ```shell 225 | curl -i -s http://${RANDOM_IP}/api/locks/abc 226 | ``` 227 | 228 | ```shell 229 | curl -i -s http://${RANDOM_IP}/api/locks/abc 230 | ``` 231 | 232 | ```shell 233 | curl -i -s http://${RANDOM_IP}/api/locks/abc 234 | ``` 235 | 236 | One shell is granted the lock, with the remaining shells waiting their 237 | turn. Drop the lock by hitting `^C` on the holder, the lock is then 238 | allocated to another waiting shell. 239 | 240 | ## Leadership Election 241 | 242 | Tansu provides cluster information via the `/api/info` resource as 243 | follows, picking a random node: 244 | 245 | ```shell 246 | curl -s http://${RANDOM_IP}/api/info|python -m json.tool 247 | ``` 248 | 249 | Each node may be in `follower` or `candidate` state, with only one 250 | node in the `leader` role: 251 | 252 | ```json 253 | { 254 | "applications": { 255 | "any": "rolling", 256 | "asn1": "4.0.2", 257 | "cowboy": "2.0.0-pre.2", 258 | "cowlib": "1.3.0", 259 | "crown": "0.0.1", 260 | "crypto": "3.6.3", 261 | "envy": "0.0.1", 262 | "gproc": "git", 263 | "gun": "1.0.0-pre.1", 264 | "inets": "6.2.2", 265 | "jsx": "2.8.0", 266 | "kernel": "4.2", 267 | "mdns": "0.4.1", 268 | "mnesia": "4.13.4", 269 | "public_key": "1.1.1", 270 | "ranch": "1.1.0", 271 | "recon": "2.2.1", 272 | "rfc4122": "0.0.3", 273 | "sasl": "2.7", 274 | "shelly": "0.1.0", 275 | "ssh": "4.2.2", 276 | "ssl": "7.3.1", 277 | "stdlib": "2.8", 278 | "tansu": "0.13.0" 279 | }, 280 | "consensus": { 281 | "cluster": "fadbf747-f700-4d87-b986-73c2a1de18e4", 282 | "commit_index": 13668, 283 | "connections": { 284 | "1c64ca8a-303b-43f7-914f-09e3b680f9ed": { 285 | "host": "172.17.0.2", 286 | "port": 80 287 | }, 288 | "41fc20ae-70be-4e9d-a40d-a8164b165283": { 289 | "host": "172.17.0.7", 290 | "port": 80 291 | }, 292 | "6343a50d-acc6-4f62-866d-b5a7dcde5d04": { 293 | "host": "172.17.0.5", 294 | "port": 80 295 | }, 296 | "e8d25d82-5f07-4598-bb0c-6074793ee111": { 297 | "host": "172.17.0.3", 298 | "port": 80 299 | }, 300 | "e9057f76-24b5-4f4f-b81c-96d555798ef4": { 301 | "host": "172.17.0.4", 302 | "port": 80 303 | } 304 | }, 305 | "env": "dev", 306 | "id": "f327cc37-114f-4237-942b-972199e364a1", 307 | "last_applied": 13668, 308 | "leader": { 309 | "commit_index": 13668, 310 | "id": "e8d25d82-5f07-4598-bb0c-6074793ee111" 311 | }, 312 | "role": "follower", 313 | "term": 877 314 | }, 315 | "version": { 316 | "major": 0, 317 | "minor": 13, 318 | "patch": 0 319 | } 320 | } 321 | ``` 322 | 323 | This section optionally uses `jq` to parse some of the JSON output 324 | from Tansu. To install use: 325 | 326 | ```shell 327 | dnf install -y jq 328 | ``` 329 | 330 | The following script iterates over the members of the Tansu cluster 331 | outputting the role of each member: 332 | 333 | ```shell 334 | for i in {1..5}; 335 | do 336 | NAME=tansu-$(printf %03d $i) 337 | IP=$(docker inspect --format={{.NetworkSettings.IPAddress}} ${NAME}) 338 | ROLE=$(curl -m 1 -s http://${IP}/api/info|jq .consensus.role) 339 | echo ${NAME} ${ROLE} 340 | done 341 | ``` 342 | 343 | As an example: 344 | 345 | ```shell 346 | tansu-001 "follower" 347 | tansu-002 "leader" 348 | tansu-003 "follower" 349 | tansu-004 "follower" 350 | tansu-005 "follower" 351 | ``` 352 | 353 | Pause the leader (replace `tansu-002` with your leader): 354 | 355 | ```shell 356 | docker pause tansu-002 357 | ``` 358 | 359 | Check that one of the other nodes has been established as the leader 360 | by repeating the curl of `/api/info` on the remaining nodes: 361 | 362 | 363 | ```shell 364 | for i in {1..5}; 365 | do 366 | NAME=tansu-$(printf %03d $i) 367 | IP=$(docker inspect --format={{.NetworkSettings.IPAddress}} ${NAME}) 368 | ROLE=$(curl -m 1 -s http://${IP}/api/info|jq .consensus.role) 369 | echo ${NAME} ${ROLE} 370 | done 371 | ``` 372 | 373 | As an example, `tansu-002` is now paused: 374 | 375 | ```shell 376 | tansu-001 "leader" 377 | tansu-002 378 | tansu-003 "follower" 379 | tansu-004 "follower" 380 | tansu-005 "follower" 381 | ``` 382 | 383 | Fire some updates into one of the remaining nodes (change `tansu-003` to a running node): 384 | 385 | ```shell 386 | IP=$(docker inspect --format={{.NetworkSettings.IPAddress}} tansu-003) 387 | for i in {0..100}; 388 | do 389 | curl -X PUT -s http://${IP}/api/keys/pqr -d value=$i; 390 | done 391 | ``` 392 | 393 | Tansu will create a snapshot of its current state every so often, and 394 | uses this snapshot when members join (or rejoin) the cluster together 395 | with any log entries subsequent to that snapshot. 396 | 397 | Unpause the diposed former leader: 398 | 399 | ```shell 400 | docker unpause tansu-002 401 | ``` 402 | 403 | Check that `tansu-002` has rejoined the cluster and is now in the `follower` role: 404 | 405 | ```shell 406 | for i in {1..5}; 407 | do 408 | NAME=tansu-$(printf %03d $i) 409 | IP=$(docker inspect --format={{.NetworkSettings.IPAddress}} ${NAME}) 410 | ROLE=$(curl -m 1 -s http://${IP}/api/info|jq .consensus.role) 411 | echo ${NAME} ${ROLE}; 412 | done 413 | ``` 414 | 415 | The node `tansu-002` is now in the `follower` role: 416 | 417 | ```shell 418 | tansu-001 "leader" 419 | tansu-002 "follower" 420 | tansu-003 "follower" 421 | tansu-004 "follower" 422 | tansu-005 "follower" 423 | ``` 424 | 425 | Ask the unpaused node for the value of `pqr`: 426 | 427 | ```shell 428 | IP=$(docker inspect --format={{.NetworkSettings.IPAddress}} tansu-002) 429 | curl -i http://${IP}/api/keys/pqr 430 | ``` 431 | 432 | The node will have same value as the remainder of the cluster: 433 | 434 | ```text 435 | 100 436 | ``` 437 | 438 | ## Configuration 439 | 440 | Tansu uses the following configuration environment. 441 | 442 | |environment variable |default | 443 | |-----------------------------------------|---------------------| 444 | |TANSU\_BATCH\_SIZE\_APPEND\_ENTRIES |32 | 445 | |TANSU\_CAN\_ADVERTISE |true | 446 | |TANSU\_CAN\_MESH |true | 447 | |TANSU\_SNAPSHOT\_DIRECTORY |/snapshots | 448 | |TANSU\_DEBUG |false | 449 | |TANSU\_SM |tansu\_sm\_mnesia\_kv| 450 | |TANSU\_ENDPOINT\_SERVER |/server | 451 | |TANSU\_ENDPOINT\_API |/api | 452 | |TANSU\_HTTP\_PORT |80 | 453 | |TANSU\_DB\_SCHEMA |ram | 454 | |TANSU\_ENVIRONMENT |dev | 455 | |TANSU\_ACCEPTORS |100 | 456 | |TANSU\_TIMEOUT\_ELECTION\_LOW |1500 | 457 | |TANSU\_TIMEOUT\_ELECTION\_HIGH |3000 | 458 | |TANSU\_TIMEOUT\_LEADER\_LOW |500 | 459 | |TANSU\_TIMEOUT\_LEADER\_HIGH |1000 | 460 | |TANSU\_TIMEOUT\_KV\_EXPIRY |1000 | 461 | |TANSU\_TIMEOUT\_KV\_SNAPSHOT |1000 * 60 | 462 | |TANSU\_TIMEOUT\_MNESIA\_WAIT\_FOR\_TABLES|infinity | 463 | |TANSU\_TIMEOUT\_SYNC\_SEND\_EVENT |infinity | 464 | |TANSU\_TIMEOUT\_STREAM\_PING |5000 | 465 | |TANSU\_MINIMUM\_QUORUM |3 | 466 | |TANSU\_MAXIMUM\_SNAPSHOT |3 | 467 | |TANSU\_CLUSTER\_MEMBERS | | 468 | -------------------------------------------------------------------------------- /bin/app: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env escript 2 | %% -*- erlang -*- 3 | %% Copyright (c) 2013-2016 Peter Morgan 4 | %% 5 | %% Licensed under the Apache License, Version 2.0 (the "License"); 6 | %% you may not use this file except in compliance with the License. 7 | %% You may obtain a copy of the License at 8 | %% 9 | %% http://www.apache.org/licenses/LICENSE-2.0 10 | %% 11 | %% Unless required by applicable law or agreed to in writing, software 12 | %% distributed under the License is distributed on an "AS IS" BASIS, 13 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | %% See the License for the specific language governing permissions and 15 | %% limitations under the License. 16 | %% 17 | 18 | main([]) -> 19 | {application, Name, _} = application(), 20 | io:format("~w~n", [Name]). 21 | 22 | root_dir() -> 23 | filename:dirname(escript:script_name()). 24 | 25 | app() -> 26 | [Filename] = filelib:wildcard(filename:join([root_dir(),"../ebin/*.app"])), 27 | Filename. 28 | 29 | application() -> 30 | {ok, [{application, _, _} = Application]} = file:consult(app()), 31 | Application. 32 | -------------------------------------------------------------------------------- /bin/release_name: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env escript 2 | %% -*- erlang -*- 3 | %% Copyright (c) 2016 Peter Morgan 4 | %% 5 | %% Licensed under the Apache License, Version 2.0 (the "License"); 6 | %% you may not use this file except in compliance with the License. 7 | %% You may obtain a copy of the License at 8 | %% 9 | %% http://www.apache.org/licenses/LICENSE-2.0 10 | %% 11 | %% Unless required by applicable law or agreed to in writing, software 12 | %% distributed under the License is distributed on an "AS IS" BASIS, 13 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | %% See the License for the specific language governing permissions and 15 | %% limitations under the License. 16 | %% 17 | 18 | main([]) -> 19 | io:format("~s~n", 20 | lists:foldl( 21 | fun 22 | ({release, {Name, _Version}, _}, A) -> 23 | [Name | A]; 24 | 25 | (_, A) -> 26 | A 27 | end, 28 | [], 29 | relx())). 30 | 31 | root_dir() -> 32 | filename:dirname(escript:script_name()). 33 | 34 | config() -> 35 | filename:join([root_dir(),"../relx.config"]). 36 | 37 | relx() -> 38 | {ok, Configuration} = file:consult(config()), 39 | Configuration. 40 | -------------------------------------------------------------------------------- /bin/version: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env escript 2 | %% -*- erlang -*- 3 | %% Copyright (c) 2013-2016 Peter Morgan 4 | %% 5 | %% Licensed under the Apache License, Version 2.0 (the "License"); 6 | %% you may not use this file except in compliance with the License. 7 | %% You may obtain a copy of the License at 8 | %% 9 | %% http://www.apache.org/licenses/LICENSE-2.0 10 | %% 11 | %% Unless required by applicable law or agreed to in writing, software 12 | %% distributed under the License is distributed on an "AS IS" BASIS, 13 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | %% See the License for the specific language governing permissions and 15 | %% limitations under the License. 16 | %% 17 | 18 | main([]) -> 19 | io:format("~s~n", [version()]). 20 | 21 | root_dir() -> 22 | filename:dirname(escript:script_name()). 23 | 24 | app() -> 25 | [Filename] = filelib:wildcard(filename:join([root_dir(),"../ebin/*.app"])), 26 | Filename. 27 | 28 | version() -> 29 | {ok, [{application, _, Properties}]} = file:consult(app()), 30 | proplists:get_value(vsn, Properties). 31 | -------------------------------------------------------------------------------- /dev.config: -------------------------------------------------------------------------------- 1 | %% -*- mode: erlang -*- 2 | %% Copyright (c) 2012-2016 Peter Morgan 3 | %% 4 | %% Licensed under the Apache License, Version 2.0 (the "License"); 5 | %% you may not use this file except in compliance with the License. 6 | %% You may obtain a copy of the License at 7 | %% 8 | %% http://www.apache.org/licenses/LICENSE-2.0 9 | %% 10 | %% Unless required by applicable law or agreed to in writing, software 11 | %% distributed under the License is distributed on an "AS IS" BASIS, 12 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | %% See the License for the specific language governing permissions and 14 | %% limitations under the License. 15 | 16 | [ 17 | {kernel, [ 18 | {error_logger, {file, "log/kernel.log"}} 19 | ]}, 20 | 21 | {sasl, [ 22 | {sasl_error_logger, {file, "log/sasl.log"}}, 23 | {error_logger_mf_dir,"log"}, 24 | {error_logger_mf_maxbytes,10485760}, 25 | {error_logger_mf_maxfiles, 10}, 26 | {errlog_type, all} 27 | ]}, 28 | 29 | {mnesia, [{dir, db}]}, 30 | 31 | {tansu, [{db_schema, ram}, 32 | {http_port, 8081}, 33 | {snapshot_directory, "snapshots"}]}, 34 | 35 | {shelly, [{port, 22022}]} 36 | ]. 37 | -------------------------------------------------------------------------------- /log/.gitignore: -------------------------------------------------------------------------------- 1 | [0-9]* 2 | *.log 3 | index 4 | -------------------------------------------------------------------------------- /rel/sys.config: -------------------------------------------------------------------------------- 1 | %% -*- mode: erlang -*- 2 | %% Copyright (c) 2012-2015 Peter Morgan 3 | %% 4 | %% Licensed under the Apache License, Version 2.0 (the "License"); 5 | %% you may not use this file except in compliance with the License. 6 | %% You may obtain a copy of the License at 7 | %% 8 | %% http://www.apache.org/licenses/LICENSE-2.0 9 | %% 10 | %% Unless required by applicable law or agreed to in writing, software 11 | %% distributed under the License is distributed on an "AS IS" BASIS, 12 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | %% See the License for the specific language governing permissions and 14 | %% limitations under the License. 15 | 16 | [ 17 | {tansu, [{db_schema, disc}]} 18 | ]. 19 | -------------------------------------------------------------------------------- /rel/vm.args: -------------------------------------------------------------------------------- 1 | -mnesia dir db 2 | +C multi_time_warp 3 | +K true 4 | -------------------------------------------------------------------------------- /relx.config: -------------------------------------------------------------------------------- 1 | {release, {tansu_release, "1"}, [tansu]}. 2 | {extended_start_script, true}. 3 | {sys_config, "rel/sys.config"}. 4 | {vm_args, "rel/vm.args"}. 5 | -------------------------------------------------------------------------------- /snapshots/.gitignore: -------------------------------------------------------------------------------- 1 | ????-??-??T??:??:??Z 2 | -------------------------------------------------------------------------------- /src/tansu.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu). 16 | 17 | -export([description/0]). 18 | -export([start/0]). 19 | -export([trace/1]). 20 | -export([vsn/0]). 21 | 22 | start() -> 23 | application:ensure_all_started(?MODULE). 24 | 25 | ensure_loaded() -> 26 | lists:foreach(fun code:ensure_loaded/1, modules()). 27 | 28 | modules() -> 29 | modules(?MODULE). 30 | 31 | modules(Application) -> 32 | {ok, Modules} = application:get_key(Application, modules), 33 | Modules. 34 | 35 | 36 | trace(true) -> 37 | ensure_loaded(), 38 | case recon_trace:calls([m(Module) || Module <- modules()], 39 | {500000,1000}, 40 | [{scope, local}, 41 | {pid, all}]) of 42 | Matches when Matches > 0 -> 43 | ok; 44 | _ -> 45 | error 46 | end; 47 | trace(false) -> 48 | recon_trace:clear(). 49 | 50 | m(Module) -> 51 | {Module, '_', '_'}. 52 | 53 | description() -> 54 | get_key(description). 55 | 56 | vsn() -> 57 | get_key(vsn). 58 | 59 | get_key(Key) -> 60 | {ok, Value} = application:get_key(?MODULE, Key), 61 | Value. 62 | -------------------------------------------------------------------------------- /src/tansu_api.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_api). 16 | 17 | -export([info/0]). 18 | -export([kv_delete/1]). 19 | -export([kv_get/1]). 20 | -export([kv_get_children_of/1]). 21 | -export([kv_set/2]). 22 | -export([kv_set/3]). 23 | -export([kv_subscribe/1]). 24 | -export([kv_test_and_delete/2]). 25 | -export([kv_test_and_set/3]). 26 | -export([kv_test_and_set/4]). 27 | -export([kv_unsubscribe/1]). 28 | 29 | -define(CATEGORY, user). 30 | 31 | info() -> 32 | tansu_consensus:info(). 33 | 34 | kv_delete(Key) -> 35 | tansu_consensus:ckv_delete(?CATEGORY, Key). 36 | 37 | kv_get(Key) -> 38 | tansu_consensus:ckv_get(?CATEGORY, Key). 39 | 40 | kv_get_children_of(Parent) -> 41 | maps:fold( 42 | fun 43 | ({?CATEGORY, Child}, {Data, Metadata}, A) -> 44 | A#{Child => {Data, Metadata}}; 45 | 46 | (_, _, A) -> 47 | A 48 | end, 49 | #{}, 50 | tansu_consensus:ckv_get_children_of(?CATEGORY, Parent)). 51 | 52 | kv_set(Key, Value) -> 53 | kv_set(Key, Value, #{}). 54 | 55 | kv_set(Key, Value, Options) -> 56 | tansu_consensus:ckv_set(?CATEGORY, Key, Value, Options). 57 | 58 | kv_test_and_delete(Key, ExistingValue) -> 59 | tansu_consensus:ckv_test_and_delete(?CATEGORY, Key, ExistingValue). 60 | 61 | kv_test_and_set(Key, ExistingValue, NewValue) -> 62 | kv_test_and_set(Key, ExistingValue, NewValue, #{}). 63 | 64 | kv_test_and_set(Key, ExistingValue, NewValue, Options) -> 65 | tansu_consensus:ckv_test_and_set(?CATEGORY, Key, ExistingValue, NewValue, Options). 66 | 67 | kv_subscribe(Key) -> 68 | tansu_sm:subscribe(?CATEGORY, Key). 69 | 70 | kv_unsubscribe(Key) -> 71 | tansu_sm:unsubscribe(?CATEGORY, Key). 72 | -------------------------------------------------------------------------------- /src/tansu_api_info_resource.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_api_info_resource). 16 | 17 | -export([allowed_methods/2]). 18 | -export([content_types_provided/2]). 19 | -export([init/2]). 20 | -export([options/2]). 21 | -export([to_json/2]). 22 | 23 | 24 | init(Req, _) -> 25 | {cowboy_rest, cors:allow_origin(Req), #{}}. 26 | 27 | allowed_methods(Req, State) -> 28 | {allowed(), Req, State}. 29 | 30 | options(Req, State) -> 31 | cors:options(Req, State, allowed()). 32 | 33 | content_types_provided(Req, State) -> 34 | {[{{<<"application">>, <<"json">>, '*'}, to_json}], Req, State}. 35 | 36 | allowed() -> 37 | [<<"GET">>, 38 | <<"HEAD">>, 39 | <<"OPTIONS">>]. 40 | 41 | to_json(Req, State) -> 42 | [Major, Minor, Patch] = string:tokens(tansu:vsn(), "."), 43 | {jsx:encode( 44 | #{applications => lists:foldl( 45 | fun 46 | ({Application, _, VSN}, A) -> 47 | A#{Application => any:to_binary(VSN)} 48 | end, 49 | #{}, 50 | application:which_applications()), 51 | consensus => tansu_consensus:info(), 52 | version => #{major => any:to_integer(Major), 53 | minor => any:to_integer(Minor), 54 | patch => any:to_integer(Patch)}}), 55 | Req, 56 | State}. 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/tansu_api_keys_resource.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | 16 | -module(tansu_api_keys_resource). 17 | 18 | -export([allowed_methods/2]). 19 | -export([content_types_accepted/2]). 20 | -export([content_types_provided/2]). 21 | -export([delete_resource/2]). 22 | -export([from_form_urlencoded/2]). 23 | -export([from_identity/2]). 24 | -export([info/3]). 25 | -export([init/2]). 26 | -export([options/2]). 27 | -export([resource_exists/2]). 28 | -export([terminate/3]). 29 | -export([to_identity/2]). 30 | 31 | init(Req, _) -> 32 | init( 33 | Req, 34 | cowboy_req:method(Req), 35 | tansu_consensus:info(), 36 | maps:merge( 37 | #{<<"role">> => <<"any">>, 38 | <<"children">> => <<"false">>}, 39 | maps:from_list(cowboy_req:parse_qs(Req))), 40 | cowboy_req:header(<<"ttl">>, Req), 41 | cowboy_req:header(<<"content-type">>, Req)). 42 | 43 | 44 | init(Req, <<"GET">>, Info, #{<<"stream">> := <<"true">>} = QS, _, _) -> 45 | %% An event stream can be established with any member of 46 | %% the cluster. 47 | Headers = [{<<"content-type">>, <<"text/event-stream">>}, 48 | {<<"cache-control">>, <<"no-cache">>} | headers(Info)], 49 | tansu_api:kv_subscribe(key(Req)), 50 | {cowboy_loop, 51 | cowboy_req:chunked_reply(200, Headers, Req), 52 | #{info => Info, 53 | path => cowboy_req:path(Req), 54 | key => key(Req), 55 | qs => QS}}; 56 | 57 | init(Req, <<"GET">>, #{role := follower, leader := _, cluster := _} = Info, #{<<"role">> := <<"any">>} = QS, _, _) -> 58 | %% followers with an established leader and cluster can 59 | %% handle simple KV GET requests. 60 | {cowboy_rest, 61 | headers(Info, cors:allow_origin(Req)), 62 | #{info => Info, 63 | path => cowboy_req:path(Req), 64 | key => key(Req), 65 | qs => QS, 66 | parent => parent(Req)}}; 67 | 68 | init(Req, _, #{role := follower, connections := Connections, leader := #{id := Leader}, cluster := _}, _, _, _) -> 69 | %% Requests other than GETs should be proxied to the 70 | %% leader. 71 | case Connections of 72 | #{Leader := #{host := Host, port := Port}} -> 73 | tansu_api_proxy_resource:init( 74 | Req, #{host => binary_to_list(Host), port => Port}); 75 | 76 | #{} -> 77 | {ok, service_unavailable(Req), #{}} 78 | end; 79 | 80 | init(Req, _, #{role := leader} = Info, QS, undefined, undefined) -> 81 | %% The leader can deal directly with any request. 82 | {cowboy_rest, 83 | headers(Info, cors:allow_origin(Req)), 84 | #{info => Info, 85 | path => cowboy_req:path(Req), 86 | key => key(Req), 87 | qs => QS, 88 | parent => parent(Req)}}; 89 | 90 | init(Req, _, #{role := leader} = Info, QS, undefined, ContentType) -> 91 | %% The leader can deal directly with any request. 92 | {cowboy_rest, 93 | headers(Info, cors:allow_origin(Req)), 94 | #{info => Info, 95 | content_type => ContentType, 96 | path => cowboy_req:path(Req), 97 | qs => QS, 98 | key => key(Req), 99 | parent => parent(Req)}}; 100 | 101 | init(Req, _, #{role := leader} = Info, QS, TTL, undefined) -> 102 | %% The leader can deal directly with any request. 103 | {cowboy_rest, 104 | headers(Info, cors:allow_origin(Req)), 105 | #{info => Info, 106 | path => cowboy_req:path(Req), 107 | ttl => binary_to_integer(TTL), 108 | key => key(Req), 109 | qs => QS, 110 | parent => parent(Req)}}; 111 | 112 | init(Req, _, #{role := leader} = Info, QS, TTL, ContentType) -> 113 | %% The leader can deal directly with any request. 114 | {cowboy_rest, 115 | headers(Info, cors:allow_origin(Req)), 116 | #{info => Info, 117 | content_type => ContentType, 118 | path => cowboy_req:path(Req), 119 | ttl => binary_to_integer(TTL), 120 | key => key(Req), 121 | qs => QS, 122 | parent => parent(Req)}}; 123 | 124 | init(Req, _, _, _, _, _) -> 125 | %% Neither a leader nor a follower with an established 126 | %% leader then the service is unavailable. 127 | {ok, service_unavailable(Req), #{}}. 128 | 129 | allowed_methods(Req, State) -> 130 | {allowed(), Req, State}. 131 | 132 | options(Req, State) -> 133 | cors:options(Req, State, allowed()). 134 | 135 | allowed() -> 136 | [<<"DELETE">>, 137 | <<"GET">>, 138 | <<"HEAD">>, 139 | <<"OPTIONS">>, 140 | <<"POST">>, 141 | <<"PUT">>]. 142 | 143 | content_types_accepted(Req, #{content_type := <<"application/x-www-form-urlencoded">> = ContentType} = State) -> 144 | {[{ContentType, from_form_urlencoded}], Req, State}; 145 | 146 | content_types_accepted(Req, #{content_type := ContentType} = State) -> 147 | {[{ContentType, from_identity}], Req, State}. 148 | 149 | content_types_provided(Req, #{key := Key, qs := #{<<"children">> := <<"true">>}} = State) -> 150 | case {tansu_api:kv_get(Key), tansu_api:kv_get_children_of(Key)} of 151 | {{ok, Value, Metadata}, Children} when map_size(Children) == 0 -> 152 | {[{<<"application/json">>, to_identity}], Req, State#{value => #{data => jsx:encode(#{value => Value, children => #{}}), metadata => Metadata}}}; 153 | 154 | {{ok, ParentValue, #{content_type := <<"application/json">>} = ParentMetadata}, Children} -> 155 | {[{<<"application/json">>, to_identity}], 156 | Req, 157 | State#{value => #{ 158 | data => 159 | jsx:encode( 160 | #{ 161 | value => jsx:decode(ParentValue), 162 | children => 163 | maps:fold( 164 | fun 165 | (Child, {Value, #{content_type := <<"application/json">>} = Metadata}, A) -> 166 | A#{Child => #{value => jsx:decode(Value), metadata => without_reserved(Metadata)}}; 167 | 168 | (Child, {Value, Metadata}, A) -> 169 | A#{Child => #{value => Value, metadata => without_reserved(Metadata)}} 170 | end, 171 | #{}, 172 | Children) 173 | }), 174 | metadata => ParentMetadata 175 | }}}; 176 | 177 | {{ok, ParentValue, ParentMetadata}, Children} -> 178 | {[{<<"application/json">>, to_identity}], 179 | Req, 180 | State#{value => #{ 181 | data => 182 | jsx:encode( 183 | #{ 184 | value => ParentValue, 185 | children => 186 | maps:fold( 187 | fun 188 | (Child, {Value, #{content_type := <<"application/json">>} = Metadata}, A) -> 189 | A#{Child => #{value => jsx:decode(Value), metadata => without_reserved(Metadata)}}; 190 | 191 | (Child, {Value, Metadata}, A) -> 192 | A#{Child => #{value => Value, metadata => without_reserved(Metadata)}} 193 | end, 194 | #{}, 195 | Children) 196 | }), 197 | metadata => ParentMetadata#{content_type => <<"application/json">>} 198 | }}}; 199 | 200 | {{error, _} = Error, _} -> 201 | {[{<<"text/plain">>, dummy_to_text_plain}], Req, State#{value => Error}} 202 | end; 203 | 204 | content_types_provided(Req, #{key := Key} = State) -> 205 | case {cowboy_req:method(Req), tansu_api:kv_get(Key)} of 206 | {<<"PUT">>, {ok, Value, Metadata}} -> 207 | {[{<<"application/json">>, to_identity}], Req, State#{value => #{data => Value, metadata => Metadata}}}; 208 | 209 | {_, {ok, Value, #{tansu := #{content_type := ContentType}} = Metadata}} -> 210 | {[{ContentType, to_identity}], Req, State#{value => #{data => Value, metadata => Metadata}}}; 211 | 212 | {<<"PUT">>, {error, _}} = Error -> 213 | {[{<<"application/json">>, dummy_to_text_plain}], Req, State#{value => Error}}; 214 | 215 | {_, {error, _} = Error} -> 216 | {[{<<"text/plain">>, dummy_to_text_plain}], Req, State#{value => Error}} 217 | end. 218 | 219 | without_reserved(Metadata) -> 220 | maps:without(reserved(), Metadata). 221 | 222 | reserved() -> 223 | [parent, ttl]. 224 | 225 | to_identity(Req, #{value := #{data := Data, metadata := #{tansu := Tansu}}} = State) -> 226 | {Data, add_metadata_headers(Req, Tansu), State}. 227 | 228 | add_metadata_headers(Req, Metadata) -> 229 | maps:fold( 230 | fun 231 | (created, Index, A) -> 232 | cowboy_req:set_resp_header(<<"created-index">>, any:to_binary(Index), A); 233 | (updated, Index, A) -> 234 | cowboy_req:set_resp_header(<<"updated-index">>, any:to_binary(Index), A); 235 | (ttl, TTL, A) -> 236 | cowboy_req:set_resp_header(<<"ttl">>, any:to_binary(TTL), A); 237 | (_, _, A) -> 238 | A 239 | end, 240 | Req, 241 | Metadata). 242 | 243 | key(Req) -> 244 | slash_separated(cowboy_req:path_info(Req)). 245 | 246 | parent(Req) -> 247 | case cowboy_req:path_info(Req) of 248 | [] -> 249 | <<"/">>; 250 | 251 | Path -> 252 | slash_separated(lists:droplast(Path)) 253 | end. 254 | 255 | slash_separated([]) -> 256 | <<"/">>; 257 | slash_separated(PathInfo) -> 258 | lists:foldl( 259 | fun 260 | (Path, <<>>) -> 261 | <<"/", Path/bytes>>; 262 | (Path, A) -> 263 | <> 264 | end, 265 | <<>>, 266 | PathInfo). 267 | 268 | from_form_urlencoded(Req, State) -> 269 | case cowboy_req:has_body(Req) of 270 | true -> 271 | from_form_urlencoded_body(cowboy_req:body(Req), <<>>, State); 272 | 273 | false -> 274 | {stop, bad_request(Req), State} 275 | end. 276 | 277 | from_form_urlencoded_body({ok, Remainder, Req}, Partial, State) -> 278 | from_form_url_encoded(Req, maps:from_list(cow_qs:parse_qs(<>)), State); 279 | from_form_urlencoded_body({more, Remainder, Req}, Partial, State) -> 280 | from_form_urlencoded_body(cowboy_req:body(Req), <>, State). 281 | 282 | from_form_url_encoded(Req, #{<<"value">> := Value}, State) -> 283 | from_identity({ok, Value, Req}, <<>>, State). 284 | 285 | from_identity(Req, State) -> 286 | from_identity(cowboy_req:body(Req), <<>>, State). 287 | 288 | from_identity({ok, Final, Req}, Partial, #{qs := #{<<"prevExist">> := <<"false">>}, key := Key, content_type := <<"application/x-www-form-urlencoded">>} = State) -> 289 | kv_test_and_set( 290 | Req, 291 | Key, 292 | undefined, 293 | <>, 294 | State#{content_type := <<"text/plain">>}); 295 | 296 | from_identity({ok, Final, Req}, Partial, #{qs := #{<<"prevIndex">> := Updated}, key := Key, content_type := <<"application/x-www-form-urlencoded">>} = State) -> 297 | kv_test_and_set( 298 | Req, 299 | Key, 300 | undefined, 301 | <>, 302 | State#{content_type := <<"text/plain">>, updated => any:to_integer(Updated)}); 303 | 304 | from_identity({ok, Final, Req}, Partial, #{qs := #{<<"prevValue">> := ExistingValue}, key := Key, content_type := <<"application/x-www-form-urlencoded">>} = State) -> 305 | kv_test_and_set( 306 | Req, 307 | Key, 308 | ExistingValue, 309 | <>, 310 | State#{content_type := <<"text/plain">>}); 311 | 312 | from_identity({ok, Final, Req}, Partial, #{key := Key, content_type := <<"application/x-www-form-urlencoded">>} = State) -> 313 | kv_set( 314 | Req, 315 | Key, 316 | <>, 317 | State#{content_type := <<"text/plain">>}); 318 | 319 | from_identity({ok, Final, Req}, Partial, #{key := Key} = State) -> 320 | kv_set( 321 | Req, 322 | Key, 323 | <>, 324 | State); 325 | 326 | from_identity({more, Part, Req}, Partial, State) -> 327 | from_identity(cowboy_req:body(Req), <>, State). 328 | 329 | kv_test_and_set(Req, Key, ExistingValue, NewValue, State) -> 330 | case tansu_api:kv_test_and_set( 331 | Key, 332 | ExistingValue, 333 | NewValue, 334 | #{tansu => maps:with([content_type, parent, ttl, updated], State)}) of 335 | 336 | {ok, Current, #{tansu := #{current := #{content_type := <<"application/json">>}} = Tansu} = Metadata} -> 337 | {true, 338 | add_metadata_headers( 339 | cowboy_req:set_resp_body( 340 | jsx:encode(#{value => jsx:decode(Current), metadata => Metadata}), 341 | Req), 342 | Tansu), 343 | State}; 344 | 345 | {ok, Current, #{tansu := #{current := Tansu}} = Metadata} -> 346 | {true, 347 | add_metadata_headers( 348 | cowboy_req:set_resp_body( 349 | jsx:encode(#{value => Current, metadata => Metadata}), 350 | Req), 351 | Tansu), 352 | State}; 353 | 354 | error -> 355 | {stop, conflict(Req), State}; 356 | 357 | {error, not_leader} -> 358 | {stop, service_unavailable(Req), State} 359 | end. 360 | 361 | kv_set(Req, Key, Value, State) -> 362 | case tansu_api:kv_set( 363 | Key, 364 | Value, 365 | #{tansu => maps:with([content_type, parent, ttl], State)}) of 366 | 367 | {ok, Current, #{tansu := #{current := #{content_type := <<"application/json">>}} = Tansu} = Metadata} -> 368 | {true, 369 | add_metadata_headers( 370 | cowboy_req:set_resp_body( 371 | jsx:encode(#{value => jsx:decode(Current), metadata => Metadata}), 372 | Req), 373 | Tansu), 374 | State}; 375 | 376 | {ok, Current, #{tansu := #{current := Tansu}} = Metadata} -> 377 | {true, 378 | add_metadata_headers( 379 | cowboy_req:set_resp_body( 380 | jsx:encode(#{value => Current, metadata => Metadata}), 381 | Req), 382 | Tansu), 383 | State}; 384 | 385 | {error, not_leader} -> 386 | {stop, service_unavailable(Req), State} 387 | end. 388 | 389 | delete_resource(Req, #{qs := #{<<"prevValue">> := ExistingValue}, key := Key} = State) -> 390 | case tansu_api:kv_test_and_delete(Key, ExistingValue) of 391 | {ok, Current, #{tansu := #{current := #{content_type := <<"application/json">>}} = Tansu} = Metadata} -> 392 | {true, 393 | add_metadata_headers( 394 | cowboy_req:set_resp_body( 395 | jsx:encode(#{value => jsx:decode(Current), metadata => Metadata}), 396 | Req), 397 | Tansu), 398 | State}; 399 | 400 | {ok, Current, #{tansu := #{current := Tansu}} = Metadata} -> 401 | {true, 402 | add_metadata_headers( 403 | cowboy_req:set_resp_body( 404 | jsx:encode(#{value => Current, metadata => Metadata}), 405 | Req), 406 | Tansu), 407 | State}; 408 | 409 | error -> 410 | {stop, conflict(Req), State} 411 | end; 412 | 413 | delete_resource(Req, #{key := Key} = State) -> 414 | case tansu_api:kv_delete(Key) of 415 | {ok, Current, #{tansu := #{current := #{content_type := <<"application/json">>}} = Tansu} = Metadata} -> 416 | {true, 417 | add_metadata_headers( 418 | cowboy_req:set_resp_body( 419 | jsx:encode(#{value => jsx:decode(Current), metadata => Metadata}), 420 | Req), 421 | Tansu), 422 | State}; 423 | 424 | {ok, Current, #{tansu := #{current := Tansu}} = Metadata} -> 425 | {true, 426 | add_metadata_headers( 427 | cowboy_req:set_resp_body( 428 | jsx:encode(#{value => Current, metadata => Metadata}), 429 | Req), 430 | Tansu), 431 | State}; 432 | 433 | error -> 434 | {false, Req, State} 435 | end. 436 | 437 | 438 | resource_exists(Req, State) -> 439 | resource_exists(Req, cowboy_req:method(Req), State). 440 | 441 | resource_exists(Req, _, #{value := #{data := _, metadata := #{tansu := #{ttl := TTL}}}} = State) -> 442 | {true, cowboy_req:set_resp_header(<<"ttl">>, any:to_binary(TTL), Req), State}; 443 | resource_exists(Req, _, #{value := #{data := _, metadata := _}} = State) -> 444 | {true, Req, State}; 445 | resource_exists(Req, _, #{value := {error, not_found}} = State) -> 446 | {false, Req, State}; 447 | resource_exists(Req, _, #{value := {error, not_leader}} = State) -> 448 | %% whoa, we were the leader, but we're not now 449 | {stop, service_unavailable(Req), State}; 450 | resource_exists(Req, _, State) -> 451 | {true, Req, State}. 452 | 453 | 454 | info(#{data := #{metadata := #{tansu := #{origin := _}}}, module := tansu_sm}, Req, #{qs := #{<<"children">> := <<"false">>}} = State) -> 455 | %% Change events that contain an origin are from a child and 456 | %% should be ignored when children is false. 457 | {ok, Req, State}; 458 | 459 | info(#{data := #{metadata := #{tansu := #{origin := Key} = Tansu} = Metadata} = Data, module := tansu_sm} = Info, Req, State) -> 460 | info(Info#{data := Data#{key := Key, metadata := Metadata#{tansu := maps:without([origin], Tansu)}}}, Req, State); 461 | 462 | info(#{id := Id, event := Event, data := #{metadata := #{tansu := #{content_type := <<"application/json">>}}, value := Value} = Data, module := tansu_sm}, Req, State) -> 463 | {tansu_stream:chunk(Id, Event, Data#{value := jsx:decode(Value)}, Req), Req, State}; 464 | 465 | info(#{id := Id, event := Event, data := Data, module := tansu_sm}, Req, State) -> 466 | {tansu_stream:chunk(Id, Event, Data, Req), Req, State}; 467 | 468 | info(Event, Req, #{proxy := Proxy} = State) -> 469 | Proxy:info(Event, Req, State). 470 | 471 | 472 | terminate(Reason, Req, #{proxy := Proxy} = State) -> 473 | Proxy:terminate(Reason, Req, State); 474 | 475 | terminate(_Reason, _Req, _) -> 476 | %% nothing to clean up here. 477 | tansu_sm:goodbye(). 478 | 479 | bad_request(Req) -> 480 | stop_with_code(400, Req). 481 | 482 | conflict(Req) -> 483 | stop_with_code(409, Req). 484 | 485 | service_unavailable(Req) -> 486 | stop_with_code(503, Req). 487 | 488 | stop_with_code(Code, Req) -> 489 | cowboy_req:reply(Code, Req). 490 | 491 | headers(Info, Req) -> 492 | lists:foldl( 493 | fun 494 | ({Key, Value}, A) -> 495 | cowboy_req:set_resp_header(Key, Value, A) 496 | end, 497 | Req, 498 | headers(Info)). 499 | 500 | headers(Info) -> 501 | maps:fold( 502 | fun 503 | (cluster, Cluster, A) -> 504 | [{<<"cluster-id">>, Cluster} | A]; 505 | 506 | (commit_index, CI, A) -> 507 | [{<<"raft-ci">>, any:to_binary(CI)} | A]; 508 | 509 | (env, Env, A) -> 510 | [{<<"env">>, Env} | A]; 511 | 512 | (id, Id, A) -> 513 | [{<<"node-id">>, Id} | A]; 514 | 515 | (index, Index, A) -> 516 | [{<<"index">>, any:to_binary(Index)} | A]; 517 | 518 | (last_applied, LA, A) -> 519 | [{<<"raft-la">>, any:to_binary(LA)} | A]; 520 | 521 | (term, Term, A) -> 522 | [{<<"raft-term">>, any:to_binary(Term)} | A]; 523 | 524 | (role, Role, A) -> 525 | [{<<"role">>, any:to_binary(Role)} | A]; 526 | 527 | (_, _, A) -> 528 | A 529 | end, 530 | [], 531 | Info). 532 | 533 | -------------------------------------------------------------------------------- /src/tansu_api_locks_resource.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | 16 | -module(tansu_api_locks_resource). 17 | -export([init/2]). 18 | -export([info/3]). 19 | -export([terminate/3]). 20 | 21 | 22 | init(Req, _) -> 23 | case tansu_consensus:info() of 24 | #{role := leader} -> 25 | self() ! try_lock, 26 | tansu_api:kv_subscribe(cowboy_req:path_info(Req)), 27 | do_ping(), 28 | Key = tansu_uuid:new(), 29 | Headers = [{<<"content-type">>, <<"text/event-stream">>}, 30 | {<<"cache-control">>, <<"no-cache">>}, 31 | {<<"key">>, Key}], 32 | {cowboy_loop, 33 | cowboy_req:chunked_reply(200, Headers, Req), 34 | #{lock => cowboy_req:path_info(Req), 35 | key => Key, 36 | n => 1}}; 37 | 38 | #{role := follower, connections := Connections, leader := #{id := Leader}} -> 39 | case Connections of 40 | #{Leader := #{host := Host, port := Port}} -> 41 | %% We are connected to a follower with an 42 | %% established leader, proxy this request through 43 | %% to the leader. 44 | tansu_api_proxy_resource:init( 45 | Req, #{host => binary_to_list(Host), port => Port}); 46 | 47 | #{} -> 48 | service_unavailable(Req, #{}) 49 | end; 50 | 51 | #{} -> 52 | %% Neither a leader nor a follower with an established 53 | %% leader then the service is unavailable. 54 | service_unavailable(Req, #{}) 55 | end. 56 | 57 | info(try_lock, Req, State) -> 58 | do_try_lock(Req, State); 59 | 60 | 61 | info(#{event := delete, module := tansu_sm}, Req, State) -> 62 | %% The lock has been deleted, try and obtain the lock if there is 63 | %% also a leader. 64 | do_try_lock(Req, State); 65 | 66 | info(#{data := #{value := Key}, module := tansu_sm}, Req, #{key := Key, n := N} = State) -> 67 | %% The lock has been granted, by setting the value to our key. 68 | {ok, Req, State#{n := N+1}}; 69 | 70 | info(#{data := #{value := Key}, module := tansu_sm}, Req, #{n := N} = State) -> 71 | %% The lock has been granted to someone else by setting its value 72 | %% to their key. 73 | tansu_stream:chunk(N, not_granted, #{locked_by => Key}, Req), 74 | {ok, Req, State#{n := N+1}}; 75 | 76 | info(ping, Req, #{n := N} = State) -> 77 | tansu_stream:chunk(N, ping, Req), 78 | do_ping(), 79 | {ok, Req, State#{n := N+1}}; 80 | 81 | info(Event, Req, #{proxy := Proxy} = State) -> 82 | Proxy:info(Event, Req, State). 83 | 84 | terminate(Reason, Req, #{proxy := Proxy} = State) -> 85 | Proxy:terminate(Reason, Req, State); 86 | 87 | terminate(_Reason, _Req, #{lock := Lock, key := Key}) -> 88 | tansu_api:kv_test_and_delete(Lock, Key), 89 | ok. 90 | 91 | do_ping() -> 92 | erlang:send_after(tansu_config:timeout(stream_ping), self(), ping). 93 | 94 | do_try_lock(Req, #{lock := Lock, key := Key, n := N} = State) -> 95 | case tansu_api:kv_test_and_set(Lock, undefined, Key) of 96 | not_leader -> 97 | %% whoa, we were the leader. 98 | tansu_stream:chunk(N, not_granted, #{service_unavailable => not_leader}, Req), 99 | {stop, Req, State#{n := N+1}}; 100 | 101 | {ok, _Value, _Metadata} -> 102 | tansu_stream:chunk(N, granted, Req), 103 | {ok, Req, State#{n := N+1}}; 104 | 105 | error -> 106 | tansu_stream:chunk(N, not_granted, Req), 107 | {ok, Req, State#{n := N+1}} 108 | end. 109 | 110 | service_unavailable(Req, State) -> 111 | stop_with_code(503, Req, State). 112 | 113 | stop_with_code(Code, Req, State) -> 114 | {stop, cowboy_req:reply(Code, Req), State}. 115 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /src/tansu_api_proxy_resource.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | 16 | -module(tansu_api_proxy_resource). 17 | 18 | -export([init/2]). 19 | -export([info/3]). 20 | -export([terminate/3]). 21 | 22 | 23 | init(Req, #{host := Host, port := Port}) -> 24 | {ok, Origin} = gun:open(Host, Port, #{transport => tcp}), 25 | {cowboy_loop, Req, #{monitor => erlang:monitor(process, Origin), 26 | proxy => ?MODULE, 27 | origin => Origin, 28 | qs => cowboy_req:qs(Req), 29 | path => cowboy_req:path(Req)}}. 30 | 31 | 32 | info({gun_up, Origin, _}, Req, #{path := Path, qs := QS, origin := Origin} = State) -> 33 | %% A http connection to origin is up and available, proxy 34 | %% client request through to the origin. 35 | {ok, Req, maybe_request_body(Req, State, Origin, Path, QS)}; 36 | 37 | info({gun_response, _, _, nofin, Status, Headers}, Req, State) -> 38 | %% We have an initial http response from the origin together with 39 | %% some headers to forward to the client. 40 | {ok, cowboy_req:chunked_reply(Status, lists:keydelete(<<"content-length">>, 1, Headers), Req), State}; 41 | 42 | info({gun_response, _, _, fin, Status, Headers}, Req, State) -> 43 | %% short and sweet, we have final http response from the origin 44 | %% with just status and headers and no response body. 45 | {stop, cowboy_req:reply(Status, Headers, Req), State}; 46 | 47 | info({gun_data, _, _, nofin, Data}, Req, State) -> 48 | ok = cowboy_req:chunk(Data, Req), 49 | {ok, Req, State}; 50 | 51 | info({gun_data, _, _, fin, Data}, Req, State) -> 52 | %% we received a final response body chunk from the origin, 53 | %% chunk and forward to the client - and then hang up the 54 | %% connection. 55 | cowboy_req:chunk(Data, Req), 56 | {stop, Req, State}; 57 | 58 | info({request_body, #{complete := Data}}, Req, #{origin := Origin, 59 | request := Request} = State) -> 60 | %% the client has streamed the http request body to us, and we 61 | %% have received the last chunk, forward the chunk to the origin 62 | %% letting them know not to expect any more 63 | gun:data(Origin, Request, fin, Data), 64 | {ok, Req, State}; 65 | 66 | info({request_body, #{more := More}}, Req, #{origin := Origin, 67 | request := Request} = State) -> 68 | %% the client is streaming the http request body to us, forward 69 | %% the chunk to the origin letting them know that we're not done 70 | %% yet. 71 | gun:data(Origin, Request, nofin, More), 72 | {ok, Req, request_body(Req, State)}; 73 | 74 | info({'DOWN', Monitor, _, _, _}, Req, #{monitor := Monitor} = State) -> 75 | %% whoa, our monitor has noticed the http connection to the origin 76 | %% is emulating a Norwegian Blue parrot, time to declare to the 77 | %% client that the gateway has turned bad. 78 | bad_gateway(Req, State). 79 | 80 | 81 | terminate(_Reason, _Req, #{origin := Origin, monitor := Monitor}) -> 82 | %% we are terminating and have a monitored connection to the 83 | %% origin, try and be a nice citizen and demonitor and pull the 84 | %% plug on the connection to the origin. 85 | erlang:demonitor(Monitor), 86 | gun:close(Origin); 87 | 88 | terminate(_Reason, _Req, #{origin := Origin}) -> 89 | %% we are terminating and just have a connection to the origin, 90 | %% try and pull the plug on it. 91 | gun:close(Origin). 92 | 93 | 94 | maybe_request_body(Req, State, Origin, Path, QS) -> 95 | %% Proxy the http request through to the origin, and start 96 | %% streaming the request body from the client is there is one. 97 | maybe_request_body(Req, State#{request => proxy(Req, Origin, Path, QS)}). 98 | 99 | 100 | proxy(Req, Origin, Path, <<>>) -> 101 | %% Act as a proxy for a http request to the origin from the 102 | %% client. 103 | Method = cowboy_req:method(Req), 104 | Headers = cowboy_req:headers(Req), 105 | gun:request(Origin, Method, Path, Headers); 106 | 107 | proxy(Req, Origin, Path, QS) -> 108 | %% Act as a proxy for a http request to the origin from the 109 | %% client. 110 | Method = cowboy_req:method(Req), 111 | Headers = cowboy_req:headers(Req), 112 | gun:request(Origin, Method, <>, Headers). 113 | 114 | 115 | maybe_request_body(Req, State) -> 116 | case cowboy_req:has_body(Req) of 117 | true -> 118 | %% We have a http request body, start streaming it from 119 | %% the client. 120 | request_body(Req, State); 121 | 122 | false -> 123 | %% There is no request body from the client, time to move 124 | %% on 125 | State 126 | end. 127 | 128 | 129 | request_body(Req, State) -> 130 | %% We are streaming the request body from the client 131 | case cowboy_req:body(Req) of 132 | {ok, Data, _} -> 133 | %% We have streamed all of the request body from the 134 | %% client. 135 | self() ! {request_body, #{complete => Data}}, 136 | State; 137 | 138 | {more, Data, _} -> 139 | %% We have part of the request body, but there is still 140 | %% more waiting for us. 141 | self() ! {request_body, #{more => Data}} 142 | end. 143 | 144 | 145 | bad_gateway(Req, State) -> 146 | stop_with_code(502, Req, State). 147 | 148 | stop_with_code(Code, Req, State) -> 149 | {stop, cowboy_req:reply(Code, Req), State}. 150 | 151 | 152 | 153 | -------------------------------------------------------------------------------- /src/tansu_api_server_resource.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_api_server_resource). 16 | 17 | -export([init/2]). 18 | -export([terminate/3]). 19 | -export([websocket_handle/3]). 20 | -export([websocket_info/3]). 21 | 22 | 23 | init(Req, State) -> 24 | TansuId = cowboy_req:header(<<"tansu-id">>, Req), 25 | {PeerIP, _} = cowboy_req:peer(Req), 26 | TansuPort = cowboy_req:port(Req), 27 | init(Req, TansuId, inet:ntoa(PeerIP), TansuPort, State). 28 | 29 | 30 | init(Req, TansuId, TansuHost, TansuPort, State) when TansuId == undefined orelse 31 | TansuHost == undefined orelse 32 | TansuPort == undefined -> 33 | {ok, cowboy_req:reply(400, Req), State}; 34 | 35 | init(Req, TansuId, TansuHost, TansuPort, State) -> 36 | Outgoing = outgoing(self()), 37 | tansu_consensus:add_connection( 38 | self(), 39 | TansuId, 40 | TansuHost, 41 | any:to_integer(TansuPort), 42 | Outgoing, 43 | closer(self())), 44 | ok = Outgoing(tansu_rpc:ping(tansu_consensus:id())), 45 | {cowboy_websocket, Req, State}. 46 | 47 | 48 | websocket_handle({binary, Message}, Req, State) -> 49 | tansu_consensus:demarshall(self(), tansu_rpc:decode(Message)), 50 | {ok, Req, State}. 51 | 52 | websocket_info({message, Message}, Req, State) -> 53 | {reply, {binary, tansu_rpc:encode(Message)}, Req, State}; 54 | 55 | websocket_info(close, Req, State) -> 56 | {stop, Req, State}. 57 | 58 | terminate(_Reason, _Req, _State) -> 59 | ok. 60 | 61 | outgoing(Recipient) -> 62 | fun(Message) -> 63 | Recipient ! {message, Message}, 64 | ok 65 | end. 66 | 67 | closer(Recipient) -> 68 | fun() -> 69 | Recipient ! close, 70 | ok 71 | end. 72 | -------------------------------------------------------------------------------- /src/tansu_app.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_app). 16 | -behaviour(application). 17 | 18 | -export([create_schema/0]). 19 | -export([create_tables/0]). 20 | -export([start/2]). 21 | -export([stop/1]). 22 | 23 | start(_Type, _Args) -> 24 | try 25 | create_schema() andalso create_tables(), 26 | {ok, Sup} = tansu_sup:start_link(), 27 | _ = start_advertiser(tansu_tcp_advertiser), 28 | [tansu:trace(true) || tansu_config:enabled(debug)], 29 | {ok, Sup, #{listeners => [perhaps_start([http])]}} 30 | catch 31 | _:Reason -> 32 | {error, Reason} 33 | end. 34 | 35 | 36 | stop(#{listeners := Listeners}) -> 37 | lists:foreach(fun cowboy:stop_listener/1, Listeners); 38 | stop(_State) -> 39 | ok. 40 | 41 | start_advertiser(Advertiser) -> 42 | _ = [mdns_discover_sup:start_child(Advertiser) || tansu_config:can(discover)], 43 | _ = [mdns_advertise_sup:start_child(Advertiser) || tansu_config:can(advertise)]. 44 | 45 | 46 | perhaps_start(Prefixes) -> 47 | perhaps_start(Prefixes, []). 48 | 49 | perhaps_start([], A) -> 50 | A; 51 | 52 | perhaps_start([Prefix | Prefixes], A) -> 53 | case tansu_config:enabled(Prefix) of 54 | false -> 55 | perhaps_start(Prefixes, A); 56 | 57 | true -> 58 | perhaps_start(Prefixes, [start_http(Prefix) | A]) 59 | end. 60 | 61 | 62 | start_http(Prefix) -> 63 | {ok, _} = cowboy:start_http( 64 | Prefix, 65 | tansu_config:acceptors(Prefix), 66 | [{port, tansu_config:port(Prefix)}], 67 | [{env, [dispatch(Prefix)]}]), 68 | Prefix. 69 | 70 | 71 | dispatch(Prefix) -> 72 | {dispatch, cowboy_router:compile(resources(Prefix))}. 73 | 74 | 75 | resources(http) -> 76 | [{'_', endpoints()}]. 77 | 78 | 79 | endpoints() -> 80 | [endpoint(server, tansu_api_server_resource), 81 | endpoint(api, "/keys/[...]", tansu_api_keys_resource), 82 | endpoint(api, "/locks/[...]", tansu_api_locks_resource), 83 | endpoint(api, "/swagger.json", tansu_oapi_resource), 84 | endpoint(api, "/info", tansu_api_info_resource)]. 85 | 86 | 87 | endpoint(Endpoint, Module) -> 88 | endpoint(Endpoint, undefined, Module, []). 89 | 90 | endpoint(Endpoint, Pattern, Module) -> 91 | endpoint(Endpoint, Pattern, Module, []). 92 | 93 | endpoint(Endpoint, undefined, Module, Parameters) -> 94 | {tansu_config:endpoint(Endpoint), Module, Parameters}; 95 | 96 | endpoint(Endpoint, Pattern, Module, Parameters) -> 97 | {tansu_config:endpoint(Endpoint) ++ Pattern, Module, Parameters}. 98 | 99 | 100 | 101 | create_schema() -> 102 | case tansu_config:db_schema() of 103 | ram -> 104 | case mnesia:table_info(schema, ram_copies) of 105 | [] -> 106 | false; 107 | [_] -> 108 | true 109 | end; 110 | 111 | _ -> 112 | case mnesia:table_info(schema, disc_copies) of 113 | [] -> 114 | ok = application:stop(mnesia), 115 | 116 | case mnesia:create_schema([node()]) of 117 | {error, {_, {already_exists, _}}} -> 118 | ok = application:start(mnesia), 119 | true; 120 | 121 | ok -> 122 | ok = application:start(mnesia), 123 | true; 124 | 125 | {error, Reason} -> 126 | error(Reason) 127 | end; 128 | 129 | [_] -> 130 | true 131 | end 132 | end. 133 | 134 | create_tables() -> 135 | tansu_ps:create_table() andalso tansu_log:create_table(). 136 | -------------------------------------------------------------------------------- /src/tansu_cluster_membership.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_cluster_membership). 16 | -behaviour(gen_server). 17 | 18 | -export([code_change/3]). 19 | -export([handle_call/3]). 20 | -export([handle_cast/2]). 21 | -export([handle_info/2]). 22 | -export([init/1]). 23 | -export([start_link/0]). 24 | -export([stop/0]). 25 | -export([terminate/2]). 26 | 27 | start_link() -> 28 | gen_server:start_link({local, ?MODULE}, ?MODULE, members(), []). 29 | 30 | stop() -> 31 | gen_server:cast(?MODULE, stop). 32 | 33 | init([]) -> 34 | ignore; 35 | 36 | init(Members) -> 37 | {ok, #{members => queue:from_list(Members), size => length(Members)}, tansu_config:timeout(cluster_add_member)}. 38 | 39 | handle_call(_, _, State) -> 40 | {stop, error, State}. 41 | 42 | handle_cast(stop, State) -> 43 | {stop, normal, State}. 44 | 45 | handle_info(timeout, #{members := Members, size := Size} = State) -> 46 | case tansu_consensus:info() of 47 | #{connections := Connections} when map_size(Connections) + 1 == Size -> 48 | {noreply, State, tansu_config:timeout(cluster_add_member)}; 49 | 50 | _ -> 51 | {{value, Member}, Remaining} = queue:out(Members), 52 | tansu_consensus:add_server(url(Member)), 53 | {noreply, State#{members := queue:in(Member, Remaining)}, tansu_config:timeout(cluster_add_member)} 54 | end. 55 | 56 | terminate(_, _) -> 57 | ok. 58 | 59 | code_change(_, State, _) -> 60 | {ok, State}. 61 | 62 | members() -> 63 | string:tokens(tansu_config:cluster(members), ","). 64 | 65 | url(Member) -> 66 | "http://" ++ Member ++ tansu_config:endpoint(server). 67 | -------------------------------------------------------------------------------- /src/tansu_config.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_config). 16 | 17 | -export([acceptors/1]). 18 | -export([batch_size/1]). 19 | -export([can/1]). 20 | -export([cluster/1]). 21 | -export([db_schema/0]). 22 | -export([directory/1]). 23 | -export([enabled/1]). 24 | -export([endpoint/1]). 25 | -export([environment/0]). 26 | -export([maximum/1]). 27 | -export([minimum/1]). 28 | -export([port/1]). 29 | -export([sm/0]). 30 | -export([timeout/1]). 31 | 32 | 33 | batch_size(append_entries) -> 34 | envy(to_integer, batch_size_append_entries, 32). 35 | 36 | 37 | can(advertise) -> 38 | envy(to_boolean, can_advertise, true); 39 | can(discover) -> 40 | envy(to_boolean, can_discover, true); 41 | can(mesh) -> 42 | envy(to_boolean, can_mesh, true). 43 | 44 | cluster(members) -> 45 | envy(to_list, cluster_members, ""). 46 | 47 | directory(snapshot) -> 48 | envy(to_list, snapshot_directory, "/snapshots"). 49 | 50 | enabled(debug) -> 51 | envy(to_boolean, debug, false); 52 | 53 | enabled(http) -> 54 | envy(to_boolean, http_enabled, true). 55 | 56 | sm() -> 57 | envy(to_atom, sm, tansu_sm_mnesia_kv). 58 | 59 | endpoint(server) -> 60 | endpoint(api) ++ envy(to_list, endpoint_server, "/server"); 61 | endpoint(api) -> 62 | envy(to_list, endpoint_api, "/api"). 63 | 64 | port(http) -> 65 | envy(to_integer, http_port, 80). 66 | 67 | db_schema() -> 68 | envy(to_atom, db_schema, ram). 69 | 70 | environment() -> 71 | envy(to_list, environment, "dev"). 72 | 73 | acceptors(http) -> 74 | envy(to_integer, http_acceptors, 100). 75 | 76 | timeout(cluster_add_member) -> 77 | envy(to_integer, timeout_cluster_add_member, 15000); 78 | timeout(election_low) -> 79 | envy(to_integer, timeout_election_low, 1500); 80 | timeout(election_high) -> 81 | envy(to_integer, timeout_election_high, 3000); 82 | timeout(leader_low) -> 83 | envy(to_integer, timeout_leader_low, 500); 84 | timeout(leader_high) -> 85 | envy(to_integer, timeout_leader_high, 1000); 86 | timeout(kv_expiry) -> 87 | envy(to_integer, timeout_kv_expiry, 1000); 88 | timeout(kv_snapshot) -> 89 | envy(to_integer, timeout_kv_snapshot, 1000 * 60); 90 | timeout(mnesia_wait_for_tables) -> 91 | envy(to_integer_or_atom, timeout_mnesia_wait_for_tables, infinity); 92 | timeout(sync_send_event) -> 93 | envy(to_integer_or_atom, timeout_sync_send_event, infinity); 94 | timeout(stream_ping) -> 95 | envy(to_integer, timeout_stream_ping, 5000). 96 | 97 | 98 | 99 | minimum(quorum) -> 100 | envy(to_integer, minimum_quorum, 3). 101 | 102 | maximum(snapshot) -> 103 | envy(to_integer, maximum_snapshot, 3). 104 | 105 | envy(To, Name, Default) -> 106 | envy:To(tansu, Name, default(Default)). 107 | 108 | default(Default) -> 109 | [os_env, app_env, {default, Default}]. 110 | 111 | -------------------------------------------------------------------------------- /src/tansu_consensus_candidate.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_consensus_candidate). 16 | -export([add_server/2]). 17 | -export([append_entries/2]). 18 | -export([append_entries_response/2]). 19 | -export([ping/2]). 20 | -export([remove_server/2]). 21 | -export([request_vote/2]). 22 | -export([rerun_election/1]). 23 | -export([transition_to_follower/1]). 24 | -export([vote/2]). 25 | 26 | 27 | add_server(_, #{change := _} = Data) -> 28 | %% change already in progress 29 | {next_state, candidate, Data}; 30 | 31 | add_server(URI, Data) -> 32 | {next_state, candidate, tansu_consensus:do_add_server(URI, Data)}. 33 | 34 | ping(_, Data) -> 35 | {next_state, candidate, Data}. 36 | 37 | 38 | remove_server(_, #{change := _} = Data) -> 39 | %% change already in progress 40 | {next_state, candidate, Data}; 41 | remove_server(_, Data) -> 42 | {next_state, candidate, Data}. 43 | 44 | rerun_election(#{term := T0, id := Id, commit_index := CI} = D0) -> 45 | T1 = tansu_ps:increment(Id, T0), 46 | tansu_consensus:do_broadcast( 47 | tansu_rpc:request_vote(T1, Id, CI, CI), 48 | D0), 49 | D1 = D0#{term => T1, 50 | voted_for => tansu_ps:voted_for(Id, Id), 51 | for => [Id], 52 | against => []}, 53 | {next_state, candidate, tansu_consensus:do_rerun_election_after_timeout(D1)}. 54 | 55 | %% If RPC request or response contains term T > currentTerm: set 56 | %% currentTerm = T, convert to follower (§5.1) 57 | append_entries(#{term := T}, #{id := Id, 58 | term := CT} = Data) when T > CT -> 59 | {next_state, follower, tansu_consensus:do_call_election_after_timeout( 60 | tansu_consensus:do_drop_votes(Data#{term := tansu_ps:term(Id, T)}))}; 61 | 62 | %% Reply false if term < currentTerm (§5.1) 63 | append_entries(#{term := Term, 64 | prev_log_index := PrevLogIndex, 65 | prev_log_term := PrevLogTerm, 66 | leader := Leader}, 67 | #{term := Current, id := Id} = Data) when Term < Current -> 68 | tansu_consensus:do_send( 69 | tansu_rpc:append_entries_response( 70 | Leader, Id, Current, PrevLogIndex, PrevLogTerm, false), 71 | Leader, 72 | Data), 73 | {next_state, candidate, Data}; 74 | 75 | append_entries(#{entries := [], 76 | prev_log_index := PrevLogIndex, 77 | prev_log_term := PrevLogTerm, 78 | leader := L, term := T}, 79 | #{id := Id} = Data) -> 80 | tansu_consensus:do_send( 81 | tansu_rpc:append_entries_response( 82 | L, Id, T, PrevLogIndex, PrevLogTerm, true), 83 | L, 84 | Data), 85 | {next_state, follower, maps:without([voted_for, 86 | for, 87 | against], 88 | tansu_consensus:do_call_election_after_timeout( 89 | tansu_consensus:do_drop_votes( 90 | Data#{term => tansu_ps:term(Id, T), leader => L})))}. 91 | 92 | 93 | append_entries_response(#{term := Term}, #{term := Current} = Data) when Term < Current -> 94 | {next_state, candidate, Data}. 95 | 96 | 97 | request_vote(#{term := T0}, #{term := T1, id := Id} = Data) when T0 > T1 -> 98 | {next_state, 99 | follower, 100 | tansu_consensus:do_call_election_after_timeout( 101 | tansu_consensus:do_drop_votes(Data#{term := tansu_ps:term(Id, T0)}))}; 102 | 103 | request_vote(#{candidate := C}, #{term := T, id := Id} = Data) -> 104 | {next_state, 105 | candidate, 106 | tansu_consensus:do_send( 107 | tansu_rpc:vote(Id, T, false), 108 | C, 109 | Data)}. 110 | 111 | 112 | %% Ignore votes from earlier terms. 113 | vote(#{term := T}, #{term := CT} = Data) when T < CT -> 114 | {next_state, candidate, Data}; 115 | 116 | %% If RPC request or response contains term T > currentTerm: set 117 | %% currentTerm = T, convert to follower (§5.1) 118 | vote(#{term := T}, #{id := Id, term := CT} = Data) when T > CT -> 119 | {next_state, follower, tansu_consensus:do_call_election_after_timeout( 120 | tansu_consensus:do_drop_votes(Data#{term := tansu_ps:term(Id, T)}))}; 121 | 122 | vote(#{elector := Elector, term := Term, granted := true}, 123 | #{for := For, 124 | connections := Connections, 125 | associations := Associations, 126 | term := Term} = Data) when map_size(Connections) == map_size(Associations) -> 127 | 128 | Members = map_size(Associations) + 1, 129 | Quorum = tansu_consensus:quorum(Data), 130 | 131 | case ordsets:add_element(Elector, For) of 132 | Proposers when (Members >= Quorum) andalso (length(Proposers) > (Members div 2)) -> 133 | {next_state, leader, appoint_leader(Data#{for := Proposers})}; 134 | 135 | Proposers -> 136 | {next_state, candidate, Data#{for := Proposers}} 137 | end; 138 | 139 | vote(#{elector := Elector, term := Term, granted := true}, 140 | #{for := For, 141 | term := Term} = Data) -> 142 | {next_state, candidate, Data#{for := ordsets:add_element(Elector, For)}}; 143 | 144 | 145 | vote(#{elector := Elector, term := Term, granted := false}, 146 | #{against := Against, term := Term} = State) -> 147 | {next_state, candidate, State#{against => ordsets:add_element( 148 | Elector, Against)}}. 149 | 150 | 151 | 152 | appoint_leader(#{term := Term, 153 | associations := Associations, 154 | id := Id, 155 | commit_index := CI, 156 | last_applied := LA} = Data) -> 157 | tansu_consensus:do_broadcast( 158 | tansu_rpc:heartbeat(Term, Id, LA, tansu_log:term_for_index(LA), CI), 159 | Data), 160 | tansu_consensus:do_end_of_term_after_timeout( 161 | maybe_init_log( 162 | Data#{next_indexes => lists:foldl( 163 | fun 164 | (Server, A) -> 165 | A#{Server => CI+1} 166 | end, 167 | #{}, 168 | maps:keys(Associations)), 169 | match_indexes => lists:foldl( 170 | fun 171 | (Server, A) -> 172 | A#{Server => 0} 173 | end, 174 | #{}, 175 | maps:keys(Associations))})). 176 | 177 | 178 | maybe_init_log(#{state_machine := undefined} = Data) -> 179 | tansu_consensus:do_log( 180 | #{m => tansu_sm, 181 | f => ckv_set, 182 | a => [user, <<"/">>, #{}, #{}]}, 183 | tansu_consensus:do_log( 184 | #{m => tansu_sm, 185 | f => ckv_set, 186 | a => [system, [<<"cluster">>], tansu_uuid:new(), #{}]}, 187 | tansu_consensus:do_log( 188 | #{m => tansu_sm, 189 | f => new}, Data))); 190 | 191 | maybe_init_log(#{state_machine := _} = Data) -> 192 | Data. 193 | 194 | 195 | transition_to_follower(Data) -> 196 | tansu_consensus:do_call_election_after_timeout( 197 | tansu_consensus:do_drop_votes(Data)). 198 | -------------------------------------------------------------------------------- /src/tansu_consensus_follower.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_consensus_follower). 16 | -export([add_server/2]). 17 | -export([append_entries/2]). 18 | -export([append_entries_response/2]). 19 | -export([call_election/1]). 20 | -export([install_snapshot/2]). 21 | -export([log/2]). 22 | -export([ping/2]). 23 | -export([remove_server/2]). 24 | -export([request_vote/2]). 25 | -export([vote/2]). 26 | 27 | 28 | add_server(_, #{change := _} = Data) -> 29 | %% change already in progress 30 | {next_state, follower, Data}; 31 | 32 | add_server(URI, Data) -> 33 | {next_state, follower, tansu_consensus:do_add_server(URI, Data)}. 34 | 35 | ping(_, Data) -> 36 | {next_state, follower, Data}. 37 | 38 | 39 | remove_server(_, #{change := _} = Data) -> 40 | %% change already in progress 41 | {next_state, follower, Data}; 42 | remove_server(_, Data) -> 43 | {next_state, follower, Data}. 44 | 45 | %% If election timeout elapses without receiving AppendEntries RPC 46 | %% from current leader or granting vote to candidate: convert to 47 | %% candidate 48 | call_election(#{term := T0, id := Id} = D0) -> 49 | T1 = tansu_ps:increment(Id, T0), 50 | #{index := LastLogIndex, term := LastLogTerm} = tansu_log:last(), 51 | tansu_consensus:do_broadcast(tansu_rpc:request_vote(T1, Id, LastLogIndex, LastLogTerm), D0), 52 | D1 = tansu_consensus:do_drop_votes(D0), 53 | D2 = D1#{term => T1, 54 | voted_for => tansu_ps:voted_for(Id, Id), 55 | for => [Id], 56 | against => []}, 57 | {next_state, candidate, tansu_consensus:do_rerun_election_after_timeout(D2)}. 58 | 59 | %% Drop obsolete vote responses from earlier terms 60 | vote(#{term := Term}, #{term := Current} = Data) when Term < Current -> 61 | {next_state, follower, Data}; 62 | 63 | %% an old vote for when we were a candidate 64 | vote(#{granted := _}, Data) -> 65 | {next_state, follower, Data}. 66 | 67 | 68 | install_snapshot(#{data := {Name, StateMachine, Snapshot}, 69 | done := true, 70 | last_config := _, 71 | last_index := LastIndex, 72 | last_term := LastTerm, 73 | leader := L, 74 | term := T}, 75 | #{id := Id, term := Current} = Data) when T >= Current -> 76 | tansu_sm:install_snapshot(Name, Snapshot, StateMachine), 77 | {next_state, 78 | follower, 79 | tansu_consensus:do_call_election_after_timeout( 80 | tansu_consensus:do_send( 81 | tansu_rpc:append_entries_response( 82 | L, Id, T, LastIndex, LastTerm, true), 83 | L, 84 | Data#{term => tansu_ps:term(Id, T), 85 | commit_index => LastIndex, 86 | state_machine => StateMachine, 87 | last_applied => LastIndex}))}; 88 | install_snapshot(_, Data) -> 89 | %% ignore install snapshot from an earlier term 90 | {next_state, follower, Data}. 91 | 92 | %% Reply false if term < currentTerm (§5.1) 93 | append_entries(#{term := Term, 94 | leader := Leader, 95 | prev_log_index := PrevLogIndex, 96 | prev_log_term := PrevLogTerm, 97 | entries := _}, 98 | #{term := Current, id := Id} = Data) when Term < Current -> 99 | tansu_consensus:do_send( 100 | tansu_rpc:append_entries_response( 101 | Leader, Id, Current, PrevLogIndex, PrevLogTerm, false), 102 | Leader, 103 | Data), 104 | {next_state, follower, Data}; 105 | 106 | append_entries(#{entries := Entries, 107 | prev_log_index := PrevLogIndex, 108 | prev_log_term := PrevLogTerm, 109 | leader_commit := LeaderCommit, 110 | leader := L, term := T}, 111 | #{commit_index := Commit0, 112 | last_applied := LastApplied, 113 | state_machine := SM, 114 | id := Id} = D0) -> 115 | 116 | case tansu_log:append_entries(PrevLogIndex, PrevLogTerm, Entries) of 117 | {ok, LastIndex} when LeaderCommit > Commit0 -> 118 | D1 = case min(LeaderCommit, LastIndex) of 119 | Commit1 when Commit1 > LastApplied -> 120 | D0#{state_machine => do_apply_to_state_machine( 121 | LastApplied + 1, 122 | Commit1, 123 | SM), 124 | commit_index => Commit1, 125 | last_applied => Commit1 126 | }; 127 | 128 | _ -> 129 | D0 130 | end, 131 | tansu_consensus:do_send( 132 | tansu_rpc:append_entries_response( 133 | L, Id, T, LastIndex, tansu_log:term_for_index(LastIndex), true), 134 | L, 135 | D1), 136 | {next_state, follower, tansu_consensus:do_call_election_after_timeout( 137 | D1#{term => tansu_ps:term(Id, T), 138 | leader => #{id => L, commit_index => LeaderCommit}})}; 139 | 140 | {ok, LastIndex} -> 141 | tansu_consensus:do_send( 142 | tansu_rpc:append_entries_response( 143 | L, Id, T, LastIndex, tansu_log:term_for_index(LastIndex), true), 144 | L, 145 | D0), 146 | {next_state, follower, tansu_consensus:do_call_election_after_timeout( 147 | D0#{term => tansu_ps:term(Id, T), 148 | leader => #{id => L, commit_index => LeaderCommit}})}; 149 | 150 | {error, unmatched_term} -> 151 | #{index := LastIndex, term := LastTerm} = tansu_log:last(), 152 | tansu_consensus:do_send( 153 | tansu_rpc:append_entries_response( 154 | L, Id, T, LastIndex, LastTerm, false), 155 | L, 156 | D0), 157 | {next_state, follower, tansu_consensus:do_call_election_after_timeout( 158 | D0#{term => tansu_ps:term(Id, T), 159 | leader => #{id => L, commit_index => LeaderCommit}})} 160 | end. 161 | 162 | 163 | append_entries_response(#{term := Term}, #{term := Current} = Data) when Term =< Current -> 164 | {next_state, follower, Data}; 165 | 166 | append_entries_response(#{term := Term}, #{id := Id} = Data) -> 167 | %% An append entries response with a future term, drop our current 168 | %% vote and adopt the new term. 169 | tansu_ps:voted_for(Id, undefined), 170 | {next_state, 171 | follower, 172 | maps:without( 173 | [voted_for], 174 | tansu_consensus:do_call_election_after_timeout( 175 | Data#{term => tansu_ps:term(Id, Term)}))}. 176 | 177 | 178 | log(Command, #{leader := #{id := Leader}} = Data) -> 179 | tansu_consensus:do_send( 180 | tansu_rpc:log(Command), 181 | Leader, 182 | Data), 183 | {next_state, follower, Data}. 184 | 185 | 186 | %% Reply false if term < currentTerm (§5.1) 187 | request_vote(#{term := Term, candidate := Candidate}, 188 | #{term := Current, id := Id} = Data) when Term < Current -> 189 | tansu_consensus:do_send( 190 | tansu_rpc:vote(Id, Current, false), 191 | Candidate, 192 | Data), 193 | {next_state, follower, Data}; 194 | 195 | %% If votedFor is null or candidateId, and candidate’s log is at least 196 | %% as up-to-date as receiver’s log, grant vote (§5.2, §5.4) 197 | request_vote(#{term := T, 198 | candidate := Candidate}, 199 | #{id := Id, term := T, voted_for := Candidate} = Data) -> 200 | tansu_consensus:do_send( 201 | tansu_rpc:vote(Id, T, true), 202 | Candidate, 203 | Data), 204 | {next_state, follower, tansu_consensus:do_call_election_after_timeout(Data)}; 205 | 206 | 207 | %% If votedFor is null or candidateId, and candidate’s log is at least 208 | %% as up-to-date as receiver’s log, grant vote (§5.2, §5.4) 209 | request_vote(#{term := T, candidate := Candidate}, 210 | #{id := Id, term := T, voted_for := _} = Data) -> 211 | tansu_consensus:do_send( 212 | tansu_rpc:vote(Id, T, false), 213 | Candidate, 214 | Data), 215 | {next_state, follower, Data}; 216 | 217 | %% If votedFor is null or candidateId, and candidate’s log is at least 218 | %% as up-to-date as receiver’s log, grant vote (§5.2, §5.4) 219 | request_vote(#{term := Term, 220 | candidate := Candidate, 221 | last_log_index := LastLogIndex, 222 | last_log_term := LastLogTerm}, 223 | #{term := Current, id := Id} = Data) when (Term >= Current) -> 224 | 225 | %% Raft determines which of two logs is more up-to-date by 226 | %% comparing the index and term of the last entries in the 227 | %% logs. If the logs have last entries with different terms, then 228 | %% the log with the later term is more up-to-date. If the logs end 229 | %% with the same term, then whichever log is longer is more 230 | %% up-to-date. 231 | case tansu_log:last() of 232 | #{term := LogTerm} when LogTerm > LastLogTerm -> 233 | tansu_consensus:do_send( 234 | tansu_rpc:vote(Id, Term, false), 235 | Candidate, 236 | Data), 237 | tansu_ps:voted_for(Id, undefined), 238 | {next_state, follower, maps:without( 239 | [voted_for], 240 | tansu_consensus:do_call_election_after_timeout( 241 | Data#{term => tansu_ps:term(Id, Term)}))}; 242 | 243 | #{term := LogTerm} when LogTerm < LastLogTerm -> 244 | tansu_consensus:do_send( 245 | tansu_rpc:vote(Id, Term, true), 246 | Candidate, 247 | Data), 248 | {next_state, follower, tansu_consensus:do_call_election_after_timeout( 249 | Data#{term => tansu_ps:term(Id, Term), 250 | voted_for => tansu_ps:voted_for( 251 | Id, Candidate)})}; 252 | 253 | 254 | #{index := LogIndex} when LastLogIndex >= LogIndex-> 255 | tansu_consensus:do_send( 256 | tansu_rpc:vote(Id, Term, true), 257 | Candidate, 258 | Data), 259 | {next_state, follower, tansu_consensus:do_call_election_after_timeout( 260 | Data#{term => tansu_ps:term(Id, Term), 261 | voted_for => tansu_ps:voted_for( 262 | Id, Candidate)})}; 263 | 264 | #{index := LogIndex} when LastLogIndex < LogIndex-> 265 | tansu_consensus:do_send( 266 | tansu_rpc:vote(Id, Term, false), 267 | Candidate, 268 | Data), 269 | tansu_ps:voted_for(Id, undefined), 270 | {next_state, follower, maps:without( 271 | [voted_for], 272 | tansu_consensus:do_call_election_after_timeout( 273 | Data#{term => tansu_ps:term(Id, Term)}))} 274 | end. 275 | 276 | 277 | do_apply_to_state_machine(LastApplied, CommitIndex, State) -> 278 | do_apply_to_state_machine(lists:seq(LastApplied, CommitIndex), State). 279 | 280 | do_apply_to_state_machine([H | T], undefined) -> 281 | case tansu_log:read(H) of 282 | #{command := #{f := F, a := A}} -> 283 | {_, State} = apply(tansu_sm, F, A), 284 | do_apply_to_state_machine(T, State); 285 | 286 | #{command := #{f := F}} -> 287 | {_, State} = apply(tansu_sm, F, []), 288 | do_apply_to_state_machine(T, State) 289 | end; 290 | 291 | do_apply_to_state_machine([H | T], S0) -> 292 | case tansu_log:read(H) of 293 | #{command := #{f := F, a := A}} -> 294 | {_, S1} = apply(tansu_sm, F, A ++ [S0]), 295 | do_apply_to_state_machine(T, S1); 296 | 297 | #{command := #{f := F}} -> 298 | {_, S1} = apply(tansu_sm, F, [S0]), 299 | do_apply_to_state_machine(T, S1) 300 | end; 301 | 302 | do_apply_to_state_machine([], State) -> 303 | State. 304 | -------------------------------------------------------------------------------- /src/tansu_consensus_leader.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_consensus_leader). 16 | -export([add_server/2]). 17 | -export([append_entries/2]). 18 | -export([append_entries_response/2]). 19 | -export([end_of_term/1]). 20 | -export([log/2]). 21 | -export([ping/2]). 22 | -export([remove_server/2]). 23 | -export([request_vote/2]). 24 | -export([transition_to_follower/1]). 25 | -export([vote/2]). 26 | 27 | add_server(_, #{change := _, match_indexes := _, next_indexes := _} = Data) -> 28 | %% change already in progress 29 | {next_state, leader, Data}; 30 | add_server(URI, #{match_indexes := _, next_indexes := _} = Data) -> 31 | {next_state, leader, tansu_consensus:do_add_server(URI, Data)}. 32 | 33 | ping(_, Data) -> 34 | {next_state, leader, Data}. 35 | 36 | remove_server(_, #{change := _, match_indexes := _, next_indexes := _} = Data) -> 37 | %% change already in progress 38 | {next_state, leader, Data}. 39 | 40 | log(Command, #{match_indexes := _, next_indexes := _} = Data) -> 41 | {next_state, leader, tansu_consensus:do_log(Command, Data)}. 42 | 43 | %% If RPC request or response contains term T > currentTerm: set 44 | %% currentTerm = T, convert to follower (§5.1) 45 | %% TODO 46 | append_entries(#{term := Term}, #{id := Id, 47 | match_indexes := _, 48 | next_indexes := _, 49 | term := Current} = Data) when Term > Current -> 50 | {next_state, follower, maps:without( 51 | [match_indexes, next_indexes], 52 | tansu_consensus:do_call_election_after_timeout( 53 | tansu_consensus:do_drop_votes( 54 | Data#{term := tansu_ps:term(Id, Term)})))}; 55 | 56 | %% Reply false if term < currentTerm (§5.1) 57 | append_entries(#{term := Term, 58 | prev_log_index := PrevLogIndex, 59 | prev_log_term := PrevLogTerm, 60 | leader := Leader}, 61 | #{term := Current, 62 | match_indexes := _, 63 | next_indexes := _, 64 | id := Id} = Data) when Term < Current -> 65 | tansu_consensus:do_send( 66 | tansu_rpc:append_entries_response( 67 | Leader, Id, Current, PrevLogIndex, PrevLogTerm, false), 68 | Leader, 69 | Data), 70 | {next_state, leader, Data}; 71 | 72 | %% Append entries from another leader in the current term. 73 | append_entries(#{prev_log_index := PrevLogIndex, 74 | prev_log_term := PrevLogTerm, 75 | leader := Leader} = AppendEntries, 76 | #{term := Current, 77 | match_indexes := _, 78 | next_indexes := _, 79 | id := Id} = Data) -> 80 | %% Looks like we've found another leader in our term, report as a 81 | %% warning. 82 | error_logger:warning_report([{module, ?MODULE}, 83 | {line, ?LINE}, 84 | {description, found_another_leader}, 85 | {append_entries, AppendEntries}, 86 | {data, Data}]), 87 | %% Refuse to append the entries that we have been given. 88 | tansu_consensus:do_send( 89 | tansu_rpc:append_entries_response( 90 | Leader, Id, Current, PrevLogIndex, PrevLogTerm, false), 91 | Leader, 92 | Data), 93 | %% Remain "the" leader. 94 | {next_state, leader, Data}. 95 | 96 | 97 | append_entries_response(#{term := Term}, #{id := Id, term := Current} = Data) when Term > Current -> 98 | {next_state, follower, maps:without( 99 | [match_indexes, next_indexes], 100 | tansu_consensus:do_call_election_after_timeout( 101 | tansu_consensus:do_drop_votes( 102 | Data#{term := tansu_ps:term(Id, Term)})))}; 103 | 104 | append_entries_response(#{term := Term}, #{term := Current} = Data) when Term < Current -> 105 | {next_state, leader, Data}; 106 | 107 | append_entries_response(#{success := false, 108 | prev_log_index := LastIndex, 109 | follower := Follower}, 110 | #{match_indexes := _, 111 | next_indexes := NextIndexes} = Data) -> 112 | {next_state, leader, Data#{next_indexes := NextIndexes#{Follower := LastIndex + 1}}}; 113 | 114 | append_entries_response(#{success := true, 115 | prev_log_index := PrevLogIndex, 116 | follower := Follower}, 117 | #{match_indexes := Match, 118 | next_indexes := Next, 119 | last_applied := LastApplied, 120 | state_machine := SM, 121 | commit_index := Commit0} = Data) -> 122 | 123 | case commit_index_majority(Match#{Follower => PrevLogIndex}, Commit0) of 124 | Commit1 when Commit1 > LastApplied -> 125 | {next_state, 126 | leader, 127 | Data#{state_machine => do_apply_to_state_machine( 128 | LastApplied + 1, 129 | Commit1, 130 | SM), 131 | match_indexes := Match#{Follower => PrevLogIndex}, 132 | next_indexes := Next#{Follower => PrevLogIndex + 1}, 133 | commit_index => Commit1, 134 | last_applied => Commit1}}; 135 | 136 | _ -> 137 | {next_state, 138 | leader, 139 | Data#{ 140 | match_indexes := Match#{Follower => PrevLogIndex}, 141 | next_indexes := Next#{Follower => PrevLogIndex + 1}}} 142 | end. 143 | 144 | 145 | end_of_term(#{term := T0, 146 | commit_index := CI, 147 | id := Id, 148 | match_indexes := _, 149 | next_indexes := NI, 150 | state_machine := StateMachine, 151 | last_applied := LA} = D0) -> 152 | T1 = tansu_ps:increment(Id, T0), 153 | {next_state, leader, 154 | tansu_consensus:do_end_of_term_after_timeout( 155 | D0#{ 156 | term => T1, 157 | next_indexes := maps:fold( 158 | fun 159 | (Follower, Index, A) when LA >= Index -> 160 | case tansu_log:first() of 161 | #{index := LastIndexInSnapshot, command := #{m := tansu_sm, f := apply_snapshot, a := [Name]}, term := SnapshotTerm} when Index =< LastIndexInSnapshot -> 162 | 163 | Filename = filename:join(tansu_config:directory(snapshot), Name), 164 | {ok, Snapshot} = file:read_file(Filename), 165 | 166 | tansu_consensus:do_send( 167 | tansu_rpc:install_snapshot( 168 | T1, 169 | Id, 170 | LastIndexInSnapshot, 171 | SnapshotTerm, 172 | 0, 173 | 0, 174 | {Name, StateMachine, Snapshot}, 175 | true), 176 | Follower, 177 | D0), 178 | 179 | A#{Follower => LastIndexInSnapshot+1}; 180 | 181 | _ -> 182 | 183 | Batch = min(tansu_config:batch_size(append_entries), LA - Index), 184 | Entries = tansu_log:read(Index, Index + Batch), 185 | tansu_consensus:do_send( 186 | tansu_rpc:append_entries( 187 | T1, 188 | Id, 189 | Index-1, 190 | tansu_log:term_for_index(Index-1), 191 | CI, 192 | Entries), 193 | Follower, 194 | D0), 195 | A#{Follower => Index + Batch + 1} 196 | end; 197 | 198 | (Follower, Index, A) -> 199 | tansu_consensus:do_send( 200 | tansu_rpc:append_entries( 201 | T1, 202 | Id, 203 | Index-1, 204 | tansu_log:term_for_index(Index-1), 205 | CI, 206 | []), 207 | Follower, 208 | D0), 209 | A#{Follower => Index} 210 | end, 211 | #{}, 212 | NI)})}. 213 | 214 | %% If RPC request or response contains term T > currentTerm: set 215 | %% currentTerm = T, convert to follower (§5.1) 216 | %% TODO 217 | vote(#{term := Term}, #{id := Id, 218 | match_indexes := _, 219 | next_indexes := _, 220 | term := Current} = Data) when Term > Current -> 221 | {next_state, follower, maps:without( 222 | [match_indexes, next_indexes], 223 | tansu_consensus:do_call_election_after_timeout( 224 | tansu_consensus:do_drop_votes( 225 | Data#{term := tansu_ps:term(Id, Term)})))}; 226 | 227 | %% Votes from an earlier term are dropped. 228 | vote(#{term := Term}, #{term := Current} = Data) when Term < Current -> 229 | {next_state, leader, Data}; 230 | 231 | vote(#{term := T, elector := Elector, granted := true}, 232 | #{term := T, 233 | commit_index := CI, 234 | match_indexes := _, 235 | next_indexes := NI} = Data) -> 236 | {next_state, leader, Data#{next_indexes := NI#{Elector => CI + 1}}}; 237 | 238 | vote(#{term := T, elector := Elector, granted := false}, 239 | #{term := T, against := Against} = Data) -> 240 | {next_state, leader, Data#{against := [Elector | Against]}}. 241 | 242 | 243 | %% If RPC request or response contains term T > currentTerm: set 244 | %% currentTerm = T, convert to follower (§5.1) 245 | %% TODO 246 | request_vote(#{term := Term}, 247 | #{id := Id, 248 | match_indexes := _, 249 | next_indexes := _, 250 | term := Current} = Data) when Term > Current -> 251 | {next_state, follower, maps:without( 252 | [match_indexes, next_indexes], 253 | tansu_consensus:do_call_election_after_timeout( 254 | tansu_consensus:do_drop_votes( 255 | Data#{term := tansu_ps:term(Id, Term)})))}; 256 | 257 | request_vote(#{term := Term, 258 | candidate := Candidate}, 259 | #{id := Id, 260 | commit_index := CI, 261 | match_indexes := _, 262 | next_indexes := NI} = Data) -> 263 | tansu_consensus:do_send( 264 | tansu_rpc:vote(Id, Term, false), 265 | Candidate, 266 | Data), 267 | {next_state, leader, Data#{next_indexes := NI#{Candidate => CI + 1}}}. 268 | 269 | 270 | commit_index_majority(Values, CI) -> 271 | commit_index_majority( 272 | lists:reverse(ordsets:from_list(maps:values(Values))), 273 | maps:values(Values), 274 | CI). 275 | 276 | commit_index_majority([H | T], Values, CI) when H > CI -> 277 | case [I || I <- Values, I >= H] of 278 | Majority when length(Majority) > (length(Values) / 2) -> 279 | H; 280 | _ -> 281 | commit_index_majority(T, Values, CI) 282 | end; 283 | commit_index_majority(_, _, CI) -> 284 | CI. 285 | 286 | 287 | do_apply_to_state_machine(LastApplied, CommitIndex, State) -> 288 | do_apply_to_state_machine(lists:seq(LastApplied, CommitIndex), State). 289 | 290 | do_apply_to_state_machine([H | T], undefined) -> 291 | case tansu_log:read(H) of 292 | #{command := #{f := F, a := A}} -> 293 | {_, State} = apply(tansu_sm, F, A), 294 | do_apply_to_state_machine(T, State); 295 | 296 | #{command := #{f := F}} -> 297 | {_, State} = apply(tansu_sm, F, []), 298 | do_apply_to_state_machine(T, State) 299 | end; 300 | 301 | do_apply_to_state_machine([H | T], S0) -> 302 | case tansu_log:read(H) of 303 | #{command := #{f := F, a := A, from := From}} -> 304 | {Result, S1} = apply(tansu_sm, F, A ++ [S0]), 305 | gen_fsm:reply(From, Result), 306 | do_apply_to_state_machine(T, S1); 307 | 308 | #{command := #{f := F, a := A}} -> 309 | {_, S1} = apply(tansu_sm, F, A ++ [S0]), 310 | do_apply_to_state_machine(T, S1); 311 | 312 | #{command := #{f := F, from := From}} -> 313 | {Result, S1} = apply(tansu_sm, F, [S0]), 314 | gen_fsm:reply(From, Result), 315 | do_apply_to_state_machine(T, S1); 316 | 317 | #{command := #{f := F}} -> 318 | {_, S1} = apply(tansu_sm, F, [S0]), 319 | do_apply_to_state_machine(T, S1) 320 | end; 321 | 322 | do_apply_to_state_machine([], State) -> 323 | State. 324 | 325 | 326 | transition_to_follower(Data) -> 327 | maps:without( 328 | [match_indexes, next_indexes], 329 | tansu_consensus:do_call_election_after_timeout( 330 | tansu_consensus:do_drop_votes(Data))). 331 | -------------------------------------------------------------------------------- /src/tansu_kv_expiry.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_kv_expiry). 16 | -behaviour(gen_server). 17 | 18 | -export([code_change/3]). 19 | -export([handle_call/3]). 20 | -export([handle_cast/2]). 21 | -export([handle_info/2]). 22 | -export([init/1]). 23 | -export([start_link/0]). 24 | -export([terminate/2]). 25 | 26 | start_link() -> 27 | gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). 28 | 29 | init([]) -> 30 | {ok, undefined, tansu_config:timeout(kv_expiry)}. 31 | 32 | handle_call(_, _, State) -> 33 | {stop, error, State}. 34 | 35 | handle_cast(_, State) -> 36 | {stop, error, State}. 37 | 38 | handle_info(timeout, State) -> 39 | case tansu_consensus:expired() of 40 | {ok, Expired} -> 41 | lists:foreach( 42 | fun 43 | ({Category, Key}) -> 44 | tansu_consensus:ckv_delete(Category, Key) 45 | end, 46 | Expired), 47 | {noreply, State, tansu_config:timeout(kv_expiry)}; 48 | 49 | {error, not_leader} -> 50 | {noreply, State, tansu_config:timeout(kv_expiry)} 51 | end. 52 | 53 | terminate(_, _) -> 54 | ok. 55 | 56 | code_change(_, State, _) -> 57 | {ok, State}. 58 | -------------------------------------------------------------------------------- /src/tansu_kv_snapshot.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_kv_snapshot). 16 | -behaviour(gen_server). 17 | 18 | -export([code_change/3]). 19 | -export([handle_call/3]). 20 | -export([handle_cast/2]). 21 | -export([handle_info/2]). 22 | -export([init/1]). 23 | -export([start_link/0]). 24 | -export([terminate/2]). 25 | 26 | start_link() -> 27 | gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). 28 | 29 | init([]) -> 30 | {ok, undefined, tansu_config:timeout(kv_snapshot)}. 31 | 32 | handle_call(_, _, State) -> 33 | {stop, error, State}. 34 | 35 | handle_cast(_, State) -> 36 | {stop, error, State}. 37 | 38 | handle_info(timeout, State) -> 39 | ok = tansu_consensus:snapshot(), 40 | {noreply, State, tansu_config:timeout(kv_snapshot)}. 41 | 42 | terminate(_, _) -> 43 | ok. 44 | 45 | code_change(_, State, _) -> 46 | {ok, State}. 47 | -------------------------------------------------------------------------------- /src/tansu_log.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_log). 16 | 17 | -export([append_entries/3]). 18 | -export([commit_index/0]). 19 | -export([create_table/0]). 20 | -export([do_clear_table/0]). 21 | -export([do_delete/2]). 22 | -export([do_first/0]). 23 | -export([do_last/0]). 24 | -export([do_read/1]). 25 | -export([do_write/3]). 26 | -export([first/0]). 27 | -export([last/0]). 28 | -export([read/1]). 29 | -export([read/2]). 30 | -export([term_for_index/1]). 31 | -export([trace/1]). 32 | -export([write/2]). 33 | 34 | -include("tansu_log.hrl"). 35 | 36 | create_table() -> 37 | Attributes = [{attributes, record_info(fields, ?MODULE)}, 38 | {type, ordered_set}], 39 | 40 | Definition = case tansu_config:db_schema() of 41 | ram -> 42 | Attributes; 43 | 44 | _ -> 45 | [{disc_copies, [node()]} | Attributes] 46 | end, 47 | case mnesia:create_table(?MODULE, Definition) of 48 | {atomic, ok} -> 49 | true; 50 | 51 | {aborted, {already_exists, _}} -> 52 | true; 53 | 54 | {aborted, Reason} -> 55 | error(badarg, [Reason]) 56 | end. 57 | 58 | do_clear_table() -> 59 | mnesia:clear_table(?MODULE). 60 | 61 | first() -> 62 | activity(fun do_first/0). 63 | 64 | last() -> 65 | activity(fun do_last/0). 66 | 67 | do_first() -> 68 | first_or_last(first). 69 | 70 | do_last() -> 71 | first_or_last(last). 72 | 73 | first_or_last(FirstOrLast) -> 74 | case mnesia:FirstOrLast(?MODULE) of 75 | '$end_of_table' -> 76 | #{index => 0, term => 0}; 77 | 78 | Index -> 79 | [#?MODULE{ 80 | index = I, 81 | term = T, 82 | command = C}] = mnesia:read(?MODULE, Index), 83 | #{index => I, term => T, command => C} 84 | end. 85 | 86 | 87 | append_entries(PrevLogIndex, PrevLogTerm, Entries) -> 88 | activity( 89 | fun 90 | () -> 91 | case {mnesia:last(?MODULE), mnesia:read(?MODULE, PrevLogIndex)} of 92 | {'$end_of_table', []} when PrevLogIndex == 0 -> 93 | {ok, append_entries(PrevLogIndex, Entries)}; 94 | 95 | {PrevLogIndex, [#?MODULE{term = PrevLogTerm}]} -> 96 | {ok, append_entries(PrevLogIndex, Entries)}; 97 | 98 | _ -> 99 | {error, unmatched_term} 100 | end 101 | end). 102 | 103 | 104 | append_entries(PrevLogIndex, Entries) -> 105 | lists:foldl( 106 | fun 107 | (#{term := T, command := C}, I) -> 108 | mnesia:write(#?MODULE{index = I+1, 109 | term = T, 110 | command = C}), 111 | I+1 112 | end, 113 | PrevLogIndex, 114 | Entries). 115 | 116 | 117 | read(Index) -> 118 | activity( 119 | fun 120 | () -> 121 | do_read(Index) 122 | end). 123 | 124 | do_read(Index) -> 125 | case mnesia:read(?MODULE, Index) of 126 | [#?MODULE{term = T, command = C}] -> 127 | #{term => T, command => C}; 128 | [] -> 129 | error(badarg, [Index]) 130 | end. 131 | 132 | read(StartIndex, LastIndex) -> 133 | activity( 134 | fun 135 | () -> 136 | do_read(StartIndex, LastIndex) 137 | end). 138 | 139 | do_read(StartIndex, LastIndex) -> 140 | lists:map(fun do_read/1, lists:seq(StartIndex, LastIndex)). 141 | 142 | 143 | write(Term, Command) -> 144 | activity( 145 | fun 146 | () -> 147 | Index = commit_index() + 1, 148 | do_write(Index, Term, Command), 149 | Index 150 | end). 151 | 152 | do_write(Index, Term, Command) -> 153 | mnesia:write(#?MODULE{index = Index, 154 | term = Term, 155 | command = Command}). 156 | 157 | 158 | commit_index() -> 159 | #{index := Index} = last(), 160 | Index. 161 | 162 | term_for_index(0) -> 163 | 0; 164 | term_for_index(Index) -> 165 | activity( 166 | fun 167 | () -> 168 | case mnesia:read(?MODULE, Index) of 169 | [#?MODULE{term = Term}] -> 170 | Term; 171 | [] -> 172 | error(badarg, [Index]) 173 | end 174 | end). 175 | 176 | 177 | do_delete(First, Last) -> 178 | lists:foreach( 179 | fun 180 | (Index) -> 181 | mnesia:delete({?MODULE, Index}) 182 | end, 183 | lists:seq(First, Last)). 184 | 185 | 186 | 187 | activity(F) -> 188 | mnesia:activity(transaction, F). 189 | 190 | 191 | trace(true) -> 192 | recon_trace:calls({?MODULE, '_', '_'}, 193 | {1000, 500}, 194 | [{scope, local}, 195 | {pid, all}]); 196 | trace(false) -> 197 | recon_trace:clear(). 198 | -------------------------------------------------------------------------------- /src/tansu_log.hrl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -record(tansu_log, {index, term, command}). 16 | -------------------------------------------------------------------------------- /src/tansu_mnesia.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_mnesia). 16 | -export([activity/1]). 17 | -export([create_table/2]). 18 | 19 | -spec create_table(Table :: atom(), Options :: list()) -> ok | {error, Reason :: atom()} | {timeout, Tables :: list(atom())}. 20 | 21 | create_table(Table, Options) -> 22 | Definition = case tansu_config:db_schema() of 23 | ram -> 24 | Options; 25 | 26 | _ -> 27 | [{disc_copies, [node()]} | Options] 28 | end, 29 | case mnesia:create_table(Table, Definition) of 30 | {atomic, ok} -> 31 | ok; 32 | 33 | {aborted, {already_exists, _}} -> 34 | case mnesia:wait_for_tables([Table], tansu_config:timeout(mnesia_wait_for_tables)) of 35 | {timeout, Tables} -> 36 | {timeout, Tables}; 37 | 38 | {error, _} = Error -> 39 | Error; 40 | 41 | ok -> 42 | ok 43 | end; 44 | 45 | {aborted, Reason} -> 46 | {error, Reason} 47 | end. 48 | 49 | activity(F) -> 50 | mnesia:activity(transaction, F). 51 | -------------------------------------------------------------------------------- /src/tansu_oapi_resource.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_oapi_resource). 16 | 17 | -export([allowed_methods/2]). 18 | -export([content_types_provided/2]). 19 | -export([init/2]). 20 | -export([options/2]). 21 | -export([to_json/2]). 22 | 23 | init(Req, _) -> 24 | {cowboy_rest, cors:allow_origin(Req), #{}}. 25 | 26 | allowed_methods(Req, State) -> 27 | {allowed(), Req, State}. 28 | 29 | options(Req, State) -> 30 | cors:options(Req, State, allowed()). 31 | 32 | content_types_provided(Req, State) -> 33 | {[{{<<"application">>, <<"json">>, '*'}, to_json}], Req, State}. 34 | 35 | allowed() -> 36 | [<<"GET">>, 37 | <<"HEAD">>, 38 | <<"OPTIONS">>]. 39 | 40 | 41 | to_json(Req, State) -> 42 | {jsx:encode(root(Req)), Req, State}. 43 | 44 | root(Req) -> 45 | #{ 46 | swagger => <<"2.0">>, 47 | info => info(), 48 | host => <<(cowboy_req:host(Req))/bytes, ":", (any:to_binary(tansu_config:port(http)))/bytes>>, 49 | <<"basePath">> => any:to_binary(tansu_config:endpoint(api)), 50 | schemes => [<<"http">>], 51 | consumes => [json()], 52 | produces => [json()], 53 | paths => paths() 54 | }. 55 | 56 | json() -> 57 | <<"application/json">>. 58 | 59 | info() -> 60 | #{ 61 | title => tansu, 62 | description => any:to_binary(tansu:description()), 63 | <<"termsOfService">> => <<"http://swagger.io/terms">>, 64 | contact => #{ 65 | name => <<"API Support">>, 66 | url => <<"http://swagger.io/support">>, 67 | email => <<"support@swagger.io">> 68 | }, 69 | license => #{ 70 | name => <<"Apache 2.0">>, 71 | url => <<"http://www.apache.org/licenses/LICENSE-2.0.html">> 72 | }, 73 | version => any:to_binary(tansu:vsn()) 74 | }. 75 | 76 | paths() -> 77 | path(<<"/keys/{key}">>). 78 | 79 | path(<<"/keys/{key}">> = Keys) -> 80 | #{Keys => #{ 81 | get => #{ 82 | description => <<"Returns the value of the supplied key">>, 83 | <<"operationId">> => <<"getValue">>, 84 | produces => [json()], 85 | responses => #{ 86 | 200 => #{ 87 | description => <<"Value of the supplied key">> 88 | }, 89 | 90 | 404 => #{ 91 | description => <<"The supplied key does not exist">> 92 | } 93 | } 94 | }, 95 | 96 | post => #{ 97 | description => <<"Sets the value of the supplied key">>, 98 | <<"operationId">> => <<"setValue">>, 99 | responses => #{ 100 | 204 => #{ 101 | description => <<"Value has been accepted">> 102 | } 103 | } 104 | }, 105 | 106 | delete => #{ 107 | description => <<"Remove the key from the store">>, 108 | <<"operationId">> => <<"deleteKey">>, 109 | responses => #{ 110 | 204 => #{ 111 | description => <<"The key has been deleted from the store">> 112 | } 113 | } 114 | }, 115 | 116 | parameters => [#{ 117 | name => key, 118 | in => path, 119 | description => <<"The key">>, 120 | required => true, 121 | type => string 122 | }] 123 | } 124 | }. 125 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /src/tansu_ps.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_ps). 16 | -export([create_table/0]). 17 | -export([term/1]). 18 | -export([term/2]). 19 | -export([id/0]). 20 | -export([increment/2]). 21 | -export([voted_for/1]). 22 | -export([voted_for/2]). 23 | 24 | 25 | -record(?MODULE, {id, term, voted_for}). 26 | 27 | create_table() -> 28 | Attributes = [{attributes, record_info(fields, ?MODULE)}, 29 | {type, ordered_set}], 30 | 31 | Definition = case tansu_config:db_schema() of 32 | ram -> 33 | Attributes; 34 | 35 | _ -> 36 | [{disc_copies, [node()]} | Attributes] 37 | end, 38 | case mnesia:create_table(?MODULE, Definition) of 39 | {atomic, ok} -> 40 | new(), 41 | true; 42 | 43 | {aborted, {already_exists, _}} -> 44 | true; 45 | 46 | {aborted, Reason} -> 47 | error(Reason) 48 | end. 49 | 50 | id() -> 51 | activity( 52 | fun 53 | () -> 54 | mnesia:first(?MODULE) 55 | end). 56 | 57 | term(Id) -> 58 | activity( 59 | fun() -> 60 | case mnesia:read(?MODULE, Id) of 61 | [#?MODULE{term = CurrentTerm}] -> 62 | CurrentTerm; 63 | [] -> 64 | error(badarg, [Id]) 65 | end 66 | end). 67 | 68 | term(Id, New) -> 69 | activity( 70 | fun() -> 71 | case mnesia:read(?MODULE, Id) of 72 | [#?MODULE{term = Current} = PS] when New > Current -> 73 | ok = mnesia:write(PS#?MODULE{term = New}), 74 | New; 75 | 76 | [#?MODULE{term = Current}] when New < Current -> 77 | error(badarg, [Id]); 78 | 79 | [#?MODULE{term = Current}] -> 80 | Current; 81 | 82 | [] -> 83 | error(badarg, [Id]) 84 | end 85 | end). 86 | 87 | increment(Id, CurrentTerm) -> 88 | activity( 89 | fun() -> 90 | case mnesia:read(?MODULE, Id) of 91 | [#?MODULE{term = CurrentTerm} = PS] -> 92 | ok = mnesia:write(PS#?MODULE{term = CurrentTerm + 1}), 93 | CurrentTerm+1; 94 | 95 | [#?MODULE{term = _}] -> 96 | error(badarg, [Id, CurrentTerm]); 97 | 98 | [] -> 99 | error(badarg, [Id, CurrentTerm]) 100 | end 101 | end). 102 | 103 | voted_for(Id) -> 104 | activity( 105 | fun() -> 106 | case mnesia:read(?MODULE, Id) of 107 | [#?MODULE{voted_for = VotedFor}] -> 108 | VotedFor; 109 | [] -> 110 | error(badarg, [Id]) 111 | end 112 | end). 113 | 114 | voted_for(Id, VotedFor) -> 115 | activity( 116 | fun() -> 117 | case mnesia:read(?MODULE, Id) of 118 | [#?MODULE{} = PS] -> 119 | ok = mnesia:write( 120 | PS#?MODULE{voted_for = VotedFor}), 121 | VotedFor; 122 | [] -> 123 | error(badarg, [Id, VotedFor]) 124 | end 125 | end). 126 | 127 | 128 | new() -> 129 | activity( 130 | fun() -> 131 | mnesia:write(#?MODULE{id = tansu_uuid:new(), term = 0}) 132 | end). 133 | 134 | activity(F) -> 135 | mnesia:activity(transaction, F). 136 | -------------------------------------------------------------------------------- /src/tansu_rpc.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_rpc). 16 | 17 | -export([append_entries/6]). 18 | -export([append_entries_response/6]). 19 | -export([decode/1]). 20 | -export([encode/1]). 21 | -export([heartbeat/5]). 22 | -export([install_snapshot/8]). 23 | -export([log/1]). 24 | -export([ping/1]). 25 | -export([request_vote/4]). 26 | -export([vote/3]). 27 | 28 | 29 | ping(Id) -> 30 | #{ping => Id}. 31 | 32 | request_vote(Term, Candidate, LastLogIndex, LastLogTerm) -> 33 | #{request_vote => #{ 34 | term => Term, 35 | candidate => Candidate, 36 | last_log_index => LastLogIndex, 37 | last_log_term => LastLogTerm}}. 38 | 39 | 40 | append_entries_response(Leader, Follower, Term, PrevLogIndex, PrevLogTerm, Success) -> 41 | #{append_entries_response => #{ 42 | term => Term, 43 | prev_log_index => PrevLogIndex, 44 | prev_log_term => PrevLogTerm, 45 | follower => Follower, 46 | leader => Leader, 47 | success => Success}}. 48 | 49 | append_entries(LeaderTerm, Leader, LastApplied, PrevLogTerm, 50 | LeaderCommitIndex, Entries) -> 51 | #{append_entries => #{ 52 | term => LeaderTerm, 53 | leader => Leader, 54 | prev_log_index =>LastApplied, 55 | prev_log_term => PrevLogTerm, 56 | entries => Entries, 57 | leader_commit => LeaderCommitIndex}}. 58 | 59 | log(Command) -> 60 | #{log => Command}. 61 | 62 | 63 | vote(Elector, Term, Granted) -> 64 | #{vote => #{elector => Elector, 65 | term => Term, 66 | granted => Granted}}. 67 | 68 | 69 | heartbeat(LeaderTerm, Leader, LastApplied, PrevLogTerm, LeaderCommitIndex) -> 70 | #{append_entries => #{term => LeaderTerm, 71 | leader => Leader, 72 | prev_log_index =>LastApplied, 73 | prev_log_term => PrevLogTerm, 74 | entries => [], 75 | leader_commit => LeaderCommitIndex}}. 76 | 77 | install_snapshot(LeaderTerm, Leader, LastIndex, LastTerm, LastConfig, Offset, Data, Done) -> 78 | #{install_snapshot => #{term => LeaderTerm, 79 | leader => Leader, 80 | last_index => LastIndex, 81 | last_term => LastTerm, 82 | last_config => LastConfig, 83 | offset => Offset, 84 | data => Data, 85 | done => Done}}. 86 | 87 | 88 | decode(<>) -> 89 | binary_to_term(BERT). 90 | 91 | 92 | encode(Term) -> 93 | BERT = term_to_binary(Term), 94 | <<(byte_size(BERT)):32, BERT/binary>>. 95 | -------------------------------------------------------------------------------- /src/tansu_sm.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_sm). 16 | 17 | -export([ckv_delete/3]). 18 | -export([ckv_get/3]). 19 | -export([ckv_get_children_of/3]). 20 | -export([ckv_set/5]). 21 | -export([ckv_test_and_delete/4]). 22 | -export([ckv_test_and_set/6]). 23 | -export([expired/1]). 24 | -export([goodbye/0]). 25 | -export([install_snapshot/3]). 26 | -export([new/0]). 27 | -export([notify/3]). 28 | -export([snapshot/3]). 29 | -export([subscribe/2]). 30 | -export([unsubscribe/2]). 31 | -export([current/2]). 32 | 33 | 34 | -type state_machine() :: any(). 35 | 36 | -callback new() -> {ok, StateMachine :: state_machine()} | 37 | {error, Reason :: string()} | 38 | {error, Reason :: atom()}. 39 | 40 | 41 | -callback expired(StateMachine :: state_machine()) -> {[{Category :: atom(), Key :: binary()}], StateMachine :: state_machine()}. 42 | 43 | 44 | -callback ckv_get(Category :: atom(), 45 | Key :: binary(), 46 | StateMachine :: state_machine()) -> {{ok, Value :: any(), Metadata :: map()} | 47 | {error, Reason :: string()} | 48 | {error, Reason :: atom()}, 49 | StateMachine :: state_machine()}. 50 | 51 | -callback ckv_get_children_of(ParentCategory :: atom(), 52 | ParentKey :: binary(), 53 | StateMachine :: state_machine()) -> {{ok, #{Key :: binary() => {Value :: any(), Metadata :: map()}}} | 54 | {error, Reason :: string()} | 55 | {error, Reason :: atom()}, 56 | StateMachine :: state_machine()}. 57 | 58 | -callback ckv_delete(Category :: atom(), 59 | Key :: binary(), 60 | StateMachine :: state_machine()) -> {ok | 61 | {error, Reason :: string()} | 62 | {error, Reason :: atom()}, 63 | StateMachine :: state_machine()}. 64 | 65 | -callback ckv_set(Category :: atom(), 66 | Key :: binary(), 67 | Value :: any(), 68 | Metadata :: map(), 69 | StateMachine :: state_machine()) -> {{ok, PreviousValue :: any()} | 70 | {error, Reason :: string()} | 71 | {error, Reason :: atom()}, 72 | StateMachine :: state_machine()}. 73 | 74 | 75 | -callback ckv_test_and_delete(Category :: atom(), 76 | Key :: binary(), 77 | ExistingValue :: any(), 78 | StateMachine :: state_machine()) -> {{ok, PreviousValue :: any()} | 79 | {error, Reason :: string()} | 80 | {error, Reason :: atom()}, 81 | StateMachine :: state_machine()}. 82 | -callback ckv_test_and_set(Category :: atom(), 83 | Key :: binary(), 84 | ExistingValue :: any(), 85 | NewValue :: any(), 86 | Options :: map(), 87 | StateMachine :: state_machine()) -> {{ok, PreviousValue :: any()} | 88 | {error, Reason :: string()} | 89 | {error, Reason :: atom()}, 90 | StateMachine :: state_machine()}. 91 | 92 | new() -> 93 | (tansu_config:sm()):new(). 94 | 95 | ckv_get(Category, Key, StateMachine) -> 96 | (tansu_config:sm()):ckv_get(Category, Key, StateMachine). 97 | 98 | ckv_get_children_of(Category, Key, StateMachine) -> 99 | (tansu_config:sm()):ckv_get_children_of(Category, Key, StateMachine). 100 | 101 | ckv_delete(Category, Key, StateMachine) -> 102 | (tansu_config:sm()):ckv_delete(Category, Key, StateMachine). 103 | 104 | ckv_set(Category, Key, Value, Metadata, StateMachine) -> 105 | (tansu_config:sm()):ckv_set(Category, Key, Value, Metadata, StateMachine). 106 | 107 | ckv_test_and_delete(Category, Key, ExistingValue, StateMachine) -> 108 | (tansu_config:sm()):ckv_test_and_delete(Category, Key, ExistingValue, StateMachine). 109 | 110 | ckv_test_and_set(Category, Key, ExistingValue, NewValue, Options, StateMachine) -> 111 | (tansu_config:sm()):ckv_test_and_set(Category, Key, ExistingValue, NewValue, Options, StateMachine). 112 | 113 | expired(StateMachine) -> 114 | (tansu_config:sm()):expired(StateMachine). 115 | 116 | snapshot(Name, LastApplied, StateMachine) -> 117 | (tansu_config:sm()):snapshot(Name, LastApplied, StateMachine). 118 | 119 | install_snapshot(Name, Data, StateMachine) -> 120 | (tansu_config:sm()):install_snapshot(Name, Data, StateMachine). 121 | 122 | current(Metric, StateMachine) -> 123 | (tansu_config:sm()):current(Metric, StateMachine). 124 | 125 | subscribe(Category, Key) -> 126 | gproc:reg(key(Category, Key)). 127 | 128 | unsubscribe(Category, Key) -> 129 | gproc:unreg(key(Category, Key)). 130 | 131 | notify(Category, Key, Data) when map_size(Data) == 1 -> 132 | [Event] = maps:keys(Data), 133 | gproc:send(key(Category, Key), 134 | #{module => ?MODULE, 135 | id => erlang:unique_integer(), 136 | event => Event, 137 | data => Data#{category => Category, key => Key}}); 138 | 139 | notify(Category, Key, #{id := Id, event := Event} = Data) -> 140 | gproc:send(key(Category, Key), 141 | #{module => ?MODULE, 142 | id => Id, 143 | event => Event, 144 | data => maps:without([id, event], Data#{category => Category, key => Key})}); 145 | 146 | notify(Category, Key, #{event := Event} = Data) -> 147 | gproc:send(key(Category, Key), 148 | #{module => ?MODULE, 149 | id => erlang:unique_integer(), 150 | event => Event, 151 | data => maps:without([event], Data#{category => Category, key => Key})}). 152 | 153 | key(Category, Key) -> 154 | {p, l, {?MODULE, {Category, Key}}}. 155 | 156 | goodbye() -> 157 | gproc:goodbye(). 158 | 159 | -------------------------------------------------------------------------------- /src/tansu_sm_mnesia_expiry.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_sm_mnesia_expiry). 16 | -export([cancel/3]). 17 | -export([expired/0]). 18 | -export([new/0]). 19 | -export([set/3]). 20 | 21 | -record(?MODULE, {composite, category, key}). 22 | 23 | new() -> 24 | tansu_mnesia:create_table(?MODULE, [{attributes, 25 | record_info(fields, ?MODULE)}, 26 | {type, ordered_set}]). 27 | 28 | cancel(Category, Key, Expiry) -> 29 | mnesia:delete_object(#?MODULE{composite = {Expiry, Category, Key}, 30 | category = Category, 31 | key = Key}). 32 | 33 | set(Category, Key, Expiry) -> 34 | mnesia:write(#?MODULE{composite = {Expiry, Category, Key}, 35 | category = Category, 36 | key = Key}). 37 | 38 | expired() -> 39 | expired(calendar:datetime_to_gregorian_seconds(erlang:universaltime())). 40 | 41 | expired(RightNow) -> 42 | expired(RightNow, mnesia:first(?MODULE), []). 43 | 44 | expired(RightNow, {Expiry, Category, Key} = Composite, A) when RightNow > Expiry -> 45 | expired(RightNow, mnesia:next(?MODULE, Composite), [{Category, Key} | A]); 46 | expired(_, _, A) -> 47 | A. 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/tansu_sm_mnesia_kv.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_sm_mnesia_kv). 16 | 17 | -export([ckv_delete/3]). 18 | -export([ckv_get/3]). 19 | -export([ckv_get_children_of/3]). 20 | -export([ckv_set/5]). 21 | -export([ckv_test_and_delete/4]). 22 | -export([ckv_test_and_set/6]). 23 | -export([current/2]). 24 | -export([expired/1]). 25 | -export([install_snapshot/3]). 26 | -export([new/0]). 27 | -export([snapshot/3]). 28 | 29 | -behaviour(tansu_sm). 30 | 31 | -include_lib("stdlib/include/qlc.hrl"). 32 | -include("tansu_log.hrl"). 33 | 34 | -record(?MODULE, {key, parent, metadata = #{}, expiry, value, created, updated, content_type}). 35 | 36 | 37 | new() -> 38 | {lists:foldl( 39 | fun 40 | (Action, ok) -> 41 | Action(); 42 | 43 | (_, A) -> 44 | A 45 | end, 46 | ok, 47 | [fun init/0, fun tansu_sm_mnesia_expiry:new/0, fun tansu_sm_mnesia_tx:new/0]), ?MODULE}. 48 | 49 | init() -> 50 | tansu_mnesia:create_table( 51 | ?MODULE, 52 | [{attributes, 53 | record_info(fields, ?MODULE)}, 54 | {index, [parent]}, 55 | {type, ordered_set}]). 56 | 57 | 58 | ckv_get(Category, Key, ?MODULE = StateMachine) -> 59 | {do_get(Category, Key), StateMachine}. 60 | 61 | ckv_get_children_of(Category, Key, ?MODULE = StateMachine) -> 62 | {do_get_children_of(Category, Key), StateMachine}. 63 | 64 | ckv_delete(Category, Key, ?MODULE = StateMachine) -> 65 | {do_delete(Category, Key), StateMachine}. 66 | 67 | ckv_set(Category, Key, Value, Options, ?MODULE = StateMachine) -> 68 | {do_set(Category, Key, Value, Options), StateMachine}. 69 | 70 | ckv_test_and_delete(Category, Key, ExistingValue, ?MODULE = StateMachine) -> 71 | {do_test_and_delete(Category, Key, ExistingValue), StateMachine}. 72 | 73 | ckv_test_and_set(Category, Key, ExistingValue, NewValue, TTL, ?MODULE = StateMachine) -> 74 | {do_test_and_set(Category, Key, ExistingValue, NewValue, TTL), StateMachine}. 75 | 76 | expired(?MODULE = StateMachine) -> 77 | {do_expired(), StateMachine}. 78 | 79 | snapshot(Name, LastApplied, ?MODULE = StateMachine) -> 80 | {do_snapshot(Name, LastApplied), StateMachine}. 81 | 82 | install_snapshot(Name, Data, ?MODULE = StateMachine) -> 83 | {do_install_snapshot(Name, Data), StateMachine}. 84 | 85 | current(Metric, StateMachine) -> 86 | {do_current(Metric), StateMachine}. 87 | 88 | 89 | do_get(Category, Key) -> 90 | activity( 91 | fun 92 | () -> 93 | case {mnesia:read(?MODULE, {Category, Key}), calendar:datetime_to_gregorian_seconds(erlang:universaltime())} of 94 | {[#?MODULE{value = Value, expiry = undefined, metadata = Metadata} = Record], _} -> 95 | {ok, Value, Metadata#{tansu => metadata_from_record(Record)}}; 96 | 97 | {[#?MODULE{value = Value, expiry = Expiry, metadata = Metadata} = Record], Now} when Expiry >= Now -> 98 | {ok, Value, Metadata#{tansu => metadata_from_record(Record)}}; 99 | 100 | {[#?MODULE{expiry = Expiry}], Now} when Expiry < Now -> 101 | {error, not_found}; 102 | 103 | {[], _} -> 104 | {error, not_found} 105 | end 106 | end). 107 | 108 | do_get_children_of(ParentCategory, ParentKey) -> 109 | activity( 110 | fun 111 | () -> 112 | qlc:fold( 113 | fun 114 | (#?MODULE{key = Key, value = Value, metadata = Metadata}, A) -> 115 | A#{Key => {Value, Metadata}} 116 | end, 117 | #{}, 118 | qlc:q( 119 | [Child || #?MODULE{parent = Parent} = Child <- mnesia:table(?MODULE), 120 | Parent == {ParentCategory, ParentKey}])) 121 | end). 122 | 123 | do_delete(Category, Key) -> 124 | activity( 125 | fun 126 | () -> 127 | recursive_remove(Category, Key) 128 | end). 129 | 130 | recursive_remove(ParentCategory, ParentKey) -> 131 | Children = qlc:cursor( 132 | qlc:q( 133 | [Key || #?MODULE{key = Key, parent = Parent} <- mnesia:table(?MODULE), 134 | Parent == {ParentCategory, ParentKey}])), 135 | cursor_remove(Children, qlc:next_answers(Children)), 136 | cancel_expiry(ParentCategory, ParentKey), 137 | case mnesia:read(?MODULE, {ParentCategory, ParentKey}) of 138 | [#?MODULE{value = Value, metadata = Metadata} = Record] -> 139 | mnesia:delete({?MODULE, {ParentCategory, ParentKey}}), 140 | TxId = tansu_sm_mnesia_tx:next(), 141 | tansu_sm:notify( 142 | ParentCategory, 143 | ParentKey, 144 | #{event => delete, 145 | id => TxId, 146 | value => Value, 147 | metadata => Metadata#{tansu => metadata_from_record(Record#?MODULE{updated = TxId})}}), 148 | {ok, Value, Metadata#{tansu => #{current => metadata_from_record(Record#?MODULE{updated = TxId}), 149 | previous => metadata_from_record(Record)}}}; 150 | 151 | [] -> 152 | {ok, undefined, #{}} 153 | end. 154 | 155 | 156 | cursor_remove(Cursor, []) -> 157 | qlc:delete_cursor(Cursor); 158 | cursor_remove(Cursor, Answers) -> 159 | lists:foreach( 160 | fun 161 | ({Category, Key}) -> 162 | recursive_remove(Category, Key) 163 | end, 164 | Answers), 165 | cursor_remove(Cursor, qlc:next_answers(Cursor)). 166 | 167 | cancel_expiry(Category, Key) -> 168 | case mnesia:read(?MODULE, {Category, Key}) of 169 | [#?MODULE{expiry = Previous} = Current] when is_integer(Previous) -> 170 | tansu_sm_mnesia_expiry:cancel(Category, Key, Previous), 171 | Current; 172 | 173 | [#?MODULE{} = Current]-> 174 | Current; 175 | 176 | _ -> 177 | undefined 178 | end. 179 | 180 | do_set(Category, Key, Value, Metadata) -> 181 | activity( 182 | fun 183 | () -> 184 | case cancel_expiry(Category, Key) of 185 | undefined -> 186 | write_tx( 187 | Category, 188 | create, 189 | #{key => Key, 190 | value => Value}, 191 | Metadata, 192 | undefined); 193 | 194 | #?MODULE{created = Created} = PreviousValue -> 195 | write_tx( 196 | Category, 197 | set, 198 | #{key => Key, 199 | value => Value, 200 | created => Created}, 201 | Metadata, 202 | PreviousValue) 203 | end 204 | end). 205 | 206 | 207 | do_test_and_set(Category, Key, undefined, NewValue, #{tansu := #{updated := Index}} = Metadata) -> 208 | activity( 209 | fun 210 | () -> 211 | case mnesia:read(?MODULE, {Category, Key}) of 212 | [#?MODULE{created = Created, 213 | updated = Index} = ExistingValue] -> 214 | cancel_expiry(Category, Key), 215 | write_tx( 216 | Category, 217 | set, 218 | #{key => Key, 219 | created => Created, 220 | value => NewValue}, 221 | Metadata, 222 | ExistingValue, 223 | #{type => cas}); 224 | 225 | [#?MODULE{}] -> 226 | %% Not the value that we were expecting. 227 | error; 228 | 229 | [] -> 230 | error 231 | end 232 | end); 233 | 234 | do_test_and_set(Category, Key, undefined, NewValue, Metadata) -> 235 | activity( 236 | fun 237 | () -> 238 | case mnesia:read(?MODULE, {Category, Key}) of 239 | [#?MODULE{}] -> 240 | error; 241 | 242 | [] -> 243 | write_tx( 244 | Category, 245 | set, 246 | #{key => Key, 247 | value => NewValue}, 248 | Metadata, 249 | undefined, 250 | #{type => cas}) 251 | end 252 | end); 253 | 254 | do_test_and_set(Category, Key, ExistingValue, NewValue, Metadata) -> 255 | activity( 256 | fun 257 | () -> 258 | case mnesia:read(?MODULE, {Category, Key}) of 259 | [#?MODULE{created = Created, 260 | value = ExistingValue} = Previous] -> 261 | cancel_expiry(Category, Key), 262 | write_tx( 263 | Category, 264 | set, 265 | #{key => Key, 266 | created => Created, 267 | value => NewValue}, 268 | Metadata, 269 | Previous, 270 | #{type => cas}); 271 | 272 | [#?MODULE{}] -> 273 | %% Not the value that we were expecting. 274 | error; 275 | 276 | [] -> 277 | error 278 | end 279 | end). 280 | 281 | do_test_and_delete(Category, Key, ExistingValue) -> 282 | activity( 283 | fun 284 | () -> 285 | case mnesia:read(?MODULE, {Category, Key}) of 286 | [#?MODULE{value = ExistingValue}] -> 287 | recursive_remove(Category, Key); 288 | 289 | [#?MODULE{}] -> 290 | error; 291 | 292 | [] -> 293 | error 294 | end 295 | end). 296 | 297 | 298 | write_tx( 299 | Category, 300 | Event, 301 | KV, 302 | Metadata, 303 | PreviousValue) -> 304 | write_tx(Category, Event, KV, Metadata, PreviousValue, #{}). 305 | 306 | write_tx( 307 | Category, 308 | Event, 309 | #{value := NewValue} = KV, 310 | Metadata, 311 | #?MODULE{value = PreviousValue, 312 | content_type = <<"application/json">>} = ExistingValue, 313 | Commentary) -> 314 | Record = integrate_metadata(to_record(Category, KV), Metadata), 315 | mnesia:write(Record), 316 | notification(Event, Record, PreviousValue, Commentary), 317 | {ok, 318 | NewValue, 319 | Metadata#{ 320 | tansu => #{ 321 | previous => metadata_from_record( 322 | ExistingValue, 323 | #{value => jsx:decode(PreviousValue)}), 324 | current => metadata_from_record(Record)}}}; 325 | 326 | write_tx( 327 | Category, 328 | Event, 329 | #{value := NewValue} = KV, 330 | Metadata, 331 | #?MODULE{value = PreviousValue} = ExistingValue, 332 | Commentary) -> 333 | Record = integrate_metadata(to_record(Category, KV), Metadata), 334 | mnesia:write(Record), 335 | notification(Event, Record, PreviousValue, Commentary), 336 | {ok, 337 | NewValue, 338 | Metadata#{ 339 | tansu => #{ 340 | previous => metadata_from_record( 341 | ExistingValue, 342 | #{value => PreviousValue}), 343 | current => metadata_from_record(Record)}}}; 344 | 345 | write_tx( 346 | Category, 347 | Event, 348 | #{value := NewValue} = KV, 349 | Metadata, 350 | undefined = PreviousValue, 351 | Commentary) -> 352 | Record = integrate_metadata(to_record(Category, KV), Metadata), 353 | mnesia:write(Record), 354 | notification(Event, Record, PreviousValue, Commentary), 355 | {ok, 356 | NewValue, 357 | Metadata#{ 358 | tansu => #{ 359 | current => metadata_from_record(Record)}}}. 360 | 361 | integrate_metadata(#?MODULE{key = {Category, Key}} = KV, Metadata) -> 362 | maps:fold( 363 | fun 364 | (parent, Parent, A) -> 365 | A#?MODULE{parent = {Category, Parent}}; 366 | 367 | (ttl, TTL, A) -> 368 | Expiry = calendar:datetime_to_gregorian_seconds(erlang:universaltime()) + TTL, 369 | tansu_sm_mnesia_expiry:set(Category, Key, Expiry), 370 | A#?MODULE{expiry = Expiry}; 371 | 372 | (content_type, ContentType, A) -> 373 | A#?MODULE{content_type = ContentType}; 374 | 375 | (_, _, A) -> 376 | A 377 | end, 378 | KV, 379 | maps:get(tansu, Metadata, #{})). 380 | 381 | to_record(Category, #{created := _, updated := _} = M) -> 382 | maps:fold( 383 | fun 384 | (key, Key, A) -> 385 | A#?MODULE{key = {Category, Key}}; 386 | 387 | (value, Value, A) -> 388 | A#?MODULE{value = Value}; 389 | 390 | (metadata, Metadata, A) -> 391 | A#?MODULE{metadata = maps:without([tansu], Metadata)}; 392 | 393 | (created, Created, A) -> 394 | A#?MODULE{created = Created}; 395 | 396 | (updated, Updated, A) -> 397 | A#?MODULE{updated = Updated}; 398 | 399 | (content_type, ContentType, A) -> 400 | A#?MODULE{content_type = ContentType} 401 | end, 402 | #?MODULE{}, 403 | M); 404 | to_record(Category, #{created := _} = M) -> 405 | to_record(Category, M#{updated => tansu_sm_mnesia_tx:next()}); 406 | to_record(Category, M) -> 407 | TxId = tansu_sm_mnesia_tx:next(), 408 | to_record(Category, M#{created => TxId, updated => TxId}). 409 | 410 | 411 | notification( 412 | Event, 413 | #?MODULE{parent = undefined} = Record, 414 | Previous, 415 | Commentary) -> 416 | notify( 417 | Event, 418 | Record, 419 | Previous, 420 | Commentary); 421 | 422 | notification( 423 | Event, 424 | #?MODULE{parent = {_, Parent}} = Record, 425 | Previous, 426 | Commentary) -> 427 | notify( 428 | Event, 429 | Record, 430 | Previous, 431 | Commentary), 432 | notification( 433 | Event, 434 | Record, 435 | Previous, 436 | Commentary, 437 | tl( 438 | binary:split( 439 | Parent, 440 | <<"/">>, 441 | [global]))). 442 | 443 | notification(_, _, _, _, []) -> 444 | ok; 445 | notification( 446 | Event, 447 | #?MODULE{key = {Category, OriginKey}} = Record, 448 | Previous, 449 | Commentary, 450 | Hierarchy) -> 451 | notify( 452 | Event, 453 | Record#?MODULE{key = {Category, slash_separated(Hierarchy)}}, 454 | Previous, 455 | Commentary#{origin => OriginKey}), 456 | notification( 457 | Event, 458 | Record, 459 | Previous, 460 | Commentary, 461 | lists:droplast(Hierarchy)). 462 | 463 | slash_separated([]) -> 464 | <<"/">>; 465 | slash_separated(PathInfo) -> 466 | lists:foldl( 467 | fun 468 | (Path, <<>>) -> 469 | <<"/", Path/bytes>>; 470 | (Path, A) -> 471 | <> 472 | end, 473 | <<>>, 474 | PathInfo). 475 | 476 | notify( 477 | Event, 478 | #?MODULE{key = {Category, Key}, 479 | updated = Updated, 480 | value = Value, 481 | metadata = Metadata} = Record, 482 | undefined, 483 | Commentary) -> 484 | tansu_sm:notify( 485 | Category, 486 | Key, 487 | #{event => Event, 488 | id => Updated, 489 | metadata => Metadata#{tansu => metadata_from_record(Record, Commentary)}, 490 | value => Value}); 491 | 492 | notify( 493 | Event, 494 | #?MODULE{key = {Category, Key}, 495 | updated = Updated, 496 | value = Value, 497 | metadata = Metadata} = Record, 498 | Previous, 499 | Commentary) -> 500 | tansu_sm:notify( 501 | Category, 502 | Key, 503 | #{event => Event, 504 | id => Updated, 505 | metadata => Metadata#{tansu => metadata_from_record(Record, Commentary)}, 506 | previous => Previous, 507 | value => Value}). 508 | 509 | metadata_from_record(#?MODULE{} = Record) -> 510 | metadata_from_record(Record, #{}). 511 | 512 | metadata_from_record(#?MODULE{} = Record, A) -> 513 | metadata_from_record( 514 | content_type, 515 | Record#?MODULE.content_type, 516 | metadata_from_record( 517 | created, 518 | Record#?MODULE.created, 519 | metadata_from_record( 520 | updated, 521 | Record#?MODULE.updated, 522 | metadata_from_record( 523 | parent, 524 | Record#?MODULE.parent, 525 | metadata_from_record( 526 | expiry, 527 | Record#?MODULE.expiry, 528 | A))))). 529 | 530 | metadata_from_record(_, undefined, A) -> 531 | A; 532 | metadata_from_record(parent, {_Category, Parent}, A) -> 533 | A#{parent => Parent}; 534 | metadata_from_record(expiry, Expiry, A) -> 535 | case calendar:datetime_to_gregorian_seconds(erlang:universaltime()) of 536 | Now when (Expiry - Now) > 0 -> 537 | A#{ttl => Expiry - Now}; 538 | _ -> 539 | A#{ttl => 0} 540 | end; 541 | metadata_from_record(Key, Value, A) -> 542 | A#{Key => Value}. 543 | 544 | temporary() -> 545 | Unique = erlang:phash2({erlang:phash2(node()), 546 | erlang:monotonic_time(), 547 | erlang:unique_integer()}), 548 | filename:join( 549 | tansu_config:directory(snapshot), 550 | "snapshot-" ++ integer_to_list(Unique)). 551 | 552 | 553 | do_snapshot(Name, LastApplied) -> 554 | activity( 555 | fun 556 | () -> 557 | case {tansu_log:do_first(), tansu_log:do_last()} of 558 | {#{index := First}, #{index := Last}} when (First < LastApplied) andalso (Last >= LastApplied) -> 559 | #{term := Term} = tansu_log:do_read(LastApplied), 560 | tansu_log:do_delete(First, LastApplied), 561 | tansu_log:do_write(LastApplied, Term, #{m => tansu_sm, f => apply_snapshot, a => [Name]}), 562 | {ok, Checkpoint, _} = mnesia:activate_checkpoint([{min, tables(snapshot)}]), 563 | 564 | Temporary = temporary(), 565 | ok = filelib:ensure_dir(Temporary), 566 | ok = mnesia:backup_checkpoint(Checkpoint, Temporary), 567 | ok = mnesia:deactivate_checkpoint(Checkpoint), 568 | 569 | Filename = filename:join(tansu_config:directory(snapshot), Name), 570 | {ok, _} = mnesia:traverse_backup( 571 | Temporary, 572 | Filename, 573 | fun 574 | (#tansu_log{index = Index}, A) when Index == LastApplied -> 575 | {[#tansu_log{index = LastApplied, 576 | term = Term, 577 | command = #{m => tansu_sm, 578 | f => apply_snapshot, 579 | a => [Name]}}], A}; 580 | 581 | (#tansu_log{}, A) -> 582 | %% Drop all log records from the snapshot. 583 | {[], A}; 584 | 585 | (Other, A) -> 586 | {[Other], A} 587 | end, 588 | unused), 589 | ok = file:delete(Temporary); 590 | 591 | _ -> 592 | ok 593 | end 594 | end). 595 | 596 | do_install_snapshot(Name, Data) -> 597 | Temporary = temporary(), 598 | ok = filelib:ensure_dir(Temporary), 599 | ok = file:write_file(Temporary, Data), 600 | Transformed = filename:join(tansu_config:directory(snapshot), Name), 601 | {ok, _} = mnesia:traverse_backup( 602 | Temporary, 603 | Transformed, 604 | fun 605 | ({schema, db_nodes, _}, A) -> 606 | %% Ensure that the schema DB nodes is just 607 | %% this one. 608 | {[{schema, db_nodes, [node()]}], A}; 609 | 610 | ({schema, Tab, Options}, A) -> 611 | {[{schema, 612 | Tab, 613 | lists:map( 614 | fun 615 | ({Key, Nodes}) when (length(Nodes) > 0) andalso 616 | (Key == ram_copies orelse 617 | Key == disc_copies orelse 618 | Key == disc_only_copies) -> 619 | {Key, [node()]}; 620 | 621 | ({Key, Value}) -> 622 | {Key, Value} 623 | end, 624 | Options)}], 625 | A}; 626 | 627 | (Other, A) -> 628 | {[Other], A} 629 | end, 630 | unused), 631 | ok = file:delete(Temporary), 632 | {atomic, _} = mnesia:restore(Transformed, [{recreate_tables, tables(snapshot)}]), 633 | ok. 634 | 635 | tables(snapshot) -> 636 | [?MODULE, tansu_log, tansu_sm_mnesia_expiry, tansu_sm_mnesia_tx]. 637 | 638 | 639 | do_expired() -> 640 | activity( 641 | fun 642 | () -> 643 | tansu_sm_mnesia_expiry:expired() 644 | end). 645 | 646 | activity(F) -> 647 | mnesia:activity(transaction, F). 648 | 649 | do_current(index) -> 650 | tansu_sm_mnesia_tx:current(). 651 | -------------------------------------------------------------------------------- /src/tansu_sm_mnesia_tx.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_sm_mnesia_tx). 16 | -export([current/0]). 17 | -export([new/0]). 18 | -export([next/0]). 19 | -record(?MODULE, {id, tx}). 20 | 21 | new() -> 22 | Attributes = [{attributes, record_info(fields, ?MODULE)}, 23 | {type, ordered_set}], 24 | 25 | Definition = case tansu_config:db_schema() of 26 | ram -> 27 | Attributes; 28 | 29 | _ -> 30 | [{disc_copies, [node()]} | Attributes] 31 | end, 32 | case mnesia:create_table(?MODULE, Definition) of 33 | {atomic, ok} -> 34 | tansu_mnesia:activity( 35 | fun() -> 36 | mnesia:write(#?MODULE{id = tansu_uuid:new(), tx = 0}) 37 | end), 38 | ok; 39 | 40 | {aborted, {already_exists, _}} -> 41 | ok; 42 | 43 | {aborted, Reason} -> 44 | {error, Reason} 45 | end. 46 | 47 | current() -> 48 | tansu_mnesia:activity( 49 | fun 50 | () -> 51 | [#?MODULE{tx = Tx}] = mnesia:read( 52 | ?MODULE, 53 | mnesia:first(?MODULE)), 54 | Tx 55 | end). 56 | 57 | next() -> 58 | [#?MODULE{tx = Tx} = TxId] = mnesia:read( 59 | ?MODULE, 60 | mnesia:first(?MODULE)), 61 | ok = mnesia:write( 62 | TxId#?MODULE{tx = Tx + 1}), 63 | Tx. 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/tansu_stream.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | 16 | -module(tansu_stream). 17 | 18 | -export([chunk/3]). 19 | -export([chunk/4]). 20 | 21 | 22 | chunk(Id, Event, Req) -> 23 | cowboy_req:chunk( 24 | ["id: ", 25 | any:to_list(Id), 26 | "\nevent: ", 27 | any:to_list(Event), 28 | "\n\n"], 29 | Req). 30 | 31 | chunk(Id, Event, Data, Req) -> 32 | cowboy_req:chunk( 33 | ["id: ", 34 | any:to_list(Id), 35 | "\nevent: ", 36 | any:to_list(Event), 37 | "\ndata: ", 38 | jsx:encode(Data), 39 | "\n\n"], 40 | Req). 41 | -------------------------------------------------------------------------------- /src/tansu_sup.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_sup). 16 | -behaviour(supervisor). 17 | 18 | -export([init/1]). 19 | -export([start_link/0]). 20 | 21 | start_link() -> 22 | supervisor:start_link({local, ?MODULE}, ?MODULE, []). 23 | 24 | init([]) -> 25 | children( 26 | [worker(tansu_consensus), 27 | worker(tansu_kv_expiry), 28 | worker(tansu_kv_snapshot), 29 | worker(tansu_cluster_membership)]). 30 | 31 | children(Children) -> 32 | {ok, {#{}, Children}}. 33 | 34 | worker(Module) -> 35 | worker(Module, transient). 36 | 37 | worker(Module, Restart) -> 38 | worker(Module, Restart, []). 39 | 40 | worker(Module, Restart, Parameters) -> 41 | worker(Module, Module, Restart, Parameters). 42 | 43 | worker(Id, Module, Restart, Parameters) -> 44 | #{id => Id, 45 | start => {Module, start_link, Parameters}, 46 | restart => Restart, 47 | shutdown => 5000}. 48 | -------------------------------------------------------------------------------- /src/tansu_tcp_advertiser.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2012-2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_tcp_advertiser). 16 | 17 | -export([instances/0]). 18 | -export([service/0]). 19 | 20 | service() -> 21 | "_tansu._tcp". 22 | 23 | instances() -> 24 | {ok, Hostname} = inet:gethostname(), 25 | Id = any:to_list(tansu_consensus:id()), 26 | [#{hostname => Hostname, 27 | port => tansu_config:port(http), 28 | instance => instance(Id, Hostname), 29 | properties => #{host => net_adm:localhost(), 30 | env => tansu_config:environment(), 31 | id => tansu_consensus:id(), 32 | la => tansu_consensus:last_applied(), 33 | ci => tansu_consensus:commit_index(), 34 | vsn => tansu:vsn()}, 35 | priority => 0, 36 | weight => 0}]. 37 | 38 | 39 | instance(Node, Hostname) -> 40 | Node ++ "@" ++ Hostname ++ "." ++ service() ++ mdns_config:domain(). 41 | -------------------------------------------------------------------------------- /src/tansu_timeout.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_timeout). 16 | -export([election/0]). 17 | -export([leader/0]). 18 | 19 | 20 | election() -> 21 | crypto:rand_uniform(tansu_config:timeout(election_low), 22 | tansu_config:timeout(election_high)). 23 | 24 | leader() -> 25 | crypto:rand_uniform(tansu_config:timeout(leader_low), 26 | tansu_config:timeout(leader_high)). 27 | -------------------------------------------------------------------------------- /src/tansu_uuid.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_uuid). 16 | -export([new/0]). 17 | 18 | new() -> 19 | rfc4122:external(rfc4122:v4()). 20 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | -------------------------------------------------------------------------------- /test/common.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2012-2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(common). 16 | -include_lib("common_test/include/ct.hrl"). 17 | 18 | -export([all/1]). 19 | -export([consult/2]). 20 | -export([read_file/2]). 21 | 22 | is_a_test(is_a_test) -> 23 | false; 24 | is_a_test(Function) -> 25 | hd(lists:reverse(string:tokens(atom_to_list(Function), "_"))) =:= "test". 26 | 27 | all(Module) -> 28 | [Function || {Function, Arity} <- Module:module_info(exports), 29 | Arity =:= 1, 30 | is_a_test(Function)]. 31 | 32 | consult(Config, Name) -> 33 | {ok, Terms} = file:consult(filename:join(data_dir(Config), Name)), 34 | Terms. 35 | 36 | read_file(Config, Name) -> 37 | {ok, Contents} = file:read_file(filename:join(data_dir(Config), Name)), 38 | Contents. 39 | 40 | data_dir(Config) -> 41 | ?config(data_dir, Config). 42 | -------------------------------------------------------------------------------- /test/docker_client.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(docker_client). 16 | -behaviour(gen_server). 17 | 18 | -export([code_change/3]). 19 | -export([create_container/2]). 20 | -export([handle_call/3]). 21 | -export([handle_cast/2]). 22 | -export([handle_info/2]). 23 | -export([info/1]). 24 | -export([init/1]). 25 | -export([inspect_container/2]). 26 | -export([logs_container/3]). 27 | -export([kill_container/2]). 28 | -export([remove_container/4]). 29 | -export([start/4]). 30 | -export([start_container/2]). 31 | -export([stop/1]). 32 | -export([terminate/2]). 33 | 34 | -include_lib("kernel/include/inet.hrl"). 35 | -include_lib("public_key/include/public_key.hrl"). 36 | 37 | 38 | start(Host, CertPath, Cert, Key) -> 39 | gen_server:start(?MODULE, [Host, CertPath, Cert, Key], []). 40 | 41 | info(Docker) -> 42 | gen_server:call(Docker, info, infinity). 43 | 44 | create_container(Docker, Detail) -> 45 | gen_server:call(Docker, {create_container, Detail}, infinity). 46 | 47 | start_container(Docker, ContainerId) -> 48 | gen_server:call(Docker, {start_container, ContainerId}, infinity). 49 | 50 | kill_container(Docker, ContainerId) -> 51 | gen_server:call(Docker, {kill_container, ContainerId}, infinity). 52 | 53 | inspect_container(Docker, ContainerId) -> 54 | gen_server:call(Docker, {inspect_container, ContainerId}, infinity). 55 | 56 | remove_container(Docker, ContainerId, Volumes, Force) -> 57 | gen_server:call(Docker, {remove_container, ContainerId, Volumes, Force}, infinity). 58 | 59 | logs_container(Docker, ContainerId, PrivDir) -> 60 | gen_server:call(Docker, {logs_container, ContainerId, PrivDir}, infinity). 61 | 62 | 63 | stop(Docker) -> 64 | gen_server:cast(Docker, stop). 65 | 66 | 67 | init([InHost, InCertPath, InCert, InKey]) -> 68 | case connection(InHost, InCertPath, InCert, InKey) of 69 | {ok, #{host := Host, 70 | port := Port, 71 | cert := Cert, 72 | key := Key}} -> 73 | 74 | case gun:open(Host, 75 | Port, 76 | #{transport => ssl, 77 | transport_opts => haystack_docker_util:ssl(Cert, 78 | Key)}) of 79 | {ok, Pid} -> 80 | {ok, #{docker => Pid, 81 | monitor => monitor(process, Pid), 82 | requests => #{}, 83 | host => Host, 84 | port => Port, 85 | cert => Cert, 86 | key => Key}}; 87 | 88 | {error, Reason} -> 89 | {stop, Reason} 90 | end; 91 | 92 | {ok, #{host := Host, 93 | port := Port}} -> 94 | 95 | case gun:open(Host, 96 | Port, 97 | #{transport => tcp}) of 98 | {ok, Pid} -> 99 | {ok, #{docker => Pid, 100 | monitor => monitor(process, Pid), 101 | requests => #{}, 102 | host => Host, 103 | port => Port}}; 104 | 105 | {error, Reason} -> 106 | {stop, Reason} 107 | end; 108 | 109 | {error, Reason} -> 110 | error_logger:error_report( 111 | [{module, ?MODULE}, 112 | {line, ?LINE}, 113 | {reason, Reason}]), 114 | ignore 115 | end. 116 | 117 | handle_call({create_container, Detail}, Reply, #{docker := Gun, requests := Requests} = State) -> 118 | {noreply, State#{requests := Requests#{gun:post(Gun, "/containers/create", [{<<"content-type">>, <<"application/json">>}], jsx:encode(Detail)) => #{from => Reply, partial => <<>>}}}}; 119 | 120 | handle_call({inspect_container, ContainerId}, Reply, #{docker := Gun, requests := Requests} = State) -> 121 | {noreply, State#{requests := Requests#{gun:get(Gun, ["/containers/", ContainerId, "/json"]) => #{from => Reply, partial => <<>>}}}}; 122 | 123 | handle_call({start_container, ContainerId}, Reply, #{docker := Gun, requests := Requests} = State) -> 124 | {noreply, State#{requests := Requests#{gun:post(Gun, ["/containers/", ContainerId, "/start"], [], []) => #{from => Reply, partial => <<>>}}}}; 125 | 126 | handle_call({kill_container, ContainerId}, Reply, #{docker := Gun, requests := Requests} = State) -> 127 | {noreply, State#{requests := Requests#{gun:post(Gun, ["/containers/", ContainerId, "/kill"], [], []) => #{from => Reply, partial => <<>>}}}}; 128 | 129 | handle_call({remove_container, ContainerId, Volumes, Force}, Reply, #{docker := Gun, requests := Requests} = State) -> 130 | {noreply, State#{requests := Requests#{gun:delete(Gun, ["/containers/", ContainerId, "?v=", any:to_list(Volumes), "&force=", any:to_list(Force)]) => #{from => Reply, partial => <<>>}}}}; 131 | 132 | handle_call({logs_container, ContainerId, PrivDir}, Reply, #{docker := Gun, requests := Requests} = State) -> 133 | Filename = filename:join(PrivDir, any:to_list(ContainerId) ++ ".log"), 134 | {ok, Logs} = file:open(Filename, [write]), 135 | {noreply, State#{requests := Requests#{gun:get(Gun, ["/containers/", ContainerId, "/logs?stdout=true&stderr=true"]) => #{from => Reply, partial => <<>>, filename => Filename, output => Logs}}}}; 136 | 137 | handle_call(info, Reply, #{docker := Gun, requests := Requests} = State) -> 138 | {noreply, State#{requests := Requests#{gun:get(Gun, "/info") => #{from => Reply, partial => <<>>}}}}. 139 | 140 | handle_cast(stop, State) -> 141 | {stop, normal, State}. 142 | 143 | handle_info({'DOWN', Monitor, process, _, normal}, #{monitor := Monitor} = State) -> 144 | {stop, {error, lost_connection}, State}; 145 | 146 | handle_info({gun_up, Gun, http}, #{docker := Gun} = State) -> 147 | {noreply, State}; 148 | 149 | handle_info({gun_down, Gun, http, _, _, _}, #{docker := Gun} = State) -> 150 | {noreply, State}; 151 | 152 | handle_info({gun_data, Gun, Request, fin, Data}, #{docker := Gun, requests := Requests} = State) -> 153 | case Requests of 154 | #{Request := #{from := From, partial := Partial, headers := #{<<"transfer-encoding">> := <<"chunked">>}, filename := Filename, output := Output}} -> 155 | write_chunks(Output, <>), 156 | file:close(Output), 157 | gen_server:reply(From, {ok, Filename}), 158 | {noreply, State#{requests := maps:without([Request], Requests)}}; 159 | 160 | #{Request := #{from := From, partial := Partial, headers := Headers}} -> 161 | gen_server:reply(From, {ok, decode(Headers, <>)}), 162 | {noreply, State#{requests := maps:without([Request], Requests)}} 163 | end; 164 | 165 | handle_info({gun_data, Gun, Request, nofin, Data}, #{docker := Gun, requests := Requests} = State) -> 166 | case Requests of 167 | #{Request := #{partial := Partial, headers := #{<<"transfer-encoding">> := <<"chunked">>}, output := Output} = Detail} -> 168 | {noreply, State#{requests := Requests#{Request := Detail#{partial := write_chunks(Output, <>)}}}}; 169 | 170 | #{Request := #{partial := Partial} = Detail} -> 171 | {noreply, State#{requests := Requests#{Request := Detail#{partial := <>}}}} 172 | end; 173 | 174 | handle_info({gun_response, Gun, Request, nofin, StatusCode, Headers}, #{docker := Gun, requests := Requests} = State) -> 175 | #{Request := Detail} = Requests, 176 | {noreply, State#{requests := Requests#{Request := Detail#{status => StatusCode, headers => maps:from_list(Headers)}}}}; 177 | 178 | handle_info({gun_response, Gun, Request, fin, 204, _}, #{docker := Gun, requests := Requests} = State) -> 179 | #{Request := #{from := From}} = Requests, 180 | gen_server:reply(From, ok), 181 | {noreply, State#{requests := maps:without([Request], Requests)}}. 182 | 183 | 184 | terminate(_, _) -> 185 | ok. 186 | 187 | code_change(_, State, _) -> 188 | {ok, State}. 189 | 190 | 191 | connection(undefined, _CertPath, _Cert, _Key) -> 192 | {error, {missing, "DOCKER_HOST"}}; 193 | connection(URI, undefined, undefined, undefined) -> 194 | connection(URI); 195 | connection(_, undefined, _, undefined) -> 196 | {error, {missing, "DOCKER_KEY"}}; 197 | connection(_, undefined, undefined, _) -> 198 | {error, {missing, "DOCKER_CERT"}}; 199 | connection(URI, _, Cert, Key) when is_list(Cert) andalso is_list(Key) -> 200 | connection(URI, list_to_binary(Cert), list_to_binary(Key)); 201 | connection(URI, CertPath, undefined, undefined) -> 202 | case {read_file(CertPath, "cert.pem"), 203 | read_file(CertPath, "key.pem")} of 204 | 205 | {{ok, Cert}, {ok, Key}} -> 206 | connection(URI, Cert, Key); 207 | 208 | {{error, _} = Error, _} -> 209 | Error; 210 | 211 | {_, {error, _} = Error}-> 212 | Error 213 | end. 214 | 215 | connection(URI, Cert, Key) -> 216 | [{KeyType, Value, _}] = public_key:pem_decode(Key), 217 | [{_, Certificate, _}] = public_key:pem_decode(Cert), 218 | case connection(URI) of 219 | {ok, Details} -> 220 | {ok, Details#{cert => Certificate, 221 | key => {KeyType, Value}}}; 222 | 223 | {error, _} = Error -> 224 | Error 225 | end. 226 | 227 | 228 | connection(URI) -> 229 | case http_uri:parse(URI) of 230 | {ok, {_, _, Host, Port, _, _}} -> 231 | {ok, #{host => Host, port => Port}}; 232 | 233 | {error, _} = Error -> 234 | Error 235 | end. 236 | 237 | 238 | read_file(Path, File) -> 239 | file:read_file(filename:join(Path, File)). 240 | 241 | 242 | write_chunks(Output, <<1, 0, 0, 0, 0, 0, 0, Length:8, Data:Length/bytes, Remainder/bytes>>) -> 243 | file:write(Output, Data), 244 | write_chunks(Output, Remainder); 245 | write_chunks(_Output, Partial) -> 246 | Partial. 247 | 248 | 249 | decode(#{<<"transfer-encoding">> := <<"chunked">>} = Headers, <<1, 0, 0, 0, 0, 0, 0, Length:8, Data:Length/bytes, Remainder/bytes>>) -> 250 | <>; 251 | 252 | decode(#{<<"content-type">> := <<"application/json">>}, JSON) -> 253 | jsx:decode(JSON, [return_maps]); 254 | decode(#{}, Body) -> 255 | Body. 256 | 257 | 258 | -------------------------------------------------------------------------------- /test/kvc.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(kvc). 16 | 17 | -export([code_change/3]). 18 | -export([delete/1]). 19 | -export([get/1]). 20 | -export([handle_call/3]). 21 | -export([handle_cast/2]). 22 | -export([handle_info/2]). 23 | -export([init/1]). 24 | -export([set/2]). 25 | -export([start/1]). 26 | -export([stop/0]). 27 | -export([terminate/2]). 28 | 29 | start(Cluster) -> 30 | gen_server:start({local, ?MODULE}, ?MODULE, [Cluster], []). 31 | 32 | set(Key, Value) -> 33 | gen_server:call(?MODULE, {set, Key, Value}, infinity). 34 | 35 | get(Key) -> 36 | gen_server:call(?MODULE, {get, Key}, infinity). 37 | 38 | delete(Key) -> 39 | gen_server:call(?MODULE, {delete, Key}, infinity). 40 | 41 | stop() -> 42 | gen_server:cast(?MODULE, stop). 43 | 44 | 45 | init([Cluster]) -> 46 | {ok, 47 | #{cluster => lists:foldl( 48 | fun 49 | (Host, A) -> 50 | {ok, Origin} = gun:open(Host, 80, #{transport => tcp}), 51 | A#{Origin => #{monitor => erlang:monitor(process, Origin), 52 | host => Host}} 53 | end, 54 | #{}, 55 | Cluster), 56 | requests => #{}}}. 57 | 58 | 59 | 60 | handle_call({set, Key, Value}, From, State) -> 61 | {noreply, do_set(Key, Value, From, State)}; 62 | 63 | handle_call({get, Key}, From, State) -> 64 | {noreply, do_get(Key, From, State)}; 65 | 66 | handle_call({delete, Key}, From, State) -> 67 | {noreply, do_delete(Key, From, State)}; 68 | 69 | handle_call(_, _, State) -> 70 | {stop, error, State}. 71 | 72 | handle_cast(stop, State) -> 73 | {stop, normal, State}. 74 | 75 | handle_info({gun_up, _, _}, State) -> 76 | {noreply, State}; 77 | 78 | handle_info({gun_down, _, http, _, _, _}, State) -> 79 | {noreply, State}; 80 | 81 | handle_info({gun_error, _, Request, Error}, #{requests := Requests} = State) -> 82 | #{Request := #{from := From}} = Requests, 83 | gen_server:reply(From, {error, Error}), 84 | {noreply, State#{requests := maps:without([Request], Requests)}}; 85 | 86 | handle_info({gun_data, _, Request, fin, Data}, #{requests := Requests} = State) -> 87 | #{Request := #{from := From, partial := Partial, headers := Headers}} = Requests, 88 | gen_server:reply(From, {ok, decode(Headers, <>)}), 89 | {noreply, State#{requests := maps:without([Request], Requests)}}; 90 | 91 | handle_info({gun_data, _, Request, nofin, Data}, #{requests := Requests} = State) -> 92 | #{Request := #{partial := Partial} = Detail} = Requests, 93 | {noreply, State#{requests := Requests#{Request := Detail#{partial := <>}}}}; 94 | 95 | handle_info({gun_response, _, Request, nofin, StatusCode, Headers}, #{requests := Requests} = State) -> 96 | #{Request := Detail} = Requests, 97 | {noreply, State#{requests := Requests#{Request := Detail#{status => StatusCode, headers => Headers}}}}; 98 | 99 | handle_info({gun_response, _, Request, fin, 204, _}, #{requests := Requests} = State) -> 100 | #{Request := #{from := From}} = Requests, 101 | gen_server:reply(From, ok), 102 | {noreply, State#{requests := maps:without([Request], Requests)}}; 103 | 104 | handle_info({gun_response, _, Request, fin, 503, _}, #{requests := Requests} = State) -> 105 | #{Request := #{from := From}} = Requests, 106 | gen_server:reply(From, service_unavailable), 107 | {noreply, State#{requests := maps:without([Request], Requests)}}. 108 | 109 | 110 | terminate(_, _) -> 111 | ok. 112 | 113 | code_change(_, State, _) -> 114 | {ok, State}. 115 | 116 | 117 | do_set(Key, Value, From, State) -> 118 | do_request(<<"POST">>, Key, Value, From, State). 119 | 120 | do_get(Key, From, State) -> 121 | do_request(<<"GET">>, Key, From, State). 122 | 123 | do_delete(Key, From, State) -> 124 | do_request(<<"DELETE">>, Key, From, State). 125 | 126 | decode(Headers, Body) when is_list(Headers) -> 127 | decode(maps:from_list(Headers), Body); 128 | 129 | decode(#{<<"content-type">> := <<"application/json">>}, JSON) -> 130 | jsx:decode(JSON, [return_maps]); 131 | decode(#{}, Body) -> 132 | Body. 133 | 134 | 135 | do_request(Method, Key, Value, From, #{requests := Requests} = State) -> 136 | State#{requests := Requests#{gun:request( 137 | pick_from(State), 138 | Method, 139 | ["/client/keys/", any:to_list(Key)], 140 | [{<<"content-type">>, <<"application/json">>}], 141 | [jsx:encode(Value)]) => #{from => From, partial => <<>>}}}. 142 | 143 | 144 | do_request(Method, Key, From, #{requests := Requests} = State) -> 145 | State#{requests := Requests#{gun:request( 146 | pick_from(State), 147 | Method, 148 | ["/client/keys/", any:to_list(Key)], 149 | []) => #{from => From, partial => <<>>}}}. 150 | 151 | 152 | pick_from(#{cluster := Cluster}) -> 153 | Members = maps:keys(Cluster), 154 | lists:nth(random:uniform(length(Members)), Members). 155 | 156 | 157 | -------------------------------------------------------------------------------- /test/property_test/kv_simple.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(kv_simple). 16 | 17 | -export([initial_state/0]). 18 | -export([initial_state_data/0]). 19 | -export([kv/1]). 20 | -export([next_state_data/5]). 21 | -export([postcondition/5]). 22 | -export([precondition/4]). 23 | -export([prop_kv/1]). 24 | -export([set/2]). 25 | 26 | -include_lib("triq/include/triq.hrl"). 27 | 28 | initial_state() -> 29 | kv. 30 | 31 | initial_state_data() -> 32 | #{}. 33 | 34 | kv(_State) -> 35 | [{kv, {call, ?MODULE, set, [key(), value()]}}]. 36 | 37 | key() -> 38 | atom(1). 39 | 40 | value() -> 41 | choose(0,9). 42 | 43 | 44 | set(Key, Value) -> 45 | kvc:set(Key, Value). 46 | 47 | precondition(_From, _Target, _StateData, {call, _, _, _}) -> 48 | true. 49 | 50 | postcondition(_From, _Target, _StateData, {call, _, _, _} = _Op, ok = Result) -> 51 | Result =:= ok; 52 | postcondition(From, Target, StateData, {call, _, _, _} = Op, Result) -> 53 | ct:log("~p", [{From, Target, StateData, Op, Result}]), 54 | Result =:= ok. 55 | 56 | next_state_data(_From, _Target, StateData, _Result, {call, ?MODULE, set, [Key, Value]}) -> 57 | StateData#{Key => Value}. 58 | 59 | 60 | prop_kv(_Config) -> 61 | ?FORALL( 62 | Cmds, triq_fsm:commands(?MODULE), 63 | begin 64 | ?WHENFAIL(ct:log("Cmds: ~p", [Cmds]), begin 65 | {History, State, Result} = triq_fsm:run_commands(?MODULE, Cmds), 66 | ?WHENFAIL(ct:log("History: ~p~nState: ~p~nResult: ~p~n", 67 | [History, State, Result]), 68 | Result =:= ok) 69 | end) 70 | end). 71 | -------------------------------------------------------------------------------- /test/tansu_SUITE.erl: -------------------------------------------------------------------------------- 1 | %% Copyright (c) 2016 Peter Morgan 2 | %% 3 | %% Licensed under the Apache License, Version 2.0 (the "License"); 4 | %% you may not use this file except in compliance with the License. 5 | %% You may obtain a copy of the License at 6 | %% 7 | %% http://www.apache.org/licenses/LICENSE-2.0 8 | %% 9 | %% Unless required by applicable law or agreed to in writing, software 10 | %% distributed under the License is distributed on an "AS IS" BASIS, 11 | %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | %% See the License for the specific language governing permissions and 13 | %% limitations under the License. 14 | 15 | -module(tansu_SUITE). 16 | -compile(export_all). 17 | 18 | -include_lib("common_test/include/ct.hrl"). 19 | 20 | 21 | all() -> 22 | common:all(?MODULE). 23 | 24 | init_per_suite(Config) -> 25 | {ok, _} = application:ensure_all_started(any), 26 | {ok, _} = application:ensure_all_started(inets), 27 | {ok, _} = application:ensure_all_started(jsx), 28 | {ok, _} = application:ensure_all_started(gun), 29 | cluster_is_available( 30 | [{availability_tries, 5}, 31 | {availability_timeout, 5000} | 32 | start_kvc( 33 | start_cluster( 34 | [{cluster_size, 5}, 35 | {cluster_env, tansu_uuid:new()} | 36 | connect_to_docker( 37 | [{docker_host, "tcp://localhost:2375"}, 38 | {docker_cert_path, undefined}, 39 | {docker_cert, undefined}, 40 | {docker_key, undefined} | Config])]))]). 41 | 42 | 43 | end_per_suite(Config) -> 44 | disconnect_from_docker( 45 | remove_cluster( 46 | stop_kvc( 47 | Config))). 48 | 49 | write_test(_Config) -> 50 | lists:foreach( 51 | fun 52 | (I) -> 53 | ok = kvc:set(a, I) 54 | end, 55 | lists:seq(1, 100)). 56 | 57 | 58 | until(Pattern, F) -> 59 | case F() of 60 | Pattern -> 61 | ok; 62 | 63 | {error, _} = Error -> 64 | ct:fail(Error); 65 | 66 | Other -> 67 | ct:log("~p", [Other]), 68 | timer:sleep(1000), 69 | until(Pattern, F) 70 | end. 71 | 72 | 73 | cluster_is_available(Config) -> 74 | cluster_is_available( 75 | config(availability_tries, Config), 76 | config(availability_timeout, Config), 77 | config(cluster_size, Config), 78 | Config). 79 | 80 | cluster_is_available(0, _, _, _) -> 81 | ct:fail(timeout); 82 | 83 | cluster_is_available(Tries, Timeout, Size, Config) -> 84 | case info(Config) of 85 | {ok, #{<<"consensus">> := #{<<"leader">> := #{<<"connections">> := Connections}} = Consensus}} when map_size(Connections) >= Size - 1 -> 86 | ct:log("~p", [Consensus]), 87 | Config; 88 | 89 | {ok, #{<<"consensus">> := #{<<"follower">> := #{<<"leader">> := _, <<"connections">> := Connections}}} = Consensus} when map_size(Connections) >= Size - 1 -> 90 | ct:log("~p", [Consensus]), 91 | Config; 92 | 93 | Otherwise -> 94 | ct:log("~p", [Otherwise]), 95 | timer:sleep(Timeout), 96 | cluster_is_available(Tries - 1, Timeout, Size, Config) 97 | end. 98 | 99 | 100 | connect_to_docker(Config) -> 101 | Host = config(docker_host, Config), 102 | CertPath = config(docker_cert_path, Config), 103 | Cert = config(docker_cert, Config), 104 | Key = config(docker_key, Config), 105 | {ok, Docker} = docker_client:start(Host, CertPath, Cert, Key), 106 | [{docker, Docker} | Config]. 107 | 108 | disconnect_from_docker(Config) -> 109 | docker_client:stop(config(docker, Config)), 110 | lists:keydelete(docker, 1, Config). 111 | 112 | info(Config) -> 113 | URL = "http://" ++ ip_from_cluster(Config) ++ "/client/info", 114 | request(get, {URL, []}). 115 | 116 | request(Method, Request) -> 117 | case httpc:request(Method, Request, [], [{body_format, binary}]) of 118 | {ok, {{_, 200, _}, Headers, Body}} -> 119 | {ok, decode(Headers, Body)}; 120 | 121 | {ok, {{_, 204, _}, _, _}} -> 122 | ok; 123 | 124 | {ok, {{_, 404, _}, _, _}} -> 125 | not_found; 126 | 127 | {ok, {{_, Code, Reason}, _, _}} when (Code div 100) == 5 -> 128 | {error, Reason}; 129 | 130 | {error, _} = Error -> 131 | Error 132 | end. 133 | 134 | ip_from_cluster(Config) -> 135 | Cluster = maps:values(config(cluster, Config)), 136 | lists:nth(random:uniform(length(Cluster)), Cluster). 137 | 138 | %% log_cluster(Config) -> 139 | %% log_cluster(config(docker, Config), config(cluster, Config), config(priv_dir, Config)), 140 | %% Config. 141 | 142 | %% log_cluster(Docker, Cluster, PrivDir) -> 143 | %% maps:fold( 144 | %% fun(Id, _, A) -> 145 | %% {ok, _Logs} = docker_client:logs_container(Docker, Id, PrivDir), 146 | %% %% ct:log("container: ~s~n~s~n", [binary_to_list(Id), binary_to_list(Logs)]), 147 | %% A 148 | %% end, 149 | %% ignore, 150 | %% Cluster). 151 | 152 | 153 | remove_cluster(Config) -> 154 | remove_cluster(config(docker, Config), config(cluster, Config)), 155 | lists:keydelete(cluster, 1, Config). 156 | 157 | remove_cluster(Docker, Cluster) -> 158 | maps:fold( 159 | fun 160 | (Id, _, _) -> 161 | docker_client:remove_container(Docker, Id, true, true) 162 | end, 163 | ignore, 164 | Cluster). 165 | 166 | start_cluster(Config) -> 167 | [{cluster, start_cluster(config(docker, Config), config(cluster_env, Config), config(cluster_size, Config))} | Config]. 168 | 169 | start_cluster(Docker, Env, Size) -> 170 | lists:foldl( 171 | fun 172 | (_, A) -> 173 | {Id, IP} = start_container(Docker, Env), 174 | A#{Id => IP} 175 | end, 176 | #{}, 177 | lists:seq(1, Size)). 178 | 179 | start_kvc(Config) -> 180 | {ok, KVC} = kvc:start(maps:values(config(cluster, Config))), 181 | [{kvc, KVC} | Config]. 182 | 183 | stop_kvc(Config) -> 184 | ok = kvc:stop(), 185 | lists:keydelete(kvc, 1, Config). 186 | 187 | start_container(Docker, Env) -> 188 | Configuration = #{<<"Image">> => <<"shortishly/tansu">>, 189 | <<"Env">> => maybe_add_authorized_keys([<<"TANSU_ENVIRONMENT=", Env/bytes>>, 190 | <<"TANSU_DEBUG=false">>])}, 191 | {ok, #{<<"Id">> := Id}} = docker_client:create_container(Docker, Configuration), 192 | ok = docker_client:start_container(Docker, Id), 193 | {ok, #{<<"NetworkSettings">> := #{<<"Networks">> := #{<<"bridge">> := #{<<"IPAddress">> := IP}}}}} = docker_client:inspect_container(Docker, Id), 194 | {Id, any:to_list(IP)}. 195 | 196 | maybe_add_authorized_keys(Environment) -> 197 | case os:getenv("HOME") of 198 | false -> 199 | Environment; 200 | HOME -> 201 | case file:read_file(filename:join(HOME, ".ssh/authorized_keys")) of 202 | {ok, AuthorisedKeys} -> 203 | [<<"SHELLY_AUTHORIZED_KEYS=", AuthorisedKeys/bytes>> | Environment]; 204 | {error, _} -> 205 | Environment 206 | end 207 | end. 208 | 209 | config(Key, Config) -> 210 | ?config(Key, Config). 211 | 212 | 213 | decode(Headers, Body) when is_list(Headers) -> 214 | decode(maps:from_list(Headers), Body); 215 | 216 | decode(#{"content-type" := "application/json"}, JSON) -> 217 | jsx:decode(JSON, [return_maps]); 218 | decode(#{}, Body) -> 219 | Body. 220 | --------------------------------------------------------------------------------